Files
AetherBags/AetherBags/Addons/AddonInventoryWindow.cs
T
2025-12-21 05:29:24 -05:00

271 lines
8.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Extensions;
using AetherBags.Inventory;
using AetherBags.Nodes;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit;
using KamiToolKit.Classes;
namespace AetherBags.Addons;
public class AddonInventoryWindow : NativeAddon
{
private readonly InventoryCategoryHoverCoordinator _hoverCoordinator = new();
private readonly HashSet<InventoryCategoryNode> _hoverSubscribed = new();
private WrappingGridNode<InventoryCategoryNode> _categoriesNode = null!;
private TextInputWithHintNode _searchInputNode = null!;
private InventoryFooterNode _footerNode = null!;
// Window constraints
private const float MinWindowWidth = 300;
private const float MaxWindowWidth = 800;
private const float MinWindowHeight = 200;
private const float MaxWindowHeight = 1000;
// Layout settings
private const float CategorySpacing = 12;
private const float ItemSize = 40;
private const float ItemPadding = 4;
private const float FooterHeight = 28f;
private const float FooterTopSpacing = 4f;
private bool _refreshQueued;
private bool _refreshAutosizeQueued;
protected override unsafe void OnSetup(AtkUnitBase* addon)
{
_categoriesNode = new WrappingGridNode<InventoryCategoryNode>
{
Position = ContentStartPosition,
Size = ContentSize,
HorizontalSpacing = CategorySpacing,
VerticalSpacing = CategorySpacing,
TopPadding = 4.0f,
BottomPadding = 4.0f,
};
_categoriesNode.AttachNode(this);
var size = new Vector2(addon->Size.X / 2.0f, 28.0f);
var header = addon->WindowHeaderCollisionNode;
float headerX = header->X;
float headerY = header->Y;
float headerW = header->Width;
float headerH = header->Height;
float x = headerX + (headerW - size.X) * 0.5f;
float y = headerY + (headerH - size.Y) * 0.5f;
_searchInputNode = new TextInputWithHintNode
{
Position = new Vector2(x, y),
Size = size,
OnInputReceived = _ => RefreshCategoriesCore(autosize: false),
};
_searchInputNode.AttachNode(this);
_footerNode = new InventoryFooterNode
{
Size = ContentSize with { Y = FooterHeight },
SlotAmountText = InventoryState.GetEmptyItemSlotsString(),
};
_footerNode.AttachNode(this);
LayoutContent();
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate);
addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
InventoryState.RefreshFromGame();
RefreshCategoriesCore(autosize: true);
base.OnSetup(addon);
}
protected override unsafe void OnUpdate(AtkUnitBase* addon)
{
if (_refreshQueued)
{
bool doAutosize = _refreshAutosizeQueued;
_refreshQueued = false;
_refreshAutosizeQueued = false;
RefreshCategoriesCore(doAutosize);
}
base.OnUpdate(addon);
}
private void OnInventoryUpdate(AddonEvent type, AddonArgs args)
{
InventoryState.RefreshFromGame();
RefreshCategoriesCore(autosize: true);
}
protected override unsafe void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
{
base.OnRequestedUpdate(addon, numberArrayData, stringArrayData);
InventoryState.RefreshFromGame();
RefreshCategoriesCore(autosize: true);
}
private void RefreshCategoriesCore(bool autosize)
{
_footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString();
string filter = _searchInputNode.SearchString.ExtractText();
IReadOnlyList<CategorizedInventory> categories = InventoryState.GetInventoryItemCategories(filter);
float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2);
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
_categoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
dataList: categories,
getKeyFromData: c => c.Key,
getKeyFromNode: n => n.CategorizedInventory.Key,
updateNode: (node, data) =>
{
node.CategorizedInventory = data;
node.ItemsPerLine = Math.Min(data.Items.Count, maxItemsPerLine);
},
createNodeMethod: _ =>
{
return new InventoryCategoryNode
{
Size = ContentSize with { Y = 120 },
};
});
WireHoverHandlers();
if (autosize) AutoSizeWindow();
else
{
LayoutContent();
_categoriesNode.RecalculateLayout();
}
}
private 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);
};
}
}
private int CalculateOptimalItemsPerLine(float availableWidth)
{
return Math.Clamp((int)MathF.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15);
}
private void LayoutContent()
{
Vector2 contentPos = ContentStartPosition;
Vector2 contentSize = ContentSize;
float footerH = FooterHeight;
_footerNode.Position = new Vector2(contentPos.X, contentPos.Y + contentSize.Y - footerH);
_footerNode.Size = new Vector2(contentSize.X, footerH);
float gridH = contentSize.Y - footerH - FooterTopSpacing;
if (gridH < 0) gridH = 0;
_categoriesNode.Position = contentPos;
_categoriesNode.Size = new Vector2(contentSize.X, gridH);
}
private 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 InventoryCategoryNode cat)
continue;
childCount++;
float w = cat.Width;
if (w > maxChildWidth) maxChildWidth = w;
}
if (childCount == 0)
{
ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true);
return;
}
float requiredWidth = maxChildWidth + (ContentStartPosition.X * 2);
float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth);
float contentWidth = finalWidth - (ContentStartPosition.X * 2);
float gridBudget = Math.Max(0f, MaxWindowHeight - FooterHeight - FooterTopSpacing);
_categoriesNode.Position = ContentStartPosition;
_categoriesNode.Size = new Vector2(contentWidth, gridBudget);
_categoriesNode.RecalculateLayout();
float requiredGridHeight = _categoriesNode.GetRequiredHeight();
float requiredContentHeight = requiredGridHeight + FooterTopSpacing + FooterHeight;
float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X;
float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight);
ResizeWindow(finalWidth, finalHeight, recalcLayout: false);
}
private void ResizeWindow(float width, float height, bool recalcLayout)
{
SetWindowSize(width, height);
LayoutContent();
if (recalcLayout)
_categoriesNode.RecalculateLayout();
}
private void ResizeWindow(float width, float height)
=> ResizeWindow(width, height, recalcLayout: true);
protected override unsafe void OnFinalize(AtkUnitBase* addon)
{
Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate);
addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
_hoverSubscribed.Clear();
_refreshQueued = false;
_refreshAutosizeQueued = false;
base.OnFinalize(addon);
}
}