From 665d3b62ba8d941557f2cfeb29395a90c2e2adf5 Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Fri, 9 Jan 2026 17:53:07 -0500 Subject: [PATCH] Pinning, Hoisting, Recently Lotted --- .../AddonLifecycles/InventoryLifecycles.cs | 1 - AetherBags/Addons/AddonInventoryWindow.cs | 73 +++++- AetherBags/Addons/AddonRetainerWindow.cs | 8 +- AetherBags/Addons/AddonSaddleBagWindow.cs | 8 +- AetherBags/Addons/CategoryWrapper.cs | 1 - AetherBags/Addons/IInventoryWindow.cs | 3 + AetherBags/Addons/InventoryAddonBase.cs | 25 +- .../Addons/InventoryAddonContextMenu.cs | 1 - AetherBags/Commands/CommandHandler.cs | 44 +++- .../Configuration/SystemConfiguration.cs | 34 ++- .../Extensions/AddonLifecycleExtensions.cs | 3 +- .../Extensions/DragDropPayloadExtensions.cs | 5 +- AetherBags/Extensions/LoggerExtensions.cs | 10 +- AetherBags/Helpers/InventoryMoveHelper.cs | 2 - AetherBags/Helpers/Util.cs | 10 +- AetherBags/IPC/IPCService.cs | 2 - AetherBags/Inventory/InventoryOrchestrator.cs | 71 ++++-- AetherBags/Inventory/Items/InventoryStats.cs | 9 + AetherBags/Inventory/LootedItemsTracker.cs | 101 ++++++++ .../Inventory/Scanning/InventoryScanner.cs | 22 +- AetherBags/Inventory/State/InventoryState.cs | 130 ---------- .../Inventory/State/InventoryStateBase.cs | 33 +++ AetherBags/Inventory/State/RetainerState.cs | 1 - .../Category/CategoryConfigurationNode.cs | 1 - .../CategoryDefinitionConfigurationNode.cs | 3 - .../General/ImportExportResetNode.cs | 1 - .../InventoryCategoryHoverCoordinator.cs | 98 +++++--- .../Nodes/Inventory/InventoryCategoryNode.cs | 9 +- .../Inventory/InventoryCategoryNodeBase.cs | 20 ++ .../InventoryCategoryPinCoordinator.cs | 6 +- .../Nodes/Inventory/InventoryDragDropNode.cs | 1 - .../Nodes/Inventory/InventoryFooterNode.cs | 6 +- .../Inventory/InventoryNotificationNode.cs | 1 - .../Nodes/Inventory/LootedItemDisplayNode.cs | 84 +++++++ .../Inventory/LootedItemsCategoryNode.cs | 229 ++++++++++++++++++ .../Nodes/Inventory/SaddleBagFooterNode.cs | 1 - AetherBags/Plugin.cs | 11 +- AetherBags/System.cs | 2 + 38 files changed, 802 insertions(+), 268 deletions(-) create mode 100644 AetherBags/Inventory/LootedItemsTracker.cs delete mode 100644 AetherBags/Inventory/State/InventoryState.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs create mode 100644 AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs create mode 100644 AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs diff --git a/AetherBags/AddonLifecycles/InventoryLifecycles.cs b/AetherBags/AddonLifecycles/InventoryLifecycles.cs index 317bf90..33c2922 100644 --- a/AetherBags/AddonLifecycles/InventoryLifecycles.cs +++ b/AetherBags/AddonLifecycles/InventoryLifecycles.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using AetherBags.Configuration; -using AetherBags.Inventory; using AetherBags.Inventory.Context; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 8348b7b..2c1f5de 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using System.Numerics; using AetherBags.Inventory.Context; +using AetherBags.Inventory.Items; using AetherBags.Inventory.State; using AetherBags.Nodes.Input; using AetherBags.Nodes.Inventory; @@ -15,6 +17,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase { private readonly MainBagState _inventoryState = new(); private InventoryNotificationNode _notificationNode = null!; + private LootedItemsCategoryNode _lootedCategoryNode = null!; protected override InventoryStateBase InventoryState => _inventoryState; @@ -22,7 +25,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase { InitializeBackgroundDropTarget(); - CategoriesNode = new WrappingGridNode + CategoriesNode = new WrappingGridNode { Position = ContentStartPosition, Size = ContentSize, @@ -33,6 +36,13 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase }; CategoriesNode.AttachNode(this); + _lootedCategoryNode = new LootedItemsCategoryNode + { + ItemsPerLine = 10, + OnDismissItem = OnDismissLootedItem, + OnClearAll = OnClearAllLootedItems, + }; + var header = CalculateHeaderLayout(addon); _notificationNode = new InventoryNotificationNode @@ -71,14 +81,69 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); - _isSetupComplete = true; + System.LootedItemsTracker.OnLootedItemsChanged += OnLootedItemsChanged; + + IsSetupComplete = true; _inventoryState.RefreshFromGame(); + + var existingLoot = System.LootedItemsTracker.LootedItems; + if (existingLoot.Count > 0) + { + UpdateLootedCategory(existingLoot); + } + RefreshCategoriesCore(autosize: true); base.OnSetup(addon); } + private void OnLootedItemsChanged(IReadOnlyList lootedItems) + { + if (!IsOpen || !IsSetupComplete) return; + + Services.Framework.RunOnTick(() => + { + if (!IsOpen) return; + UpdateLootedCategory(lootedItems); + }, delayTicks: 1); + } + + private void UpdateLootedCategory(IReadOnlyList lootedItems) + { + _lootedCategoryNode.UpdateLootedItems(lootedItems); + + if (lootedItems.Count > 0) + { + if (CategoriesNode.HoistedNode != _lootedCategoryNode) + { + CategoriesNode.SetHoistedNode(_lootedCategoryNode); + } + } + else + { + using (CategoriesNode.DeferRecalculateLayout()) + { + if (CategoriesNode.HoistedNode == _lootedCategoryNode) + { + CategoriesNode.SetHoistedNode(null); + } + + CategoriesNode.RemoveNode(_lootedCategoryNode); + } + } + } + + private void OnDismissLootedItem(int index) + { + System.LootedItemsTracker.RemoveByIndex(index); + } + + private void OnClearAllLootedItems() + { + System.LootedItemsTracker.Clear(); + } + public void ManualCurrencyRefresh() { if (!Services.ClientState.IsLoggedIn) return; @@ -95,6 +160,8 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase protected override void OnFinalize(AtkUnitBase* addon) { + System.LootedItemsTracker.OnLootedItemsChanged -= OnLootedItemsChanged; + ref var blockingAddonId = ref AgentInventoryContext.Instance()->BlockingAddonId; if (blockingAddonId != 0) { @@ -103,7 +170,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); - _isSetupComplete = false; + IsSetupComplete = false; base.OnFinalize(addon); } } \ No newline at end of file diff --git a/AetherBags/Addons/AddonRetainerWindow.cs b/AetherBags/Addons/AddonRetainerWindow.cs index f710013..62dc25e 100644 --- a/AetherBags/Addons/AddonRetainerWindow.cs +++ b/AetherBags/Addons/AddonRetainerWindow.cs @@ -38,7 +38,7 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase WindowNode?.AddColor = _tintColor; - CategoriesNode = new WrappingGridNode + CategoriesNode = new WrappingGridNode { Position = ContentStartPosition, Size = ContentSize, @@ -107,7 +107,7 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase LayoutContent(); _inventoryState.RefreshFromGame(); - _isSetupComplete = true; + IsSetupComplete = true; RefreshCategoriesCore(autosize: true); @@ -116,7 +116,7 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase protected override void RefreshCategoriesCore(bool autosize) { - if (!_isSetupComplete) + if (!IsSetupComplete) return; _slotCounterNode.String = _inventoryState.GetEmptySlotsString(); @@ -179,7 +179,7 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase protected override void OnFinalize(AtkUnitBase* addon) { - _isSetupComplete = false; + IsSetupComplete = false; CloseRetainerWindows(); diff --git a/AetherBags/Addons/AddonSaddleBagWindow.cs b/AetherBags/Addons/AddonSaddleBagWindow.cs index 71ceabc..4f00a6b 100644 --- a/AetherBags/Addons/AddonSaddleBagWindow.cs +++ b/AetherBags/Addons/AddonSaddleBagWindow.cs @@ -31,7 +31,7 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase WindowNode?.AddColor = _tintColor; - CategoriesNode = new WrappingGridNode + CategoriesNode = new WrappingGridNode { Position = ContentStartPosition, Size = ContentSize, @@ -80,7 +80,7 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase _inventoryState.RefreshFromGame(); - _isSetupComplete = true; + IsSetupComplete = true; RefreshCategoriesCore(autosize: true); @@ -89,7 +89,7 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase protected override void RefreshCategoriesCore(bool autosize) { - if (!_isSetupComplete) + if (!IsSetupComplete) return; _slotCounterNode.String = _inventoryState.GetEmptySlotsString(); @@ -99,7 +99,7 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase protected override void OnFinalize(AtkUnitBase* addon) { - _isSetupComplete = false; + IsSetupComplete = false; if (System.Config.General.HideGameSaddleBags) { diff --git a/AetherBags/Addons/CategoryWrapper.cs b/AetherBags/Addons/CategoryWrapper.cs index c0e488e..7729881 100644 --- a/AetherBags/Addons/CategoryWrapper.cs +++ b/AetherBags/Addons/CategoryWrapper.cs @@ -1,5 +1,4 @@ using AetherBags.Configuration; -using AetherBags.Inventory; using AetherBags.Inventory.Categories; using KamiToolKit.Premade; diff --git a/AetherBags/Addons/IInventoryWindow.cs b/AetherBags/Addons/IInventoryWindow.cs index b8abd4b..b3d9e1c 100644 --- a/AetherBags/Addons/IInventoryWindow.cs +++ b/AetherBags/Addons/IInventoryWindow.cs @@ -1,3 +1,5 @@ +using AetherBags.Inventory.Items; + namespace AetherBags.Addons; public interface IInventoryWindow @@ -8,4 +10,5 @@ public interface IInventoryWindow void ManualRefresh(); void ItemRefresh(); void SetSearchText(string searchText); + InventoryStats GetStats(); } \ No newline at end of file diff --git a/AetherBags/Addons/InventoryAddonBase.cs b/AetherBags/Addons/InventoryAddonBase.cs index b516d7e..3010e67 100644 --- a/AetherBags/Addons/InventoryAddonBase.cs +++ b/AetherBags/Addons/InventoryAddonBase.cs @@ -6,6 +6,7 @@ using AetherBags.Helpers; using AetherBags.Inventory; using AetherBags.Inventory.Categories; using AetherBags.Inventory.Context; +using AetherBags.Inventory.Items; using AetherBags.Inventory.Scanning; using AetherBags.Inventory.State; using AetherBags.Nodes.Input; @@ -27,7 +28,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected readonly HashSet HoverSubscribed = new(); protected DragDropNode BackgroundDropTarget = null!; - protected WrappingGridNode CategoriesNode = null!; + protected WrappingGridNode CategoriesNode = null!; protected TextInputWithButtonNode SearchInputNode = null!; protected InventoryFooterNode FooterNode = null!; protected TextNode? SlotCounterNode { get; set; } @@ -49,8 +50,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected bool RefreshQueued; protected bool RefreshAutosizeQueued; - private bool _isRefreshing; - protected bool _isSetupComplete; + protected bool IsSetupComplete; protected abstract InventoryStateBase InventoryState { get; } @@ -59,13 +59,14 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected virtual bool HasSlotCounter => false; private readonly HashSet _searchMatchScratch = new(); + private bool _isRefreshing; public void ManualRefresh() { if (!IsOpen) return; if (!Services.ClientState.IsLoggedIn) return; if (_isRefreshing) return; - if (!_isSetupComplete) return; + if (!IsSetupComplete) return; try { @@ -82,6 +83,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow public string GetSearchText() => SearchInputNode?.SearchString.ExtractText() ?? string.Empty; + public InventoryStats GetStats() => InventoryState.GetStats(); + public virtual void SetSearchText(string searchText) { Services.Framework.RunOnTick(() => @@ -93,7 +96,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow public void RefreshFromLifecycle() { - if (!_isSetupComplete) return; + if (!IsSetupComplete) return; if (!IsOpen) return; if (_isRefreshing) return; @@ -111,7 +114,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected virtual void RefreshCategoriesCore(bool autosize) { - if (!_isSetupComplete) + if (!IsSetupComplete) return; var config = System.Config.General; @@ -166,7 +169,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow CategoriesNode.SyncWithListDataByKey( dataList: categories, getKeyFromData: categorizedInventory => categorizedInventory.Key, - getKeyFromNode: node => node.CategorizedInventory.Key, + getKeyFromNode: node => node.Key, updateNode: (node, data) => { node.CategorizedInventory = data; @@ -394,7 +397,13 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow protected void ResizeWindow(float width, float height) => ResizeWindow(width, height, recalcLayout: true); - public void ItemRefresh() => RefreshCategoriesCore(false); + public void ItemRefresh() + { + if (!IsOpen) return; + if (!IsSetupComplete) return; + + RefreshCategoriesCore(false); + } protected override void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { diff --git a/AetherBags/Addons/InventoryAddonContextMenu.cs b/AetherBags/Addons/InventoryAddonContextMenu.cs index cee746a..3824ded 100644 --- a/AetherBags/Addons/InventoryAddonContextMenu.cs +++ b/AetherBags/Addons/InventoryAddonContextMenu.cs @@ -1,4 +1,3 @@ -using System; using AetherBags.Configuration; using AetherBags.Inventory; using AetherBags.Inventory.Context; diff --git a/AetherBags/Commands/CommandHandler.cs b/AetherBags/Commands/CommandHandler.cs index bcd0366..2fb0198 100644 --- a/AetherBags/Commands/CommandHandler.cs +++ b/AetherBags/Commands/CommandHandler.cs @@ -1,9 +1,10 @@ using System; +using System.Collections.Generic; +using AetherBags.Addons; using AetherBags.Helpers; using AetherBags.Inventory; -using AetherBags.Inventory.State; +using AetherBags.Inventory.Items; using Dalamud.Game.Command; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; namespace AetherBags.Commands; @@ -30,7 +31,7 @@ public class CommandHandler : IDisposable }); } - private unsafe void OnCommand(string command, string args) + private void OnCommand(string command, string args) { var argsParts = args.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); var subCommand = argsParts.Length > 0 ? argsParts[0].ToLowerInvariant() : string.Empty; @@ -88,8 +89,7 @@ public class CommandHandler : IDisposable case "count": case "stats": - var stats = InventoryState.GetInventoryStats(); - PrintChat($"{stats.UsedSlots}/{stats.TotalSlots} slots used ({stats.UsagePercent:F0}%) | {stats.TotalItems} unique items | {stats.CategoryCount} categories"); + PrintInventoryStats(); break; case "saddle": @@ -111,6 +111,40 @@ public class CommandHandler : IDisposable } } + private void PrintInventoryStats() + { + var openWindows = new List<(string Name, IInventoryWindow Window)>(); + + if (System.AddonInventoryWindow.IsOpen) + openWindows.Add(("Main", System.AddonInventoryWindow)); + if (System.AddonSaddleBagWindow.IsOpen) + openWindows.Add(("Saddle", System.AddonSaddleBagWindow)); + if (System.AddonRetainerWindow.IsOpen) + openWindows.Add(("Retainer", System.AddonRetainerWindow)); + + if (openWindows.Count == 0) + { + PrintChat("No inventory windows are open. Open an inventory to see stats."); + return; + } + + foreach (var (name, window) in openWindows) + { + var stats = window.GetStats(); + PrintChat($"[{name}] {stats.UsedSlots}/{stats.TotalSlots} slots ({stats.UsagePercent:F0}%) | {stats.TotalItems} items | {stats.CategoryCount} categories"); + } + + if (openWindows.Count > 1) + { + var combined = new InventoryStats(); + foreach (var (_, window) in openWindows) + { + combined += window.GetStats(); + } + PrintChat($"[Total] {combined.UsedSlots}/{combined.TotalSlots} slots ({combined.UsagePercent:F0}%) | {combined.TotalItems} items | {combined.CategoryCount} categories"); + } + } + private void HandleSearch(string searchTerm) { if (!System.AddonInventoryWindow.IsOpen) diff --git a/AetherBags/Configuration/SystemConfiguration.cs b/AetherBags/Configuration/SystemConfiguration.cs index 78b0ead..653c735 100644 --- a/AetherBags/Configuration/SystemConfiguration.cs +++ b/AetherBags/Configuration/SystemConfiguration.cs @@ -4,8 +4,36 @@ public class SystemConfiguration { public const string FileName = "AetherBags.json"; + private GeneralSettings _general = new(); + private CategorySettings _categories = new(); + private CurrencySettings _currency = new(); - public GeneralSettings General { get; set; } = new(); - public CategorySettings Categories { get; set; } = new(); - public CurrencySettings Currency { get; set; } = new(); + public GeneralSettings General + { + get => _general; + set => _general = value ?? new(); + } + + public CategorySettings Categories + { + get => _categories; + set => _categories = value ?? new(); + } + + public CurrencySettings Currency + { + get => _currency; + set => _currency = value ?? new(); + } + + /// + /// Ensures all nested config objects are initialized. Call after deserialization. + /// + public void EnsureInitialized() + { + _general ??= new(); + _categories ??= new(); + _currency ??= new(); + _categories.UserCategories ??= new(); + } } \ No newline at end of file diff --git a/AetherBags/Extensions/AddonLifecycleExtensions.cs b/AetherBags/Extensions/AddonLifecycleExtensions.cs index 0c7bf4d..7d51c06 100644 --- a/AetherBags/Extensions/AddonLifecycleExtensions.cs +++ b/AetherBags/Extensions/AddonLifecycleExtensions.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; using System.Linq; -using AetherBags; using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Component.GUI; +namespace AetherBags.Extensions; + public static class AddonLifecycleExtensions { extension(IAddonLifecycle addonLifecycle) { public void LogAddon(string addonName, params AddonEvent[] loggedModules) { diff --git a/AetherBags/Extensions/DragDropPayloadExtensions.cs b/AetherBags/Extensions/DragDropPayloadExtensions.cs index 197f6ed..e4e4785 100644 --- a/AetherBags/Extensions/DragDropPayloadExtensions.cs +++ b/AetherBags/Extensions/DragDropPayloadExtensions.cs @@ -1,14 +1,11 @@ - using AetherBags.Inventory; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; -using Lumina.Text.ReadOnly; -using Lumina.Text; namespace AetherBags.Extensions; -public static unsafe class DragDropPayloadExtensions +public static class DragDropPayloadExtensions { extension(DragDropPayload payload) { diff --git a/AetherBags/Extensions/LoggerExtensions.cs b/AetherBags/Extensions/LoggerExtensions.cs index 3882e87..dce0eae 100644 --- a/AetherBags/Extensions/LoggerExtensions.cs +++ b/AetherBags/Extensions/LoggerExtensions.cs @@ -8,10 +8,16 @@ public static class LoggerExtensions { if (System.Config?.General?.DebugEnabled == true) { - Services.Logger.DebugOnly(message); + Services.Logger.Debug(message); } } - public void DebugOnly(string message, params object[] args) => DebugOnly(logger, string.Format(message, args)); + public void DebugOnly(string message, params object[] args) + { + if (System.Config?.General?.DebugEnabled == true) + { + Services.Logger.Debug(message, args); + } + } } } \ No newline at end of file diff --git a/AetherBags/Helpers/InventoryMoveHelper.cs b/AetherBags/Helpers/InventoryMoveHelper.cs index 4523303..c73143c 100644 --- a/AetherBags/Helpers/InventoryMoveHelper.cs +++ b/AetherBags/Helpers/InventoryMoveHelper.cs @@ -1,7 +1,5 @@ -using System; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; diff --git a/AetherBags/Helpers/Util.cs b/AetherBags/Helpers/Util.cs index 1e22183..f498e4d 100644 --- a/AetherBags/Helpers/Util.cs +++ b/AetherBags/Helpers/Util.cs @@ -87,11 +87,17 @@ public static class Util private static SystemConfiguration LoadConfig() { FileInfo file = JsonFileHelper.GetFileInfo(SystemConfiguration.FileName); - return JsonFileHelper.LoadFile(file.FullName); + var config = JsonFileHelper.LoadFile(file.FullName); + config?.EnsureInitialized(); + return config; } public static SystemConfiguration LoadConfigOrDefault() - => LoadConfig() ?? new SystemConfiguration(); + { + var config = LoadConfig() ?? new SystemConfiguration(); + config.EnsureInitialized(); + return config; + } public static SystemConfiguration ResetConfig() => new SystemConfiguration(); diff --git a/AetherBags/IPC/IPCService.cs b/AetherBags/IPC/IPCService.cs index 38bc733..a35d3a6 100644 --- a/AetherBags/IPC/IPCService.cs +++ b/AetherBags/IPC/IPCService.cs @@ -1,6 +1,4 @@ using System; -using Dalamud.Plugin; -using Dalamud.Plugin.Ipc; namespace AetherBags.IPC; diff --git a/AetherBags/Inventory/InventoryOrchestrator.cs b/AetherBags/Inventory/InventoryOrchestrator.cs index 36c3632..4d52dd2 100644 --- a/AetherBags/Inventory/InventoryOrchestrator.cs +++ b/AetherBags/Inventory/InventoryOrchestrator.cs @@ -8,30 +8,45 @@ namespace AetherBags.Inventory; public static unsafe class InventoryOrchestrator { private static readonly InventoryNotificationState NotificationState = new(); + private static bool _isRefreshing; public static void RefreshAll(bool updateMaps = true) { - if (updateMaps) + if (_isRefreshing) + return; + + try { - InventoryContextState.RefreshMaps(); - InventoryContextState.RefreshBlockedSlots(); - } + _isRefreshing = true; - var agent = AgentInventory.Instance(); - var contextId = agent != null ? agent->OpenTitleId : 0; - var notification = NotificationState.GetNotificationInfo(contextId); - - Services.Framework.RunOnTick(() => - { - if (System.AddonInventoryWindow.IsOpen) - System.AddonInventoryWindow.SetNotification(notification!); - - foreach (var window in GetAllWindows()) + if (updateMaps) { - if (window.IsOpen) - window.ManualRefresh(); + InventoryContextState.RefreshMaps(); + InventoryContextState.RefreshBlockedSlots(); } - }); + + if (!HasAnyWindowOpen()) + return; + + var agent = AgentInventory.Instance(); + var contextId = agent != null ? agent->OpenTitleId : 0; + var notification = NotificationState.GetNotificationInfo(contextId); + + Services.Framework.RunOnTick(() => + { + if (notification != null && System.AddonInventoryWindow.IsOpen) + System.AddonInventoryWindow.SetNotification(notification); + + foreach (var window in GetAllWindows()) + { + window.ManualRefresh(); + } + }); + } + finally + { + _isRefreshing = false; + } } public static void CloseAll() @@ -44,6 +59,9 @@ public static unsafe class InventoryOrchestrator public static void RefreshHighlights() { + if (!HasAnyWindowOpen()) + return; + Services.Framework.RunOnTick(() => { foreach (var window in GetAllWindows()) @@ -53,10 +71,23 @@ public static unsafe class InventoryOrchestrator }); } + private static bool HasAnyWindowOpen() + { + foreach (var window in GetAllWindows()) + { + if (window.IsOpen) + return true; + } + return false; + } + private static IEnumerable GetAllWindows() { - yield return System.AddonInventoryWindow; - yield return System.AddonSaddleBagWindow; - yield return System.AddonRetainerWindow; + if (System.AddonInventoryWindow != null) + yield return System.AddonInventoryWindow; + if (System.AddonSaddleBagWindow != null) + yield return System.AddonSaddleBagWindow; + if (System.AddonRetainerWindow != null) + yield return System.AddonRetainerWindow; } } \ No newline at end of file diff --git a/AetherBags/Inventory/Items/InventoryStats.cs b/AetherBags/Inventory/Items/InventoryStats.cs index c9d15b5..d43c9a4 100644 --- a/AetherBags/Inventory/Items/InventoryStats.cs +++ b/AetherBags/Inventory/Items/InventoryStats.cs @@ -9,4 +9,13 @@ public readonly struct InventoryStats public int CategoryCount { get; init; } public int UsedSlots => TotalSlots - EmptySlots; public float UsagePercent => TotalSlots > 0 ? (float)UsedSlots / TotalSlots * 100f : 0f; + + public static InventoryStats operator +(InventoryStats a, InventoryStats b) => new() + { + TotalItems = a.TotalItems + b.TotalItems, + TotalQuantity = a.TotalQuantity + b.TotalQuantity, + EmptySlots = a.EmptySlots + b.EmptySlots, + TotalSlots = a.TotalSlots + b.TotalSlots, + CategoryCount = a.CategoryCount + b.CategoryCount, + }; } \ No newline at end of file diff --git a/AetherBags/Inventory/LootedItemsTracker.cs b/AetherBags/Inventory/LootedItemsTracker.cs new file mode 100644 index 0000000..08fe6c4 --- /dev/null +++ b/AetherBags/Inventory/LootedItemsTracker.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AetherBags.Inventory.Items; +using AetherBags.Inventory.Scanning; +using Dalamud.Game.Inventory.InventoryEventArgTypes; +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace AetherBags.Inventory; + +public sealed unsafe class LootedItemsTracker : IDisposable +{ + private static IReadOnlyList StandardInventories => InventoryScanner.StandardInventories; + + private readonly List _lootedItems = new(capacity: 64); + private bool _isEnabled; + + public event Action>? OnLootedItemsChanged; + + public IReadOnlyList LootedItems => _lootedItems; + + public void Enable() + { + if (_isEnabled) return; + + _isEnabled = true; + _lootedItems.Clear(); + Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw; + } + + public void Disable() + { + if (!_isEnabled) return; + + _isEnabled = false; + Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw; + _lootedItems.Clear(); + } + + public void Clear() + { + _lootedItems.Clear(); + OnLootedItemsChanged?.Invoke(_lootedItems); + } + + public void RemoveByIndex(int index) + { + for (int i = 0; i < _lootedItems.Count; i++) + { + if (_lootedItems[i].Index == index) + { + _lootedItems.RemoveAt(i); + OnLootedItemsChanged?.Invoke(_lootedItems); + return; + } + } + } + + public void Dispose() + { + Disable(); + } + + private void OnInventoryChangedRaw(IReadOnlyCollection events) + { + if (!_isEnabled) return; + if (!Services.ClientState.IsLoggedIn) return; + + bool hasChanges = false; + + foreach (var eventData in events) + { + if (!StandardInventories.Contains((InventoryType)eventData.Item.ContainerType)) continue; + + if (eventData is not (InventoryItemAddedArgs or InventoryItemChangedArgs)) continue; + + if (eventData is InventoryItemChangedArgs changedArgs && + changedArgs.OldItemState.Quantity >= changedArgs.Item.Quantity) + { + continue; + } + + var inventoryItem = (InventoryItem*)eventData.Item.Address; + var changeAmount = eventData is InventoryItemChangedArgs changed + ? changed.Item.Quantity - changed.OldItemState.Quantity + : eventData.Item.Quantity; + + _lootedItems.Add(new LootedItemInfo( + _lootedItems.Count, + *inventoryItem, + changeAmount)); + + hasChanges = true; + } + + if (hasChanges) + { + OnLootedItemsChanged?.Invoke(_lootedItems); + } + } +} diff --git a/AetherBags/Inventory/Scanning/InventoryScanner.cs b/AetherBags/Inventory/Scanning/InventoryScanner.cs index 286d698..4053f27 100644 --- a/AetherBags/Inventory/Scanning/InventoryScanner.cs +++ b/AetherBags/Inventory/Scanning/InventoryScanner.cs @@ -180,19 +180,21 @@ public static unsafe class InventoryScanner return InventoryLocation.Invalid; } + public static int GetEmptySlots(InventorySourceType source) => (int)(source switch + { + InventorySourceType.MainBags => InventoryManager.Instance()->GetEmptySlotsInBag(), + InventorySourceType.SaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.SaddleBag), + InventorySourceType.PremiumSaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.PremiumSaddleBag), + InventorySourceType.AllSaddleBags => GetEmptySlotsInContainer(InventorySourceDefinitions.AllSaddleBags), + InventorySourceType.Retainer => GetEmptySlotsInContainer(InventorySourceDefinitions.Retainer), + _ => 0u, + }); + public static string GetEmptySlotsString(InventorySourceType source) { int total = InventorySourceDefinitions.GetTotalSlots(source); - uint empty = source switch - { - InventorySourceType.MainBags => InventoryManager.Instance()->GetEmptySlotsInBag(), - InventorySourceType.SaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.SaddleBag), - InventorySourceType.PremiumSaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.PremiumSaddleBag), - InventorySourceType.AllSaddleBags => GetEmptySlotsInContainer(InventorySourceDefinitions.AllSaddleBags), - InventorySourceType.Retainer => GetEmptySlotsInContainer(InventorySourceDefinitions.Retainer), - _ => 0, - }; - uint used = (uint)total - empty; + int empty = GetEmptySlots(source); + int used = total - empty; return $"{used}/{total}"; } diff --git a/AetherBags/Inventory/State/InventoryState.cs b/AetherBags/Inventory/State/InventoryState.cs deleted file mode 100644 index 17bdebc..0000000 --- a/AetherBags/Inventory/State/InventoryState.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using AetherBags.Configuration; -using AetherBags.Currency; -using AetherBags.Inventory.Categories; -using AetherBags.Inventory.Items; -using AetherBags.Inventory.Scanning; -using Dalamud.Game.Inventory; -using Dalamud.Game.Inventory.InventoryEventArgTypes; -using FFXIVClientStructs.FFXIV.Client.Game; - -namespace AetherBags.Inventory.State; - -public static unsafe class InventoryState -{ - private static IReadOnlyList StandardInventories => InventoryScanner.StandardInventories; - - private static readonly Dictionary AggByKey = new(capacity: 512); - private static readonly Dictionary ItemInfoByKey = new(capacity: 512); - private static readonly Dictionary BucketsByKey = new(capacity: 256); - private static readonly List SortedCategoryKeys = new(capacity: 256); - private static readonly List AllCategories = new(capacity: 256); - private static readonly List FilteredCategories = new(capacity: 256); - private static readonly List UserCategoriesSortedScratch = new(capacity: 64); - private static readonly List RemoveKeysScratch = new(capacity: 256); - private static readonly HashSet ClaimedKeys = new(capacity: 512); - private static readonly List? LootedItems = new(capacity: 512); - - public static bool TrackLootedItems = false; - - public static bool Contains(this IReadOnlyCollection inventoryTypes, GameInventoryType type) - => inventoryTypes.Contains((InventoryType)type); - - public static IReadOnlyList GetInventoryItemCategories(string filterString = "", bool invert = false) - { - return InventoryFilter.FilterCategories( - AllCategories, - BucketsByKey, - FilteredCategories, - filterString, - invert); - } - - public static InventoryStats GetInventoryStats() - { - int totalItems = ItemInfoByKey.Count; - int totalQuantity = 0; - - foreach (var kvp in ItemInfoByKey) - { - totalQuantity += kvp.Value.ItemCount; - } - - uint emptySlots = FFXIVClientStructs.FFXIV.Client.Game.InventoryManager.Instance()->GetEmptySlotsInBag(); - const int totalSlots = 140; - - var categories = GetInventoryItemCategories(string.Empty); - int categoryCount = categories.Count; - - return new InventoryStats - { - TotalItems = totalItems, - TotalQuantity = totalQuantity, - EmptySlots = (int)emptySlots, - TotalSlots = totalSlots, - CategoryCount = categoryCount, - }; - } - - public static IReadOnlyList GetCurrencyInfoList(uint[] currencyIds) - => CurrencyState.GetCurrencyInfoList(currencyIds); - - public static void InvalidateCurrencyCaches() - => CurrencyState.InvalidateCaches(); - - public static InventoryContainer* GetInventoryContainer(InventoryType inventoryType) - => InventoryScanner.GetInventoryContainer(inventoryType); - - internal static void OnRawItemAdded(IReadOnlyCollection events) - { - if (!TrackLootedItems) return; - - bool updateRequested = false; - - foreach (var eventData in events) - { - if (!StandardInventories.Contains(eventData.Item.ContainerType)) continue; - - if (!Services.ClientState.IsLoggedIn) return; - if (eventData is not (InventoryItemAddedArgs or InventoryItemChangedArgs)) return; - if (eventData is InventoryItemChangedArgs changedArgs && changedArgs.OldItemState.Quantity >= changedArgs.Item.Quantity) return; - - var inventoryItem = (InventoryItem*)eventData.Item.Address; - var changeAmount = eventData is InventoryItemChangedArgs changed ? changed.Item.Quantity - changed.OldItemState.Quantity : eventData.Item.Quantity; - - LootedItems?.Add(new LootedItemInfo( - LootedItems.Count, - *inventoryItem, - changeAmount) - ); - - updateRequested = true; - } - - if (updateRequested) - { - // System.AddonInventoryWindow?.UpdateLootedCategory(LootedItems ?? []); - } - } - - private static void ClearAll() - { - AggByKey.Clear(); - ItemInfoByKey.Clear(); - - foreach (var kvp in BucketsByKey) - { - kvp.Value.Items.Clear(); - kvp.Value.FilteredItems.Clear(); - kvp.Value.Used = false; - } - - SortedCategoryKeys.Clear(); - AllCategories.Clear(); - FilteredCategories.Clear(); - RemoveKeysScratch.Clear(); - ClaimedKeys.Clear(); - LootedItems?.Clear(); - } -} \ No newline at end of file diff --git a/AetherBags/Inventory/State/InventoryStateBase.cs b/AetherBags/Inventory/State/InventoryStateBase.cs index a3309c3..813fe26 100644 --- a/AetherBags/Inventory/State/InventoryStateBase.cs +++ b/AetherBags/Inventory/State/InventoryStateBase.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using AetherBags.Configuration; +using AetherBags.Currency; using AetherBags.Inventory.Categories; using AetherBags.Inventory.Context; using AetherBags.Inventory.Items; @@ -165,6 +166,38 @@ public abstract class InventoryStateBase public string GetEmptySlotsString() => InventoryScanner.GetEmptySlotsString(SourceType); + public InventoryStats GetStats() + { + int totalItems = ItemInfoByKey.Count; + int totalQuantity = 0; + + foreach (var kvp in ItemInfoByKey) + { + totalQuantity += kvp.Value.ItemCount; + } + + int totalSlots = InventorySourceDefinitions.GetTotalSlots(SourceType); + int emptySlots = InventoryScanner.GetEmptySlots(SourceType); + + var categories = GetCategories(string.Empty); + int categoryCount = categories.Count; + + return new InventoryStats + { + TotalItems = totalItems, + TotalQuantity = totalQuantity, + EmptySlots = emptySlots, + TotalSlots = totalSlots, + CategoryCount = categoryCount, + }; + } + + public static IReadOnlyList GetCurrencyInfoList(uint[] currencyIds) + => CurrencyState.GetCurrencyInfoList(currencyIds); + + public static void InvalidateCurrencyCaches() + => CurrencyState.InvalidateCaches(); + protected virtual void ClearAll() { AggByKey.Clear(); diff --git a/AetherBags/Inventory/State/RetainerState.cs b/AetherBags/Inventory/State/RetainerState.cs index eabe183..d557ae1 100644 --- a/AetherBags/Inventory/State/RetainerState.cs +++ b/AetherBags/Inventory/State/RetainerState.cs @@ -1,6 +1,5 @@ using AetherBags. Inventory.Scanning; using FFXIVClientStructs.FFXIV.Client.Game; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; namespace AetherBags. Inventory.State; diff --git a/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs index 3296573..b3b7821 100644 --- a/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs +++ b/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs @@ -1,6 +1,5 @@ using System; using AetherBags.Addons; -using KamiToolKit.Nodes; using KamiToolKit.Premade.Nodes; namespace AetherBags.Nodes.Configuration.Category; diff --git a/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs index 50f1eb8..c90a04f 100644 --- a/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs +++ b/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs @@ -3,15 +3,12 @@ using System.Collections.Generic; using System.Numerics; using AetherBags.Configuration; using AetherBags.Inventory; -using AetherBags.Inventory.Categories; using AetherBags.Nodes.Color; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Component.GUI; -using KamiToolKit.Classes; using KamiToolKit.Nodes; using Lumina.Excel; using Lumina.Excel.Sheets; -using Lumina.Text; using Action = System.Action; namespace AetherBags.Nodes.Configuration.Category; diff --git a/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs b/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs index a8a82fb..ed9ab36 100644 --- a/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs +++ b/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using AetherBags.Helpers; using AetherBags.Inventory; diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs b/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs index 44cf1ed..309ead0 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs @@ -1,4 +1,4 @@ -using AetherBags.Nodes.Layout; +using AetherBags.Nodes.Layout; namespace AetherBags.Nodes.Inventory; @@ -6,76 +6,94 @@ public sealed class InventoryCategoryHoverCoordinator { private InventoryCategoryNode? _active; private int _activeRowIndex = -1; + private bool _isProcessing; public void OnCategoryHoverChanged( - WrappingGridNode grid, + WrappingGridNode grid, InventoryCategoryNode source, bool hovering) { - grid.RecalculateLayout(); + if (_isProcessing) + return; - if (hovering) + try { - _active = source; + _isProcessing = true; + grid.RecalculateLayout(); - if (!grid.TryGetRowIndex(source, out _activeRowIndex)) + if (hovering) { - SuppressAllExcept(grid, source); + _active = source; + + if (!grid.TryGetRowIndex(source, out _activeRowIndex)) + { + SuppressAllExcept(grid, source); + source.SetHeaderSuppressed(false); + return; + } + + ClearAll(grid); + + var row = grid.Rows[_activeRowIndex]; + for (int i = 0; i < row.Count; i++) + { + if (row[i] is InventoryCategoryNode cat && !ReferenceEquals(cat, source)) + cat.SetHeaderSuppressed(true); + } + source.SetHeaderSuppressed(false); return; } - ClearAll(grid); + if (!ReferenceEquals(_active, source)) + return; - var row = grid.Rows[_activeRowIndex]; - for (int i = 0; i < row.Count; i++) + _active = null; + + if (_activeRowIndex >= 0 && _activeRowIndex < grid.Rows.Count) { - if (row[i] is InventoryCategoryNode cat && !ReferenceEquals(cat, source)) - cat.SetHeaderSuppressed(true); + var row = grid.Rows[_activeRowIndex]; + for (int i = 0; i < row.Count; i++) + { + if (row[i] is InventoryCategoryNode cat) + cat.SetHeaderSuppressed(false); + } + } + else + { + ClearAll(grid); } - source.SetHeaderSuppressed(false); - return; + _activeRowIndex = -1; } - - if (!ReferenceEquals(_active, source)) - return; - - _active = null; - - if (_activeRowIndex >= 0 && _activeRowIndex < grid.Rows.Count) + finally { - var row = grid.Rows[_activeRowIndex]; - for (int i = 0; i < row.Count; i++) - { - if (row[i] is InventoryCategoryNode cat) - cat.SetHeaderSuppressed(false); - } + _isProcessing = false; } - else - { - ClearAll(grid); - } - - _activeRowIndex = -1; } - public void ResetAll(WrappingGridNode grid) + public void ResetAll(WrappingGridNode grid) { _active = null; _activeRowIndex = -1; ClearAll(grid); } - private static void ClearAll(WrappingGridNode grid) + private static void ClearAll(WrappingGridNode grid) { - foreach (var cat in grid.GetNodes()) - cat.SetHeaderSuppressed(false); + foreach (var node in grid.GetNodes()) + { + if (node is InventoryCategoryNode cat) + cat.SetHeaderSuppressed(false); + } } - private static void SuppressAllExcept(WrappingGridNode grid, InventoryCategoryNode source) + private static void SuppressAllExcept(WrappingGridNode grid, InventoryCategoryNode source) { - foreach (var cat in grid.GetNodes()) - cat.SetHeaderSuppressed(!ReferenceEquals(cat, source)); + foreach (var node in grid.GetNodes()) + { + if (node is InventoryCategoryNode cat) + cat.SetHeaderSuppressed(!ReferenceEquals(cat, source)); + } } } diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs index bf4cef5..09e2501 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs @@ -1,7 +1,6 @@ using System; using System.Numerics; using AetherBags.Helpers; -using AetherBags.Hooks; using AetherBags.Inventory; using AetherBags.Inventory.Categories; using AetherBags.Inventory.Items; @@ -9,15 +8,17 @@ using AetherBags.Nodes.Layout; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI.Agent; -using FFXIVClientStructs.FFXIV.Client.UI.Misc; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; using KamiToolKit.Nodes; namespace AetherBags.Nodes.Inventory; -public class InventoryCategoryNode : SimpleComponentNode +public class InventoryCategoryNode : InventoryCategoryNodeBase { + private const uint CategoryNodeKeyBase = 0x10000000; + + public override uint Key => CategoryNodeKeyBase | CategorizedInventory.Key; private readonly TextNode _categoryNameTextNode; private readonly HybridDirectionalFlexNode _itemGridNode; @@ -109,7 +110,7 @@ public class InventoryCategoryNode : SimpleComponentNode } } - public bool IsPinnedInConfig => CategorizedInventory.Category?.IsPinned ?? false; + public override bool IsPinnedInConfig => CategorizedInventory.Category?.IsPinned ?? false; public void BeginHeaderHover() { diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs new file mode 100644 index 0000000..b295c9e --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs @@ -0,0 +1,20 @@ +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +/// +/// Base class for category-like nodes that can be displayed in the inventory grid. +/// Used to allow both regular categories and special categories (like looted items) to be hoisted/pinned. +/// +public abstract class InventoryCategoryNodeBase : SimpleComponentNode +{ + /// + /// Unique key for this category, used for sync operations. + /// + public abstract uint Key { get; } + + /// + /// Whether this category should be pinned in the layout. + /// + public virtual bool IsPinnedInConfig => false; +} diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs b/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs index 8683104..17f3ddd 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs @@ -4,13 +4,13 @@ namespace AetherBags.Nodes.Inventory; public sealed class InventoryCategoryPinCoordinator { - public bool ApplyPinnedStates(WrappingGridNode grid) + public bool ApplyPinnedStates(WrappingGridNode grid) { bool changed = false; using (grid.DeferRecalculateLayout()) { - foreach (var node in grid.GetNodes()) + foreach (var node in grid.GetNodes()) { bool shouldBePinned = node.IsPinnedInConfig; @@ -38,7 +38,7 @@ public sealed class InventoryCategoryPinCoordinator return changed; } - public bool PrunePinnedNotInGrid(WrappingGridNode grid) + public bool PrunePinnedNotInGrid(WrappingGridNode grid) { return false; } diff --git a/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs b/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs index 285e68e..c14a1fd 100644 --- a/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs @@ -1,5 +1,4 @@ using System.Numerics; -using AetherBags.Inventory; using AetherBags.Inventory.Items; using Dalamud.Game.ClientState.Keys; using FFXIVClientStructs.FFXIV.Client.Game; diff --git a/AetherBags/Nodes/Inventory/InventoryFooterNode.cs b/AetherBags/Nodes/Inventory/InventoryFooterNode.cs index 48024e3..f05aa2f 100644 --- a/AetherBags/Nodes/Inventory/InventoryFooterNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryFooterNode.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; using System.Numerics; using AetherBags.Currency; -using AetherBags.Inventory; -using AetherBags.Inventory.State; using AetherBags.Nodes.Currency; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; using KamiToolKit.Nodes; +using static AetherBags.Inventory.State.InventoryStateBase; + namespace AetherBags.Nodes.Inventory; public sealed class InventoryFooterNode : SimpleComponentNode @@ -44,7 +44,7 @@ public sealed class InventoryFooterNode : SimpleComponentNode { _currencyListNode.IsVisible = System.Config.Currency.Enabled; - IReadOnlyList currencyInfoList = InventoryState.GetCurrencyInfoList([1, 28, 0xFFFF_FFFE, 0xFFFF_FFFD]); + IReadOnlyList currencyInfoList = GetCurrencyInfoList([1, 28, 0xFFFF_FFFE, 0xFFFF_FFFD]); _currencyListNode.SyncWithListDataByKey( dataList: currencyInfoList, getKeyFromData: currencyInfo => currencyInfo.ItemId, diff --git a/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs b/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs index 550dc72..8b26f29 100644 --- a/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs @@ -1,5 +1,4 @@ using System.Numerics; -using AetherBags.Inventory; using AetherBags.Inventory.Context; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; diff --git a/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs new file mode 100644 index 0000000..b9a7a80 --- /dev/null +++ b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs @@ -0,0 +1,84 @@ +using System; +using System.Numerics; +using AetherBags.Inventory.Items; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +/// +/// A display-only item node for looted items. Not draggable, but shows tooltip and can be dismissed. +/// +public unsafe class LootedItemDisplayNode : SimpleComponentNode +{ + private readonly IconNode _iconNode; + private readonly TextNode _quantityTextNode; + private readonly ResNode _collisionNode; + + public Action? OnDismiss { get; set; } + + public LootedItemDisplayNode() + { + Size = new Vector2(42, 46); + + _iconNode = new IconNode + { + Position = new Vector2(0, 0), + Size = new Vector2(42, 46), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled, + }; + _iconNode.AttachNode(this); + + _quantityTextNode = new TextNode + { + Size = new Vector2(40.0f, 12.0f), + Position = new Vector2(4.0f, 34.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + Color = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(51), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Right, + }; + _quantityTextNode.AttachNode(this); + + _collisionNode = new ResNode + { + Size = new Vector2(42, 46), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents | NodeFlags.HasCollision, + }; + _collisionNode.AddEvent(AtkEventType.MouseOver, OnMouseOver); + _collisionNode.AddEvent(AtkEventType.MouseOut, OnMouseOut); + _collisionNode.AddEvent(AtkEventType.MouseClick, OnMouseClick); + _collisionNode.AttachNode(this); + } + + public LootedItemInfo LootedItem { get; private set; } = null!; + + public void SetLootedItem(LootedItemInfo lootedItem) + { + LootedItem = lootedItem; + var item = lootedItem.Item; + _iconNode.IconId = item.IconId; + _quantityTextNode.String = lootedItem.Quantity > 1 ? lootedItem.Quantity.ToString() : string.Empty; + } + + private void OnMouseOver(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + var item = LootedItem.Item; + _collisionNode.ShowInventoryItemTooltip(item.Container, item.Slot); + } + + private void OnMouseOut(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(_collisionNode)->Id; + AtkStage.Instance()->TooltipManager.HideTooltip(addonId); + } + + private void OnMouseClick(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + if (!atkEventData->IsLeftClick) return; + OnDismiss?.Invoke(this); + } +} diff --git a/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs new file mode 100644 index 0000000..bbe3af0 --- /dev/null +++ b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AetherBags.Inventory.Items; +using AetherBags.Nodes.Layout; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +/// +/// A special category node for displaying recently looted items. +/// Items are not draggable but can be dismissed individually or cleared entirely. +/// +public class LootedItemsCategoryNode : InventoryCategoryNodeBase +{ + private const uint LootedCategoryKey = 0x20000001; + + public override uint Key => LootedCategoryKey; + private readonly TextNode _headerTextNode; + private readonly CircleButtonNode _clearButton; + private readonly HybridDirectionalFlexNode _itemGridNode; + + private const float HeaderHeight = 20; + private const float ClearButtonSize = 20; + private const float MinWidth = 100; + + private IReadOnlyList _lootedItems = Array.Empty(); + + private int _hoverRefs; + private bool _headerExpanded; + private float _baseHeaderWidth = 96f; + private string _fullHeaderText = "Recently Looted"; + + public event Action? HeaderHoverChanged; + public Action? OnDismissItem { get; set; } + public Action? OnClearAll { get; set; } + + public int ItemsPerLine + { + get => _itemGridNode.ItemsPerLine; + set + { + if (_itemGridNode.ItemsPerLine == value) return; + _itemGridNode.ItemsPerLine = value; + RecalculateSize(); + } + } + + public bool HasItems => _lootedItems.Count > 0; + + public LootedItemsCategoryNode() + { + _headerTextNode = new TextNode + { + Position = Vector2.Zero, + Size = new Vector2(96, HeaderHeight), + AlignmentType = AlignmentType.Left, + String = "Recently Looted", + TextFlags = TextFlags.OverflowHidden | TextFlags.Ellipsis, + TextColor = new Vector4(0.9f, 0.8f, 0.5f, 1.0f), // Gold-ish color + }; + + _headerTextNode.AddEvent(AtkEventType.MouseOver, BeginHeaderHover); + _headerTextNode.AddEvent(AtkEventType.MouseOut, EndHeaderHover); + + _headerTextNode.TextFlags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; + _headerTextNode.TextFlags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); + + _headerTextNode.AddFlags(NodeFlags.EmitsEvents | NodeFlags.HasCollision); + _headerTextNode.AttachNode(this); + + _clearButton = new CircleButtonNode + { + Size = new Vector2(ClearButtonSize), + Icon = ButtonIcon.CrossSmall, + OnClick = () => OnClearAll?.Invoke(), + }; + _clearButton.AttachNode(this); + + _itemGridNode = new HybridDirectionalFlexNode + { + Position = new Vector2(0, HeaderHeight), + Size = new Vector2(240, 92), + FillRowsFirst = true, + ItemsPerLine = 10, + HorizontalPadding = 5, + VerticalPadding = 2, + }; + _itemGridNode.NodeFlags |= NodeFlags.EmitsEvents; + _itemGridNode.AttachNode(this); + + RecalculateSize(); + } + + public void UpdateLootedItems(IReadOnlyList lootedItems) + { + _lootedItems = lootedItems; + UpdateHeaderText(); + SyncItemGrid(); + RecalculateSize(); + } + + private void UpdateHeaderText() + { + _fullHeaderText = _lootedItems.Count > 0 + ? $"Recently Looted ({_lootedItems.Count})" + : "Recently Looted"; + + _headerTextNode.String = _fullHeaderText; + } + + public void BeginHeaderHover() + { + _hoverRefs++; + if (_hoverRefs != 1) return; + + _headerExpanded = true; + ApplyHeaderVisualStateAndSize(); + HeaderHoverChanged?.Invoke(this, true); + } + + public void EndHeaderHover() + { + if (_hoverRefs <= 0) return; + + _hoverRefs--; + if (_hoverRefs != 0) return; + + _headerExpanded = false; + ApplyHeaderVisualStateAndSize(); + HeaderHoverChanged?.Invoke(this, false); + } + + private void ApplyHeaderVisualStateAndSize() + { + var flags = _headerTextNode.TextFlags; + flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); + + if (_headerExpanded) + { + flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis); + _headerTextNode.TextFlags = flags; + + if (!string.IsNullOrEmpty(_fullHeaderText)) + _headerTextNode.String = _fullHeaderText; + + Vector2 drawSize = _headerTextNode.GetTextDrawSize(); + float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f); + _headerTextNode.Size = _headerTextNode.Size with { X = expandedWidth }; + } + else + { + _headerTextNode.Size = _headerTextNode.Size with { X = _baseHeaderWidth }; + + if (!string.IsNullOrEmpty(_fullHeaderText)) + _headerTextNode.String = _fullHeaderText; + + flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; + _headerTextNode.TextFlags = flags; + } + } + + private void SyncItemGrid() + { + _itemGridNode.SyncWithListData( + _lootedItems, + node => node.LootedItem, + CreateLootedItemNode); + } + + private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem) + { + var node = new LootedItemDisplayNode + { + OnDismiss = OnItemDismissed, + }; + node.SetLootedItem(lootedItem); + return node; + } + + private void OnItemDismissed(LootedItemDisplayNode node) + { + int index = node.LootedItem.Index; + OnDismissItem?.Invoke(index); + } + + private void RecalculateSize() + { + int itemCount = _lootedItems.Count; + + if (itemCount == 0) + { + float width = MinWidth; + Size = new Vector2(width, HeaderHeight); + _baseHeaderWidth = width - ClearButtonSize - 4; + _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); + _clearButton.Position = new Vector2(width - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); + _clearButton.IsVisible = false; + _itemGridNode.Position = new Vector2(0, HeaderHeight); + _itemGridNode.Size = new Vector2(width, 0); + ApplyHeaderVisualStateAndSize(); + return; + } + + int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); + 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); + float gridHeight = rows * cellH + (rows - 1) * vPad; + float totalHeight = HeaderHeight + gridHeight; + + Size = new Vector2(calculatedWidth, totalHeight); + _baseHeaderWidth = calculatedWidth - ClearButtonSize - 4; + _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); + _clearButton.Position = new Vector2(calculatedWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); + _clearButton.IsVisible = true; + _itemGridNode.Position = new Vector2(0, HeaderHeight); + _itemGridNode.Size = new Vector2(calculatedWidth, gridHeight); + ApplyHeaderVisualStateAndSize(); + } +} diff --git a/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs b/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs index 1080390..e3f078e 100644 --- a/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs +++ b/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs @@ -1,6 +1,5 @@ using System. Numerics; using FFXIVClientStructs.FFXIV.Component.GUI; -using KamiToolKit.Classes; using KamiToolKit.Nodes; namespace AetherBags.Nodes.Inventory; diff --git a/AetherBags/Plugin.cs b/AetherBags/Plugin.cs index 5199378..b10ce2e 100644 --- a/AetherBags/Plugin.cs +++ b/AetherBags/Plugin.cs @@ -6,16 +6,13 @@ using AetherBags.Helpers; using AetherBags.Hooks; using AetherBags.Inventory; using AetherBags.Inventory.Context; -using AetherBags.Inventory.State; using AetherBags.IPC; -using Dalamud.Game.Gui; using Dalamud.Plugin; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; using KamiToolKit; namespace AetherBags; -public unsafe class Plugin : IDalamudPlugin +public class Plugin : IDalamudPlugin { private readonly CommandHandler _commandHandler; private readonly InventoryHooks _inventoryHooks; @@ -32,6 +29,7 @@ public unsafe class Plugin : IDalamudPlugin KamiToolKitLibrary.Initialize(pluginInterface); System.IPC = new IPCService(); + System.LootedItemsTracker = new LootedItemsTracker(); System.AddonInventoryWindow = new AddonInventoryWindow { @@ -83,6 +81,7 @@ public unsafe class Plugin : IDalamudPlugin _inventoryHooks.Dispose(); _inventoryLifecycles.Dispose(); + System.LootedItemsTracker.Dispose(); System.IPC.Dispose(); HighlightState.ClearAll(); @@ -98,7 +97,7 @@ public unsafe class Plugin : IDalamudPlugin private void OnLogin() { System.Config = Util.LoadConfigOrDefault(); - InventoryState.TrackLootedItems = true; + System.LootedItemsTracker.Enable(); #if DEBUG System.AddonInventoryWindow.Toggle(); @@ -109,7 +108,7 @@ public unsafe class Plugin : IDalamudPlugin private void OnLogout(int type, int code) { Util.SaveConfig(System.Config); - InventoryState.TrackLootedItems = false; + System.LootedItemsTracker.Disable(); System.AddonInventoryWindow.Close(); System.AddonSaddleBagWindow.Close(); System.AddonRetainerWindow.Close(); diff --git a/AetherBags/System.cs b/AetherBags/System.cs index 0821faa..a25528a 100644 --- a/AetherBags/System.cs +++ b/AetherBags/System.cs @@ -1,5 +1,6 @@ using AetherBags.Addons; using AetherBags.Configuration; +using AetherBags.Inventory; using AetherBags.IPC; namespace AetherBags; @@ -12,4 +13,5 @@ public static class System public static AddonConfigurationWindow AddonConfigurationWindow { get; set; } = null!; public static IPCService IPC { get; set; } = null!; public static SystemConfiguration Config { get; set; } = null!; + public static LootedItemsTracker LootedItemsTracker { get; set; } = null!; } \ No newline at end of file