Merge pull request #2 from Seeker1437/feature/grid-layout-optimization-and-hover

grid layout optimization and hover
This commit is contained in:
Jeffrey Veenhuis
2025-12-20 15:35:01 +01:00
committed by GitHub
7 changed files with 700 additions and 305 deletions
+78 -14
View File
@@ -17,6 +17,9 @@ namespace AetherBags.Addons;
public class AddonInventoryWindow : NativeAddon public class AddonInventoryWindow : NativeAddon
{ {
private readonly InventoryCategoryHoverCoordinator _hoverCoordinator = new();
private readonly HashSet<InventoryCategoryNode> _hoverSubscribed = new();
private WrappingGridNode<InventoryCategoryNode> _categoriesNode = null!; private WrappingGridNode<InventoryCategoryNode> _categoriesNode = null!;
private TextInputWithHintNode _searchInputNode = null!; private TextInputWithHintNode _searchInputNode = null!;
private InventoryFooterNode _footerNode = null!; private InventoryFooterNode _footerNode = null!;
@@ -30,7 +33,10 @@ public class AddonInventoryWindow : NativeAddon
// Layout settings // Layout settings
private const float CategorySpacing = 12; private const float CategorySpacing = 12;
private const float ItemSize = 40; private const float ItemSize = 40;
private const float ItemPadding = 6; private const float ItemPadding = 4;
private const float FooterHeight = 28f;
private const float FooterTopSpacing = 4f;
protected override unsafe void OnSetup(AtkUnitBase* addon) protected override unsafe void OnSetup(AtkUnitBase* addon)
{ {
@@ -39,14 +45,17 @@ public class AddonInventoryWindow : NativeAddon
Position = ContentStartPosition, Position = ContentStartPosition,
Size = ContentSize, Size = ContentSize,
HorizontalSpacing = CategorySpacing, HorizontalSpacing = CategorySpacing,
VerticalSpacing = CategorySpacing VerticalSpacing = CategorySpacing,
TopPadding = 4.0f,
BottomPadding = 4.0f,
}; };
_categoriesNode.AttachNode(this); _categoriesNode.AttachNode(this);
var size = new Vector2(addon->Size.X / 2.0f, 28.0f); var size = new Vector2(addon->Size.X / 2.0f, 28.0f);
Vector2 headerSize = new Vector2(addon->WindowHeaderCollisionNode->Width, addon->WindowHeaderCollisionNode->Height); Vector2 headerSize = new Vector2(addon->WindowHeaderCollisionNode->Width, addon->WindowHeaderCollisionNode->Height);
_searchInputNode = new TextInputWithHintNode { _searchInputNode = new TextInputWithHintNode
{
Position = headerSize / 2.0f - size / 2.0f + new Vector2(25.0f, 10.0f), Position = headerSize / 2.0f - size / 2.0f + new Vector2(25.0f, 10.0f),
Size = size, Size = size,
OnInputReceived = _ => RefreshCategories(false), OnInputReceived = _ => RefreshCategories(false),
@@ -55,11 +64,13 @@ public class AddonInventoryWindow : NativeAddon
_footerNode = new InventoryFooterNode _footerNode = new InventoryFooterNode
{ {
Size = ContentSize with { Y = 28 }, Size = ContentSize with { Y = FooterHeight },
SlotAmountText = InventoryState.GetEmptyItemSlotsString() SlotAmountText = InventoryState.GetEmptyItemSlotsString()
}; };
_footerNode.AttachNode(this); _footerNode.AttachNode(this);
LayoutContent();
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate); Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate);
addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
@@ -69,7 +80,6 @@ public class AddonInventoryWindow : NativeAddon
protected override unsafe void OnUpdate(AtkUnitBase* addon) protected override unsafe void OnUpdate(AtkUnitBase* addon)
{ {
// Haven't needed it yet but just in case.
base.OnUpdate(addon); base.OnUpdate(addon);
} }
@@ -78,13 +88,16 @@ public class AddonInventoryWindow : NativeAddon
RefreshCategories(); RefreshCategories();
} }
protected override unsafe void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { protected override unsafe void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
{
base.OnRequestedUpdate(addon, numberArrayData, stringArrayData); base.OnRequestedUpdate(addon, numberArrayData, stringArrayData);
RefreshCategories(); RefreshCategories();
} }
private void RefreshCategories(bool autosize = true) private void RefreshCategories(bool autosize = true)
{ {
_footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString();
var categories = InventoryState.GetInventoryItemCategories(_searchInputNode.SearchString.ExtractText()); var categories = InventoryState.GetInventoryItemCategories(_searchInputNode.SearchString.ExtractText());
float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2); float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2);
@@ -105,7 +118,32 @@ public class AddonInventoryWindow : NativeAddon
node.ItemsPerLine = Math.Min(node.CategorizedInventory.Items.Count, maxItemsPerLine); node.ItemsPerLine = Math.Min(node.CategorizedInventory.Items.Count, maxItemsPerLine);
} }
if(autosize) AutoSizeWindow(); WireHoverHandlers();
if (autosize) AutoSizeWindow();
else
{
LayoutContent();
_categoriesNode.RecalculateLayout();
}
}
private void WireHoverHandlers()
{
List<InventoryCategoryNode> categoryNodes = _categoriesNode.GetNodes<InventoryCategoryNode>().ToList();
for (int i = 0; i < categoryNodes.Count; i++)
{
InventoryCategoryNode node = categoryNodes[i];
if (!_hoverSubscribed.Add(node))
continue;
node.HeaderHoverChanged += (src, hovering) =>
{
_hoverCoordinator.OnCategoryHoverChanged(_categoriesNode, src, hovering);
};
}
} }
private int CalculateOptimalItemsPerLine(float availableWidth) private int CalculateOptimalItemsPerLine(float availableWidth)
@@ -113,6 +151,23 @@ public class AddonInventoryWindow : NativeAddon
return Math.Clamp((int)Math.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15); return Math.Clamp((int)Math.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15);
} }
private void LayoutContent()
{
Vector2 contentPos = ContentStartPosition;
Vector2 contentSize = ContentSize;
float footerH = FooterHeight;
_footerNode.Position = new Vector2(contentPos.X, contentPos.Y + contentSize.Y - footerH);
_footerNode.Size = new Vector2(contentSize.X, footerH);
float gridH = contentSize.Y - footerH - FooterTopSpacing;
if (gridH < 0) gridH = 0;
_categoriesNode.Position = contentPos;
_categoriesNode.Size = new Vector2(contentSize.X, gridH);
}
private void AutoSizeWindow() private void AutoSizeWindow()
{ {
List<InventoryCategoryNode> childNodes = _categoriesNode.GetNodes<InventoryCategoryNode>().ToList(); List<InventoryCategoryNode> childNodes = _categoriesNode.GetNodes<InventoryCategoryNode>().ToList();
@@ -122,19 +177,25 @@ public class AddonInventoryWindow : NativeAddon
return; return;
} }
float requiredWidth = childNodes.Max(node => node. Width); float requiredWidth = childNodes.Max(node => node.Width);
requiredWidth += ContentStartPosition.X * 2; requiredWidth += ContentStartPosition.X * 2;
float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth); float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth);
float contentWidth = finalWidth - (ContentStartPosition.X * 2); float contentWidth = finalWidth - (ContentStartPosition.X * 2);
_categoriesNode.Size = new Vector2(contentWidth, MaxWindowHeight);
float gridBudget = Math.Max(0f, MaxWindowHeight - FooterHeight - FooterTopSpacing);
_categoriesNode.Position = ContentStartPosition;
_categoriesNode.Size = new Vector2(contentWidth, gridBudget);
_categoriesNode.RecalculateLayout(); _categoriesNode.RecalculateLayout();
float requiredHeight = _categoriesNode.GetRequiredHeight(); float requiredGridHeight = _categoriesNode.GetRequiredHeight();
requiredHeight += ContentStartPosition.Y + ContentStartPosition.X; float requiredContentHeight = requiredGridHeight + FooterTopSpacing + FooterHeight;
float finalHeight = Math.Clamp(requiredHeight, MinWindowHeight, MaxWindowHeight); float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X;
float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight);
ResizeWindow(finalWidth, finalHeight); ResizeWindow(finalWidth, finalHeight);
} }
@@ -142,8 +203,9 @@ public class AddonInventoryWindow : NativeAddon
private void ResizeWindow(float width, float height) private void ResizeWindow(float width, float height)
{ {
SetWindowSize(width, height); SetWindowSize(width, height);
_categoriesNode.Size = ContentSize;
_footerNode.Size = ContentSize with { Y = 28 }; LayoutContent();
_categoriesNode.RecalculateLayout(); _categoriesNode.RecalculateLayout();
} }
@@ -152,5 +214,7 @@ public class AddonInventoryWindow : NativeAddon
base.OnFinalize(addon); base.OnFinalize(addon);
Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate); Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate);
addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
_hoverSubscribed.Clear();
} }
} }
+13
View File
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace AetherBags.Nodes;
public enum FlexGrowDirection
{
DownRight,
DownLeft,
UpRight,
UpLeft
}
+74 -48
View File
@@ -1,57 +1,64 @@
using System;
using System.Linq;
using KamiToolKit; using KamiToolKit;
using KamiToolKit.Nodes; using KamiToolKit.Nodes;
using System;
using System.Runtime.CompilerServices;
namespace AetherBags.Nodes namespace AetherBags.Nodes;
public class HybridDirectionalFlexNode : HybridDirectionalFlexNode<NodeBase> { }
public class HybridDirectionalFlexNode<T> : LayoutListNode where T : NodeBase
{ {
public enum FlexGrowDirection public FlexGrowDirection GrowDirection
{ {
DownRight, get => field;
DownLeft, set
UpRight,
UpLeft
}
public class HybridDirectionalFlexNode : HybridDirectionalFlexNode<NodeBase> { }
public class HybridDirectionalFlexNode<T> : LayoutListNode where T : NodeBase
{ {
if (field == value) return;
public FlexGrowDirection GrowDirection { get;
set {
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
} = FlexGrowDirection.DownRight; } = FlexGrowDirection.DownRight;
public int ItemsPerLine { public int ItemsPerLine
get; {
set { get => field;
set
{
if (field == value) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
} = 1; } = 1;
public bool FillRowsFirst { public bool FillRowsFirst
get; {
set { get => field;
set
{
if (field == value) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
} = true; } = true;
public float HorizontalPadding { public float HorizontalPadding
get; {
set { get => field;
set
{
if (field.Equals(value)) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
} = 1; } = 1;
public float VerticalPadding { public float VerticalPadding
get; {
set { get => field;
set
{
if (field.Equals(value)) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
@@ -59,48 +66,67 @@ namespace AetherBags.Nodes
protected override void InternalRecalculateLayout() protected override void InternalRecalculateLayout()
{ {
if (NodeList.Count == 0) { int count = NodeList.Count;
return; if (count == 0) return;
}
int itemsPerLine = Math.Max(1, ItemsPerLine); int itemsPerLine = ItemsPerLine;
if (itemsPerLine < 1) itemsPerLine = 1;
float nodeWidth = NodeList.First().Width; NodeBase first = NodeList[0];
float nodeHeight = NodeList.First().Height; float nodeWidth = first.Width;
float nodeHeight = first.Height;
bool alignRight = GrowDirection is FlexGrowDirection.DownLeft or FlexGrowDirection.UpLeft; float hPad = HorizontalPadding;
bool alignBottom = GrowDirection is FlexGrowDirection.UpRight or FlexGrowDirection.UpLeft; float vPad = VerticalPadding;
FlexGrowDirection dir = GrowDirection;
bool alignRight = dir == FlexGrowDirection.DownLeft || dir == FlexGrowDirection.UpLeft;
bool alignBottom = dir == FlexGrowDirection.UpRight || dir == FlexGrowDirection.UpLeft;
float startX = alignRight ? Width : 0f; float startX = alignRight ? Width : 0f;
float startY = alignBottom ? Height : 0f; float startY = alignBottom ? Height : 0f;
int idx = 0; float stepX = nodeWidth + hPad;
foreach (var node in NodeList) float stepY = nodeHeight + vPad;
bool fillRowsFirst = FillRowsFirst;
int major = 0;
int minor = 0;
for (int i = 0; i < count; i++)
{ {
int row, col; int row, col;
if (FillRowsFirst) if (fillRowsFirst)
{ {
row = idx / itemsPerLine; row = major;
col = idx % itemsPerLine; col = minor;
} }
else else
{ {
col = idx / itemsPerLine; col = major;
row = idx % itemsPerLine; row = minor;
} }
float x = alignRight float x = alignRight
? startX - (col + 1) * nodeWidth - col * HorizontalPadding ? startX - nodeWidth - col * stepX
: startX + col * (nodeWidth + HorizontalPadding); : startX + col * stepX;
float y = alignBottom float y = alignBottom
? startY - (row + 1) * nodeHeight - row * VerticalPadding ? startY - nodeHeight - row * stepY
: startY + row * (nodeHeight + VerticalPadding); : startY + row * stepY;
NodeBase node = NodeList[i];
node.X = x; node.X = x;
node.Y = y; node.Y = y;
AdjustNode(node); AdjustNode(node);
idx++;
minor++;
if (minor == itemsPerLine)
{
minor = 0;
major++;
} }
} }
} }
+59 -37
View File
@@ -2,15 +2,16 @@ using System;
using KamiToolKit; using KamiToolKit;
using KamiToolKit.Nodes; using KamiToolKit.Nodes;
namespace AetherBags.Nodes namespace AetherBags.Nodes;
public class HybridDirectionalStackNode<T> : LayoutListNode where T : NodeBase
{ {
public class HybridDirectionalStackNode<T> : LayoutListNode where T : NodeBase
{
public FlexGrowDirection GrowDirection public FlexGrowDirection GrowDirection
{ {
get; get => field;
set set
{ {
if (field == value) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
@@ -18,9 +19,10 @@ namespace AetherBags.Nodes
public bool Vertical public bool Vertical
{ {
get; get => field;
set set
{ {
if (field == value) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
@@ -28,9 +30,10 @@ namespace AetherBags.Nodes
public float Spacing public float Spacing
{ {
get; get => field;
set set
{ {
if (field.Equals(value)) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
@@ -38,9 +41,10 @@ namespace AetherBags.Nodes
public bool StretchCrossAxis public bool StretchCrossAxis
{ {
get; get => field;
set set
{ {
if (field == value) return;
field = value; field = value;
RecalculateLayout(); RecalculateLayout();
} }
@@ -48,46 +52,64 @@ namespace AetherBags.Nodes
protected override void InternalRecalculateLayout() protected override void InternalRecalculateLayout()
{ {
if (NodeList.Count == 0) int count = NodeList.Count;
return; if (count == 0) return;
bool alignRight = GrowDirection is FlexGrowDirection.DownLeft or FlexGrowDirection.UpLeft; FlexGrowDirection dir = GrowDirection;
bool alignBottom = GrowDirection is FlexGrowDirection.UpRight or FlexGrowDirection.UpLeft; bool alignRight = dir == FlexGrowDirection.DownLeft || dir == FlexGrowDirection.UpLeft;
bool alignBottom = dir == FlexGrowDirection.UpRight || dir == FlexGrowDirection.UpLeft;
float startX = alignRight ? Width : 0f; bool vertical = Vertical;
float startY = alignBottom ? Height : 0f; bool stretchCross = StretchCrossAxis;
float containerW = Width;
float containerH = Height;
float startX = alignRight ? containerW : 0f;
float startY = alignBottom ? containerH : 0f;
float spacing = Spacing;
float cursor = 0f; float cursor = 0f;
for (int i = 0; i < NodeList.Count; i++) if (vertical)
{ {
var node = NodeList[i]; for (int i = 0; i < count; i++)
{
NodeBase node = NodeList[i];
if (StretchCrossAxis) if (stretchCross)
{ node.Width = containerW;
if (Vertical)
node.Width = Width;
else
node.Height = Height;
}
float x, y; float w = node.Width;
if (Vertical) float h = node.Height;
{
x = alignRight ? startX - node.Width : startX; node.X = alignRight ? startX - w : startX;
y = alignBottom ? startY - node.Height - cursor : startY + cursor; node.Y = alignBottom ? startY - h - cursor : startY + cursor;
cursor += node.Height + Spacing;
}
else
{
x = alignRight ? startX - node.Width - cursor : startX + cursor;
y = alignBottom ? startY - node.Height : startY;
cursor += node.Width + Spacing;
}
node.X = x;
node.Y = y;
AdjustNode(node); AdjustNode(node);
cursor += node.Height + spacing;
}
}
else
{
for (int i = 0; i < count; i++)
{
NodeBase node = NodeList[i];
if (stretchCross)
node.Height = containerH;
float w = node.Width;
float h = node.Height;
node.X = alignRight ? startX - w - cursor : startX + cursor;
node.Y = alignBottom ? startY - h : startY;
AdjustNode(node);
cursor += node.Width + spacing;
} }
} }
} }
@@ -0,0 +1,81 @@
using System;
namespace AetherBags.Nodes;
public sealed class InventoryCategoryHoverCoordinator
{
private InventoryCategoryNode? _active;
private int _activeRowIndex = -1;
public void OnCategoryHoverChanged(
WrappingGridNode<InventoryCategoryNode> grid,
InventoryCategoryNode source,
bool hovering)
{
grid.RecalculateLayout();
if (hovering)
{
_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;
}
if (!ReferenceEquals(_active, source))
return;
_active = null;
if (_activeRowIndex >= 0 && _activeRowIndex < grid.Rows.Count)
{
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);
}
_activeRowIndex = -1;
}
public void ResetAll(WrappingGridNode<InventoryCategoryNode> grid)
{
_active = null;
_activeRowIndex = -1;
ClearAll(grid);
}
private static void ClearAll(WrappingGridNode<InventoryCategoryNode> grid)
{
foreach (var cat in grid.GetNodes<InventoryCategoryNode>())
cat.SetHeaderSuppressed(false);
}
private static void SuppressAllExcept(WrappingGridNode<InventoryCategoryNode> grid, InventoryCategoryNode source)
{
foreach (var cat in grid.GetNodes<InventoryCategoryNode>())
cat.SetHeaderSuppressed(!ReferenceEquals(cat, source));
}
}
+160 -27
View File
@@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Extensions; using AetherBags.Extensions;
using AetherBags.Inventory; using AetherBags.Inventory;
using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit;
using KamiToolKit.Classes; using KamiToolKit.Classes;
using KamiToolKit.Nodes; using KamiToolKit.Nodes;
using System;
using System.Numerics;
namespace AetherBags.Nodes; namespace AetherBags.Nodes;
@@ -16,32 +15,50 @@ public class InventoryCategoryNode : SimpleComponentNode
private readonly TextNode _categoryNameTextNode; private readonly TextNode _categoryNameTextNode;
private readonly HybridDirectionalFlexNode<DragDropNode> _itemGridNode; private readonly HybridDirectionalFlexNode<DragDropNode> _itemGridNode;
private const float ItemSize = 40; private const float FallbackItemSize = 46;
private const float ItemHorizontalPadding = 6;
private const float ItemVerticalPadding = 6;
private const float HeaderHeight = 16; private const float HeaderHeight = 16;
private const float MinWidth = 40; private const float MinWidth = 40;
private float? _fixedWidth; private float? _fixedWidth;
private int _hoverRefs;
private bool _headerSuppressed;
private bool _headerExpanded;
private float _baseHeaderWidth = 96f;
private string _fullHeaderText = string.Empty;
public event Action<InventoryCategoryNode, bool>? HeaderHoverChanged;
public InventoryCategoryNode() public InventoryCategoryNode()
{ {
_categoryNameTextNode = new TextNode _categoryNameTextNode = new TextNode
{ {
Size = new Vector2(100, 14), Size = new Vector2(96, 16),
AlignmentType = AlignmentType.Left AlignmentType = AlignmentType.Left,
}; };
_categoryNameTextNode.AddEvent(AtkEventType.MouseOver, BeginHeaderHover);
_categoryNameTextNode.AddEvent(AtkEventType.MouseOut, EndHeaderHover);
_categoryNameTextNode.TextFlags |= TextFlags.OverflowHidden | TextFlags.Ellipsis;
_categoryNameTextNode.TextFlags &= ~(TextFlags.WordWrap | TextFlags.MultiLine);
_categoryNameTextNode.AddFlags(NodeFlags.EmitsEvents | NodeFlags.HasCollision);
_categoryNameTextNode.AttachNode(this); _categoryNameTextNode.AttachNode(this);
_itemGridNode = new HybridDirectionalFlexNode<DragDropNode> _itemGridNode = new HybridDirectionalFlexNode<DragDropNode>
{ {
Position = new Vector2(0, HeaderHeight), Position = new Vector2(0, HeaderHeight),
Size = new Vector2(240, 100), Size = new Vector2(240, 92),
FillRowsFirst = true, FillRowsFirst = true,
ItemsPerLine = 10, ItemsPerLine = 10,
HorizontalPadding = ItemHorizontalPadding, HorizontalPadding = 5,
VerticalPadding = ItemVerticalPadding, VerticalPadding = 2,
}; };
_itemGridNode.NodeFlags |= NodeFlags.EmitsEvents;
_itemGridNode.AttachNode(this); _itemGridNode.AttachNode(this);
} }
@@ -52,8 +69,11 @@ public class InventoryCategoryNode : SimpleComponentNode
{ {
field = value; field = value;
_categoryNameTextNode.String = value.Category.Name; _fullHeaderText = value.Category.Name;
_categoryNameTextNode.String = _fullHeaderText;
_categoryNameTextNode.TextColor = value.Category.Color; _categoryNameTextNode.TextColor = value.Category.Color;
_categoryNameTextNode.TooltipString = value.Category.Description; _categoryNameTextNode.TooltipString = value.Category.Description;
UpdateItemGrid(); UpdateItemGrid();
@@ -66,6 +86,7 @@ public class InventoryCategoryNode : SimpleComponentNode
get => _itemGridNode.ItemsPerLine; get => _itemGridNode.ItemsPerLine;
set set
{ {
if (_itemGridNode.ItemsPerLine == value) return;
_itemGridNode.ItemsPerLine = value; _itemGridNode.ItemsPerLine = value;
RecalculateSize(); RecalculateSize();
} }
@@ -76,55 +97,156 @@ public class InventoryCategoryNode : SimpleComponentNode
get => _fixedWidth; get => _fixedWidth;
set set
{ {
if (_fixedWidth.Equals(value)) return;
_fixedWidth = value; _fixedWidth = value;
RecalculateSize(); RecalculateSize();
} }
} }
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);
}
public void SetHeaderSuppressed(bool suppressed)
{
if (_headerSuppressed == suppressed) return;
_headerSuppressed = suppressed;
ApplyHeaderVisualStateAndSize();
}
private void ApplyHeaderVisualStateAndSize()
{
_categoryNameTextNode.IsVisible = !_headerSuppressed;
if (_headerSuppressed)
return;
var flags = _categoryNameTextNode.TextFlags;
flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine);
if (_headerExpanded)
{
flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis);
_categoryNameTextNode.TextFlags = flags;
if (!string.IsNullOrEmpty(_fullHeaderText))
_categoryNameTextNode.String = _fullHeaderText;
Vector2 drawSize = _categoryNameTextNode.GetTextDrawSize();
float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f);
_categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = expandedWidth };
}
else
{
_categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = _baseHeaderWidth };
if (!string.IsNullOrEmpty(_fullHeaderText))
_categoryNameTextNode.String = _fullHeaderText;
flags |= (TextFlags.OverflowHidden | TextFlags.Ellipsis);
_categoryNameTextNode.TextFlags = flags;
}
}
private void RecalculateSize() private void RecalculateSize()
{ {
int itemCount = CategorizedInventory.Items.Count; int itemCount = CategorizedInventory.Items.Count;
if (itemCount == 0) if (itemCount == 0)
{ {
float width = _fixedWidth ?? MinWidth; float width = _fixedWidth ?? MinWidth;
Size = new Vector2(width, HeaderHeight); Size = new Vector2(width, HeaderHeight);
_categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = width };
_baseHeaderWidth = width;
_itemGridNode.Position = new Vector2(0, HeaderHeight);
_itemGridNode.Size = new Vector2(width, 0);
ApplyHeaderVisualStateAndSize();
return; return;
} }
int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); int itemsPerLine = _itemGridNode.ItemsPerLine;
int rows = (int)Math.Ceiling((float)itemCount / itemsPerLine); if (itemsPerLine < 1) itemsPerLine = 1;
int rows = (itemCount + itemsPerLine - 1) / itemsPerLine;
int actualColumns = Math.Min(itemCount, itemsPerLine);
float cellW, cellH;
if (_itemGridNode.Nodes.Count > 0)
{
var firstChild = _itemGridNode.Nodes[0];
cellW = firstChild.Width;
cellH = firstChild.Height;
}
else
{
cellW = FallbackItemSize;
cellH = FallbackItemSize;
}
float hPad = _itemGridNode.HorizontalPadding;
float vPad = _itemGridNode.VerticalPadding;
float calculatedWidth; float calculatedWidth;
if (_fixedWidth. HasValue) if (_fixedWidth.HasValue)
{ {
calculatedWidth = _fixedWidth.Value; calculatedWidth = _fixedWidth.Value;
} }
else else
{ {
int actualColumns = Math.Min(itemCount, itemsPerLine); calculatedWidth = actualColumns * cellW + (actualColumns - 1) * hPad;
calculatedWidth = actualColumns * ItemSize + (actualColumns - 1) * ItemHorizontalPadding; if (calculatedWidth < MinWidth) calculatedWidth = MinWidth;
calculatedWidth = Math.Max(calculatedWidth, MinWidth);
} }
float height = HeaderHeight + rows * ItemSize + (rows - 1) * ItemVerticalPadding; float height = HeaderHeight + rows * cellH + (rows - 1) * vPad;
Size = new Vector2(calculatedWidth, height); Size = new Vector2(calculatedWidth, height);
_itemGridNode.Position = new Vector2(0, HeaderHeight);
_itemGridNode.Size = new Vector2(calculatedWidth, height - HeaderHeight); _itemGridNode.Size = new Vector2(calculatedWidth, height - HeaderHeight);
_categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = calculatedWidth };
_baseHeaderWidth = calculatedWidth;
ApplyHeaderVisualStateAndSize();
} }
private void UpdateItemGrid() private void UpdateItemGrid()
{ {
_itemGridNode.SyncWithListData(CategorizedInventory.Items, node => node.ItemInfo, CreateInventoryDragDropNode); _itemGridNode.SyncWithListData(
CategorizedInventory.Items,
node => node.ItemInfo,
CreateInventoryDragDropNode);
} }
private InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) private InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data)
{ {
InventoryItem item = data.Item; InventoryItem item = data.Item;
InventoryDragDropNode node = new InventoryDragDropNode
var node = new InventoryDragDropNode
{ {
Size = new Vector2(40), Size = new Vector2(42, 46),
IsVisible = true, IsVisible = true,
IconId = item.IconId, IconId = item.IconId,
AcceptedType = DragDropType.Nothing, AcceptedType = DragDropType.Nothing,
@@ -136,10 +258,21 @@ public class InventoryCategoryNode : SimpleComponentNode
Int2 = (int)item.ItemId, Int2 = (int)item.ItemId,
}, },
IsClickable = true, IsClickable = true,
OnRollOver = node => node.ShowInventoryItemTooltip(item.Container, item.Slot),
OnRollOut = node => node.HideTooltip(), OnRollOver = n =>
{
BeginHeaderHover();
n.ShowInventoryItemTooltip(item.Container, item.Slot);
},
OnRollOut = n =>
{
EndHeaderHover();
n.HideTooltip();
},
ItemInfo = data ItemInfo = data
}; };
return node; return node;
} }
} }
+63 -7
View File
@@ -1,26 +1,44 @@
using KamiToolKit;
using KamiToolKit.Nodes;
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using KamiToolKit;
using KamiToolKit. Nodes;
namespace AetherBags.Nodes namespace AetherBags.Nodes;
public sealed class WrappingGridNode<T> : LayoutListNode where T : NodeBase
{ {
public sealed class WrappingGridNode<T> : LayoutListNode where T : NodeBase
{
public float HorizontalSpacing { get; set; } = 10f; public float HorizontalSpacing { get; set; } = 10f;
public float VerticalSpacing { get; set; } = 10f; public float VerticalSpacing { get; set; } = 10f;
public float TopPadding { get; set; } = 0f;
public float BottomPadding { get; set; } = 0f;
private readonly List<List<NodeBase>> _rows = new(); private readonly List<List<NodeBase>> _rows = new();
private readonly Stack<List<NodeBase>> _rowPool = new(); private readonly Stack<List<NodeBase>> _rowPool = new();
private readonly Dictionary<NodeBase, int> _rowIndex = new(ReferenceEqualityComparer<NodeBase>.Instance);
private float _requiredHeight; private float _requiredHeight;
private bool _requiredHeightDirty = true; private bool _requiredHeightDirty = true;
private readonly IReadOnlyList<IReadOnlyList<NodeBase>> _rowsView;
public WrappingGridNode()
{
_rowsView = new RowsReadOnlyView(_rows);
}
public IReadOnlyList<IReadOnlyList<NodeBase>> Rows => _rowsView;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetRowIndex(NodeBase node, out int rowIndex) => _rowIndex.TryGetValue(node, out rowIndex);
protected override void InternalRecalculateLayout() protected override void InternalRecalculateLayout()
{ {
RecycleAllRows(); RecycleAllRows();
_rowIndex.Clear();
int count = NodeList.Count; int count = NodeList.Count;
if (count == 0) if (count == 0)
@@ -30,15 +48,18 @@ namespace AetherBags.Nodes
return; return;
} }
_rowIndex.EnsureCapacity(count);
float availableWidth = Width; float availableWidth = Width;
float hSpace = HorizontalSpacing; float hSpace = HorizontalSpacing;
float vSpace = VerticalSpacing; float vSpace = VerticalSpacing;
float startX = FirstItemSpacing; float startX = FirstItemSpacing;
float currentX = startX; float currentX = startX;
float currentY = 0f; float currentY = TopPadding;
float rowHeight = 0f; float rowHeight = 0f;
int currentRowIndex = 0;
List<NodeBase> currentRow = RentRowList(capacityHint: 8); List<NodeBase> currentRow = RentRowList(capacityHint: 8);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
@@ -50,6 +71,7 @@ namespace AetherBags.Nodes
if (currentRow.Count != 0 && (currentX + nodeWidth) > availableWidth) if (currentRow.Count != 0 && (currentX + nodeWidth) > availableWidth)
{ {
_rows.Add(currentRow); _rows.Add(currentRow);
currentRowIndex++;
currentY += rowHeight + vSpace; currentY += rowHeight + vSpace;
currentX = startX; currentX = startX;
@@ -67,6 +89,7 @@ namespace AetherBags.Nodes
if (nodeHeight > rowHeight) rowHeight = nodeHeight; if (nodeHeight > rowHeight) rowHeight = nodeHeight;
currentRow.Add(node); currentRow.Add(node);
_rowIndex[node] = currentRowIndex;
currentX += nodeWidth + hSpace; currentX += nodeWidth + hSpace;
} }
@@ -98,6 +121,9 @@ namespace AetherBags.Nodes
if (bottom > maxBottom) maxBottom = bottom; if (bottom > maxBottom) maxBottom = bottom;
} }
maxBottom += BottomPadding;
_requiredHeight = maxBottom; _requiredHeight = maxBottom;
_requiredHeightDirty = false; _requiredHeightDirty = false;
return maxBottom; return maxBottom;
@@ -133,5 +159,35 @@ namespace AetherBags.Nodes
row.Clear(); row.Clear();
_rowPool.Push(row); _rowPool.Push(row);
} }
private sealed class RowsReadOnlyView : IReadOnlyList<IReadOnlyList<NodeBase>>
{
private readonly List<List<NodeBase>> _rows;
public RowsReadOnlyView(List<List<NodeBase>> rows) => _rows = rows;
public int Count => _rows.Count;
public IReadOnlyList<NodeBase> this[int index] => _rows[index];
public IEnumerator<IReadOnlyList<NodeBase>> GetEnumerator()
{
for (int i = 0; i < _rows.Count; i++)
yield return _rows[i];
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
private sealed class ReferenceEqualityComparer<TRef> : IEqualityComparer<TRef> where TRef : class
{
public static readonly ReferenceEqualityComparer<TRef> Instance = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Equals(TRef? x, TRef? y) => ReferenceEquals(x, y);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetHashCode(TRef obj) => RuntimeHelpers.GetHashCode(obj);
} }
} }