From 592fa392bf899084d4cd01e854bedbc061d909b4 Mon Sep 17 00:00:00 2001 From: Zeffuro Date: Sat, 20 Dec 2025 09:21:44 +0100 Subject: [PATCH] Improve layout --- AetherBags.sln.DotSettings.user | 3 +- AetherBags/Addons/AddonInventoryWindow.cs | 148 ++++++++++++------ .../Extensions/AddonLifecycleExtensions.cs | 54 +++++++ AetherBags/Inventory/CategorizedInventory.cs | 5 + AetherBags/Inventory/InventoryState.cs | 48 ++++++ AetherBags/Inventory/ItemInfo.cs | 3 +- .../Nodes/HybridDirectionalStackNode.cs | 94 +++++++++++ AetherBags/Nodes/InventoryCategoryNode.cs | 86 +++++++--- AetherBags/Nodes/WrappingGridNode.cs | 72 +++++++++ AetherBags/Plugin.cs | 1 + 10 files changed, 449 insertions(+), 65 deletions(-) create mode 100644 AetherBags/Extensions/AddonLifecycleExtensions.cs create mode 100644 AetherBags/Inventory/CategorizedInventory.cs create mode 100644 AetherBags/Nodes/HybridDirectionalStackNode.cs create mode 100644 AetherBags/Nodes/WrappingGridNode.cs diff --git a/AetherBags.sln.DotSettings.user b/AetherBags.sln.DotSettings.user index a250ab0..a860f90 100644 --- a/AetherBags.sln.DotSettings.user +++ b/AetherBags.sln.DotSettings.user @@ -1,2 +1,3 @@  - ForceIncluded \ No newline at end of file + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 4787062..de9ea4d 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Linq; using System.Numerics; using AetherBags.Extensions; @@ -15,58 +17,33 @@ namespace AetherBags.Addons; public class AddonInventoryWindow : NativeAddon { - private InventoryCategoryNode _categoryNode; - private InventoryDragDropNode _dragDropNode; + private WrappingGridNode _categoriesNode; + + // Window constraints + private const float MinWindowWidth = 300; + private const float MaxWindowWidth = 800; + private const float MinWindowHeight = 200; + private const float MaxWindowHeight = 1000; + + // Layout settings + private const float CategorySpacing = 10; + private const float ItemSize = 40; + private const float ItemPadding = 6; + protected override unsafe void OnSetup(AtkUnitBase* addon) { Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate); - _categoryNode = new InventoryCategoryNode + addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); + _categoriesNode = new WrappingGridNode { Position = ContentStartPosition, Size = ContentSize, - Category = new CategoryInfo - { - Name = "AetherBags", - }, - Items = InventoryState.GetInventoryItems() + HorizontalSpacing = CategorySpacing, + VerticalSpacing = CategorySpacing }; - _categoryNode.AttachNode(this); - /* - var data = InventoryState.GetInventoryItems().Find(item => item.Name.Contains("Cookie")); - - - if (data != null) - { - var item = data.Item; - _dragDropNode = new InventoryDragDropNode - { - Size = new Vector2(48), - IsVisible = true, - IconId = data.IconId, - AcceptedType = DragDropType.Nothing, - IsDraggable = false, - Payload = new DragDropPayload - { - Type = DragDropType.Item, - Int1 = (int)data.Item.Container, - Int2 = (int)data.Item.ItemId, - }, - IsClickable = true, - OnRollOver = node => node.ShowInventoryItemTooltip(data.Item.Container, data.Item.Slot), - OnRollOut = node => node.HideTooltip(), - OnClicked = _ => - { - - AgentInventoryContext* context = AgentInventoryContext.Instance(); - context->OpenForItemSlot(data.Item.Container, data.Item.Slot, 0, context->AddonId); - //item.UseItem(); - }, - ItemInfo = data - }; - _dragDropNode.AttachNode(this); - } - */ + _categoriesNode.AttachNode(this); + RefreshCategories(); } protected override unsafe void OnUpdate(AtkUnitBase* addon) @@ -76,11 +53,94 @@ public class AddonInventoryWindow : NativeAddon private void OnInventoryUpdate(AddonEvent type, AddonArgs args) { + RefreshCategories(); + } + protected override unsafe void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { + RefreshCategories(); + } + + private void RefreshCategories() + { + var categories = InventoryState.GetInventoryItemCategories(); + + float maxContentWidth = MaxWindowWidth - (ContentStartPosition.X * 2); + int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth); + + _categoriesNode.SyncWithListData( + categories, + node => node.CategorizedInventory, + data => + { + var node = new InventoryCategoryNode + { + Size = ContentSize with { Y = 120 }, + CategorizedInventory = data + }; + + UpdateItemsPerLine(node, maxItemsPerLine); + return node; + }); + + foreach (InventoryCategoryNode node in _categoriesNode.GetNodes()) + { + UpdateItemsPerLine(node, maxItemsPerLine); + } + + AutoSizeWindow(); + } + + private static void UpdateItemsPerLine(InventoryCategoryNode node, int maxItemsPerLine) + { + int itemCount = node.CategorizedInventory.Items.Count; + int itemsPerLine = Math.Min(itemCount, maxItemsPerLine); + node.SetItemsPerLine(itemsPerLine); + } + + private int CalculateOptimalItemsPerLine(float availableWidth) + { + float itemWithPadding = ItemSize + ItemPadding; + int maxItems = (int)Math.Floor((availableWidth + ItemPadding) / itemWithPadding); + + return Math.Clamp(maxItems, 1, 15); + } + + private void AutoSizeWindow() + { + List childNodes = _categoriesNode.GetNodes().ToList(); + if (childNodes.Count == 0) + { + ResizeWindow(MinWindowWidth, MinWindowHeight); + return; + } + + 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); + + _categoriesNode.RecalculateLayout(); + + float requiredHeight = _categoriesNode.GetRequiredHeight(); + requiredHeight += ContentStartPosition.Y + ContentStartPosition.X; + + float finalHeight = Math.Clamp(requiredHeight, MinWindowHeight, MaxWindowHeight); + + ResizeWindow(finalWidth, finalHeight); + } + + private void ResizeWindow(float width, float height) + { + SetWindowSize(width, height); + _categoriesNode.Size = ContentSize; + _categoriesNode.RecalculateLayout(); } protected override unsafe void OnFinalize(AtkUnitBase* addon) { Services.AddonLifecycle.UnregisterListener(OnInventoryUpdate); + addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory); } } \ No newline at end of file diff --git a/AetherBags/Extensions/AddonLifecycleExtensions.cs b/AetherBags/Extensions/AddonLifecycleExtensions.cs new file mode 100644 index 0000000..03324f4 --- /dev/null +++ b/AetherBags/Extensions/AddonLifecycleExtensions.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using AetherBags; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Component.GUI; + +public static class AddonLifecycleExtensions { + extension(IAddonLifecycle addonLifecycle) { + public void LogAddon(string addonName, params AddonEvent[] loggedModules) { + if (loggedModules.Length is 0) { + loggedModules = [ + AddonEvent.PostSetup, + AddonEvent.PostOpen, + AddonEvent.PostClose, + AddonEvent.PostShow, + AddonEvent.PostHide, + AddonEvent.PostRefresh, + AddonEvent.PostRequestedUpdate, + AddonEvent.PreFinalize, + ]; + } + + ActiveLoggers.TryAdd(addonName, loggedModules.ToList()); + foreach (var loggedModule in loggedModules) { + addonLifecycle.RegisterListener(loggedModule, addonName, Logger); + } + } + + public void UnLogAddon(string addonName) { + if (!ActiveLoggers.TryGetValue(addonName, out var loggedModules)) return; + + foreach (var loggedModule in loggedModules) { + addonLifecycle.UnregisterListener(loggedModule, addonName, Logger); + + } + } + } + + private static readonly Dictionary> ActiveLoggers = []; + + private static void Logger(AddonEvent type, AddonArgs args) { + switch (args) { + case AddonReceiveEventArgs receiveEventArgs: + Services.Logger.Debug($"[{args.AddonName}] {(AtkEventType)receiveEventArgs.AtkEventType}: {receiveEventArgs.EventParam}"); + break; + + default: + Services.Logger.Debug($"{args.AddonName} called {type.ToString().Replace("Post", string.Empty)}"); + break; + } + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/CategorizedInventory.cs b/AetherBags/Inventory/CategorizedInventory.cs new file mode 100644 index 0000000..d08e992 --- /dev/null +++ b/AetherBags/Inventory/CategorizedInventory.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace AetherBags.Inventory; + +public readonly record struct CategorizedInventory(CategoryInfo Category, List Items); \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryState.cs b/AetherBags/Inventory/InventoryState.cs index f82b6c8..0b97f8d 100644 --- a/AetherBags/Inventory/InventoryState.cs +++ b/AetherBags/Inventory/InventoryState.cs @@ -3,6 +3,7 @@ using System.Linq; using Dalamud.Game.Inventory; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using Lumina.Excel.Sheets; namespace AetherBags.Inventory; @@ -34,6 +35,22 @@ public static unsafe class InventoryState public static bool Contains(this List inventoryTypes, GameInventoryType type) => inventoryTypes.Contains((InventoryType)type); + public static List GetInventoryItemCategories() + { + var items = GetInventoryItems(); + + return items + .GroupBy(GetItemUiCategoryKey) + .OrderBy(g => g.Key) + .Select(g => + { + var category = GetCategoryInfoForKey(g.Key, g.FirstOrDefault()); + var list = g.OrderByDescending(i => i.ItemCount).ToList(); + return new CategorizedInventory(category, list); + }) + .ToList(); + } + public static List GetInventoryItems() { List inventories = [ InventoryType.Inventory1, InventoryType.Inventory2, InventoryType.Inventory3, InventoryType.Inventory4 ]; List items = []; @@ -60,4 +77,35 @@ public static unsafe class InventoryState return itemInfos; } + + public static List GetInventoryItems(string filterString, bool invert = false) + => GetInventoryItems().Where(item => item.IsRegexMatch(filterString) != invert).ToList(); + + private static uint GetItemUiCategoryKey(ItemInfo info) + => info.UiCategory.RowId; + + private static CategoryInfo GetCategoryInfoForKey(uint key, ItemInfo? sample) + { + if (key == 0) + { + return new CategoryInfo + { + Name = "Misc", + Description = "Uncategorized items", + }; + } + + var uiCat = sample?.UiCategory.Value; + var name = uiCat?.Name.ToString(); + if (string.IsNullOrWhiteSpace(name)) + name = $"Category\\ {key}"; + + return new CategoryInfo + { + Name = name, + }; + } + + + } \ No newline at end of file diff --git a/AetherBags/Inventory/ItemInfo.cs b/AetherBags/Inventory/ItemInfo.cs index 41a7e1b..1ef6e50 100644 --- a/AetherBags/Inventory/ItemInfo.cs +++ b/AetherBags/Inventory/ItemInfo.cs @@ -3,6 +3,7 @@ using System.Numerics; using System.Text.RegularExpressions; using AetherBags.Extensions; using FFXIVClientStructs.FFXIV.Client.Game; +using Lumina.Excel; using Lumina.Excel.Sheets; namespace AetherBags.Inventory; @@ -25,7 +26,7 @@ public class ItemInfo : IEquatable { public int Rarity => ItemData.Rarity; - public int UiCategory => (int) ItemData.ItemUICategory.RowId; + public RowRef UiCategory => ItemData.ItemUICategory; private string Description => ItemData.Description.ToString(); diff --git a/AetherBags/Nodes/HybridDirectionalStackNode.cs b/AetherBags/Nodes/HybridDirectionalStackNode.cs new file mode 100644 index 0000000..f24ea58 --- /dev/null +++ b/AetherBags/Nodes/HybridDirectionalStackNode.cs @@ -0,0 +1,94 @@ +using System; +using KamiToolKit; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes +{ + public class HybridDirectionalStackNode : LayoutListNode where T : NodeBase + { + public FlexGrowDirection GrowDirection + { + get; + set + { + field = value; + RecalculateLayout(); + } + } = FlexGrowDirection.DownRight; + + public bool Vertical + { + get; + set + { + field = value; + RecalculateLayout(); + } + } = true; + + public float Spacing + { + get; + set + { + field = value; + RecalculateLayout(); + } + } = 1f; + + public bool StretchCrossAxis + { + get; + set + { + field = value; + RecalculateLayout(); + } + } = true; + + protected override void InternalRecalculateLayout() + { + 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++) + { + var node = NodeList[i]; + + if (StretchCrossAxis) + { + if (Vertical) + node.Width = Width; + else + node.Height = Height; + } + + 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; + } + + node.X = x; + node.Y = y; + AdjustNode(node); + } + } + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/InventoryCategoryNode.cs b/AetherBags/Nodes/InventoryCategoryNode.cs index b27c630..b682e80 100644 --- a/AetherBags/Nodes/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/InventoryCategoryNode.cs @@ -3,6 +3,7 @@ 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.Classes; @@ -14,6 +15,15 @@ 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 HeaderHeight = 16; + private const float MinWidth = 40; + + private float? _fixedWidth = null; + public InventoryCategoryNode() { _categoryNameTextNode = new TextNode @@ -25,63 +35,101 @@ public class InventoryCategoryNode : SimpleComponentNode _itemGridNode = new HybridDirectionalFlexNode { - Position = new Vector2(0, 16), + Position = new Vector2(0, HeaderHeight), Size = new Vector2(240, 100), FillRowsFirst = true, ItemsPerLine = 10, - HorizontalPadding = 6, - VerticalPadding = 6, + HorizontalPadding = ItemHorizontalPadding, + VerticalPadding = ItemVerticalPadding, }; _itemGridNode.AttachNode(this); } - public required CategoryInfo Category + public required CategorizedInventory CategorizedInventory { get; set { field = value; - _categoryNameTextNode.String = value.Name; - _categoryNameTextNode.TextColor = value.Color; - _categoryNameTextNode.TooltipString = value.Description; + _categoryNameTextNode.String = value.Category.Name; + _categoryNameTextNode.TextColor = value.Category.Color; + _categoryNameTextNode.TooltipString = value.Category.Description; + + UpdateItemGrid(); + RecalculateSize(); } } - public required List Items + public void SetItemsPerLine(int itemsPerLine) { - get; - set - { - field = value; + _itemGridNode.ItemsPerLine = itemsPerLine; + RecalculateSize(); + } - UpdateItemGrid(); + public void SetFixedWidth(float width) + { + _fixedWidth = width; + RecalculateSize(); + } + + 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 }; + return; } + + int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); + int rows = (int)Math.Ceiling((float)itemCount / itemsPerLine); + + float calculatedWidth; + if (_fixedWidth. HasValue) + { + calculatedWidth = _fixedWidth.Value; + } + else + { + int actualColumns = Math.Min(itemCount, itemsPerLine); + calculatedWidth = actualColumns * ItemSize + (actualColumns - 1) * ItemHorizontalPadding; + calculatedWidth = Math.Max(calculatedWidth, MinWidth); + } + + float height = HeaderHeight + rows * ItemSize + (rows - 1) * ItemVerticalPadding; + + Size = new Vector2(calculatedWidth, height); + _itemGridNode.Size = new Vector2(calculatedWidth, height - HeaderHeight); + _categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = calculatedWidth }; } private bool UpdateItemGrid() { - var listUpdated = _itemGridNode.SyncWithListData(Items, node => node.ItemInfo, data => CreateInventoryDragDropNode(data)); + var listUpdated = _itemGridNode.SyncWithListData(CategorizedInventory.Items, node => node.ItemInfo, CreateInventoryDragDropNode); return listUpdated; } - private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) + private InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) { + InventoryItem item = data.Item; InventoryDragDropNode node = new InventoryDragDropNode { Size = new Vector2(40), IsVisible = true, - IconId = data.IconId, + IconId = item.IconId, AcceptedType = DragDropType.Nothing, IsDraggable = false, Payload = new DragDropPayload { Type = DragDropType.Item, - Int1 = (int)data.Item.Container, - Int2 = (int)data.Item.ItemId, + Int1 = (int)item.Container, + Int2 = (int)item.ItemId, }, IsClickable = true, - OnRollOver = node => node.ShowInventoryItemTooltip(data.Item.Container, data.Item.Slot), + OnRollOver = node => node.ShowInventoryItemTooltip(item.Container, item.Slot), OnRollOut = node => node.HideTooltip(), ItemInfo = data }; diff --git a/AetherBags/Nodes/WrappingGridNode.cs b/AetherBags/Nodes/WrappingGridNode.cs new file mode 100644 index 0000000..9b11941 --- /dev/null +++ b/AetherBags/Nodes/WrappingGridNode.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using KamiToolKit; +using KamiToolKit. Nodes; + +namespace AetherBags.Nodes +{ + public class WrappingGridNode : LayoutListNode where T : NodeBase + { + public float HorizontalSpacing { get; set; } = 10; + public float VerticalSpacing { get; set; } = 10; + + private List> _rows = new(); + + protected override void InternalRecalculateLayout() + { + if (NodeList. Count == 0) + return; + + _rows.Clear(); + + float availableWidth = Width; + float currentX = 0f; + float currentY = 0f; + float rowHeight = 0f; + List currentRow = new(); + + foreach (var node in NodeList) + { + float nodeWidth = node.Width; + float nodeHeight = node.Height; + + if (currentX + nodeWidth > availableWidth && currentRow.Count > 0) + { + _rows.Add(currentRow); + currentRow = new(); + currentY += rowHeight + VerticalSpacing; + currentX = 0f; + rowHeight = 0f; + } + + node.X = currentX; + node. Y = currentY; + AdjustNode(node); + + currentX += nodeWidth + HorizontalSpacing; + rowHeight = Math.Max(rowHeight, nodeHeight); + currentRow.Add(node); + } + + if (currentRow.Count > 0) + { + _rows.Add(currentRow); + } + } + + public float GetRequiredHeight() + { + if (NodeList.Count == 0) + return 0f; + + float maxY = 0f; + foreach (var node in NodeList) + { + float nodeBottom = node.Y + node.Height; + maxY = Math. Max(maxY, nodeBottom); + } + + return maxY; + } + } +} \ No newline at end of file diff --git a/AetherBags/Plugin.cs b/AetherBags/Plugin.cs index 9617931..5673c41 100644 --- a/AetherBags/Plugin.cs +++ b/AetherBags/Plugin.cs @@ -43,6 +43,7 @@ public class Plugin : IDalamudPlugin if (Services.ClientState.IsLoggedIn) { Services.Framework.RunOnFrameworkThread(OnLogin); } + Services.AddonLifecycle.LogAddon("Inventory"); } public void Dispose()