Files
AetherBags/AetherBags/Addons/InventoryAddonBase.cs
T
2026-02-20 22:18:15 +01:00

713 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Configuration;
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.Monitoring;
using AetherBags.Nodes.Input;
using AetherBags.Nodes.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit;
using KamiToolKit.Classes;
using KamiToolKit.ContextMenu;
using KamiToolKit.Nodes;
namespace AetherBags.Addons;
public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
{
protected readonly InventoryCategoryHoverCoordinator HoverCoordinator = new();
protected readonly InventoryCategoryPinCoordinator PinCoordinator = new();
protected readonly HashSet<InventoryCategoryNode> HoverSubscribed = new();
protected DragDropNode BackgroundDropTarget = null!;
protected InventoryScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>> ScrollableCategories = null!;
protected WrappingGridNode<InventoryCategoryNodeBase> CategoriesNode = null!;
protected TextInputWithButtonNode SearchInputNode = null!;
protected InventoryFooterNode FooterNode = null!;
protected TextNode? SlotCounterNode { get; set; }
protected CircleButtonNode SettingsButtonNode = null!;
internal ContextMenu ContextMenu = null!;
protected readonly SharedNodePool<InventoryDragDropNode> SharedItemNodePool = new(
maxSize: 256,
factory: null,
resetAction: node => node.ResetForReuse());
protected readonly SharedNodePool<InventoryCategoryNode> 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;
protected virtual float MaxWindowHeight => 1000;
protected const float CategorySpacing = 12;
protected const float ItemSize = 42;
protected const float ItemPadding = 5;
protected const float FooterHeight = 28f;
protected const float FooterTopSpacing = 4f;
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 = 50;
protected abstract InventoryStateBase InventoryState { get; }
protected virtual bool HasFooter => true;
protected virtual bool HasPinning => true;
protected virtual bool HasSlotCounter => false;
private readonly HashSet<uint> _searchMatchScratch = new();
private bool _isRefreshing;
private string _lastSearchText = string.Empty;
private int _requestedUpdateCount;
private int _refreshFromLifecycleCount;
private long _lastLogTick;
public void ManualRefresh() => ExecuteRefresh(true);
public string GetSearchText() => SearchInputNode?.SearchString.ExtractText() ?? string.Empty;
public InventoryStats GetStats() => InventoryState.GetStats();
public IReadOnlyList<CategorizedInventory>? GetVisibleCategories()
{
if (!IsSetupComplete) return null;
string filter = GetSearchText();
return InventoryState.GetCategories(filter);
}
public virtual void SetSearchText(string searchText)
{
Services.Framework.RunOnTick(() =>
{
if (IsOpen) SearchInputNode.SearchString = searchText;
RefreshCategoriesCore(autosize: true);
}, delayTicks: 3);
}
private void ExecuteRefresh(bool autosize)
{
if (!IsSetupComplete || !IsOpen || _isRefreshing) return;
try
{
_isRefreshing = true;
InventoryState.RefreshFromGame();
System.LootedItemsTracker.FlushPendingChanges();
RefreshCategoriesCore(autosize);
}
finally
{
_isRefreshing = false;
}
}
public void RefreshFromLifecycle() => ExecuteRefresh(autosize: true);
protected virtual void RefreshCategoriesCore(bool autosize)
{
if (!IsSetupComplete)
return;
var config = System.Config.General;
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();
var allData = InventoryState.GetCategories(string.Empty);
for (int i = 0; i < allData.Count; i++)
{
var cat = allData[i];
for (int j = 0; j < cat.Items.Count; j++)
{
var item = cat.Items[j];
if (item.IsRegexMatch(searchText))
{
_searchMatchScratch.Add(item.Item.ItemId);
}
}
}
HighlightState.SetFilter(HighlightSource.Search, _searchMatchScratch);
}
else
{
HighlightState.ClearFilter(HighlightSource.Search);
}
if (SearchInputNode != null)
{
bool atActive = !string.IsNullOrEmpty(HighlightState.SelectedAllaganToolsFilterKey);
SearchInputNode.HintAddColor = (atActive)
? new Vector3(0.0f, 0.3f, 0.3f)
: Vector3.Zero;
}
if (HasFooter)
{
FooterNode.SlotAmountText = InventoryState.GetEmptySlotsString();
FooterNode.RefreshCurrencies();
}
string dataFilter = config.SearchMode == SearchMode.Filter ? searchText : string.Empty;
var categories = InventoryState.GetCategories(dataFilter);
float maxContentWidth = CategoriesNode.Width > 0 ? CategoriesNode.Width : ContentSize.X;
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
bool deferItems = !_deferredPopulationInProgress && !_initialPopulationComplete;
CategoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
dataList: categories,
getKeyFromData: categorizedInventory => categorizedInventory.Key,
getKeyFromNode: node => node.CategorizedInventory.Key,
updateNode: (node, data) =>
{
node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine), deferItemCreation: deferItems);
if (!deferItems) node.RefreshNodeVisuals();
},
createNodeMethod: _ => CreateCategoryNode(),
resetNodeForReuse: ResetCategoryNodeForReuse,
externalPool: SharedCategoryNodePool);
if (HasPinning)
{
bool pinsChanged = PinCoordinator.ApplyPinnedStates(CategoriesNode);
if (pinsChanged) HoverCoordinator.ResetAll(CategoriesNode);
}
WireHoverHandlers();
CategoriesNode.InvalidateLayout();
if (autosize)
AutoSizeWindow();
else
{
LayoutContent();
CategoriesNode.RecalculateLayout();
}
if (deferItems && !_deferredPopulationInProgress)
{
StartDeferredItemPopulation();
}
else if (!deferItems && !_initialPopulationComplete)
{
_initialPopulationComplete = true;
}
System.AetherBagsAPI?.API.RaiseCategoriesRefreshed();
}
private void StartDeferredItemPopulation()
{
_deferredPopulationInProgress = true;
Services.Framework.RunOnTick(PopulateCategoryBatch, delayTicks: 1);
}
private void PopulateCategoryBatch()
{
if (!IsOpen)
{
_deferredPopulationInProgress = false;
return;
}
UpdateCategoryVisibility();
int itemsPopulated = 0;
using (CategoriesNode.DeferRecalculateLayout())
{
var nodes = CategoriesNode.Nodes;
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)
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)
break;
categoryNode.PopulateItems();
categoryNode.RefreshNodeVisuals();
itemsPopulated += categoryItemCount;
if (itemsPopulated >= ItemsPerFrame)
break;
}
}
}
bool hasMore = false;
foreach (var node in CategoriesNode.Nodes)
{
if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation)
{
hasMore = true;
break;
}
}
if (hasMore)
{
Services.Framework.RunOnTick(PopulateCategoryBatch);
}
else
{
_deferredPopulationInProgress = false;
_initialPopulationComplete = true;
}
}
protected readonly struct HeaderLayout
{
public Vector2 SearchPosition { get; init; }
public Vector2 SearchSize { get; init; }
public float HeaderWidth { get; init; }
public float HeaderY { get; init; }
}
protected HeaderLayout CalculateHeaderLayout(AtkUnitBase* addon)
{
var header = addon->WindowHeaderCollisionNode;
float headerW = header->Width;
float itemY = header->Y + (header->Height - 28f) * 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
{
SearchPosition = new Vector2(searchX, itemY),
SearchSize = new Vector2(searchWidth, 28f),
HeaderWidth = headerW,
HeaderY = itemY
};
}
protected void InitializeBackgroundDropTarget()
{
BackgroundDropTarget = new DragDropNode
{
Position = ContentStartPosition,
Size = ContentSize,
IconId = 0,
IsDraggable = false,
IsClickable = false,
AcceptedType = DragDropType.Item,
};
BackgroundDropTarget.DragDropBackgroundNode.IsVisible = false;
BackgroundDropTarget.IconNode.IsVisible = false;
BackgroundDropTarget.OnPayloadAccepted = OnBackgroundPayloadAccepted;
BackgroundDropTarget.AttachNode(this);
}
protected virtual InventoryCategoryNode CreateCategoryNode()
{
var node = SharedCategoryNodePool.TryRent();
if (node == null)
{
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)
{
if (!acceptedPayload.IsValidInventoryPayload) return;
InventoryLocation emptyLocation = InventoryScanner.GetFirstEmptySlot(InventoryState.SourceType);
if (!emptyLocation.IsValid)
{
Services.Logger.Error("No empty slots available to receive drop.");
return;
}
InventoryMappedLocation visualLocation = InventoryContextState.GetVisualLocation(emptyLocation.Container, emptyLocation.Slot);
var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container);
int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot;
var targetPayload = new DragDropPayload
{
Type = DragDropType.Item,
Int1 = visualLocation.Container,
Int2 = visualLocation.Slot,
ReferenceIndex = (short)absoluteIndex
};
Services.Logger.DebugOnly($"[BackgroundDrop] Target: {emptyLocation} -> Visual: {visualLocation} (Ref: {absoluteIndex})");
InventoryMoveHelper.HandleItemMovePayload(acceptedPayload, targetPayload);
ManualRefresh();
}
protected void WireHoverHandlers()
{
var nodes = CategoriesNode.Nodes;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is not InventoryCategoryNode node)
continue;
if (!HoverSubscribed.Add(node))
continue;
node.HeaderHoverChanged += (src, hovering) =>
{
HoverCoordinator.OnCategoryHoverChanged(CategoriesNode, src, hovering);
};
}
}
protected int CalculateOptimalItemsPerLine(float availableWidth)
=> Math.Clamp((int)MathF.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15);
protected virtual void LayoutContent()
{
Vector2 contentPos = ContentStartPosition;
Vector2 contentSize = ContentSize;
float footerH = HasFooter || HasSlotCounter ? FooterHeight : 0;
if (HasFooter)
{
FooterNode.Position = new Vector2(contentPos.X, contentPos.Y + contentSize.Y - footerH);
FooterNode.Size = new Vector2(contentSize.X, footerH);
}
else if (HasSlotCounter && SlotCounterNode != null)
{
SlotCounterNode.Position = new Vector2(contentSize.X -80f, contentPos.Y + contentSize.Y - footerH + 4f);
}
float gridH = contentSize.Y - (HasFooter ? FooterHeight + FooterTopSpacing : 0);
if (gridH < 0) gridH = 0;
ScrollableCategories.Position = contentPos;
ScrollableCategories.Size = new Vector2(contentSize.X, gridH);
float categoriesWidth = contentSize.X - ScrollBarWidth;
CategoriesNode.Width = categoriesWidth;
UpdateCategoryMaxWidths(categoriesWidth);
}
private void UpdateCategoryMaxWidths(float maxWidth)
{
foreach (var node in CategoriesNode.Nodes)
{
if (node is InventoryCategoryNodeBase categoryNode && categoryNode.MaxWidth != maxWidth)
{
categoryNode.MaxWidth = maxWidth;
categoryNode.RecalculateSize();
}
}
}
protected virtual void AutoSizeWindow()
{
var nodes = CategoriesNode.Nodes;
float maxChildWidth = 0f;
int childCount = 0;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is not InventoryCategoryNodeBase cat)
continue;
childCount++;
float w = cat.Width;
if (w > maxChildWidth) maxChildWidth = w;
}
if (childCount == 0)
{
ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true);
UpdateScrollParameters();
return;
}
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 - SettingsButtonOffset;
}
float contentWidth = finalWidth - (ContentStartPosition.X * 2);
float categoriesWidth = contentWidth - ScrollBarWidth;
CategoriesNode.Width = categoriesWidth;
UpdateCategoryMaxWidths(categoriesWidth);
CategoriesNode.RecalculateLayout();
float requiredGridHeight = CategoriesNode.GetRequiredHeight();
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)
{
SetWindowSize(width, height);
if (BackgroundDropTarget != null)
{
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)
=> ResizeWindow(width, height, recalcLayout: true);
public void ItemRefresh()
{
if (!IsOpen) return;
if (!IsSetupComplete) return;
RefreshCategoriesCore(false);
}
private void LogRefreshStats()
{
long now = Environment.TickCount64;
if (now - _lastLogTick > 1000) // Log every second
{
Services.Logger.DebugOnly($"[Perf] Last 1s: OnRequestedUpdate={_requestedUpdateCount}, RefreshFromLifecycle={_refreshFromLifecycleCount}");
_requestedUpdateCount = 0;
_refreshFromLifecycleCount = 0;
_lastLogTick = now;
}
}
protected override void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
{
base.OnRequestedUpdate(addon, numberArrayData, stringArrayData);
if (DragDropState.IsDragging) return;
ExecuteRefresh(autosize: true);
}
protected override void OnSetup(AtkUnitBase* addon)
{
ContextMenu = new ContextMenu();
System.AetherBagsAPI?.API.RaiseInventoryOpened();
if (ScrollableCategories != null)
{
ScrollableCategories.ScrollBarNode.OnValueChanged = OnScrollValueChanged;
}
base.OnSetup(addon);
}
protected override void OnUpdate(AtkUnitBase* addon)
{
if (RefreshQueued)
{
bool doAutosize = RefreshAutosizeQueued;
RefreshQueued = false;
RefreshAutosizeQueued = false;
RefreshCategoriesCore(doAutosize);
}
base.OnUpdate(addon);
}
protected override void OnFinalize(AtkUnitBase* addon)
{
System.AetherBagsAPI?.API.RaiseInventoryClosed();
ContextMenu?.Dispose();
HoverSubscribed.Clear();
RefreshQueued = false;
RefreshAutosizeQueued = false;
_deferredPopulationInProgress = false;
_initialPopulationComplete = false;
SharedItemNodePool.Clear();
SharedCategoryNodePool.Clear();
CategoryVirtualization.ClearLayout();
base.OnFinalize(addon);
}
}