From 2752e8d9303d358d6e639063296c8517120813c5 Mon Sep 17 00:00:00 2001 From: Shawrkie Williams Date: Sat, 20 Dec 2025 09:27:52 -0500 Subject: [PATCH] Improves inventory window layout and interaction --- AetherBags/Addons/AddonInventoryWindow.cs | 96 +++++-- AetherBags/Nodes/FlexGrowDirection.cs | 13 + AetherBags/Nodes/HybridDirectionalFlexNode.cs | 208 +++++++------- .../Nodes/HybridDirectionalStackNode.cs | 162 ++++++----- .../InventoryCategoryHoverCoordinator.cs | 81 ++++++ AetherBags/Nodes/InventoryCategoryNode.cs | 191 +++++++++++-- AetherBags/Nodes/WrappingGridNode.cs | 255 +++++++++++------- 7 files changed, 700 insertions(+), 306 deletions(-) create mode 100644 AetherBags/Nodes/FlexGrowDirection.cs create mode 100644 AetherBags/Nodes/InventoryCategoryHoverCoordinator.cs diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 432a24d..c005e46 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -17,6 +17,9 @@ namespace AetherBags.Addons; public class AddonInventoryWindow : NativeAddon { + private readonly InventoryCategoryHoverCoordinator _hoverCoordinator = new(); + private readonly HashSet _hoverSubscribed = new(); + private WrappingGridNode _categoriesNode = null!; private TextInputWithHintNode _searchInputNode = null!; private InventoryFooterNode _footerNode = null!; @@ -30,7 +33,10 @@ public class AddonInventoryWindow : NativeAddon // Layout settings private const float CategorySpacing = 12; 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) { @@ -38,15 +44,18 @@ public class AddonInventoryWindow : NativeAddon { Position = ContentStartPosition, Size = ContentSize, - HorizontalSpacing = CategorySpacing, - VerticalSpacing = CategorySpacing + ItemSpacing = CategorySpacing, + VerticalSpacing = CategorySpacing, + TopPadding = 4.0f, + BottomPadding = 4.0f, }; _categoriesNode.AttachNode(this); var size = new Vector2(addon->Size.X / 2.0f, 28.0f); 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), Size = size, OnInputReceived = _ => RefreshCategories(false), @@ -55,11 +64,13 @@ public class AddonInventoryWindow : NativeAddon _footerNode = new InventoryFooterNode { - Size = ContentSize with { Y = 28 }, + Size = ContentSize with { Y = FooterHeight }, SlotAmountText = InventoryState.GetEmptyItemSlotsString() }; _footerNode.AttachNode(this); + LayoutContent(); + Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate); addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); @@ -69,7 +80,6 @@ public class AddonInventoryWindow : NativeAddon protected override unsafe void OnUpdate(AtkUnitBase* addon) { - // Haven't needed it yet but just in case. base.OnUpdate(addon); } @@ -78,13 +88,16 @@ public class AddonInventoryWindow : NativeAddon 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); RefreshCategories(); } private void RefreshCategories(bool autosize = true) { + _footerNode.SlotAmountText = InventoryState.GetEmptyItemSlotsString(); + var categories = InventoryState.GetInventoryItemCategories(_searchInputNode.SearchString.ExtractText()); float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2); @@ -105,7 +118,32 @@ public class AddonInventoryWindow : NativeAddon node.ItemsPerLine = Math.Min(node.CategorizedInventory.Items.Count, maxItemsPerLine); } - if(autosize) AutoSizeWindow(); + WireHoverHandlers(); + + if (autosize) AutoSizeWindow(); + else + { + LayoutContent(); + _categoriesNode.RecalculateLayout(); + } + } + + private void WireHoverHandlers() + { + List categoryNodes = _categoriesNode.GetNodes().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) @@ -113,6 +151,23 @@ public class AddonInventoryWindow : NativeAddon 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() { List childNodes = _categoriesNode.GetNodes().ToList(); @@ -122,19 +177,25 @@ public class AddonInventoryWindow : NativeAddon return; } - float requiredWidth = childNodes.Max(node => node. Width); + float requiredWidth = childNodes.Max(node => node.Width); requiredWidth += ContentStartPosition.X * 2; float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth); 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(); - float requiredHeight = _categoriesNode.GetRequiredHeight(); - requiredHeight += ContentStartPosition.Y + ContentStartPosition.X; + float requiredGridHeight = _categoriesNode.GetRequiredHeight(); + 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); } @@ -142,8 +203,9 @@ public class AddonInventoryWindow : NativeAddon private void ResizeWindow(float width, float height) { SetWindowSize(width, height); - _categoriesNode.Size = ContentSize; - _footerNode.Size = ContentSize with { Y = 28 }; + + LayoutContent(); + _categoriesNode.RecalculateLayout(); } @@ -152,5 +214,7 @@ public class AddonInventoryWindow : NativeAddon base.OnFinalize(addon); Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate); addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); + + _hoverSubscribed.Clear(); } -} \ No newline at end of file +} diff --git a/AetherBags/Nodes/FlexGrowDirection.cs b/AetherBags/Nodes/FlexGrowDirection.cs new file mode 100644 index 0000000..d3f94a3 --- /dev/null +++ b/AetherBags/Nodes/FlexGrowDirection.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace AetherBags.Nodes; + +public enum FlexGrowDirection +{ + DownRight, + DownLeft, + UpRight, + UpLeft +} diff --git a/AetherBags/Nodes/HybridDirectionalFlexNode.cs b/AetherBags/Nodes/HybridDirectionalFlexNode.cs index 89e636b..c92a041 100644 --- a/AetherBags/Nodes/HybridDirectionalFlexNode.cs +++ b/AetherBags/Nodes/HybridDirectionalFlexNode.cs @@ -1,107 +1,133 @@ -using System; -using System.Linq; using KamiToolKit; using KamiToolKit.Nodes; +using System; +using System.Runtime.CompilerServices; -namespace AetherBags.Nodes +namespace AetherBags.Nodes; + +public class HybridDirectionalFlexNode : HybridDirectionalFlexNode { } + +public class HybridDirectionalFlexNode : LayoutListNode where T : NodeBase { - public enum FlexGrowDirection + public FlexGrowDirection GrowDirection { - DownRight, - DownLeft, - UpRight, - UpLeft - } - - public class HybridDirectionalFlexNode : HybridDirectionalFlexNode { } - - public class HybridDirectionalFlexNode : LayoutListNode where T : NodeBase - { - - public FlexGrowDirection GrowDirection { get; - set { - field = value; - RecalculateLayout(); - } - } = FlexGrowDirection.DownRight; - - public int ItemsPerLine { - get; - set { - field = value; - RecalculateLayout(); - } - } = 1; - - public bool FillRowsFirst { - get; - set { - field = value; - RecalculateLayout(); - } - } = true; - - public float HorizontalPadding { - get; - set { - field = value; - RecalculateLayout(); - } - } = 1; - - public float VerticalPadding { - get; - set { - field = value; - RecalculateLayout(); - } - } = 1; - - protected override void InternalRecalculateLayout() + get => field; + set { - if (NodeList.Count == 0) { - return; + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = FlexGrowDirection.DownRight; + + public int ItemsPerLine + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = 1; + + public bool FillRowsFirst + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = true; + + public float HorizontalPadding + { + get => field; + set + { + if (field.Equals(value)) return; + field = value; + RecalculateLayout(); + } + } = 1; + + public float VerticalPadding + { + get => field; + set + { + if (field.Equals(value)) return; + field = value; + RecalculateLayout(); + } + } = 1; + + protected override void InternalRecalculateLayout() + { + int count = NodeList.Count; + if (count == 0) return; + + int itemsPerLine = ItemsPerLine; + if (itemsPerLine < 1) itemsPerLine = 1; + + NodeBase first = NodeList[0]; + float nodeWidth = first.Width; + float nodeHeight = first.Height; + + float hPad = HorizontalPadding; + 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 startY = alignBottom ? Height : 0f; + + float stepX = nodeWidth + hPad; + float stepY = nodeHeight + vPad; + + bool fillRowsFirst = FillRowsFirst; + + int major = 0; + int minor = 0; + + for (int i = 0; i < count; i++) + { + int row, col; + if (fillRowsFirst) + { + row = major; + col = minor; + } + else + { + col = major; + row = minor; } - int itemsPerLine = Math.Max(1, ItemsPerLine); + float x = alignRight + ? startX - nodeWidth - col * stepX + : startX + col * stepX; - float nodeWidth = NodeList.First().Width; - float nodeHeight = NodeList.First().Height; + float y = alignBottom + ? startY - nodeHeight - row * stepY + : startY + row * stepY; - bool alignRight = GrowDirection is FlexGrowDirection.DownLeft or FlexGrowDirection.UpLeft; - bool alignBottom = GrowDirection is FlexGrowDirection.UpRight or FlexGrowDirection.UpLeft; + NodeBase node = NodeList[i]; + node.X = x; + node.Y = y; - float startX = alignRight ? Width : 0f; - float startY = alignBottom ? Height : 0f; + AdjustNode(node); - int idx = 0; - foreach (var node in NodeList) + minor++; + if (minor == itemsPerLine) { - int row, col; - if (FillRowsFirst) - { - row = idx / itemsPerLine; - col = idx % itemsPerLine; - } - else - { - col = idx / itemsPerLine; - row = idx % itemsPerLine; - } - - float x = alignRight - ? startX - (col + 1) * nodeWidth - col * HorizontalPadding - : startX + col * (nodeWidth + HorizontalPadding); - - float y = alignBottom - ? startY - (row + 1) * nodeHeight - row * VerticalPadding - : startY + row * (nodeHeight + VerticalPadding); - - node.X = x; - node.Y = y; - AdjustNode(node); - idx++; + minor = 0; + major++; } } } -} +} \ No newline at end of file diff --git a/AetherBags/Nodes/HybridDirectionalStackNode.cs b/AetherBags/Nodes/HybridDirectionalStackNode.cs index f24ea58..dc051b2 100644 --- a/AetherBags/Nodes/HybridDirectionalStackNode.cs +++ b/AetherBags/Nodes/HybridDirectionalStackNode.cs @@ -2,92 +2,114 @@ using System; using KamiToolKit; using KamiToolKit.Nodes; -namespace AetherBags.Nodes +namespace AetherBags.Nodes; + +public class HybridDirectionalStackNode : LayoutListNode where T : NodeBase { - public class HybridDirectionalStackNode : LayoutListNode where T : NodeBase + public FlexGrowDirection GrowDirection { - public FlexGrowDirection GrowDirection + get => field; + set { - get; - set - { - field = value; - RecalculateLayout(); - } - } = FlexGrowDirection.DownRight; + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = FlexGrowDirection.DownRight; - public bool Vertical + public bool Vertical + { + get => field; + set { - get; - set - { - field = value; - RecalculateLayout(); - } - } = true; + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = true; - public float Spacing + public float Spacing + { + get => field; + set { - get; - set - { - field = value; - RecalculateLayout(); - } - } = 1f; + if (field.Equals(value)) return; + field = value; + RecalculateLayout(); + } + } = 1f; - public bool StretchCrossAxis + public bool StretchCrossAxis + { + get => field; + set { - get; - set - { - field = value; - RecalculateLayout(); - } - } = true; + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = true; - protected override void InternalRecalculateLayout() + protected override void InternalRecalculateLayout() + { + int count = NodeList.Count; + if (count == 0) return; + + FlexGrowDirection dir = GrowDirection; + bool alignRight = dir == FlexGrowDirection.DownLeft || dir == FlexGrowDirection.UpLeft; + bool alignBottom = dir == FlexGrowDirection.UpRight || dir == FlexGrowDirection.UpLeft; + + bool vertical = Vertical; + 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; + + if (vertical) { - if (NodeList.Count == 0) - return; - - bool alignRight = GrowDirection is FlexGrowDirection.DownLeft or FlexGrowDirection.UpLeft; - bool alignBottom = GrowDirection is FlexGrowDirection.UpRight or FlexGrowDirection.UpLeft; - - float startX = alignRight ? Width : 0f; - float startY = alignBottom ? Height : 0f; - - float cursor = 0f; - - for (int i = 0; i < NodeList.Count; i++) + for (int i = 0; i < count; i++) { - var node = NodeList[i]; + NodeBase node = NodeList[i]; - if (StretchCrossAxis) - { - if (Vertical) - node.Width = Width; - else - node.Height = Height; - } + if (stretchCross) + node.Width = containerW; - float x, y; - if (Vertical) - { - x = alignRight ? startX - node.Width : startX; - y = alignBottom ? startY - node.Height - 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; - } + float w = node.Width; + float h = node.Height; + + node.X = alignRight ? startX - w : startX; + node.Y = alignBottom ? startY - h - cursor : startY + cursor; - node.X = x; - node.Y = y; 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; } } } diff --git a/AetherBags/Nodes/InventoryCategoryHoverCoordinator.cs b/AetherBags/Nodes/InventoryCategoryHoverCoordinator.cs new file mode 100644 index 0000000..f9054db --- /dev/null +++ b/AetherBags/Nodes/InventoryCategoryHoverCoordinator.cs @@ -0,0 +1,81 @@ +using System; + +namespace AetherBags.Nodes; + +public sealed class InventoryCategoryHoverCoordinator +{ + private InventoryCategoryNode? _active; + private int _activeRowIndex = -1; + + public void OnCategoryHoverChanged( + WrappingGridNode 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 grid) + { + _active = null; + _activeRowIndex = -1; + ClearAll(grid); + } + + private static void ClearAll(WrappingGridNode grid) + { + foreach (var cat in grid.GetNodes()) + cat.SetHeaderSuppressed(false); + } + + private static void SuppressAllExcept(WrappingGridNode grid, InventoryCategoryNode source) + { + foreach (var cat in grid.GetNodes()) + cat.SetHeaderSuppressed(!ReferenceEquals(cat, source)); + } +} diff --git a/AetherBags/Nodes/InventoryCategoryNode.cs b/AetherBags/Nodes/InventoryCategoryNode.cs index 389b9ef..144e312 100644 --- a/AetherBags/Nodes/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/InventoryCategoryNode.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Numerics; using AetherBags.Extensions; using AetherBags.Inventory; using FFXIVClientStructs.FFXIV.Client.Game; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit; using KamiToolKit.Classes; using KamiToolKit.Nodes; +using System; +using System.Numerics; namespace AetherBags.Nodes; @@ -16,32 +15,50 @@ public class InventoryCategoryNode : SimpleComponentNode private readonly TextNode _categoryNameTextNode; private readonly HybridDirectionalFlexNode _itemGridNode; - private const float ItemSize = 40; - private const float ItemHorizontalPadding = 6; - private const float ItemVerticalPadding = 6; + private const float FallbackItemSize = 46; private const float HeaderHeight = 16; 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? HeaderHoverChanged; public InventoryCategoryNode() { _categoryNameTextNode = new TextNode { - Size = new Vector2(100, 14), - AlignmentType = AlignmentType.Left + Size = new Vector2(96, 16), + 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); _itemGridNode = new HybridDirectionalFlexNode { Position = new Vector2(0, HeaderHeight), - Size = new Vector2(240, 100), + Size = new Vector2(240, 92), FillRowsFirst = true, ItemsPerLine = 10, - HorizontalPadding = ItemHorizontalPadding, - VerticalPadding = ItemVerticalPadding, + HorizontalPadding = 5, + VerticalPadding = 2, }; + + _itemGridNode.NodeFlags |= NodeFlags.EmitsEvents; _itemGridNode.AttachNode(this); } @@ -52,8 +69,11 @@ public class InventoryCategoryNode : SimpleComponentNode { field = value; - _categoryNameTextNode.String = value.Category.Name; + _fullHeaderText = value.Category.Name; + + _categoryNameTextNode.String = _fullHeaderText; _categoryNameTextNode.TextColor = value.Category.Color; + _categoryNameTextNode.TooltipString = value.Category.Description; UpdateItemGrid(); @@ -66,6 +86,7 @@ public class InventoryCategoryNode : SimpleComponentNode get => _itemGridNode.ItemsPerLine; set { + if (_itemGridNode.ItemsPerLine == value) return; _itemGridNode.ItemsPerLine = value; RecalculateSize(); } @@ -76,55 +97,156 @@ public class InventoryCategoryNode : SimpleComponentNode get => _fixedWidth; set { + if (_fixedWidth.Equals(value)) return; _fixedWidth = value; 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() { int itemCount = CategorizedInventory.Items.Count; + if (itemCount == 0) { float width = _fixedWidth ?? MinWidth; + 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; } - int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); - int rows = (int)Math.Ceiling((float)itemCount / itemsPerLine); + int itemsPerLine = _itemGridNode.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; - if (_fixedWidth. HasValue) + if (_fixedWidth.HasValue) { calculatedWidth = _fixedWidth.Value; } else { - int actualColumns = Math.Min(itemCount, itemsPerLine); - calculatedWidth = actualColumns * ItemSize + (actualColumns - 1) * ItemHorizontalPadding; - calculatedWidth = Math.Max(calculatedWidth, MinWidth); + calculatedWidth = actualColumns * cellW + (actualColumns - 1) * hPad; + if (calculatedWidth < MinWidth) calculatedWidth = MinWidth; } - float height = HeaderHeight + rows * ItemSize + (rows - 1) * ItemVerticalPadding; + float height = HeaderHeight + rows * cellH + (rows - 1) * vPad; Size = new Vector2(calculatedWidth, height); + + _itemGridNode.Position = new Vector2(0, HeaderHeight); _itemGridNode.Size = new Vector2(calculatedWidth, height - HeaderHeight); - _categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = calculatedWidth }; + + _baseHeaderWidth = calculatedWidth; + + ApplyHeaderVisualStateAndSize(); } private void UpdateItemGrid() { - _itemGridNode.SyncWithListData(CategorizedInventory.Items, node => node.ItemInfo, CreateInventoryDragDropNode); + _itemGridNode.SyncWithListData( + CategorizedInventory.Items, + node => node.ItemInfo, + CreateInventoryDragDropNode); } private InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) { InventoryItem item = data.Item; - InventoryDragDropNode node = new InventoryDragDropNode + + var node = new InventoryDragDropNode { - Size = new Vector2(40), + Size = new Vector2(42, 46), IsVisible = true, IconId = item.IconId, AcceptedType = DragDropType.Nothing, @@ -136,10 +258,21 @@ public class InventoryCategoryNode : SimpleComponentNode Int2 = (int)item.ItemId, }, 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 }; + return node; } -} \ No newline at end of file +} diff --git a/AetherBags/Nodes/WrappingGridNode.cs b/AetherBags/Nodes/WrappingGridNode.cs index 416bdd3..5e2629a 100644 --- a/AetherBags/Nodes/WrappingGridNode.cs +++ b/AetherBags/Nodes/WrappingGridNode.cs @@ -1,137 +1,192 @@ +using KamiToolKit; +using KamiToolKit.Nodes; using System; +using System.Collections; using System.Collections.Generic; using System.Runtime.CompilerServices; -using KamiToolKit; -using KamiToolKit. Nodes; -namespace AetherBags.Nodes +namespace AetherBags.Nodes; + +public sealed class WrappingGridNode : LayoutListNode where T : NodeBase { - public sealed class WrappingGridNode : LayoutListNode where T : NodeBase + public float VerticalSpacing { get; set; } = 10f; + + public float TopPadding { get; set; } = 0f; + public float BottomPadding { get; set; } = 0f; + + private readonly List> _rows = new(); + private readonly Stack> _rowPool = new(); + + private readonly Dictionary _rowIndex = new(ReferenceEqualityComparer.Instance); + + private float _requiredHeight; + private bool _requiredHeightDirty = true; + + private readonly IReadOnlyList> _rowsView; + + public WrappingGridNode() { + _rowsView = new RowsReadOnlyView(_rows); + } - public float HorizontalSpacing { get; set; } = 10f; - public float VerticalSpacing { get; set; } = 10f; + public IReadOnlyList> Rows => _rowsView; - private readonly List> _rows = new(); - private readonly Stack> _rowPool = new(); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGetRowIndex(NodeBase node, out int rowIndex) => _rowIndex.TryGetValue(node, out rowIndex); - private float _requiredHeight; - private bool _requiredHeightDirty = true; + protected override void InternalRecalculateLayout() + { + RecycleAllRows(); + _rowIndex.Clear(); - protected override void InternalRecalculateLayout() + int count = NodeList.Count; + if (count == 0) { - RecycleAllRows(); + _requiredHeight = 0f; + _requiredHeightDirty = false; + return; + } - int count = NodeList.Count; - if (count == 0) - { - _requiredHeight = 0f; - _requiredHeightDirty = false; - return; - } + _rowIndex.EnsureCapacity(count); - float availableWidth = Width; - float hSpace = HorizontalSpacing; - float vSpace = VerticalSpacing; - float startX = FirstItemSpacing; + float availableWidth = Width; + float hSpace = ItemSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; - float currentX = startX; - float currentY = 0f; - float rowHeight = 0f; + float currentX = startX; + float currentY = TopPadding; + float rowHeight = 0f; - List currentRow = RentRowList(capacityHint: 8); + int currentRowIndex = 0; + List currentRow = RentRowList(capacityHint: 8); - for (int i = 0; i < count; i++) - { - NodeBase node = NodeList[i]; + for (int i = 0; i < count; i++) + { + NodeBase node = NodeList[i]; - float nodeWidth = node.Width; + float nodeWidth = node.Width; - if (currentRow.Count != 0 && (currentX + nodeWidth) > availableWidth) - { - _rows.Add(currentRow); - - currentY += rowHeight + vSpace; - currentX = startX; - rowHeight = 0f; - - currentRow = RentRowList(capacityHint: 8); - } - - node.X = currentX; - node.Y = currentY; - - AdjustNode(node); - - float nodeHeight = node.Height; - if (nodeHeight > rowHeight) rowHeight = nodeHeight; - - currentRow.Add(node); - - currentX += nodeWidth + hSpace; - } - - if (currentRow.Count != 0) + if (currentRow.Count != 0 && (currentX + nodeWidth) > availableWidth) { _rows.Add(currentRow); - } - else - { - RecycleRow(currentRow); + currentRowIndex++; + + currentY += rowHeight + vSpace; + currentX = startX; + rowHeight = 0f; + + currentRow = RentRowList(capacityHint: 8); } - _requiredHeightDirty = true; + node.X = currentX; + node.Y = currentY; + + AdjustNode(node); + + float nodeHeight = node.Height; + if (nodeHeight > rowHeight) rowHeight = nodeHeight; + + currentRow.Add(node); + _rowIndex[node] = currentRowIndex; + + currentX += nodeWidth + hSpace; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public float GetRequiredHeight() + if (currentRow.Count != 0) { - if (!_requiredHeightDirty) return _requiredHeight; - - float maxBottom = 0f; - int count = NodeList.Count; - - for (int i = 0; i < count; i++) - { - NodeBase node = NodeList[i]; - float bottom = node.Y + node.Height; - if (bottom > maxBottom) maxBottom = bottom; - } - - _requiredHeight = maxBottom; - _requiredHeightDirty = false; - return maxBottom; + _rows.Add(currentRow); + } + else + { + RecycleRow(currentRow); } - private void RecycleAllRows() + _requiredHeightDirty = true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float GetRequiredHeight() + { + if (!_requiredHeightDirty) return _requiredHeight; + + float maxBottom = 0f; + int count = NodeList.Count; + + for (int i = 0; i < count; i++) { - for (int i = 0; i < _rows.Count; i++) - { - List row = _rows[i]; - row.Clear(); - _rowPool.Push(row); - } - _rows.Clear(); + NodeBase node = NodeList[i]; + float bottom = node.Y + node.Height; + if (bottom > maxBottom) maxBottom = bottom; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private List RentRowList(int capacityHint) - { - if (_rowPool.Count != 0) - { - List row = _rowPool.Pop(); - if (row.Capacity < capacityHint) row.Capacity = capacityHint; - return row; - } - return new List(capacityHint); - } + maxBottom += BottomPadding; - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void RecycleRow(List row) + _requiredHeight = maxBottom; + _requiredHeightDirty = false; + return maxBottom; + } + + private void RecycleAllRows() + { + for (int i = 0; i < _rows.Count; i++) { + List row = _rows[i]; row.Clear(); _rowPool.Push(row); } + _rows.Clear(); } -} \ No newline at end of file + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentRowList(int capacityHint) + { + if (_rowPool.Count != 0) + { + List row = _rowPool.Pop(); + if (row.Capacity < capacityHint) row.Capacity = capacityHint; + return row; + } + + return new List(capacityHint); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RecycleRow(List row) + { + row.Clear(); + _rowPool.Push(row); + } + + private sealed class RowsReadOnlyView : IReadOnlyList> + { + private readonly List> _rows; + public RowsReadOnlyView(List> rows) => _rows = rows; + + public int Count => _rows.Count; + public IReadOnlyList this[int index] => _rows[index]; + + public IEnumerator> GetEnumerator() + { + for (int i = 0; i < _rows.Count; i++) + yield return _rows[i]; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer where TRef : class + { + public static readonly ReferenceEqualityComparer 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); + } +}