Pinning, Hoisting, Recently Lotted

This commit is contained in:
Shawrkie Williams
2026-01-09 17:53:07 -05:00
parent fe7a8136af
commit 665d3b62ba
38 changed files with 802 additions and 268 deletions
@@ -1,4 +1,4 @@
using AetherBags.Nodes.Layout;
using AetherBags.Nodes.Layout;
namespace AetherBags.Nodes.Inventory;
@@ -6,76 +6,94 @@ public sealed class InventoryCategoryHoverCoordinator
{
private InventoryCategoryNode? _active;
private int _activeRowIndex = -1;
private bool _isProcessing;
public void OnCategoryHoverChanged(
WrappingGridNode<InventoryCategoryNode> grid,
WrappingGridNode<InventoryCategoryNodeBase> grid,
InventoryCategoryNode source,
bool hovering)
{
grid.RecalculateLayout();
if (_isProcessing)
return;
if (hovering)
try
{
_active = source;
_isProcessing = true;
grid.RecalculateLayout();
if (!grid.TryGetRowIndex(source, out _activeRowIndex))
if (hovering)
{
SuppressAllExcept(grid, source);
_active = source;
if (!grid.TryGetRowIndex(source, out _activeRowIndex))
{
SuppressAllExcept(grid, source);
source.SetHeaderSuppressed(false);
return;
}
ClearAll(grid);
var row = grid.Rows[_activeRowIndex];
for (int i = 0; i < row.Count; i++)
{
if (row[i] is InventoryCategoryNode cat && !ReferenceEquals(cat, source))
cat.SetHeaderSuppressed(true);
}
source.SetHeaderSuppressed(false);
return;
}
ClearAll(grid);
if (!ReferenceEquals(_active, source))
return;
var row = grid.Rows[_activeRowIndex];
for (int i = 0; i < row.Count; i++)
_active = null;
if (_activeRowIndex >= 0 && _activeRowIndex < grid.Rows.Count)
{
if (row[i] is InventoryCategoryNode cat && !ReferenceEquals(cat, source))
cat.SetHeaderSuppressed(true);
var row = grid.Rows[_activeRowIndex];
for (int i = 0; i < row.Count; i++)
{
if (row[i] is InventoryCategoryNode cat)
cat.SetHeaderSuppressed(false);
}
}
else
{
ClearAll(grid);
}
source.SetHeaderSuppressed(false);
return;
_activeRowIndex = -1;
}
if (!ReferenceEquals(_active, source))
return;
_active = null;
if (_activeRowIndex >= 0 && _activeRowIndex < grid.Rows.Count)
finally
{
var row = grid.Rows[_activeRowIndex];
for (int i = 0; i < row.Count; i++)
{
if (row[i] is InventoryCategoryNode cat)
cat.SetHeaderSuppressed(false);
}
_isProcessing = false;
}
else
{
ClearAll(grid);
}
_activeRowIndex = -1;
}
public void ResetAll(WrappingGridNode<InventoryCategoryNode> grid)
public void ResetAll(WrappingGridNode<InventoryCategoryNodeBase> grid)
{
_active = null;
_activeRowIndex = -1;
ClearAll(grid);
}
private static void ClearAll(WrappingGridNode<InventoryCategoryNode> grid)
private static void ClearAll(WrappingGridNode<InventoryCategoryNodeBase> grid)
{
foreach (var cat in grid.GetNodes<InventoryCategoryNode>())
cat.SetHeaderSuppressed(false);
foreach (var node in grid.GetNodes<InventoryCategoryNodeBase>())
{
if (node is InventoryCategoryNode cat)
cat.SetHeaderSuppressed(false);
}
}
private static void SuppressAllExcept(WrappingGridNode<InventoryCategoryNode> grid, InventoryCategoryNode source)
private static void SuppressAllExcept(WrappingGridNode<InventoryCategoryNodeBase> grid, InventoryCategoryNode source)
{
foreach (var cat in grid.GetNodes<InventoryCategoryNode>())
cat.SetHeaderSuppressed(!ReferenceEquals(cat, source));
foreach (var node in grid.GetNodes<InventoryCategoryNodeBase>())
{
if (node is InventoryCategoryNode cat)
cat.SetHeaderSuppressed(!ReferenceEquals(cat, source));
}
}
}
@@ -1,7 +1,6 @@
using System;
using System.Numerics;
using AetherBags.Helpers;
using AetherBags.Hooks;
using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Items;
@@ -9,15 +8,17 @@ using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Inventory;
public class InventoryCategoryNode : SimpleComponentNode
public class InventoryCategoryNode : InventoryCategoryNodeBase
{
private const uint CategoryNodeKeyBase = 0x10000000;
public override uint Key => CategoryNodeKeyBase | CategorizedInventory.Key;
private readonly TextNode _categoryNameTextNode;
private readonly HybridDirectionalFlexNode<DragDropNode> _itemGridNode;
@@ -109,7 +110,7 @@ public class InventoryCategoryNode : SimpleComponentNode
}
}
public bool IsPinnedInConfig => CategorizedInventory.Category?.IsPinned ?? false;
public override bool IsPinnedInConfig => CategorizedInventory.Category?.IsPinned ?? false;
public void BeginHeaderHover()
{
@@ -0,0 +1,20 @@
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Inventory;
/// <summary>
/// Base class for category-like nodes that can be displayed in the inventory grid.
/// Used to allow both regular categories and special categories (like looted items) to be hoisted/pinned.
/// </summary>
public abstract class InventoryCategoryNodeBase : SimpleComponentNode
{
/// <summary>
/// Unique key for this category, used for sync operations.
/// </summary>
public abstract uint Key { get; }
/// <summary>
/// Whether this category should be pinned in the layout.
/// </summary>
public virtual bool IsPinnedInConfig => false;
}
@@ -4,13 +4,13 @@ namespace AetherBags.Nodes.Inventory;
public sealed class InventoryCategoryPinCoordinator
{
public bool ApplyPinnedStates(WrappingGridNode<InventoryCategoryNode> grid)
public bool ApplyPinnedStates(WrappingGridNode<InventoryCategoryNodeBase> grid)
{
bool changed = false;
using (grid.DeferRecalculateLayout())
{
foreach (var node in grid.GetNodes<InventoryCategoryNode>())
foreach (var node in grid.GetNodes<InventoryCategoryNodeBase>())
{
bool shouldBePinned = node.IsPinnedInConfig;
@@ -38,7 +38,7 @@ public sealed class InventoryCategoryPinCoordinator
return changed;
}
public bool PrunePinnedNotInGrid(WrappingGridNode<InventoryCategoryNode> grid)
public bool PrunePinnedNotInGrid(WrappingGridNode<InventoryCategoryNodeBase> grid)
{
return false;
}
@@ -1,5 +1,4 @@
using System.Numerics;
using AetherBags.Inventory;
using AetherBags.Inventory.Items;
using Dalamud.Game.ClientState.Keys;
using FFXIVClientStructs.FFXIV.Client.Game;
@@ -1,13 +1,13 @@
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Currency;
using AetherBags.Inventory;
using AetherBags.Inventory.State;
using AetherBags.Nodes.Currency;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
using static AetherBags.Inventory.State.InventoryStateBase;
namespace AetherBags.Nodes.Inventory;
public sealed class InventoryFooterNode : SimpleComponentNode
@@ -44,7 +44,7 @@ public sealed class InventoryFooterNode : SimpleComponentNode
{
_currencyListNode.IsVisible = System.Config.Currency.Enabled;
IReadOnlyList<CurrencyInfo> currencyInfoList = InventoryState.GetCurrencyInfoList([1, 28, 0xFFFF_FFFE, 0xFFFF_FFFD]);
IReadOnlyList<CurrencyInfo> currencyInfoList = GetCurrencyInfoList([1, 28, 0xFFFF_FFFE, 0xFFFF_FFFD]);
_currencyListNode.SyncWithListDataByKey<CurrencyInfo, CurrencyNode, uint>(
dataList: currencyInfoList,
getKeyFromData: currencyInfo => currencyInfo.ItemId,
@@ -1,5 +1,4 @@
using System.Numerics;
using AetherBags.Inventory;
using AetherBags.Inventory.Context;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
@@ -0,0 +1,84 @@
using System;
using System.Numerics;
using AetherBags.Inventory.Items;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Inventory;
/// <summary>
/// A display-only item node for looted items. Not draggable, but shows tooltip and can be dismissed.
/// </summary>
public unsafe class LootedItemDisplayNode : SimpleComponentNode
{
private readonly IconNode _iconNode;
private readonly TextNode _quantityTextNode;
private readonly ResNode _collisionNode;
public Action<LootedItemDisplayNode>? OnDismiss { get; set; }
public LootedItemDisplayNode()
{
Size = new Vector2(42, 46);
_iconNode = new IconNode
{
Position = new Vector2(0, 0),
Size = new Vector2(42, 46),
NodeFlags = NodeFlags.Visible | NodeFlags.Enabled,
};
_iconNode.AttachNode(this);
_quantityTextNode = new TextNode
{
Size = new Vector2(40.0f, 12.0f),
Position = new Vector2(4.0f, 34.0f),
NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents,
Color = ColorHelper.GetColor(50),
TextOutlineColor = ColorHelper.GetColor(51),
TextFlags = TextFlags.Edge,
AlignmentType = AlignmentType.Right,
};
_quantityTextNode.AttachNode(this);
_collisionNode = new ResNode
{
Size = new Vector2(42, 46),
NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents | NodeFlags.HasCollision,
};
_collisionNode.AddEvent(AtkEventType.MouseOver, OnMouseOver);
_collisionNode.AddEvent(AtkEventType.MouseOut, OnMouseOut);
_collisionNode.AddEvent(AtkEventType.MouseClick, OnMouseClick);
_collisionNode.AttachNode(this);
}
public LootedItemInfo LootedItem { get; private set; } = null!;
public void SetLootedItem(LootedItemInfo lootedItem)
{
LootedItem = lootedItem;
var item = lootedItem.Item;
_iconNode.IconId = item.IconId;
_quantityTextNode.String = lootedItem.Quantity > 1 ? lootedItem.Quantity.ToString() : string.Empty;
}
private void OnMouseOver(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
{
var item = LootedItem.Item;
_collisionNode.ShowInventoryItemTooltip(item.Container, item.Slot);
}
private void OnMouseOut(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
{
ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(_collisionNode)->Id;
AtkStage.Instance()->TooltipManager.HideTooltip(addonId);
}
private void OnMouseClick(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData)
{
if (!atkEventData->IsLeftClick) return;
OnDismiss?.Invoke(this);
}
}
@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Inventory.Items;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Inventory;
/// <summary>
/// A special category node for displaying recently looted items.
/// Items are not draggable but can be dismissed individually or cleared entirely.
/// </summary>
public class LootedItemsCategoryNode : InventoryCategoryNodeBase
{
private const uint LootedCategoryKey = 0x20000001;
public override uint Key => LootedCategoryKey;
private readonly TextNode _headerTextNode;
private readonly CircleButtonNode _clearButton;
private readonly HybridDirectionalFlexNode<LootedItemDisplayNode> _itemGridNode;
private const float HeaderHeight = 20;
private const float ClearButtonSize = 20;
private const float MinWidth = 100;
private IReadOnlyList<LootedItemInfo> _lootedItems = Array.Empty<LootedItemInfo>();
private int _hoverRefs;
private bool _headerExpanded;
private float _baseHeaderWidth = 96f;
private string _fullHeaderText = "Recently Looted";
public event Action<LootedItemsCategoryNode, bool>? HeaderHoverChanged;
public Action<int>? OnDismissItem { get; set; }
public Action? OnClearAll { get; set; }
public int ItemsPerLine
{
get => _itemGridNode.ItemsPerLine;
set
{
if (_itemGridNode.ItemsPerLine == value) return;
_itemGridNode.ItemsPerLine = value;
RecalculateSize();
}
}
public bool HasItems => _lootedItems.Count > 0;
public LootedItemsCategoryNode()
{
_headerTextNode = new TextNode
{
Position = Vector2.Zero,
Size = new Vector2(96, HeaderHeight),
AlignmentType = AlignmentType.Left,
String = "Recently Looted",
TextFlags = TextFlags.OverflowHidden | TextFlags.Ellipsis,
TextColor = new Vector4(0.9f, 0.8f, 0.5f, 1.0f), // Gold-ish color
};
_headerTextNode.AddEvent(AtkEventType.MouseOver, BeginHeaderHover);
_headerTextNode.AddEvent(AtkEventType.MouseOut, EndHeaderHover);
_headerTextNode.TextFlags |= TextFlags.OverflowHidden | TextFlags.Ellipsis;
_headerTextNode.TextFlags &= ~(TextFlags.WordWrap | TextFlags.MultiLine);
_headerTextNode.AddFlags(NodeFlags.EmitsEvents | NodeFlags.HasCollision);
_headerTextNode.AttachNode(this);
_clearButton = new CircleButtonNode
{
Size = new Vector2(ClearButtonSize),
Icon = ButtonIcon.CrossSmall,
OnClick = () => OnClearAll?.Invoke(),
};
_clearButton.AttachNode(this);
_itemGridNode = new HybridDirectionalFlexNode<LootedItemDisplayNode>
{
Position = new Vector2(0, HeaderHeight),
Size = new Vector2(240, 92),
FillRowsFirst = true,
ItemsPerLine = 10,
HorizontalPadding = 5,
VerticalPadding = 2,
};
_itemGridNode.NodeFlags |= NodeFlags.EmitsEvents;
_itemGridNode.AttachNode(this);
RecalculateSize();
}
public void UpdateLootedItems(IReadOnlyList<LootedItemInfo> lootedItems)
{
_lootedItems = lootedItems;
UpdateHeaderText();
SyncItemGrid();
RecalculateSize();
}
private void UpdateHeaderText()
{
_fullHeaderText = _lootedItems.Count > 0
? $"Recently Looted ({_lootedItems.Count})"
: "Recently Looted";
_headerTextNode.String = _fullHeaderText;
}
public void BeginHeaderHover()
{
_hoverRefs++;
if (_hoverRefs != 1) return;
_headerExpanded = true;
ApplyHeaderVisualStateAndSize();
HeaderHoverChanged?.Invoke(this, true);
}
public void EndHeaderHover()
{
if (_hoverRefs <= 0) return;
_hoverRefs--;
if (_hoverRefs != 0) return;
_headerExpanded = false;
ApplyHeaderVisualStateAndSize();
HeaderHoverChanged?.Invoke(this, false);
}
private void ApplyHeaderVisualStateAndSize()
{
var flags = _headerTextNode.TextFlags;
flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine);
if (_headerExpanded)
{
flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis);
_headerTextNode.TextFlags = flags;
if (!string.IsNullOrEmpty(_fullHeaderText))
_headerTextNode.String = _fullHeaderText;
Vector2 drawSize = _headerTextNode.GetTextDrawSize();
float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f);
_headerTextNode.Size = _headerTextNode.Size with { X = expandedWidth };
}
else
{
_headerTextNode.Size = _headerTextNode.Size with { X = _baseHeaderWidth };
if (!string.IsNullOrEmpty(_fullHeaderText))
_headerTextNode.String = _fullHeaderText;
flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis;
_headerTextNode.TextFlags = flags;
}
}
private void SyncItemGrid()
{
_itemGridNode.SyncWithListData(
_lootedItems,
node => node.LootedItem,
CreateLootedItemNode);
}
private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem)
{
var node = new LootedItemDisplayNode
{
OnDismiss = OnItemDismissed,
};
node.SetLootedItem(lootedItem);
return node;
}
private void OnItemDismissed(LootedItemDisplayNode node)
{
int index = node.LootedItem.Index;
OnDismissItem?.Invoke(index);
}
private void RecalculateSize()
{
int itemCount = _lootedItems.Count;
if (itemCount == 0)
{
float width = MinWidth;
Size = new Vector2(width, HeaderHeight);
_baseHeaderWidth = width - ClearButtonSize - 4;
_headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight);
_clearButton.Position = new Vector2(width - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2);
_clearButton.IsVisible = false;
_itemGridNode.Position = new Vector2(0, HeaderHeight);
_itemGridNode.Size = new Vector2(width, 0);
ApplyHeaderVisualStateAndSize();
return;
}
int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine);
int rows = (itemCount + itemsPerLine - 1) / itemsPerLine;
int actualColumns = Math.Min(itemCount, itemsPerLine);
const float cellW = 42f;
const float cellH = 46f;
float hPad = _itemGridNode.HorizontalPadding;
float vPad = _itemGridNode.VerticalPadding;
float calculatedWidth = Math.Max(MinWidth, actualColumns * cellW + (actualColumns - 1) * hPad);
float gridHeight = rows * cellH + (rows - 1) * vPad;
float totalHeight = HeaderHeight + gridHeight;
Size = new Vector2(calculatedWidth, totalHeight);
_baseHeaderWidth = calculatedWidth - ClearButtonSize - 4;
_headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight);
_clearButton.Position = new Vector2(calculatedWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2);
_clearButton.IsVisible = true;
_itemGridNode.Position = new Vector2(0, HeaderHeight);
_itemGridNode.Size = new Vector2(calculatedWidth, gridHeight);
ApplyHeaderVisualStateAndSize();
}
}
@@ -1,6 +1,5 @@
using System. Numerics;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Inventory;