Merge branch 'dev/pie-lover'

This commit is contained in:
Shawrkie Williams
2026-02-04 16:27:16 -05:00
38 changed files with 2443 additions and 319 deletions
+29 -6
View File
@@ -25,16 +25,20 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
{ {
InitializeBackgroundDropTarget(); InitializeBackgroundDropTarget();
CategoriesNode = new WrappingGridNode<InventoryCategoryNodeBase> ScrollableCategories = new ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>>
{ {
Position = ContentStartPosition, Position = ContentStartPosition,
Size = ContentSize, Size = ContentSize,
HorizontalSpacing = CategorySpacing, ContentHeight = 0f,
VerticalSpacing = CategorySpacing, AutoHideScrollBar = true,
TopPadding = 4.0f,
BottomPadding = 4.0f,
}; };
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 _lootedCategoryNode = new LootedItemsCategoryNode
{ {
@@ -127,6 +131,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
CategoriesNode.RemoveNode(_lootedCategoryNode); CategoriesNode.RemoveNode(_lootedCategoryNode);
} }
CategoriesNode.InvalidateLayout();
AutoSizeWindow(); AutoSizeWindow();
} }
} }
@@ -134,6 +139,7 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
private void OnDismissLootedItem(int index) private void OnDismissLootedItem(int index)
{ {
System.LootedItemsTracker.RemoveByIndex(index); System.LootedItemsTracker.RemoveByIndex(index);
System.LootedItemsTracker.FlushPendingChanges();
} }
private void OnClearAllLootedItems() private void OnClearAllLootedItems()
@@ -148,6 +154,21 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
FooterNode.RefreshCurrencies(); 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) public void SetNotification(InventoryNotificationInfo info)
{ {
Services.Framework.RunOnTick(() => Services.Framework.RunOnTick(() =>
@@ -168,6 +189,8 @@ public unsafe class AddonInventoryWindow : InventoryAddonBase
addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
_lootedCategoryNode?.Dispose();
IsSetupComplete = false; IsSetupComplete = false;
base.OnFinalize(addon); base.OnFinalize(addon);
} }
+11 -8
View File
@@ -27,7 +27,7 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase
private readonly Vector3 _tintColor = new(8f / 255f, -8f / 255f, -4f / 255f); 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; protected override float MaxWindowWidth => 700;
private readonly string[] _retainerAddonNames = { "InventoryRetainer", "InventoryRetainerLarge" }; private readonly string[] _retainerAddonNames = { "InventoryRetainer", "InventoryRetainerLarge" };
@@ -38,16 +38,20 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase
WindowNode?.AddColor = _tintColor; WindowNode?.AddColor = _tintColor;
CategoriesNode = new WrappingGridNode<InventoryCategoryNodeBase> ScrollableCategories = new ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>>
{ {
Position = ContentStartPosition, Position = ContentStartPosition,
Size = ContentSize, Size = ContentSize,
HorizontalSpacing = CategorySpacing, ContentHeight = 0f,
VerticalSpacing = CategorySpacing, AutoHideScrollBar = true,
TopPadding = 4.0f,
BottomPadding = 4.0f,
}; };
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); var header = CalculateHeaderLayout(addon);
@@ -90,7 +94,6 @@ public unsafe class AddonRetainerWindow : InventoryAddonBase
}; };
_entrustDuplicatesButton.AttachNode(this); _entrustDuplicatesButton.AttachNode(this);
// Slot counter
_slotCounterNode = new TextNode _slotCounterNode = new TextNode
{ {
Position = new Vector2(Size.X - 10, 0), Position = new Vector2(Size.X - 10, 0),
+11 -7
View File
@@ -22,7 +22,7 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase
private readonly Vector3 _tintColor = new (-16f / 255f, -4f / 255f, 8f / 255f); 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 float MaxWindowWidth => 600;
protected override void OnSetup(AtkUnitBase* addon) protected override void OnSetup(AtkUnitBase* addon)
@@ -31,16 +31,20 @@ public unsafe class AddonSaddleBagWindow : InventoryAddonBase
WindowNode?.AddColor = _tintColor; WindowNode?.AddColor = _tintColor;
CategoriesNode = new WrappingGridNode<InventoryCategoryNodeBase> ScrollableCategories = new ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>>
{ {
Position = ContentStartPosition, Position = ContentStartPosition,
Size = ContentSize, Size = ContentSize,
HorizontalSpacing = CategorySpacing, ContentHeight = 0f,
VerticalSpacing = CategorySpacing, AutoHideScrollBar = true,
TopPadding = 4.0f,
BottomPadding = 4.0f,
}; };
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); var header = CalculateHeaderLayout(addon);
+276 -30
View File
@@ -29,6 +29,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
protected readonly HashSet<InventoryCategoryNode> HoverSubscribed = new(); protected readonly HashSet<InventoryCategoryNode> HoverSubscribed = new();
protected DragDropNode BackgroundDropTarget = null!; protected DragDropNode BackgroundDropTarget = null!;
protected ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>> ScrollableCategories = null!;
protected WrappingGridNode<InventoryCategoryNodeBase> CategoriesNode = null!; protected WrappingGridNode<InventoryCategoryNodeBase> CategoriesNode = null!;
protected TextInputWithButtonNode SearchInputNode = null!; protected TextInputWithButtonNode SearchInputNode = null!;
protected InventoryFooterNode FooterNode = null!; protected InventoryFooterNode FooterNode = null!;
@@ -37,6 +38,18 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
internal ContextMenu ContextMenu = 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 MinWindowWidth => 600;
protected virtual float MaxWindowWidth => 800; protected virtual float MaxWindowWidth => 800;
protected virtual float MinWindowHeight => 200; protected virtual float MinWindowHeight => 200;
@@ -47,11 +60,16 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
protected const float ItemPadding = 5; protected const float ItemPadding = 5;
protected const float FooterHeight = 28f; protected const float FooterHeight = 28f;
protected const float FooterTopSpacing = 4f; 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 RefreshQueued;
protected bool RefreshAutosizeQueued; protected bool RefreshAutosizeQueued;
protected bool IsSetupComplete; protected bool IsSetupComplete;
private bool _deferredPopulationInProgress;
private bool _initialPopulationComplete;
private const int ItemsPerFrame = 50;
protected abstract InventoryStateBase InventoryState { get; } protected abstract InventoryStateBase InventoryState { get; }
@@ -61,6 +79,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
private readonly HashSet<uint> _searchMatchScratch = new(); private readonly HashSet<uint> _searchMatchScratch = new();
private bool _isRefreshing; private bool _isRefreshing;
private string _lastSearchText = string.Empty;
private int _requestedUpdateCount; private int _requestedUpdateCount;
private int _refreshFromLifecycleCount; private int _refreshFromLifecycleCount;
@@ -72,6 +91,13 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
public InventoryStats GetStats() => InventoryState.GetStats(); 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) public virtual void SetSearchText(string searchText)
{ {
Services.Framework.RunOnTick(() => Services.Framework.RunOnTick(() =>
@@ -109,6 +135,12 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
string searchText = SearchInputNode.SearchString.ExtractText(); string searchText = SearchInputNode.SearchString.ExtractText();
bool isSearching = !string.IsNullOrWhiteSpace(searchText); bool isSearching = !string.IsNullOrWhiteSpace(searchText);
if (searchText != _lastSearchText)
{
_lastSearchText = searchText;
System.AetherBagsAPI?.API.RaiseSearchChanged(searchText);
}
if (config.SearchMode == SearchMode.Highlight && isSearching) if (config.SearchMode == SearchMode.Highlight && isSearching)
{ {
_searchMatchScratch.Clear(); _searchMatchScratch.Clear();
@@ -154,17 +186,20 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
float maxContentWidth = CategoriesNode.Width > 0 ? CategoriesNode.Width : ContentSize.X; float maxContentWidth = CategoriesNode.Width > 0 ? CategoriesNode.Width : ContentSize.X;
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth); int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
bool deferItems = !_deferredPopulationInProgress && !_initialPopulationComplete;
CategoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>( CategoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
dataList: categories, dataList: categories,
getKeyFromData: categorizedInventory => categorizedInventory.Key, getKeyFromData: categorizedInventory => categorizedInventory.Key,
getKeyFromNode: node => node.CategorizedInventory.Key, getKeyFromNode: node => node.CategorizedInventory.Key,
updateNode: (node, data) => updateNode: (node, data) =>
{ {
node.MaxWidth = maxContentWidth; node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine), deferItemCreation: deferItems);
node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine)); if (!deferItems) node.RefreshNodeVisuals();
node.RefreshNodeVisuals();
}, },
createNodeMethod: _ => CreateCategoryNode(maxContentWidth)); createNodeMethod: _ => CreateCategoryNode(),
resetNodeForReuse: ResetCategoryNodeForReuse,
externalPool: SharedCategoryNodePool);
if (HasPinning) if (HasPinning)
{ {
@@ -174,6 +209,8 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
WireHoverHandlers(); WireHoverHandlers();
CategoriesNode.InvalidateLayout();
if (autosize) if (autosize)
AutoSizeWindow(); AutoSizeWindow();
else else
@@ -181,6 +218,104 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
LayoutContent(); LayoutContent();
CategoriesNode.RecalculateLayout(); 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 protected readonly struct HeaderLayout
@@ -196,11 +331,38 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
var header = addon->WindowHeaderCollisionNode; var header = addon->WindowHeaderCollisionNode;
float headerW = header->Width; float headerW = header->Width;
float settingsX = headerW - 62f;
float itemY = header->Y + (header->Height - 28f) * 0.5f; float itemY = header->Y + (header->Height - 28f) * 0.5f;
float searchWidth = headerW * 0.45f; // Reserve space for close button (~50px) and settings button (~48px + gap)
float searchX = (headerW - searchWidth) * 0.5f; 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 return new HeaderLayout
{ {
@@ -231,17 +393,29 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
BackgroundDropTarget.AttachNode(this); BackgroundDropTarget.AttachNode(this);
} }
protected virtual InventoryCategoryNode CreateCategoryNode(float? maxWidth = null) protected virtual InventoryCategoryNode CreateCategoryNode()
{ {
return new InventoryCategoryNode var node = SharedCategoryNodePool.TryRent();
if (node == null)
{
node = new InventoryCategoryNode
{ {
Size = ContentSize with { Y = 120 }, Size = ContentSize with { Y = 120 },
MaxWidth = maxWidth, SharedItemPool = SharedItemNodePool,
OnRefreshRequested = ManualRefresh,
OnDragEnd = () => InventoryOrchestrator.RefreshAll(updateMaps: true),
}; };
} }
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) private void OnBackgroundPayloadAccepted(DragDropNode node, DragDropPayload acceptedPayload)
{ {
if (!acceptedPayload.IsValidInventoryPayload) return; if (!acceptedPayload.IsValidInventoryPayload) return;
@@ -316,17 +490,20 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
float gridH = contentSize.Y - (HasFooter ? FooterHeight + FooterTopSpacing : 0); float gridH = contentSize.Y - (HasFooter ? FooterHeight + FooterTopSpacing : 0);
if (gridH < 0) gridH = 0; if (gridH < 0) gridH = 0;
CategoriesNode.Position = contentPos; ScrollableCategories.Position = contentPos;
CategoriesNode.Size = new Vector2(contentSize.X, gridH); 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) private void UpdateCategoryMaxWidths(float maxWidth)
{ {
foreach (var node in CategoriesNode.Nodes) 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.MaxWidth = maxWidth;
categoryNode.RecalculateSize(); categoryNode.RecalculateSize();
@@ -343,7 +520,7 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
for (int i = 0; i < nodes.Count; i++) for (int i = 0; i < nodes.Count; i++)
{ {
if (nodes[i] is not InventoryCategoryNode cat) if (nodes[i] is not InventoryCategoryNodeBase cat)
continue; continue;
childCount++; childCount++;
@@ -354,36 +531,68 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
if (childCount == 0) if (childCount == 0)
{ {
ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true); ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true);
UpdateScrollParameters();
return; 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); float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth);
if (SettingsButtonNode != null) if (SettingsButtonNode != null)
{ {
SettingsButtonNode.X = finalWidth - 62f; SettingsButtonNode.X = finalWidth - SettingsButtonOffset;
} }
float contentWidth = finalWidth - (ContentStartPosition.X * 2); float contentWidth = finalWidth - (ContentStartPosition.X * 2);
float categoriesWidth = contentWidth - ScrollBarWidth;
float footerSpace = HasFooter || HasSlotCounter ? FooterHeight + FooterTopSpacing : 0; CategoriesNode.Width = categoriesWidth;
float gridBudget = Math.Max(0f, MaxWindowHeight - footerSpace); UpdateCategoryMaxWidths(categoriesWidth);
CategoriesNode.Position = ContentStartPosition;
CategoriesNode.Size = new Vector2(contentWidth, gridBudget);
UpdateCategoryMaxWidths(contentWidth);
CategoriesNode.RecalculateLayout(); CategoriesNode.RecalculateLayout();
float requiredGridHeight = CategoriesNode.GetRequiredHeight(); 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); float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight);
ResizeWindow(finalWidth, finalHeight, recalcLayout: false); 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) protected void ResizeWindow(float width, float height, bool recalcLayout)
@@ -395,10 +604,32 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
BackgroundDropTarget.Size = ContentSize; BackgroundDropTarget.Size = ContentSize;
} }
UpdateHeaderLayout();
LayoutContent(); LayoutContent();
if (recalcLayout) if (recalcLayout)
CategoriesNode.RecalculateLayout(); 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) protected void ResizeWindow(float width, float height)
@@ -438,6 +669,13 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
{ {
ContextMenu = new ContextMenu(); ContextMenu = new ContextMenu();
System.AetherBagsAPI?.API.RaiseInventoryOpened();
if (ScrollableCategories != null)
{
ScrollableCategories.ScrollBarNode.OnValueChanged = OnScrollValueChanged;
}
base.OnSetup(addon); base.OnSetup(addon);
} }
@@ -457,10 +695,18 @@ public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
protected override void OnFinalize(AtkUnitBase* addon) protected override void OnFinalize(AtkUnitBase* addon)
{ {
System.AetherBagsAPI?.API.RaiseInventoryClosed();
ContextMenu?.Dispose(); ContextMenu?.Dispose();
HoverSubscribed.Clear(); HoverSubscribed.Clear();
RefreshQueued = false; RefreshQueued = false;
RefreshAutosizeQueued = false; RefreshAutosizeQueued = false;
_deferredPopulationInProgress = false;
_initialPopulationComplete = false;
SharedItemNodePool.Clear();
SharedCategoryNodePool.Clear();
CategoryVirtualization.ClearLayout();
base.OnFinalize(addon); base.OnFinalize(addon);
} }
@@ -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;
}
}
@@ -19,6 +19,7 @@ public class GeneralSettings
public bool HideGameRetainer { get; set; } = false; public bool HideGameRetainer { get; set; } = false;
public bool ShowCategoryItemCount { get; set; } = false; public bool ShowCategoryItemCount { get; set; } = false;
public bool LinkItemEnabled { get; set; } = false; public bool LinkItemEnabled { get; set; } = false;
public bool UseUnifiedExternalCategories { get; set; } = false;
} }
public enum InventoryStackMode : byte public enum InventoryStackMode : byte
+11 -4
View File
@@ -2,7 +2,7 @@ using FFXIVClientStructs.FFXIV.Client.Game;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Runtime.InteropServices;
namespace AetherBags.Currency; namespace AetherBags.Currency;
@@ -16,6 +16,7 @@ public static unsafe class CurrencyState
private static readonly Dictionary<uint, CurrencyItem> CurrencyItemByCurrencyIdCache = new(capacity: 32); private static readonly Dictionary<uint, CurrencyItem> CurrencyItemByCurrencyIdCache = new(capacity: 32);
private static readonly Dictionary<uint, CurrencyStaticInfo> CurrencyStaticByItemIdCache = new(capacity: 64); private static readonly Dictionary<uint, CurrencyStaticInfo> CurrencyStaticByItemIdCache = new(capacity: 64);
private static readonly List<CurrencyInfo> CurrencyInfoScratch = new(capacity: 8);
private static uint? _cachedLimitedTomestoneItemId; private static uint? _cachedLimitedTomestoneItemId;
private static uint? _cachedNonLimitedTomestoneItemId; private static uint? _cachedNonLimitedTomestoneItemId;
@@ -29,6 +30,12 @@ public static unsafe class CurrencyState
} }
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(uint[] currencyIds) public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(uint[] currencyIds)
=> GetCurrencyInfoListCore(currencyIds.AsSpan());
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(List<uint> currencyIds)
=> GetCurrencyInfoListCore(CollectionsMarshal.AsSpan(currencyIds));
private static IReadOnlyList<CurrencyInfo> GetCurrencyInfoListCore(ReadOnlySpan<uint> currencyIds)
{ {
if (currencyIds.Length == 0) if (currencyIds.Length == 0)
return Array.Empty<CurrencyInfo>(); return Array.Empty<CurrencyInfo>();
@@ -37,7 +44,7 @@ public static unsafe class CurrencyState
if (inventoryManager == null) if (inventoryManager == null)
return Array.Empty<CurrencyInfo>(); return Array.Empty<CurrencyInfo>();
List<CurrencyInfo> currencyInfoList = new List<CurrencyInfo>(currencyIds.Length); CurrencyInfoScratch.Clear();
for (int i = 0; i < currencyIds.Length; i++) for (int i = 0; i < currencyIds.Length; i++)
{ {
@@ -57,7 +64,7 @@ public static unsafe class CurrencyState
isCapped = weeklyAcquired >= weeklyLimit; isCapped = weeklyAcquired >= weeklyLimit;
} }
currencyInfoList.Add(new CurrencyInfo CurrencyInfoScratch.Add(new CurrencyInfo
{ {
Amount = amount, Amount = amount,
MaxAmount = staticInfo.MaxAmount, MaxAmount = staticInfo.MaxAmount,
@@ -68,7 +75,7 @@ public static unsafe class CurrencyState
}); });
} }
return currencyInfoList; return CurrencyInfoScratch;
} }
public static (uint Limited, uint NonLimited) GetCurrentTomestoneIds() public static (uint Limited, uint NonLimited) GetCurrentTomestoneIds()
@@ -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<uint>? OnItemHovered;
public event Action<uint>? OnItemUnhovered;
public event Action<uint>? OnItemClicked;
public event Action<string>? OnSearchChanged;
public event Action? OnInventoryOpened;
public event Action? OnInventoryClosed;
public event Action? OnCategoriesRefreshed;
public bool IsInventoryOpen => System.AddonInventoryWindow?.IsOpen ?? false;
public IReadOnlyList<uint> GetVisibleItemIds()
{
var window = System.AddonInventoryWindow;
if (window == null || !window.IsOpen) return Array.Empty<uint>();
var categories = window.GetVisibleCategories();
if (categories == null) return Array.Empty<uint>();
var result = new List<uint>();
foreach (var category in categories)
{
foreach (var item in category.Items)
{
result.Add(item.Item.ItemId);
}
}
return result;
}
public IReadOnlyList<uint> GetItemsInCategory(uint categoryKey)
{
var window = System.AddonInventoryWindow;
if (window == null || !window.IsOpen) return Array.Empty<uint>();
var categories = window.GetVisibleCategories();
if (categories == null) return Array.Empty<uint>();
var category = categories.FirstOrDefault(c => c.Key == categoryKey);
if (category.Items == null) return Array.Empty<uint>();
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<string> 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();
}
@@ -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<bool> _isInventoryOpen;
private readonly ICallGateProvider<List<uint>> _getVisibleItemIds;
private readonly ICallGateProvider<uint, List<uint>> _getItemsInCategory;
private readonly ICallGateProvider<uint, bool> _isItemVisible;
private readonly ICallGateProvider<string> _getSearchFilter;
private readonly ICallGateProvider<List<string>> _getRegisteredSources;
private readonly ICallGateProvider<uint, bool> _onItemHovered;
private readonly ICallGateProvider<uint, bool> _onItemUnhovered;
private readonly ICallGateProvider<uint, bool> _onItemClicked;
private readonly ICallGateProvider<string, bool> _onSearchChanged;
private readonly ICallGateProvider<bool> _onInventoryOpened;
private readonly ICallGateProvider<bool> _onInventoryClosed;
private readonly ICallGateProvider<bool> _onCategoriesRefreshed;
public AetherBagsAPIImpl API => _api;
public AetherBagsIPCProvider()
{
_api = new AetherBagsAPIImpl();
_isInventoryOpen = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}IsInventoryOpen");
_getVisibleItemIds = Services.PluginInterface.GetIpcProvider<List<uint>>($"{IpcPrefix}GetVisibleItemIds");
_getItemsInCategory = Services.PluginInterface.GetIpcProvider<uint, List<uint>>($"{IpcPrefix}GetItemsInCategory");
_isItemVisible = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}IsItemVisible");
_getSearchFilter = Services.PluginInterface.GetIpcProvider<string>($"{IpcPrefix}GetSearchFilter");
_getRegisteredSources = Services.PluginInterface.GetIpcProvider<List<string>>($"{IpcPrefix}GetRegisteredSources");
_onItemHovered = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}OnItemHovered");
_onItemUnhovered = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}OnItemUnhovered");
_onItemClicked = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}OnItemClicked");
_onSearchChanged = Services.PluginInterface.GetIpcProvider<string, bool>($"{IpcPrefix}OnSearchChanged");
_onInventoryOpened = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}OnInventoryOpened");
_onInventoryClosed = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}OnInventoryClosed");
_onCategoriesRefreshed = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}OnCategoriesRefreshed");
RegisterFunctions();
SubscribeEvents();
}
private void RegisterFunctions()
{
_isInventoryOpen.RegisterFunc(() => _api.IsInventoryOpen);
_getVisibleItemIds.RegisterFunc(() => new List<uint>(_api.GetVisibleItemIds()));
_getItemsInCategory.RegisterFunc(key => new List<uint>(_api.GetItemsInCategory(key)));
_isItemVisible.RegisterFunc(itemId => _api.IsItemVisible(itemId));
_getSearchFilter.RegisterFunc(() => _api.GetCurrentSearchFilter());
_getRegisteredSources.RegisterFunc(() => new List<string>(_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();
}
}
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using AetherBags.IPC.ExternalCategorySystem;
namespace AetherBags.IPC.AetherBagsAPI;
public interface IAetherBagsAPI
{
IReadOnlyList<uint> GetVisibleItemIds();
IReadOnlyList<uint> GetItemsInCategory(uint categoryKey);
bool IsItemVisible(uint itemId);
string GetCurrentSearchFilter();
bool IsInventoryOpen { get; }
event Action<uint>? OnItemHovered;
event Action<uint>? OnItemUnhovered;
event Action<uint>? OnItemClicked;
event Action<string>? OnSearchChanged;
event Action? OnInventoryOpened;
event Action? OnInventoryClosed;
event Action? OnCategoriesRefreshed;
void RegisterSource(IExternalItemSource source);
void UnregisterSource(string sourceName);
IReadOnlyList<string> GetRegisteredSourceNames();
}
+115
View File
@@ -1,8 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics;
using AetherBags.Inventory; using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context; using AetherBags.Inventory.Context;
using AetherBags.IPC.ExternalCategorySystem;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using KamiToolKit.Classes;
namespace AetherBags.IPC; namespace AetherBags.IPC;
@@ -188,8 +192,119 @@ public class AllaganToolsIPC : IDisposable
InventoryOrchestrator.RefreshHighlights(); 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() public void Dispose()
{ {
DisableExternalCategorySupport();
_initialized?.Unsubscribe(OnAllaganInitialized); _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<uint, ExternalCategoryAssignment>? GetCategoryAssignments()
{
if (_ipc.CachedFilterItems.Count == 0) return null;
var result = new Dictionary<uint, ExternalCategoryAssignment>();
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<uint, ItemDecoration>? GetItemDecorations() => null;
public IReadOnlyList<ContextMenuEntry>? GetContextMenuEntries(uint itemId) => null;
public IReadOnlyDictionary<uint, string[]>? GetSearchTags()
{
if (_ipc.ItemToFilters.Count == 0) return null;
var result = new Dictionary<uint, string[]>();
foreach (var (itemId, filterKeys) in _ipc.ItemToFilters)
{
var tags = new List<string>(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<ItemRelationship>? GetItemRelationships(uint itemId) => null;
}
} }
+151
View File
@@ -2,8 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AetherBags.Inventory; using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context; using AetherBags.Inventory.Context;
using AetherBags.IPC.ExternalCategorySystem;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using KamiToolKit.Classes;
namespace AetherBags.IPC; namespace AetherBags.IPC;
@@ -190,9 +193,157 @@ public class BisBuddyIPC : IDisposable
public Vector4? GetItemColor(uint itemId) public Vector4? GetItemColor(uint itemId)
=> GetBisItem(itemId)?.Color; => 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() public void Dispose()
{ {
DisableExternalCategorySupport();
_initialized?.Unsubscribe(OnBisBuddyInitialized); _initialized?.Unsubscribe(OnBisBuddyInitialized);
_inventoryHighlightItemsChanged?.Unsubscribe(OnInventoryHighlightItemsChanged); _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<uint, ExternalCategoryAssignment>? GetCategoryAssignments()
{
var items = _ipc.ItemLookup;
if (items.Count == 0) return null;
var result = new Dictionary<uint, ExternalCategoryAssignment>();
var colorGroups = new Dictionary<Vector4, List<(uint itemId, BisItemEntry entry)>>();
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<uint, ItemDecoration>? GetItemDecorations()
{
var items = _ipc.ItemLookup;
if (items.Count == 0) return null;
var result = new Dictionary<uint, ItemDecoration>();
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<ContextMenuEntry>? GetContextMenuEntries(uint itemId) => null;
public IReadOnlyDictionary<uint, string[]>? GetSearchTags()
{
var items = _ipc.ItemLookup;
if (items.Count == 0) return null;
var result = new Dictionary<uint, string[]>();
foreach (var itemId in items.Keys)
{
result[itemId] = new[] { "bis", "bestinslot", "gearset" };
}
return result;
}
public IReadOnlyList<ItemRelationship>? GetItemRelationships(uint itemId)
{
if (!_ipc.ItemLookup.TryGetValue(itemId, out var entry)) return null;
var sameSetItems = new List<uint>();
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)
)
};
}
}
} }
@@ -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<IExternalItemSource> Sources = new();
private static readonly Dictionary<uint, ExternalCategoryAssignment> CategoryCache = new();
private static readonly Dictionary<uint, ItemDecoration> DecorationCache = new();
private static readonly Dictionary<uint, List<string>> SearchTagCache = new();
private static int _lastCombinedVersion;
public static IReadOnlyList<IExternalItemSource> 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<string>(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<ulong, ItemInfo> itemInfoByKey,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> 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<ItemInfo>(16),
FilteredItems = new List<ItemInfo>(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<ContextMenuEntry>? GetContextMenuEntries(uint itemId)
{
List<ContextMenuEntry>? 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<ContextMenuEntry>(4);
result.Add(entry);
}
}
result?.Sort((a, b) => a.Order.CompareTo(b.Order));
return result;
}
public static IReadOnlyList<string>? 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<ItemRelationship>? GetItemRelationships(uint itemId)
{
List<ItemRelationship>? 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<ItemRelationship>(4);
result.AddRange(relationships);
}
return result;
}
public static HashSet<uint>? GetRelatedItemIds(uint itemId, RelationshipType? filterType = null)
{
var relationships = GetItemRelationships(itemId);
if (relationships == null || relationships.Count == 0) return null;
var result = new HashSet<uint>();
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;
}
}
@@ -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<uint, ExternalCategoryAssignment>? GetCategoryAssignments();
IReadOnlyDictionary<uint, ItemDecoration>? GetItemDecorations();
IReadOnlyList<ContextMenuEntry>? GetContextMenuEntries(uint itemId);
IReadOnlyDictionary<uint, string[]>? GetSearchTags();
IReadOnlyList<ItemRelationship>? 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<ContextMenuContext> OnClick,
int Order,
Func<uint, bool>? 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
}
+37
View File
@@ -1,4 +1,5 @@
using System; using System;
using AetherBags.Configuration;
namespace AetherBags.IPC; namespace AetherBags.IPC;
@@ -8,6 +9,42 @@ public class IPCService : IDisposable
public WotsItIPC WotsIt { get; } = new(); public WotsItIPC WotsIt { get; } = new();
public BisBuddyIPC BisBuddy { 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() public void Dispose()
{ {
AllaganTools.Dispose(); AllaganTools.Dispose();
@@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AetherBags.Configuration; using AetherBags.Configuration;
using AetherBags.Inventory.Items; using AetherBags.Inventory.Items;
using KamiToolKit.Classes; using KamiToolKit.Classes;
@@ -61,32 +63,24 @@ public static class CategoryBucketManager
{ {
sortedScratch.Clear(); sortedScratch.Clear();
sortedScratch.AddRange(userCategories); sortedScratch.AddRange(userCategories);
sortedScratch.Sort((left, right) => sortedScratch.Sort(UserCategoryComparer.Instance);
{
int priority = left.Priority.CompareTo(right.Priority);
if (priority != 0) return priority;
int order = left.Order.CompareTo(right.Order); var activeBuckets = new (uint key, CategoryBucket bucket, UserCategoryDefinition def)[sortedScratch.Count];
if (order != 0) return order; int activeCount = 0;
return string.Compare(left.Id, right.Id, StringComparison.OrdinalIgnoreCase);
});
for (int i = 0; i < sortedScratch.Count; i++) for (int i = 0; i < sortedScratch.Count; i++)
{ {
UserCategoryDefinition category = sortedScratch[i]; UserCategoryDefinition category = sortedScratch[i];
if (!category.Enabled) if (!category.Enabled || UserCategoryMatcher.IsCatchAll(category))
continue;
if (UserCategoryMatcher.IsCatchAll(category))
continue; continue;
uint bucketKey = MakeUserCategoryKey(category.Order); 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, Key = bucketKey,
Category = new CategoryInfo Category = new CategoryInfo
@@ -100,34 +94,63 @@ public static class CategoryBucketManager
FilteredItems = new List<ItemInfo>(capacity: 16), FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true, Used = true,
}; };
bucketsByKey.Add(bucketKey, bucket);
} }
else else
{ {
bucket.Used = true; bucketRef!.Used = true;
bucket.Category.Name = category.Name; bucketRef.Category.Name = category.Name;
bucket.Category.Description = category.Description; bucketRef.Category.Description = category.Description;
bucket.Category.Color = category.Color; bucketRef.Category.Color = category.Color;
bucket.Category.IsPinned = category.Pinned; bucketRef.Category.IsPinned = category.Pinned;
}
activeBuckets[activeCount++] = (bucketKey, bucketRef!, category);
} }
foreach (var itemKvp in itemInfoByKey) foreach (var itemKvp in itemInfoByKey)
{ {
ulong itemKey = itemKvp.Key; ulong itemKey = itemKvp.Key;
ItemInfo item = itemKvp.Value;
if (claimedKeys.Contains(itemKey)) if (claimedKeys.Contains(itemKey))
continue; continue;
if (UserCategoryMatcher.Matches(item, category)) ItemInfo item = itemKvp.Value;
for (int i = 0; i < activeCount; i++)
{ {
bucket.Items.Add(item); ref var entry = ref activeBuckets[i];
if (UserCategoryMatcher.Matches(item, entry.def))
{
entry.bucket.Items.Add(item);
claimedKeys.Add(itemKey); claimedKeys.Add(itemKey);
break;
}
} }
} }
if (bucket.Items.Count == 0) for (int i = 0; i < activeCount; i++)
bucket.Used = false; {
ref var entry = ref activeBuckets[i];
if (entry.bucket.Items.Count == 0)
entry.bucket.Used = false;
}
}
private sealed class UserCategoryComparer : IComparer<UserCategoryDefinition>
{
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; 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, Key = categoryKey,
Category = GetCategoryInfoCached(categoryKey, info), Category = GetCategoryInfoCached(categoryKey, info),
@@ -157,14 +182,13 @@ public static class CategoryBucketManager
FilteredItems = new List<ItemInfo>(capacity: 16), FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true, Used = true,
}; };
bucketsByKey.Add(categoryKey, bucket);
} }
else else
{ {
bucket.Used = true; bucketRef!.Used = true;
} }
bucket.Items.Add(info); bucketRef!.Items.Add(info);
} }
} }
@@ -178,22 +202,26 @@ public static class CategoryBucketManager
if (!System.IPC.AllaganTools.IsReady) return; if (!System.IPC.AllaganTools.IsReady) return;
var filters = System.IPC.AllaganTools.CachedSearchFilters; 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<string, int>(filters.Count);
int index = 0; int index = 0;
foreach (var (filterKey, filterName) in filters) foreach (var filterKey in filters.Keys)
{ {
if (!filterItems. TryGetValue(filterKey, out var itemIds)) filterKeyToIndex[filterKey] = index++;
{
index++;
continue;
} }
uint bucketKey = MakeAllaganFilterKey(index); index = 0;
foreach (var (filterKey, filterName) in filters)
if (!bucketsByKey.TryGetValue(bucketKey, out CategoryBucket? bucket))
{ {
bucket = new CategoryBucket uint bucketKey = MakeAllaganFilterKey(index);
ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists);
if (!exists)
{
bucketRef = new CategoryBucket
{ {
Key = bucketKey, Key = bucketKey,
Category = new CategoryInfo Category = new CategoryInfo
@@ -206,33 +234,44 @@ public static class CategoryBucketManager
FilteredItems = new List<ItemInfo>(capacity: 16), FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true, Used = true,
}; };
bucketsByKey. Add(bucketKey, bucket);
} }
else else
{ {
bucket.Used = true; bucketRef!.Used = true;
bucket.Category.Name = $"[AT] {filterName}"; bucketRef.Category.Name = $"[AT] {filterName}";
}
index++;
} }
foreach (var itemKvp in itemInfoByKey) foreach (var itemKvp in itemInfoByKey)
{ {
ulong itemKey = itemKvp.Key; ulong itemKey = itemKvp.Key;
ItemInfo item = itemKvp.Value;
if (claimedKeys.Contains(itemKey)) if (claimedKeys.Contains(itemKey))
continue; continue;
if (itemIds.ContainsKey(item.Item.ItemId)) 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))
{
uint bucketKey = MakeAllaganFilterKey(filterIndex);
if (bucketsByKey.TryGetValue(bucketKey, out var bucket))
{ {
bucket.Items.Add(item); bucket.Items.Add(item);
claimedKeys.Add(itemKey); 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; bucket.Used = false;
index++;
} }
} }
@@ -250,9 +289,11 @@ public static class CategoryBucketManager
uint bucketKey = MakeBisBuddyKey(); 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, Key = bucketKey,
Category = new CategoryInfo Category = new CategoryInfo
@@ -265,21 +306,22 @@ public static class CategoryBucketManager
FilteredItems = new List<ItemInfo>(capacity: 16), FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true, Used = true,
}; };
bucketsByKey.Add(bucketKey, bucket);
} }
else else
{ {
bucket.Used = true; bucketRef!.Used = true;
} }
var bucket = bucketRef!;
foreach (var itemKvp in itemInfoByKey) foreach (var itemKvp in itemInfoByKey)
{ {
ulong itemKey = itemKvp.Key; ulong itemKey = itemKvp.Key;
ItemInfo item = itemKvp.Value;
if (claimedKeys.Contains(itemKey)) if (claimedKeys.Contains(itemKey))
continue; continue;
ItemInfo item = itemKvp.Value;
if (bisItems.ContainsKey(item.Item.ItemId)) if (bisItems.ContainsKey(item.Item.ItemId))
{ {
bucket.Items.Add(item); bucket.Items.Add(item);
@@ -1,4 +1,5 @@
using System; using System;
using System.Runtime.CompilerServices;
using AetherBags.Configuration; using AetherBags.Configuration;
using AetherBags.Helpers; using AetherBags.Helpers;
using AetherBags.Inventory.Items; using AetherBags.Inventory.Items;
@@ -7,51 +8,20 @@ namespace AetherBags.Inventory.Categories;
internal static class UserCategoryMatcher internal static class UserCategoryMatcher
{ {
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Matches(ItemInfo item, UserCategoryDefinition userCategory) public static bool Matches(ItemInfo item, UserCategoryDefinition userCategory)
{ {
var rules = userCategory.Rules; var rules = userCategory.Rules;
bool hasIdentificationFilters = rules.AllowedItemIds.Count > 0 || rules.AllowedItemNamePatterns.Count > 0; if (!MatchesToggle(rules.Untradable, item.IsUntradable)) return false;
if (!MatchesToggle(rules.Unique, item.IsUnique)) return false;
if (hasIdentificationFilters) if (!MatchesToggle(rules.Collectable, item.IsCollectable)) return false;
{ if (!MatchesToggle(rules.Dyeable, item.IsDyeable)) return false;
bool matchesAnyIdentification = false; if (!MatchesToggle(rules.HighQuality, item.IsHq)) return false;
if (!MatchesToggle(rules.Repairable, item.IsRepairable)) return false;
if (rules.AllowedItemIds.Count > 0 && rules.AllowedItemIds.Contains(item.Item.ItemId)) if (!MatchesToggle(rules.Desynthesizable, item.IsDesynthesizable)) return false;
{ if (!MatchesToggle(rules.Glamourable, item.IsGlamourable)) return false;
matchesAnyIdentification = true; if (!MatchesToggle(rules.FullySpiritbonded, item.IsSpiritbonded)) return false;
}
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 (rules.Level.Enabled && !InRange(item.Level, rules.Level.Min, rules.Level.Max)) if (rules.Level.Enabled && !InRange(item.Level, rules.Level.Min, rules.Level.Max))
return false; return false;
@@ -62,19 +32,40 @@ internal static class UserCategoryMatcher
if (rules.VendorPrice.Enabled && !InRange(item.VendorPrice, rules.VendorPrice.Min, rules.VendorPrice.Max)) if (rules.VendorPrice.Enabled && !InRange(item.VendorPrice, rules.VendorPrice.Min, rules.VendorPrice.Max))
return false; return false;
if (!MatchesToggle(rules.Untradable, item.IsUntradable)) return false; if (rules.AllowedRarities.Count > 0 && !rules.AllowedRarities.Contains(item.Rarity))
if (!MatchesToggle(rules.Unique, item.IsUnique)) return false; return false;
if (!MatchesToggle(rules.Collectable, item.IsCollectable)) return false;
if (!MatchesToggle(rules.Dyeable, item.IsDyeable)) return false; if (rules.AllowedUiCategoryIds.Count > 0 && !rules.AllowedUiCategoryIds.Contains(item.UiCategory.RowId))
if (!MatchesToggle(rules.Repairable, item.IsRepairable)) return false; return false;
if (!MatchesToggle(rules.HighQuality, item.IsHq)) return false;
if (!MatchesToggle(rules.Desynthesizable, item.IsDesynthesizable)) return false; bool hasIdentificationFilters = rules.AllowedItemIds.Count > 0 || rules.AllowedItemNamePatterns.Count > 0;
if (!MatchesToggle(rules.Glamourable, item.IsGlamourable)) return false;
if (!MatchesToggle(rules.FullySpiritbonded, item.IsSpiritbonded)) return false; 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; return true;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool InRange<T>(T value, T min, T max) where T : struct, IComparable<T> private static bool InRange<T>(T value, T min, T max) where T : struct, IComparable<T>
=> value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0; => value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0;
@@ -112,12 +103,13 @@ internal static class UserCategoryMatcher
return true; return true;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool MatchesToggle(StateFilter filter, bool itemHasProperty) private static bool MatchesToggle(StateFilter filter, bool itemHasProperty)
=> filter.ToggleState switch
{ {
ToggleFilterState.Ignored => true, var state = filter.ToggleState;
ToggleFilterState.Allow => itemHasProperty, if (state == ToggleFilterState.Ignored) return true;
ToggleFilterState.Disallow => !itemHasProperty, if (state == ToggleFilterState.Allow) return itemHasProperty;
_ => true if (state == ToggleFilterState.Disallow) return !itemHasProperty;
}; return true;
}
} }
@@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices;
namespace AetherBags.Inventory.Context; namespace AetherBags.Inventory.Context;
@@ -8,6 +9,7 @@ public enum HighlightSource
Search, Search,
AllaganTools, AllaganTools,
BiSBuddy, BiSBuddy,
Relationship,
} }
public record HighlightEntry(uint ItemId, Vector3 Color); public record HighlightEntry(uint ItemId, Vector3 Color);
@@ -40,6 +42,7 @@ public static class HighlightState
_version++; _version++;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsInActiveFilters(uint itemId) public static bool IsInActiveFilters(uint itemId)
{ {
if (Filters.Count == 0) return true; if (Filters.Count == 0) return true;
@@ -48,6 +51,7 @@ public static class HighlightState
return false; return false;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static HighlightEntry? GetHighlightEntry(uint itemId) public static HighlightEntry? GetHighlightEntry(uint itemId)
{ {
EnsureCacheValid(); EnsureCacheValid();
@@ -88,6 +92,7 @@ public static class HighlightState
_version++; _version++;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector3? GetLabelColor(uint itemId) public static Vector3? GetLabelColor(uint itemId)
=> GetHighlightEntry(itemId)?.Color; => GetHighlightEntry(itemId)?.Color;
@@ -168,4 +173,16 @@ public static class HighlightState
PerItemLabels.Remove(source); PerItemLabels.Remove(source);
InvalidateCache(); InvalidateCache();
} }
public static void SetRelationshipHighlight(HashSet<uint>? 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);
}
} }
+23 -5
View File
@@ -1,8 +1,10 @@
using System; using System;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using AetherBags.Helpers; using AetherBags.Helpers;
using AetherBags.Inventory.Context; using AetherBags.Inventory.Context;
using AetherBags.IPC.ExternalCategorySystem;
using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game;
using Lumina.Excel; using Lumina.Excel;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
@@ -30,6 +32,7 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
private int _cachedHighlightVersion = -1; private int _cachedHighlightVersion = -1;
private float _cachedVisualAlpha; private float _cachedVisualAlpha;
private Vector3 _cachedHighlightColor; private Vector3 _cachedHighlightColor;
private bool _cachedIsRelationshipHighlighted;
private ref readonly Item Row private ref readonly Item Row
{ {
@@ -117,6 +120,15 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
} }
} }
public bool IsRelationshipHighlighted
{
get
{
EnsureVisualStateCached();
return _cachedIsRelationshipHighlighted;
}
}
private void EnsureVisualStateCached() private void EnsureVisualStateCached()
{ {
int currentVersion = HighlightState.Version; int currentVersion = HighlightState.Version;
@@ -127,6 +139,10 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
_cachedHighlightColor = System.Config.Categories.BisBuddyEnabled _cachedHighlightColor = System.Config.Categories.BisBuddyEnabled
? HighlightState.GetLabelColor(Item.ItemId) ?? Vector3.Zero ? HighlightState.GetLabelColor(Item.ItemId) ?? Vector3.Zero
: Vector3.Zero; : Vector3.Zero;
var entry = HighlightState.GetHighlightEntry(Item.ItemId);
_cachedIsRelationshipHighlighted = entry != null;
_cachedHighlightVersion = currentVersion; _cachedHighlightVersion = currentVersion;
} }
@@ -160,6 +176,7 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
public bool IsMainInventory => InventoryPage >= 0; public bool IsMainInventory => InventoryPage >= 0;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsRegexMatch(string searchTerms) public bool IsRegexMatch(string searchTerms)
{ {
if (string.IsNullOrEmpty(searchTerms)) if (string.IsNullOrEmpty(searchTerms))
@@ -171,22 +188,23 @@ public sealed class ItemInfo : IEquatable<ItemInfo>
if (re.IsMatch(Name)) return true; if (re.IsMatch(Name)) return true;
if (re.IsMatch(Description)) return true;
if (re.IsMatch(LevelString)) return true; if (re.IsMatch(LevelString)) return true;
if (re.IsMatch(ItemLevelString)) return true; if (re.IsMatch(ItemLevelString)) return true;
if (ExternalCategoryManager.MatchesSearchTag(Item.ItemId, searchTerms)) return true;
if (re.IsMatch(Description)) return true;
return false; return false;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsRegexMatch(Regex re) public bool IsRegexMatch(Regex re)
{ {
if (re.IsMatch(Name)) return true; if (re.IsMatch(Name)) return true;
if (re.IsMatch(Description)) return true;
if (re.IsMatch(LevelString)) return true; if (re.IsMatch(LevelString)) return true;
if (re.IsMatch(ItemLevelString)) return true; if (re.IsMatch(ItemLevelString)) return true;
if (re.IsMatch(Description)) return true;
return false; return false;
} }
@@ -5,6 +5,7 @@ using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context; using AetherBags.Inventory.Context;
using AetherBags.Inventory.Items; using AetherBags.Inventory.Items;
using AetherBags.Inventory.Scanning; using AetherBags.Inventory.Scanning;
using AetherBags.IPC.ExternalCategorySystem;
using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.State; namespace AetherBags.Inventory.State;
@@ -87,6 +88,24 @@ public abstract class InventoryStateBase
); );
} }
bool useUnified = config.General.UseUnifiedExternalCategories;
if (useUnified)
{
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 (allaganCategoriesEnabled)
{ {
if (config.Categories.AllaganToolsFilterMode == PluginFilterMode.Categorize) if (config.Categories.AllaganToolsFilterMode == PluginFilterMode.Categorize)
@@ -120,6 +139,7 @@ public abstract class InventoryStateBase
{ {
HighlightState.ClearFilter(HighlightSource.BiSBuddy); HighlightState.ClearFilter(HighlightSource.BiSBuddy);
} }
}
if (gameCategoriesEnabled) if (gameCategoriesEnabled)
{ {
@@ -205,6 +225,9 @@ public abstract class InventoryStateBase
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(uint[] currencyIds) public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(uint[] currencyIds)
=> CurrencyState.GetCurrencyInfoList(currencyIds); => CurrencyState.GetCurrencyInfoList(currencyIds);
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(List<uint> currencyIds)
=> CurrencyState.GetCurrencyInfoList(currencyIds);
public static void InvalidateCurrencyCaches() public static void InvalidateCurrencyCaches()
=> CurrencyState.InvalidateCaches(); => CurrencyState.InvalidateCaches();
+15 -4
View File
@@ -40,6 +40,7 @@ public class InventoryMonitor : IDisposable
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, saddle, OnPreFinalize); Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, saddle, OnPreFinalize);
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, retainer, OnPreFinalize); Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, retainer, OnPreFinalize);
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, bags, OnInventoryPreFinalize);
// PreRefresh Handlers // PreRefresh Handlers
Services.AddonLifecycle.RegisterListener(AddonEvent.PreRefresh, bags, InventoryPreRefreshHandler); Services.AddonLifecycle.RegisterListener(AddonEvent.PreRefresh, bags, InventoryPreRefreshHandler);
@@ -65,6 +66,11 @@ public class InventoryMonitor : IDisposable
OpenInventories(args.AddonName); OpenInventories(args.AddonName);
} }
private void OnInventoryPreFinalize(AddonEvent type, AddonArgs args)
{
System.AddonInventoryWindow.Close();
}
private unsafe void OpenInventories(string name) private unsafe void OpenInventories(string name)
{ {
GeneralSettings config = System.Config.General; GeneralSettings config = System.Config.General;
@@ -168,22 +174,27 @@ public class InventoryMonitor : IDisposable
if (atkValues.Length < 7) return; if (atkValues.Length < 7) return;
AtkValue* value1 = (AtkValue*)atkValues[1].Address;
AtkValue* value5 = (AtkValue*)atkValues[5].Address; AtkValue* value5 = (AtkValue*)atkValues[5].Address;
AtkValue* value6 = (AtkValue*)atkValues[6].Address; AtkValue* value6 = (AtkValue*)atkValues[6].Address;
if (value5->Type != ValueType.ManagedString || value6->Type != ValueType.ManagedString) if (value5->Type != ValueType.ManagedString || value6->Type != ValueType.ManagedString)
return; return;
int openTitleId = value1->Int;
ReadOnlySeString title = value5->String.AsReadOnlySeString(); ReadOnlySeString title = value5->String.AsReadOnlySeString();
ReadOnlySeString upperTitle = value6->String.AsReadOnlySeString(); ReadOnlySeString upperTitle = value6->String.AsReadOnlySeString();
System.AddonInventoryWindow.SetNotification(new InventoryNotificationInfo(title, upperTitle)); System.AddonInventoryWindow.SetNotification(new InventoryNotificationInfo(title, upperTitle));
if (config.HideGameInventory) refreshArgs.AtkValueCount = 0; if (config.HideGameInventory)
{
refreshArgs.AtkValueCount = 0;
}
if (config.OpenWithGameInventory) if (config.OpenWithGameInventory)
{ {
AtkValue* value1 = (AtkValue*)atkValues[1].Address;
int openTitleId = value1->Int;
if (openTitleId == 0) if (openTitleId == 0)
{ {
System.AddonInventoryWindow.Toggle(); System.AddonInventoryWindow.Toggle();
@@ -234,6 +245,6 @@ public class InventoryMonitor : IDisposable
public void Dispose() public void Dispose()
{ {
Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw; Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw;
Services.AddonLifecycle.UnregisterListener(OnPostSetup, OnPreFinalize, OnInventoryUpdate, OnSaddleBagUpdate, OnRetainerInventoryUpdate); Services.AddonLifecycle.UnregisterListener(OnPostSetup, OnPreFinalize, OnInventoryUpdate, OnSaddleBagUpdate, OnRetainerInventoryUpdate, OnInventoryPreFinalize, InventoryPreRefreshHandler);
} }
} }
+26 -7
View File
@@ -25,6 +25,7 @@ public sealed unsafe class LootedItemsTracker : IDisposable
private bool _isEnabled; private bool _isEnabled;
private long _batchStartTick; private long _batchStartTick;
private bool _hasPendingRemoval; private bool _hasPendingRemoval;
private int _nextIndex;
public event Action<IReadOnlyList<LootedItemInfo>>? OnLootedItemsChanged; public event Action<IReadOnlyList<LootedItemInfo>>? OnLootedItemsChanged;
@@ -32,8 +33,6 @@ public sealed unsafe class LootedItemsTracker : IDisposable
public bool HasPendingChanges => _pendingChanges.Count > 0 || _hasPendingRemoval; public bool HasPendingChanges => _pendingChanges.Count > 0 || _hasPendingRemoval;
private int GetNextIndex() => _lootedItems.Count > 0 ? _lootedItems.Max(x => x.Index) + 1 : 0;
public void Enable() public void Enable()
{ {
if (_isEnabled) return; if (_isEnabled) return;
@@ -43,6 +42,7 @@ public sealed unsafe class LootedItemsTracker : IDisposable
_pendingChanges.Clear(); _pendingChanges.Clear();
_batchStartTick = 0; _batchStartTick = 0;
_hasPendingRemoval = false; _hasPendingRemoval = false;
_nextIndex = 0;
Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw; Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw;
Services.Framework.Update += OnFrameworkUpdate; Services.Framework.Update += OnFrameworkUpdate;
} }
@@ -58,12 +58,14 @@ public sealed unsafe class LootedItemsTracker : IDisposable
_pendingChanges.Clear(); _pendingChanges.Clear();
_batchStartTick = 0; _batchStartTick = 0;
_hasPendingRemoval = false; _hasPendingRemoval = false;
_nextIndex = 0;
} }
public void Clear() public void Clear()
{ {
_lootedItems.Clear(); _lootedItems.Clear();
_hasPendingRemoval = true; _hasPendingRemoval = true;
_nextIndex = 0;
} }
public void RemoveByIndex(int index) public void RemoveByIndex(int index)
@@ -100,9 +102,7 @@ public sealed unsafe class LootedItemsTracker : IDisposable
foreach (var ((itemId, isHq), (item, delta)) in _pendingChanges) foreach (var ((itemId, isHq), (item, delta)) in _pendingChanges)
{ {
int existingIndex = _lootedItems.FindIndex(x => int existingIndex = FindExistingItemIndex(itemId, isHq);
x.Item.ItemId == itemId &&
x.Item.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality) == isHq);
if (existingIndex >= 0) if (existingIndex >= 0)
{ {
@@ -116,13 +116,27 @@ public sealed unsafe class LootedItemsTracker : IDisposable
} }
else if (delta > 0) else if (delta > 0)
{ {
_lootedItems.Add(new LootedItemInfo(GetNextIndex(), item, delta)); _lootedItems.Add(new LootedItemInfo(_nextIndex++, item, delta));
} }
} }
_pendingChanges.Clear(); _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<InventoryEventArgs> events) private void OnInventoryChangedRaw(IReadOnlyCollection<InventoryEventArgs> events)
{ {
if (!_isEnabled || !Services.ClientState.IsLoggedIn) return; if (!_isEnabled || !Services.ClientState.IsLoggedIn) return;
@@ -156,7 +170,12 @@ public sealed unsafe class LootedItemsTracker : IDisposable
if (_pendingChanges.TryGetValue(key, out var existing)) 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 else
{ {
@@ -41,6 +41,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode
OnClick = isChecked => OnClick = isChecked =>
{ {
config.CategoriesEnabled = isChecked; config.CategoriesEnabled = isChecked;
System.IPC?.RefreshExternalSources();
RefreshInventory(); RefreshInventory();
} }
}; };
@@ -92,8 +93,9 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode
{ {
config.BisBuddyMode = selected; config.BisBuddyMode = selected;
if (selected == PluginFilterMode.Categorize) if (selected == PluginFilterMode.Categorize)
HighlightState.ClearFilter(HighlightSource.AllaganTools); HighlightState.ClearFilter(HighlightSource.BiSBuddy);
System.IPC?.RefreshExternalSources();
RefreshInventory(); RefreshInventory();
} }
}; };
@@ -110,6 +112,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode
config.BisBuddyEnabled = isChecked; config.BisBuddyEnabled = isChecked;
if (bbModeDropdown != null) bbModeDropdown.IsEnabled = isChecked; if (bbModeDropdown != null) bbModeDropdown.IsEnabled = isChecked;
if (isChecked) System.IPC.BisBuddy?.RefreshItems(); if (isChecked) System.IPC.BisBuddy?.RefreshItems();
System.IPC?.RefreshExternalSources();
RefreshInventory(); RefreshInventory();
} }
}; };
@@ -134,6 +137,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode
HighlightState.ClearFilter(HighlightSource.AllaganTools); HighlightState.ClearFilter(HighlightSource.AllaganTools);
} }
System.IPC?.RefreshExternalSources();
RefreshInventory(); RefreshInventory();
} }
}; };
@@ -153,6 +157,7 @@ public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode
config.AllaganToolsCategoriesEnabled = isChecked; config.AllaganToolsCategoriesEnabled = isChecked;
if (atModeDropdown != null) atModeDropdown.IsEnabled = isChecked; if (atModeDropdown != null) atModeDropdown.IsEnabled = isChecked;
if (isChecked) System.IPC?.AllaganTools?.RefreshFilters(); if (isChecked) System.IPC?.AllaganTools?.RefreshFilters();
System.IPC?.RefreshExternalSources();
RefreshInventory(); RefreshInventory();
} }
}; };
@@ -14,6 +14,8 @@ public sealed class CategoryScrollingAreaNode : ScrollingListNode
AddNode(new CategoryGeneralConfigurationNode()); AddNode(new CategoryGeneralConfigurationNode());
AddNode(new ExperimentalConfigurationNode());
var categoryConfigurationButtonNode = new TextButtonNode var categoryConfigurationButtonNode = new TextButtonNode
{ {
Size = new Vector2(300, 28), Size = new Vector2(300, 28),
@@ -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);
}
}
@@ -25,7 +25,8 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
private readonly TextNode _categoryNameTextNode; private readonly TextNode _categoryNameTextNode;
private readonly HybridDirectionalFlexNode<DragDropNode> _itemGridNode; private readonly HybridDirectionalFlexNode<DragDropNode> _itemGridNode;
private const float FallbackItemSize = 46; private const float ExpectedItemWidth = 42;
private const float ExpectedItemHeight = 46;
private const float HeaderHeight = 16; private const float HeaderHeight = 16;
private const float MinWidth = 40; private const float MinWidth = 40;
@@ -41,12 +42,16 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
private int _lastItemCount; private int _lastItemCount;
private ulong _lastItemsHash; private ulong _lastItemsHash;
private int _lastItemsPerLine; private int _lastItemsPerLine;
private float? _lastMaxWidth; private bool _itemsNeedPopulation;
public event Action<InventoryCategoryNode, bool>? HeaderHoverChanged; public event Action<InventoryCategoryNode, bool>? HeaderHoverChanged;
public bool NeedsItemPopulation => _itemsNeedPopulation;
public Action? OnRefreshRequested { get; set; } public Action? OnRefreshRequested { get; set; }
public Action? OnDragEnd { get; set; } public Action? OnDragEnd { get; set; }
public SharedNodePool<InventoryDragDropNode>? SharedItemPool { get; set; }
public InventoryCategoryNode() public InventoryCategoryNode()
{ {
_categoryNameTextNode = new TextNode _categoryNameTextNode = new TextNode
@@ -86,11 +91,10 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
set => SetCategoryData(value, _itemGridNode.ItemsPerLine); set => SetCategoryData(value, _itemGridNode.ItemsPerLine);
} }
public void SetCategoryData(CategorizedInventory data, int itemsPerLine) public void SetCategoryData(CategorizedInventory data, int itemsPerLine, bool deferItemCreation = false)
{ {
bool categoryChanged = data.Key != _lastCategoryKey; bool categoryChanged = data.Key != _lastCategoryKey;
bool itemsPerLineChanged = itemsPerLine != _lastItemsPerLine; bool itemsPerLineChanged = itemsPerLine != _lastItemsPerLine;
bool maxWidthChanged = _maxWidth != _lastMaxWidth;
ulong itemsHash = ComputeItemsHash(CollectionsMarshal.AsSpan(data.Items)); ulong itemsHash = ComputeItemsHash(CollectionsMarshal.AsSpan(data.Items));
bool itemsChanged = data.Items.Count != _lastItemCount || itemsHash != _lastItemsHash; bool itemsChanged = data.Items.Count != _lastItemCount || itemsHash != _lastItemsHash;
@@ -99,7 +103,6 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
_lastItemCount = data.Items.Count; _lastItemCount = data.Items.Count;
_lastItemsHash = itemsHash; _lastItemsHash = itemsHash;
_lastItemsPerLine = itemsPerLine; _lastItemsPerLine = itemsPerLine;
_lastMaxWidth = _maxWidth;
_categorizedInventory = data; _categorizedInventory = data;
@@ -112,24 +115,45 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
_categoryNameTextNode.TextTooltip = data.Category.Description; _categoryNameTextNode.TextTooltip = data.Category.Description;
if (itemsChanged || categoryChanged) if (itemsChanged || categoryChanged)
{
_itemGridNode.ItemsPerLine = itemsPerLine;
if (deferItemCreation)
{
_itemsNeedPopulation = true;
}
else
{ {
using (_itemGridNode.DeferRecalculateLayout()) using (_itemGridNode.DeferRecalculateLayout())
{ {
_itemGridNode.ItemsPerLine = itemsPerLine;
UpdateItemGrid(); UpdateItemGrid();
} }
_itemsNeedPopulation = false;
}
} }
else if (itemsPerLineChanged) else if (itemsPerLineChanged)
{ {
_itemGridNode.ItemsPerLine = itemsPerLine; _itemGridNode.ItemsPerLine = itemsPerLine;
} }
if (categoryChanged || itemsChanged || itemsPerLineChanged || maxWidthChanged) if (categoryChanged || itemsChanged || itemsPerLineChanged)
{ {
RecalculateSize(); RecalculateSize();
} }
} }
public void PopulateItems()
{
if (!_itemsNeedPopulation)
return;
using (_itemGridNode.DeferRecalculateLayout())
{
UpdateItemGrid();
}
_itemsNeedPopulation = false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static ulong ComputeItemsHash(ReadOnlySpan<ItemInfo> items) private static ulong ComputeItemsHash(ReadOnlySpan<ItemInfo> items)
{ {
@@ -164,7 +188,7 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
} }
} }
public float? MaxWidth public override float? MaxWidth
{ {
get => _maxWidth; get => _maxWidth;
set => _maxWidth = value; set => _maxWidth = value;
@@ -234,12 +258,12 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
} }
} }
public void RecalculateSize() public override void RecalculateSize()
{ {
int itemCount = CategorizedInventory.Items.Count; int itemCount = CategorizedInventory.Items.Count;
float cellW = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Width : FallbackItemSize; float cellW = ExpectedItemWidth;
float cellH = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Height : FallbackItemSize; float cellH = ExpectedItemHeight;
float hPad = _itemGridNode.HorizontalPadding; float hPad = _itemGridNode.HorizontalPadding;
float vPad = _itemGridNode.VerticalPadding; float vPad = _itemGridNode.VerticalPadding;
@@ -298,31 +322,55 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
getKeyFromData: item => item.Key, getKeyFromData: item => item.Key,
getKeyFromNode: node => node.ItemInfo?.Key ?? 0, getKeyFromNode: node => node.ItemInfo?.Key ?? 0,
updateNode: UpdateInventoryDragDropNode, updateNode: UpdateInventoryDragDropNode,
createNodeMethod: CreateInventoryDragDropNode); createNodeMethod: CreateInventoryDragDropNode,
resetNodeForReuse: ResetDragDropNodeForReuse,
externalPool: SharedItemPool);
} }
private void UpdateInventoryDragDropNode(InventoryDragDropNode node, ItemInfo data) private void UpdateInventoryDragDropNode(InventoryDragDropNode node, ItemInfo data)
{
if (node.ItemInfo?.Key == data.Key)
{ {
node.ItemInfo = data; node.ItemInfo = data;
node.Alpha = data.VisualAlpha; ApplyItemDataToNode(node, data);
node.AddColor = data.HighlightOverlayColor;
node.IsDraggable = !data.IsSlotBlocked;
return;
} }
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; InventoryItem item = data.Item;
InventoryMappedLocation visualLocation = data.VisualLocation; InventoryMappedLocation visualLocation = data.VisualLocation;
var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container); var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container);
int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot; int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot;
node.ItemInfo = data;
node.IconId = item.IconId; node.IconId = item.IconId;
node.Alpha = data.VisualAlpha; node.Alpha = data.VisualAlpha;
node.AddColor = data.HighlightOverlayColor; node.AddColor = data.HighlightOverlayColor;
node.IsDraggable = !data.IsSlotBlocked; node.IsDraggable = !data.IsSlotBlocked;
node.IconNode.IconExtras.AntsNode.IsVisible = data.IsRelationshipHighlighted;
node.Payload = new DragDropPayload node.Payload = new DragDropPayload
{ {
Type = DragDropType.Item, Type = DragDropType.Item,
@@ -332,52 +380,31 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
}; };
} }
private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) private void OnNodeDiscard(DragDropNode n)
{ {
InventoryItem item = data.Item; if (n is not InventoryDragDropNode node) return;
InventoryMappedLocation visualLocation = data.VisualLocation; OnDiscard(n, node.ItemInfo);
}
var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container); private void OnNodePayloadAccepted(DragDropNode n, DragDropPayload acceptedPayload)
int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot; {
if (n is not InventoryDragDropNode node) return;
OnPayloadAccepted(n, acceptedPayload, node.ItemInfo);
}
DragDropPayload nodePayload = new DragDropPayload private unsafe void OnNodeRollOver(DragDropNode n)
{
// 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
};
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 =>
{ {
if (n is not InventoryDragDropNode node) return;
BeginHeaderHover(); BeginHeaderHover();
node.ShowInventoryItemTooltip(item.Container, item.Slot); var item = node.ItemInfo.Item;
}, n.ShowInventoryItemTooltip(item.Container, item.Slot);
OnRollOut = node => }
private unsafe void OnNodeRollOut(DragDropNode n)
{ {
EndHeaderHover(); EndHeaderHover();
ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(n)->Id;
ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(node)->Id;
AtkStage.Instance()->TooltipManager.HideTooltip(addonId); AtkStage.Instance()->TooltipManager.HideTooltip(addonId);
},
ItemInfo = data
};
} }
public void RefreshNodeVisuals() public void RefreshNodeVisuals()
@@ -392,6 +419,7 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
float newAlpha = info.VisualAlpha; float newAlpha = info.VisualAlpha;
Vector3 newColor = info.HighlightOverlayColor; Vector3 newColor = info.HighlightOverlayColor;
bool newDraggable = !info.IsSlotBlocked; bool newDraggable = !info.IsSlotBlocked;
bool newAntsVisible = info.IsRelationshipHighlighted;
if (!NearlyEqual(itemNode.Alpha, newAlpha)) if (!NearlyEqual(itemNode.Alpha, newAlpha))
itemNode.Alpha = newAlpha; itemNode.Alpha = newAlpha;
@@ -401,6 +429,9 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
if (itemNode.IsDraggable != newDraggable) if (itemNode.IsDraggable != newDraggable)
itemNode.IsDraggable = newDraggable; itemNode.IsDraggable = newDraggable;
if (itemNode.IconNode.IconExtras.AntsNode.IsVisible != newAntsVisible)
itemNode.IconNode.IconExtras.AntsNode.IsVisible = newAntsVisible;
} }
} }
@@ -454,4 +485,30 @@ public class InventoryCategoryNode : InventoryCategoryNodeBase
Services.Logger.Error(ex, "[OnPayload] Error handling payload acceptance"); 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();
}
}
} }
@@ -17,4 +17,8 @@ public abstract class InventoryCategoryNodeBase : SimpleComponentNode
/// Whether this category should be pinned in the layout. /// Whether this category should be pinned in the layout.
/// </summary> /// </summary>
public virtual bool IsPinnedInConfig => false; public virtual bool IsPinnedInConfig => false;
public abstract float? MaxWidth { get; set; }
public abstract void RecalculateSize();
} }
@@ -1,17 +1,25 @@
using System.Numerics; using System.Numerics;
using AetherBags.Addons;
using AetherBags.Inventory.Context;
using AetherBags.Inventory.Items; using AetherBags.Inventory.Items;
using AetherBags.IPC.ExternalCategorySystem;
using Dalamud.Game.ClientState.Keys; using Dalamud.Game.ClientState.Keys;
using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes; using KamiToolKit.Classes;
using KamiToolKit.Nodes; using KamiToolKit.Nodes;
using KamiToolKit.Timelines;
namespace AetherBags.Nodes.Inventory; namespace AetherBags.Nodes.Inventory;
public class InventoryDragDropNode : DragDropNode public class InventoryDragDropNode : DragDropNode
{ {
private readonly TextNode _quantityTextNode; private readonly TextNode _quantityTextNode;
private IconNode? _badgeNode;
private ImageNode? _borderNode;
private ItemDecoration? _currentDecoration;
public unsafe InventoryDragDropNode() public unsafe InventoryDragDropNode()
{ {
_quantityTextNode = new TextNode { _quantityTextNode = new TextNode {
@@ -26,6 +34,8 @@ public class InventoryDragDropNode : DragDropNode
_quantityTextNode.AttachNode(this); _quantityTextNode.AttachNode(this);
CollisionNode.AddEvent(AtkEventType.MouseDown, OnItemMouseDown); CollisionNode.AddEvent(AtkEventType.MouseDown, OnItemMouseDown);
CollisionNode.AddEvent(AtkEventType.MouseClick, OnItemClicked); CollisionNode.AddEvent(AtkEventType.MouseClick, OnItemClicked);
CollisionNode.AddEvent(AtkEventType.MouseOver, OnItemHover);
CollisionNode.AddEvent(AtkEventType.MouseOut, OnItemUnhover);
} }
public required ItemInfo ItemInfo public required ItemInfo ItemInfo
@@ -35,9 +45,164 @@ public class InventoryDragDropNode : DragDropNode
{ {
field = value; field = value;
_quantityTextNode.String = value.ItemCount.ToString(); _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) { private unsafe void OnItemMouseDown(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) {
InventoryItem item = ItemInfo.Item; InventoryItem item = ItemInfo.Item;
if (Services.KeyState[VirtualKey.SHIFT] && atkEventData->IsLeftClick && System.Config.General.LinkItemEnabled) if (Services.KeyState[VirtualKey.SHIFT] && atkEventData->IsLeftClick && System.Config.General.LinkItemEnabled)
@@ -48,6 +213,11 @@ public class InventoryDragDropNode : DragDropNode
if (!atkEventData->IsRightClick) return; if (!atkEventData->IsRightClick) return;
if (Services.KeyState[VirtualKey.CONTROL] && ItemContextMenuHandler.TryShowExternalMenu(ItemInfo))
{
return;
}
AgentInventoryContext* context = AgentInventoryContext.Instance(); AgentInventoryContext* context = AgentInventoryContext.Instance();
context->OpenForItemSlot(item.Container, item.Slot, 0, context->AddonId); 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; if (Services.KeyState[VirtualKey.SHIFT] && System.Config.General.LinkItemEnabled) return;
InventoryItem item = ItemInfo.Item; InventoryItem item = ItemInfo.Item;
if (!atkEventData->IsLeftClick) return; if (!atkEventData->IsLeftClick) return;
System.AetherBagsAPI?.API.RaiseItemClicked(item.ItemId);
item.UseItem(); 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;
}
} }
@@ -48,8 +48,7 @@ public sealed class InventoryFooterNode : SimpleComponentNode
if (!config.Enabled) return; if (!config.Enabled) return;
//IReadOnlyList<CurrencyInfo> currencyInfoList = GetCurrencyInfoList([1, 28, 0xFFFF_FFFE, 0xFFFF_FFFD]); IReadOnlyList<CurrencyInfo> currencyInfoList = GetCurrencyInfoList(config.DisplayedCurrencies);
IReadOnlyList<CurrencyInfo> currencyInfoList = GetCurrencyInfoList(config.DisplayedCurrencies.ToArray());
_currencyListNode.SyncWithListDataByKey<CurrencyInfo, CurrencyNode, uint>( _currencyListNode.SyncWithListDataByKey<CurrencyInfo, CurrencyNode, uint>(
dataList: currencyInfoList, dataList: currencyInfoList,
getKeyFromData: currencyInfo => currencyInfo.ItemId, getKeyFromData: currencyInfo => currencyInfo.ItemId,
@@ -18,6 +18,8 @@ public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode
private readonly TextNode _quantityTextNode; private readonly TextNode _quantityTextNode;
public Action<LootedItemDisplayNode>? OnDismiss { get; set; } public Action<LootedItemDisplayNode>? OnDismiss { get; set; }
public Action<LootedItemDisplayNode>? OnRollOver { get; set; }
public Action<LootedItemDisplayNode>? OnRollOut { get; set; }
public LootedItemDisplayNode() public LootedItemDisplayNode()
{ {
@@ -28,9 +30,13 @@ public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode
Position = new Vector2(0, 0), Position = new Vector2(0, 0),
Size = new Vector2(42, 46), Size = new Vector2(42, 46),
}; };
_iconNode.AddEvent(AtkEventType.MouseClick, OnMouseClick); _iconNode.CollisionNode.NodeFlags = 0;
_iconNode.AttachNode(this); _iconNode.AttachNode(this);
CollisionNode.AddEvent(AtkEventType.MouseClick, OnMouseClick);
CollisionNode.AddEvent(AtkEventType.MouseOver, OnMouseOver);
CollisionNode.AddEvent(AtkEventType.MouseOut, OnMouseOut);
_quantityTextNode = new TextNode _quantityTextNode = new TextNode
{ {
Size = new Vector2(40.0f, 12.0f), Size = new Vector2(40.0f, 12.0f),
@@ -80,4 +86,21 @@ public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode
if (!atkEventData->IsLeftClick) return; if (!atkEventData->IsLeftClick) return;
OnDismiss?.Invoke(this); 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;
}
} }
@@ -32,6 +32,8 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
private int _lastItemCount; private int _lastItemCount;
private long _lastItemsHash; private long _lastItemsHash;
private float? _maxWidth;
private int _hoverRefs; private int _hoverRefs;
private bool _headerExpanded; private bool _headerExpanded;
private float _baseHeaderWidth = 96f; private float _baseHeaderWidth = 96f;
@@ -54,6 +56,12 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
public bool HasItems => _lootedItems.Count > 0; public bool HasItems => _lootedItems.Count > 0;
public override float? MaxWidth
{
get => _maxWidth;
set => _maxWidth = value;
}
public LootedItemsCategoryNode() public LootedItemsCategoryNode()
{ {
_headerTextNode = new TextNode _headerTextNode = new TextNode
@@ -183,6 +191,8 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
Vector2 drawSize = _headerTextNode.GetTextDrawSize(); Vector2 drawSize = _headerTextNode.GetTextDrawSize();
float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f); float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f);
_headerTextNode.Size = _headerTextNode.Size with { X = expandedWidth }; _headerTextNode.Size = _headerTextNode.Size with { X = expandedWidth };
_clearButton.Position = new Vector2(expandedWidth + 4f, (HeaderHeight - ClearButtonSize) / 2);
} }
else else
{ {
@@ -193,6 +203,9 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis;
_headerTextNode.TextFlags = flags; _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, getKeyFromData: item => item.Index,
getKeyFromNode: node => node.LootedItem?.Index ?? -1, getKeyFromNode: node => node.LootedItem?.Index ?? -1,
updateNode: UpdateLootedItemNode, updateNode: UpdateLootedItemNode,
createNodeMethod: CreateLootedItemNode); createNodeMethod: CreateLootedItemNode,
resetNodeForReuse: ResetLootedItemNodeForReuse);
} }
private static void UpdateLootedItemNode(LootedItemDisplayNode node, LootedItemInfo data) private static void UpdateLootedItemNode(LootedItemDisplayNode node, LootedItemInfo data)
@@ -211,11 +225,18 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
node.LootedItem = data; node.LootedItem = data;
} }
private static void ResetLootedItemNodeForReuse(LootedItemDisplayNode node)
{
node.ResetForReuse();
}
private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem) private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem)
{ {
return new LootedItemDisplayNode return new LootedItemDisplayNode
{ {
OnDismiss = OnItemDismissed, OnDismiss = OnItemDismissed,
OnRollOver = _ => BeginHeaderHover(),
OnRollOut = _ => EndHeaderHover(),
LootedItem = lootedItem, LootedItem = lootedItem,
}; };
} }
@@ -227,13 +248,19 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
OnDismissItem?.Invoke(index); OnDismissItem?.Invoke(index);
} }
private void RecalculateSize() public override void RecalculateSize()
{ {
int itemCount = _lootedItems.Count; int itemCount = _lootedItems.Count;
const float cellW = 42f;
const float cellH = 46f;
float hPad = _itemGridNode.HorizontalPadding;
float vPad = _itemGridNode.VerticalPadding;
if (itemCount == 0) if (itemCount == 0)
{ {
float width = MinWidth; float width = _maxWidth.HasValue ? Math.Min(MinWidth, _maxWidth.Value) : MinWidth;
Size = new Vector2(width, HeaderHeight); Size = new Vector2(width, HeaderHeight);
_baseHeaderWidth = width - ClearButtonSize - 4; _baseHeaderWidth = width - ClearButtonSize - 4;
_headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight);
@@ -246,20 +273,36 @@ public class LootedItemsCategoryNode : InventoryCategoryNodeBase
} }
int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); 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 rows = (itemCount + itemsPerLine - 1) / itemsPerLine;
int actualColumns = Math.Min(itemCount, 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 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 gridHeight = rows * cellH + (rows - 1) * vPad;
float totalHeight = HeaderHeight + gridHeight; float totalHeight = HeaderHeight + gridHeight;
Size = new Vector2(calculatedWidth, totalHeight); Size = new Vector2(calculatedWidth, totalHeight);
if (_itemGridNode.ItemsPerLine != itemsPerLine)
_itemGridNode.ItemsPerLine = itemsPerLine;
_baseHeaderWidth = calculatedWidth - ClearButtonSize - 4; _baseHeaderWidth = calculatedWidth - ClearButtonSize - 4;
_headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight);
_clearButton.Position = new Vector2(calculatedWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); _clearButton.Position = new Vector2(calculatedWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2);
@@ -15,20 +15,75 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
private int _deferRecalcDepth; private int _deferRecalcDepth;
private bool _pendingRecalc; private bool _pendingRecalc;
/// <summary> private readonly Dictionary<Type, Stack<NodeBase>> _nodePoolByType = new();
/// Hide and detach a node from the UI tree without disposing it. private const int MaxPoolSizePerType = 64;
/// Disposal happens later when KamiToolKit cleans up detached nodes.
/// </summary> [MethodImpl(MethodImplOptions.AggressiveInlining)]
protected static void SafeDetachNode(NodeBase node) private TU? TryRentFromPool<TU>(SharedNodePool<TU>? 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>(TU node, SharedNodePool<TU>? externalPool, Action<TU>? 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<NodeBase>(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 try
{ {
node.IsVisible = false; node.Dispose();
node.DetachNode();
} }
catch (Exception ex) 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; if (!NodeList.Contains(node)) return;
NodeList.Remove(node); NodeList.Remove(node);
SafeDetachNode(node); SafeDisposeNode(node);
RecalculateLayout(); RecalculateLayout();
} }
@@ -161,13 +216,16 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
{ {
var node = NodeList[i]; var node = NodeList[i];
NodeList.RemoveAt(i); NodeList.RemoveAt(i);
SafeDetachNode(node); SafeDisposeNode(node);
} }
} }
finally finally
{ {
_suppressRecalculateLayout = false; _suppressRecalculateLayout = false;
} }
DisposePool();
RecalculateLayout(); RecalculateLayout();
} }
@@ -248,13 +306,14 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
_dataKeysScratch = set; _dataKeysScratch = set;
} }
public bool SyncWithListDataByKey<T, TU, TKey>( public bool SyncWithListDataByKey<T, TU, TKey>(
IReadOnlyList<T> dataList, IReadOnlyList<T> dataList,
Func<T, TKey> getKeyFromData, Func<T, TKey> getKeyFromData,
Func<TU, TKey> getKeyFromNode, Func<TU, TKey> getKeyFromNode,
Action<TU, T> updateNode, Action<TU, T> updateNode,
CreateNewNode<T, TU> createNodeMethod, CreateNewNode<T, TU> createNodeMethod,
Action<TU>? resetNodeForReuse = null,
SharedNodePool<TU>? externalPool = null,
IEqualityComparer<TKey>? keyComparer = null) where TU : NodeBase where TKey : notnull IEqualityComparer<TKey>? keyComparer = null) where TU : NodeBase where TKey : notnull
{ {
keyComparer ??= EqualityComparer<TKey>.Default; keyComparer ??= EqualityComparer<TKey>.Default;
@@ -295,9 +354,13 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
{ {
for (int i = 0; i < toRemove.Count; i++) for (int i = 0; i < toRemove.Count; i++)
{ {
var node = toRemove[i]; var node = (TU)toRemove[i];
NodeList.Remove(node); NodeList.Remove(node);
SafeDetachNode(node);
if (!TryReturnToPool(node, externalPool, resetNodeForReuse))
{
SafeDisposeNode(node);
}
} }
} }
finally finally
@@ -345,9 +408,20 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
} }
else else
{ {
var newNode = createNodeMethod(data); TU newNode;
NodeList.Add(newNode); var pooledNode = TryRentFromPool(externalPool);
if (pooledNode != null)
{
newNode = pooledNode;
newNode.AttachNode(this); newNode.AttachNode(this);
}
else
{
newNode = createNodeMethod(data);
newNode.AttachNode(this);
}
NodeList.Add(newNode);
updateNode(newNode, data); updateNode(newNode, data);
desired.Add(newNode); desired.Add(newNode);
structureChanged = true; structureChanged = true;
@@ -425,6 +499,15 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
return structureChanged || orderChanged; return structureChanged || orderChanged;
} }
public bool SyncWithListDataByKey<T, TU, TKey>(
IReadOnlyList<T> dataList,
Func<T, TKey> getKeyFromData,
Func<TU, TKey> getKeyFromNode,
Action<TU, T> updateNode,
CreateNewNode<T, TU> createNodeMethod,
IEqualityComparer<TKey>? keyComparer) where TU : NodeBase where TKey : notnull
=> SyncWithListDataByKey(dataList, getKeyFromData, getKeyFromNode, updateNode, createNodeMethod, null, null, keyComparer);
public bool SyncWithListData<T, TU>( public bool SyncWithListData<T, TU>(
IEnumerable<T> dataList, IEnumerable<T> dataList,
GetDataFromNode<T?, TU> getDataFromNode, GetDataFromNode<T?, TU> getDataFromNode,
@@ -455,7 +538,7 @@ public abstract class DeferrableLayoutListNode : SimpleComponentNode
if (nodeData is null || !dataSet.Contains(nodeData)) if (nodeData is null || !dataSet.Contains(nodeData))
{ {
NodeList.Remove(tu); NodeList.Remove(tu);
SafeDetachNode(tu); SafeDisposeNode(tu);
anythingChanged = true; anythingChanged = true;
continue; continue;
} }
+107
View File
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using KamiToolKit;
namespace AetherBags.Nodes.Layout;
public sealed class SharedNodePool<T> where T : NodeBase
{
private readonly Stack<T> _pool;
private readonly int _maxSize;
private readonly Func<T>? _factory;
private readonly Action<T>? _resetAction;
public SharedNodePool(int maxSize = 128, Func<T>? factory = null, Action<T>? resetAction = null)
{
_maxSize = maxSize;
_factory = factory;
_resetAction = resetAction;
_pool = new Stack<T>(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);
}
}
}
@@ -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<VisibilityInfo> _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;
}
}
+16 -3
View File
@@ -36,6 +36,7 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
private int _lastCompactLookahead; private int _lastCompactLookahead;
private int[] _orderScratch = Array.Empty<int>(); private int[] _orderScratch = Array.Empty<int>();
private bool _forceFullReflow;
private T? _hoistedNode; private T? _hoistedNode;
private readonly HashSet<T> _pinned = new(ReferenceEqualityComparer<T>.Instance); private readonly HashSet<T> _pinned = new(ReferenceEqualityComparer<T>.Instance);
@@ -43,6 +44,7 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
private readonly List<NodeBase> _layoutOrder = new(capacity: 256); private readonly List<NodeBase> _layoutOrder = new(capacity: 256);
private readonly List<NodeBase> _pinnedScratch = new(capacity: 64); private readonly List<NodeBase> _pinnedScratch = new(capacity: 64);
private readonly List<NodeBase> _normalScratch = new(capacity: 256); private readonly List<NodeBase> _normalScratch = new(capacity: 256);
private readonly HashSet<T> _presentScratch = new(ReferenceEqualityComparer<T>.Instance);
public WrappingGridNode() public WrappingGridNode()
{ {
@@ -56,6 +58,11 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetRowIndex(NodeBase node, out int rowIndex) => _rowIndex.TryGetValue(node, out rowIndex); public bool TryGetRowIndex(NodeBase node, out int rowIndex) => _rowIndex.TryGetValue(node, out rowIndex);
public void InvalidateLayout()
{
_forceFullReflow = true;
}
public void SetHoistedNode(T? node) public void SetHoistedNode(T? node)
{ {
if (ReferenceEquals(_hoistedNode, node)) if (ReferenceEquals(_hoistedNode, node))
@@ -111,10 +118,14 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
_rowIndex.Clear(); _rowIndex.Clear();
_requiredHeight = 0f; _requiredHeight = 0f;
_requiredHeightDirty = false; _requiredHeightDirty = false;
_forceFullReflow = false;
RememberLayoutParams(); RememberLayoutParams();
return; return;
} }
bool forceReflow = _forceFullReflow;
_forceFullReflow = false;
bool hasSpecials = hoistedCount != 0 || pinnedCount != 0; bool hasSpecials = hoistedCount != 0 || pinnedCount != 0;
bool compactEnabled = System.Config.General.CompactPackingEnabled; bool compactEnabled = System.Config.General.CompactPackingEnabled;
@@ -128,7 +139,7 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
return; return;
} }
if (_rows.Count != 0 && LayoutParamsMatchLast() && NodeSetMatchesExistingLayout(layoutCount)) if (!forceReflow && _rows.Count != 0 && LayoutParamsMatchLast() && NodeSetMatchesExistingLayout(layoutCount))
{ {
RepositionExistingRows(); RepositionExistingRows();
_requiredHeightDirty = true; _requiredHeightDirty = true;
@@ -142,7 +153,8 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
return; return;
} }
if (_rows.Count != 0 && if (!forceReflow &&
_rows.Count != 0 &&
NodeSetMatchesExistingLayout(layoutCount) && NodeSetMatchesExistingLayout(layoutCount) &&
TryUpdateLayoutWithoutReflowOrTailReflow(layoutCount, hoistedCount, pinnedCount)) TryUpdateLayoutWithoutReflowOrTailReflow(layoutCount, hoistedCount, pinnedCount))
{ {
@@ -173,7 +185,8 @@ public sealed class WrappingGridNode<T> : DeferrableLayoutListNode where T : Nod
return 0; return 0;
} }
var present = new HashSet<T>(ReferenceEqualityComparer<T>.Instance); _presentScratch.Clear();
var present = _presentScratch;
bool hoistedPresent = false; bool hoistedPresent = false;
T? hoisted = _hoistedNode; T? hoisted = _hoistedNode;
+8
View File
@@ -6,6 +6,7 @@ using AetherBags.Hooks;
using AetherBags.Inventory; using AetherBags.Inventory;
using AetherBags.Inventory.Context; using AetherBags.Inventory.Context;
using AetherBags.IPC; using AetherBags.IPC;
using AetherBags.IPC.AetherBagsAPI;
using AetherBags.Monitoring; using AetherBags.Monitoring;
using Dalamud.Plugin; using Dalamud.Plugin;
using KamiToolKit; using KamiToolKit;
@@ -29,6 +30,8 @@ public class Plugin : IDalamudPlugin
KamiToolKitLibrary.Initialize(pluginInterface); KamiToolKitLibrary.Initialize(pluginInterface);
System.IPC = new IPCService(); System.IPC = new IPCService();
System.IPC.UpdateUnifiedCategorySupport(System.Config.General.UseUnifiedExternalCategories);
ItemContextMenuHandler.Initialize();
System.LootedItemsTracker = new LootedItemsTracker(); System.LootedItemsTracker = new LootedItemsTracker();
System.AddonInventoryWindow = new AddonInventoryWindow System.AddonInventoryWindow = new AddonInventoryWindow
@@ -62,6 +65,8 @@ public class Plugin : IDalamudPlugin
Services.PluginInterface.UiBuilder.OpenMainUi += System.AddonInventoryWindow.Toggle; Services.PluginInterface.UiBuilder.OpenMainUi += System.AddonInventoryWindow.Toggle;
Services.PluginInterface.UiBuilder.OpenConfigUi += System.AddonConfigurationWindow.Toggle; Services.PluginInterface.UiBuilder.OpenConfigUi += System.AddonConfigurationWindow.Toggle;
System.AetherBagsAPI = new AetherBagsIPCProvider();
_commandHandler = new CommandHandler(); _commandHandler = new CommandHandler();
Services.ClientState.Login += OnLogin; Services.ClientState.Login += OnLogin;
@@ -78,10 +83,12 @@ public class Plugin : IDalamudPlugin
public void Dispose() public void Dispose()
{ {
InventoryAddonContextMenu.Close(); InventoryAddonContextMenu.Close();
ItemContextMenuHandler.Dispose();
_inventoryHooks.Dispose(); _inventoryHooks.Dispose();
inventoryMonitor.Dispose(); inventoryMonitor.Dispose();
System.LootedItemsTracker.Dispose(); System.LootedItemsTracker.Dispose();
System.AetherBagsAPI?.Dispose();
System.IPC.Dispose(); System.IPC.Dispose();
HighlightState.ClearAll(); HighlightState.ClearAll();
@@ -97,6 +104,7 @@ public class Plugin : IDalamudPlugin
private void OnLogin() private void OnLogin()
{ {
System.Config = Util.LoadConfigOrDefault(); System.Config = Util.LoadConfigOrDefault();
System.IPC.UpdateUnifiedCategorySupport(System.Config.General.UseUnifiedExternalCategories);
System.LootedItemsTracker.Enable(); System.LootedItemsTracker.Enable();
System.AddonInventoryWindow.DebugOpen(); System.AddonInventoryWindow.DebugOpen();
+2
View File
@@ -2,6 +2,7 @@ using AetherBags.Addons;
using AetherBags.Configuration; using AetherBags.Configuration;
using AetherBags.Inventory; using AetherBags.Inventory;
using AetherBags.IPC; using AetherBags.IPC;
using AetherBags.IPC.AetherBagsAPI;
using AetherBags.Monitoring; using AetherBags.Monitoring;
namespace AetherBags; namespace AetherBags;
@@ -13,6 +14,7 @@ public static class System
public static AddonRetainerWindow AddonRetainerWindow { get; set; } = null!; public static AddonRetainerWindow AddonRetainerWindow { get; set; } = null!;
public static AddonConfigurationWindow AddonConfigurationWindow { get; set; } = null!; public static AddonConfigurationWindow AddonConfigurationWindow { get; set; } = null!;
public static IPCService IPC { 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 SystemConfiguration Config { get; set; } = null!;
public static LootedItemsTracker LootedItemsTracker { get; set; } = null!; public static LootedItemsTracker LootedItemsTracker { get; set; } = null!;
} }