diff --git a/AetherBags.sln.DotSettings.user b/AetherBags.sln.DotSettings.user
index 4930879..f39de2b 100644
--- a/AetherBags.sln.DotSettings.user
+++ b/AetherBags.sln.DotSettings.user
@@ -1,5 +1,6 @@
ForceIncluded
+ ForceIncluded
ForceIncluded
ForceIncluded
ForceIncluded
\ No newline at end of file
diff --git a/AetherBags/Addons/AddonCategoryConfigurationWindow.cs b/AetherBags/Addons/AddonCategoryConfigurationWindow.cs
new file mode 100644
index 0000000..3f0ac09
--- /dev/null
+++ b/AetherBags/Addons/AddonCategoryConfigurationWindow.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using AetherBags.Configuration;
+using AetherBags.Nodes.Configuration.Category;
+using FFXIVClientStructs.FFXIV.Component.GUI;
+using KamiToolKit;
+using KamiToolKit.Classes;
+using KamiToolKit.Nodes;
+using KamiToolKit.Premade.Nodes;
+
+namespace AetherBags.Addons;
+
+public class AddonCategoryConfigurationWindow : NativeAddon
+{
+ private ModifyListNode? _selectionListNode;
+ private VerticalLineNode? _separatorLine;
+ private CategoryConfigurationNode? _configNode;
+ private TextNode? _nothingSelectedTextNode;
+
+ private List _categoryWrappers = new();
+
+ protected override unsafe void OnSetup(AtkUnitBase* addon)
+ {
+ _categoryWrappers = CreateCategoryWrappers();
+
+ _selectionListNode = new ModifyListNode
+ {
+ Position = ContentStartPosition,
+ Size = new Vector2(250.0f, ContentSize.Y),
+ SelectionOptions = _categoryWrappers,
+ OnOptionChanged = OnOptionChanged,
+ AddNewEntry = OnAddNewCategory,
+ RemoveEntry = OnRemoveCategory,
+ };
+ _selectionListNode.AttachNode(this);
+
+ _separatorLine = new VerticalLineNode
+ {
+ Position = ContentStartPosition + new Vector2(250.0f + 8.0f, 0.0f),
+ Size = new Vector2(4.0f, ContentSize.Y),
+ };
+ _separatorLine.AttachNode(this);
+
+ _nothingSelectedTextNode = new TextNode
+ {
+ Position = ContentStartPosition + new Vector2(250.0f + 16.0f, 0.0f),
+ Size = ContentSize - new Vector2(250.0f + 16.0f, 0.0f),
+ AlignmentType = AlignmentType.Center,
+ TextFlags = TextFlags.WordWrap | TextFlags.MultiLine,
+ FontSize = 14,
+ LineSpacing = 22,
+ FontType = FontType.Axis,
+ String = "Please select a category on the left or add one.",
+ TextColor = ColorHelper.GetColor(1),
+ };
+ _nothingSelectedTextNode.AttachNode(this);
+
+ _configNode = new CategoryConfigurationNode
+ {
+ Position = ContentStartPosition + new Vector2(250.0f + 16.0f, 0.0f),
+ Size = ContentSize - new Vector2(250.0f + 16.0f, 0.0f),
+ IsVisible = false,
+ OnCategoryChanged = RefreshSelectionList,
+ };
+
+ _configNode.AttachNode(this);
+ }
+
+ private List CreateCategoryWrappers()
+ {
+ return System.Config.Categories.UserCategories
+ .Select(categoryDefinition => new CategoryWrapper(categoryDefinition))
+ .ToList();
+ }
+
+ private void OnOptionChanged(CategoryWrapper? newOption)
+ {
+ if (_configNode is null) return;
+
+ _configNode.IsVisible = newOption is not null;
+ if (_nothingSelectedTextNode is not null)
+ _nothingSelectedTextNode.IsVisible = newOption is null;
+
+ _configNode.ConfigurationOption = newOption;
+ }
+
+ private void OnAddNewCategory(ModifyListNode listNode)
+ {
+ var newCategory = new UserCategoryDefinition
+ {
+ Name = $"New Category {System.Config.Categories.UserCategories.Count + 1}",
+ Order = System.Config.Categories.UserCategories.Count,
+ };
+
+ System.Config.Categories.UserCategories.Add(newCategory);
+
+ var newWrapper = new CategoryWrapper(newCategory);
+ _categoryWrappers.Add(newWrapper);
+ listNode.AddOption(newWrapper);
+ }
+
+ private void OnRemoveCategory(CategoryWrapper categoryWrapper)
+ {
+ if (categoryWrapper.CategoryDefinition is null) return;
+
+ System.Config.Categories.UserCategories.Remove(categoryWrapper.CategoryDefinition);
+ _categoryWrappers.Remove(categoryWrapper);
+ }
+
+ private void RefreshSelectionList()
+ {
+ _selectionListNode?.UpdateList();
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Addons/AddonConfigurationWindow.cs b/AetherBags/Addons/AddonConfigurationWindow.cs
index 981d0d4..228b4ed 100644
--- a/AetherBags/Addons/AddonConfigurationWindow.cs
+++ b/AetherBags/Addons/AddonConfigurationWindow.cs
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using AetherBags.Nodes.Configuration;
+using AetherBags.Nodes.Configuration.Category;
+using AetherBags.Nodes.Configuration.Currency;
+using AetherBags.Nodes.Configuration.General;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit;
using KamiToolKit.Nodes;
diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs
index f732071..2cf5597 100644
--- a/AetherBags/Addons/AddonInventoryWindow.cs
+++ b/AetherBags/Addons/AddonInventoryWindow.cs
@@ -122,12 +122,14 @@ public class AddonInventoryWindow : NativeAddon
public void ManualInventoryRefresh()
{
+ if (!Services.ClientState.IsLoggedIn) return;
InventoryState.RefreshFromGame();
RefreshCategoriesCore(true);
}
public void ManualCurrencyRefresh()
{
+ if (!Services.ClientState.IsLoggedIn) return;
_footerNode.RefreshCurrencies();
}
diff --git a/AetherBags/Addons/CategoryWrapper.cs b/AetherBags/Addons/CategoryWrapper.cs
new file mode 100644
index 0000000..68b7f85
--- /dev/null
+++ b/AetherBags/Addons/CategoryWrapper.cs
@@ -0,0 +1,33 @@
+using AetherBags.Configuration;
+using KamiToolKit.Premade;
+
+namespace AetherBags.Addons;
+
+public class CategoryWrapper(UserCategoryDefinition categoryDefinition) : IInfoNodeData
+{
+ public UserCategoryDefinition? CategoryDefinition { get; } = categoryDefinition;
+
+ public string GetLabel() {
+
+ return CategoryDefinition!.Name;
+ }
+
+ public string GetSubLabel() {
+ return CategoryDefinition!.Enabled ? "Enabled" : "Disabled";
+ }
+
+ public uint? GetId() => null;
+
+ public uint? GetIconId() {
+ return 0;
+ }
+
+ public string? GetTexturePath()
+ => null;
+
+ public int Compare(IInfoNodeData other, string sortingMode) {
+ if (other is not CategoryWrapper otherWrapper) return 0;
+
+ return CategoryDefinition!.Order.CompareTo(otherWrapper.CategoryDefinition!.Order);
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Configuration/CategorySettings.cs b/AetherBags/Configuration/CategorySettings.cs
index 3aa5d18..1ab344b 100644
--- a/AetherBags/Configuration/CategorySettings.cs
+++ b/AetherBags/Configuration/CategorySettings.cs
@@ -16,6 +16,7 @@ public class CategorySettings
public class UserCategoryDefinition
{
+ public bool Enabled { get; set; } = true;
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = "New Category";
public string Description { get; set; } = string.Empty;
diff --git a/AetherBags/Currency/CurrencyState.cs b/AetherBags/Currency/CurrencyState.cs
index c0a852a..a357bab 100644
--- a/AetherBags/Currency/CurrencyState.cs
+++ b/AetherBags/Currency/CurrencyState.cs
@@ -71,12 +71,12 @@ public static unsafe class CurrencyState
return currencyInfoList;
}
- private static uint? GetLimitedTomestoneItemIdCached()
+ private static uint? GetLimitedTomestoneItemIdCached()
{
if (_cachedLimitedTomestoneItemId.HasValue)
return _cachedLimitedTomestoneItemId.Value;
- uint? itemId = Services.DataManager.GetExcelSheet()
+ uint? itemId = Services.DataManager.GetExcelSheet()
.FirstOrDefault(t => t.Tomestones.RowId == 3)
.Item.RowId;
@@ -84,7 +84,7 @@ public static unsafe class CurrencyState
return itemId;
}
- private static uint? GetNonLimitedTomestoneItemIdCached()
+ private static uint? GetNonLimitedTomestoneItemIdCached()
{
if (_cachedNonLimitedTomestoneItemId.HasValue)
return _cachedNonLimitedTomestoneItemId.Value;
diff --git a/AetherBags/Extensions/InventoryTypeExtensions.cs b/AetherBags/Extensions/InventoryTypeExtensions.cs
index a703d96..4c28d8f 100644
--- a/AetherBags/Extensions/InventoryTypeExtensions.cs
+++ b/AetherBags/Extensions/InventoryTypeExtensions.cs
@@ -85,14 +85,88 @@ public static unsafe class InventoryTypeExtensions
InventoryType.SaddleBag2 => ItemOrderModule.Instance()->SaddleBagSorter,
InventoryType.PremiumSaddleBag1 => ItemOrderModule.Instance()->PremiumSaddleBagSorter,
InventoryType.PremiumSaddleBag2 => ItemOrderModule.Instance()->PremiumSaddleBagSorter,
- _ => throw new Exception($"Type Not Implemented: {inventoryType}"),
+ _ => null,
};
public int GetInventoryStartIndex => inventoryType switch {
InventoryType.Inventory2 => inventoryType.GetInventorySorter->ItemsPerPage,
InventoryType.Inventory3 => inventoryType.GetInventorySorter->ItemsPerPage * 2,
InventoryType.Inventory4 => inventoryType.GetInventorySorter->ItemsPerPage * 3,
+ InventoryType.SaddleBag2 => inventoryType.GetInventorySorter->ItemsPerPage,
+ InventoryType.PremiumSaddleBag2 => inventoryType.GetInventorySorter->ItemsPerPage,
_ => 0,
};
+
+ public bool IsMainInventory => inventoryType is
+ InventoryType.Inventory1 or
+ InventoryType.Inventory2 or
+ InventoryType.Inventory3 or
+ InventoryType.Inventory4;
+
+ public bool IsSaddleBag => inventoryType is
+ InventoryType.SaddleBag1 or
+ InventoryType.SaddleBag2 or
+ InventoryType.PremiumSaddleBag1 or
+ InventoryType.PremiumSaddleBag2;
+
+ public bool IsArmory => inventoryType is
+ InventoryType.ArmoryMainHand or
+ InventoryType.ArmoryHead or
+ InventoryType.ArmoryBody or
+ InventoryType.ArmoryHands or
+ InventoryType.ArmoryLegs or
+ InventoryType.ArmoryFeets or
+ InventoryType.ArmoryOffHand or
+ InventoryType.ArmoryEar or
+ InventoryType.ArmoryNeck or
+ InventoryType.ArmoryWrist or
+ InventoryType.ArmoryRings or
+ InventoryType.ArmorySoulCrystal;
+
+ public int ContainerGroup => inventoryType switch
+ {
+ _ when inventoryType.IsMainInventory => 1,
+ _ when inventoryType.IsSaddleBag => 2,
+ _ when inventoryType.IsArmory => 3,
+ _ => 0,
+ };
+
+ public bool IsSameContainerGroup(InventoryType other)
+ => inventoryType.ContainerGroup == other.ContainerGroup;
+
+ ///
+ /// Resolves the real container and slot for this inventory type using ItemOrderModule.
+ /// For sorted inventories, the visual slot differs from the actual storage slot.
+ ///
+ public (InventoryType Container, ushort Slot) GetRealItemLocation(int visualSlot)
+ {
+ var sorter = inventoryType.GetInventorySorter;
+ if (sorter == null)
+ return (inventoryType, (ushort)visualSlot);
+
+ int startIndex = inventoryType.GetInventoryStartIndex;
+ int sorterIndex = startIndex + visualSlot;
+
+ if (sorterIndex < 0 || sorterIndex >= sorter->Items.LongCount)
+ return (inventoryType, (ushort)visualSlot);
+
+ var entry = sorter->Items[sorterIndex].Value;
+ if (entry == null)
+ return (inventoryType, (ushort)visualSlot);
+
+ InventoryType baseType = inventoryType switch
+ {
+ _ when inventoryType.IsMainInventory => InventoryType.Inventory1,
+ _ when inventoryType.IsSaddleBag => inventoryType is InventoryType. SaddleBag1 or InventoryType.SaddleBag2
+ ? InventoryType. SaddleBag1
+ : InventoryType.PremiumSaddleBag1,
+ _ => inventoryType,
+ };
+
+ InventoryType realContainer = baseType + entry->Page;
+ ushort realSlot = entry->Slot;
+
+ return (realContainer, realSlot);
+ }
}
}
\ No newline at end of file
diff --git a/AetherBags/Helpers/InventoryMoveHelper.cs b/AetherBags/Helpers/InventoryMoveHelper.cs
new file mode 100644
index 0000000..ad15f7c
--- /dev/null
+++ b/AetherBags/Helpers/InventoryMoveHelper.cs
@@ -0,0 +1,52 @@
+using AetherBags. Extensions;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.UI;
+using FFXIVClientStructs.FFXIV.Component. GUI;
+using ValueType = FFXIVClientStructs. FFXIV. Component.GUI.ValueType;
+
+namespace AetherBags. Helpers;
+
+public static unsafe class InventoryMoveHelper
+{
+ public static void MoveItem(InventoryType sourceContainer, ushort sourceSlot, InventoryType destContainer, ushort destSlot)
+ {
+ bool isCrossContainerMove = ! sourceContainer.IsSameContainerGroup(destContainer);
+
+ if (isCrossContainerMove)
+ {
+ MoveItemViaAgent(sourceContainer, sourceSlot, destContainer, destSlot);
+ }
+ else
+ {
+ InventoryManager.Instance()->MoveItemSlot(sourceContainer, sourceSlot, destContainer, destSlot, true);
+ }
+ }
+
+ private static void MoveItemViaAgent(InventoryType sourceInventory, ushort sourceSlot, InventoryType destInventory, ushort destSlot)
+ {
+ uint sourceContainerId = sourceInventory.AgentItemContainerId;
+ uint destContainerId = destInventory.AgentItemContainerId;
+
+ if (sourceContainerId == 0 || destContainerId == 0)
+ {
+ Services.Logger.Warning($"[MoveItemViaAgent] Invalid container IDs: src={sourceContainerId}, dst={destContainerId}");
+ return;
+ }
+
+ Services.Logger.Debug($"[MoveItemViaAgent] {sourceContainerId}:{sourceSlot} -> {destContainerId}:{destSlot}");
+
+ var atkValues = stackalloc AtkValue[4];
+ for (var i = 0; i < 4; i++)
+ atkValues[i]. Type = ValueType.UInt;
+
+ atkValues[0].SetUInt(sourceContainerId);
+ atkValues[1].SetUInt(sourceSlot);
+ atkValues[2].SetUInt(destContainerId);
+ atkValues[3].SetUInt(destSlot);
+
+ var retVal = stackalloc AtkValue[1];
+
+ RaptureAtkModule* atkModule = RaptureAtkModule.Instance();
+ atkModule->HandleItemMove(retVal, atkValues, 4);
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Hooks/InventoryHook.cs b/AetherBags/Hooks/InventoryHook.cs
index 1ed98de..5e821fc 100644
--- a/AetherBags/Hooks/InventoryHook.cs
+++ b/AetherBags/Hooks/InventoryHook.cs
@@ -17,14 +17,14 @@ public sealed unsafe class InventoryHooks : IDisposable
ushort dstSlot,
bool unk);
- private readonly Hook? _moveItemSlotHook;
+ private readonly Hook? _moveItemSlotHook;
public InventoryHooks()
{
try
{
_moveItemSlotHook = Services.GameInteropProvider.HookFromSignature(
- "E8 ?? ?? ?? ?? 48 8B 03 66 FF C5",
+ "E8 ?? ?? ?? ?? 48 8B 03 66 FF C5",
MoveItemSlotDetour);
_moveItemSlotHook.Enable();
@@ -36,8 +36,7 @@ public sealed unsafe class InventoryHooks : IDisposable
}
}
- private int MoveItemSlotDetour(
- InventoryManager* manager,
+ private int MoveItemSlotDetour(InventoryManager* manager,
InventoryType srcType,
ushort srcSlot,
InventoryType dstType,
@@ -47,8 +46,7 @@ public sealed unsafe class InventoryHooks : IDisposable
InventoryItem* sourceItem = InventoryManager.Instance()->GetInventorySlot(srcType, srcSlot);
InventoryItem* destItem = InventoryManager.Instance()->GetInventorySlot(dstType, dstSlot);
- Services.Logger.Info(
- $"[MoveItemSlot] Moving {srcType}@{srcSlot} ID:{sourceItem->ItemId} -> {dstType}@{dstSlot} ID:{destItem->ItemId} Unk: {unk}");
+ Services.Logger.Debug($"[MoveItemSlot] Moving {srcType}@{srcSlot} ID:{sourceItem->ItemId} -> {dstType}@{dstSlot} ID:{destItem->ItemId} Unk: {unk}");
return _moveItemSlotHook!.Original(manager, srcType, srcSlot, dstType, dstSlot, unk);
}
diff --git a/AetherBags/Inventory/CategoryBucketManager.cs b/AetherBags/Inventory/CategoryBucketManager.cs
index f87ead0..6d67130 100644
--- a/AetherBags/Inventory/CategoryBucketManager.cs
+++ b/AetherBags/Inventory/CategoryBucketManager.cs
@@ -56,7 +56,7 @@ public static class CategoryBucketManager
UserCategoryDefinition category = sortedScratch[i];
uint bucketKey = MakeUserCategoryKey(category.Order);
- if (!bucketsByKey.TryGetValue(bucketKey, out CategoryBucket? bucket))
+ if (!bucketsByKey.TryGetValue(bucketKey, out CategoryBucket? bucket))
{
bucket = new CategoryBucket
{
diff --git a/AetherBags/Inventory/InventoryScanner.cs b/AetherBags/Inventory/InventoryScanner.cs
index 0bb921b..00e9d2b 100644
--- a/AetherBags/Inventory/InventoryScanner.cs
+++ b/AetherBags/Inventory/InventoryScanner.cs
@@ -85,7 +85,7 @@ public static unsafe class InventoryScanner
bool isHq = (item.Flags & InventoryItem.ItemFlags.HighQuality) != 0;
ulong key = stackMode == InventoryStackMode.AggregateByItemId
- ? MakeAggregatedItemKey(id, isHq)
+ ? MakeAggregatedItemKey(id, isHq)
: MakeNaturalSlotKey(inventoryType, slot);
Services.Logger.DebugOnly($"Slot {inventoryType}[{slot}] ItemId={id} Qty={quantity} Key=0x{key: X16}");
@@ -120,7 +120,7 @@ public static unsafe class InventoryScanner
ulong key = kvp.Key;
AggregatedItem agg = kvp.Value;
- if (!itemInfoByKey.TryGetValue(key, out ItemInfo? info))
+ if (!itemInfoByKey.TryGetValue(key, out ItemInfo? info))
{
info = new ItemInfo
{
diff --git a/AetherBags/Inventory/InventoryState.cs b/AetherBags/Inventory/InventoryState.cs
index b5599fa..d4e279a 100644
--- a/AetherBags/Inventory/InventoryState.cs
+++ b/AetherBags/Inventory/InventoryState.cs
@@ -38,7 +38,7 @@ public static unsafe class InventoryState
InventoryStackMode stackMode = config.General.StackMode;
bool userCategoriesEnabled = config.Categories.UserCategoriesEnabled;
bool gameCategoriesEnabled = config.Categories.GameCategoriesEnabled;
- List userCategories = config.Categories.UserCategories;
+ List userCategories = config.Categories.UserCategories.Where(category => category.Enabled).ToList();
Services.Logger.DebugOnly($"RefreshFromGame StackMode={stackMode}");
diff --git a/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs
new file mode 100644
index 0000000..8a8eb5a
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Numerics;
+using AetherBags.Addons;
+using AetherBags.Configuration;
+using KamiToolKit.Nodes;
+using KamiToolKit.Premade.Nodes;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public class CategoryConfigurationNode : ConfigNode
+{
+ private readonly ScrollingAreaNode _categoryList;
+ private CategoryDefinitionConfigurationNode? _activeNode;
+
+ public Action? OnCategoryChanged { get; set; }
+
+ public CategoryConfigurationNode()
+ {
+ _categoryList = new ScrollingAreaNode
+ {
+ ContentHeight = 100.0f,
+ AutoHideScrollBar = true,
+ };
+ _categoryList.ContentNode.FitContents = true;
+ _categoryList.AttachNode(this);
+ }
+
+ protected override void OptionChanged(CategoryWrapper? option)
+ {
+ if (option?.CategoryDefinition is null)
+ {
+ _categoryList.IsVisible = false;
+ return;
+ }
+
+ _categoryList.IsVisible = true;
+
+ if (_activeNode is null)
+ {
+ _activeNode = new CategoryDefinitionConfigurationNode(option.CategoryDefinition)
+ {
+ Size = _categoryList.ContentNode.Size,
+ OnLayoutChanged = UpdateScrollHeight,
+ OnCategoryPropertyChanged = OnCategoryChanged,
+ };
+ _categoryList.ContentNode.AddNode(_activeNode);
+ }
+ else
+ {
+ _activeNode.SetCategory(option.CategoryDefinition);
+ }
+
+ UpdateScrollHeight();
+ }
+
+ private void UpdateScrollHeight()
+ {
+ _categoryList.ContentNode.RecalculateLayout();
+ _categoryList.ContentHeight = _categoryList.ContentNode.Height;
+ }
+
+ protected override void OnSizeChanged()
+ {
+ base.OnSizeChanged();
+ _categoryList.Size = Size;
+ _categoryList.ContentNode.Width = Width;
+
+ foreach (var node in _categoryList.ContentNode.GetNodes())
+ {
+ node.Width = Width;
+ }
+
+ UpdateScrollHeight();
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs
new file mode 100644
index 0000000..d5d261f
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs
@@ -0,0 +1,467 @@
+using System;
+using System.Numerics;
+using AetherBags.Configuration;
+using AetherBags.Nodes.Color;
+using Dalamud.Utility;
+using KamiToolKit.Classes;
+using KamiToolKit.Nodes;
+using Lumina.Excel;
+using Lumina.Excel.Sheets;
+using Action = System.Action;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public sealed class CategoryDefinitionConfigurationNode : VerticalListNode
+{
+ private readonly CheckboxNode _enabledCheckbox;
+ private readonly TextInputNode _nameInputNode;
+ private readonly TextInputNode _descriptionInputNode;
+ private readonly ColorInputRow _colorInputNode;
+ private readonly NumericInputNode _priorityInputNode;
+ private readonly NumericInputNode _orderInputNode;
+
+ private readonly CheckboxNode _levelEnabledCheckbox;
+ private readonly NumericInputNode _levelMinNode;
+ private readonly NumericInputNode _levelMaxNode;
+
+ private readonly CheckboxNode _itemLevelEnabledCheckbox;
+ private readonly NumericInputNode _itemLevelMinNode;
+ private readonly NumericInputNode _itemLevelMaxNode;
+
+ private readonly CheckboxNode _vendorPriceEnabledCheckbox;
+ private readonly NumericInputNode _vendorPriceMinNode;
+ private readonly NumericInputNode _vendorPriceMaxNode;
+
+ private readonly StateFilterRowNode _untradableFilter;
+ private readonly StateFilterRowNode _uniqueFilter;
+ private readonly StateFilterRowNode _collectableFilter;
+ private readonly StateFilterRowNode _dyeableFilter;
+ private readonly StateFilterRowNode _repairableFilter;
+
+ private readonly UintListEditorNode _allowedItemIdsEditor;
+ private readonly StringListEditorNode _allowedNamePatternsEditor;
+ private readonly UintListEditorNode _allowedUiCategoriesEditor;
+ private readonly RarityEditorNode _allowedRaritiesEditor;
+
+ private bool _isInitialized;
+
+ private static ExcelSheet- ? _sItemSheet;
+ private static ExcelSheet? _sUICategorySheet;
+
+ public Action? OnLayoutChanged { get; set; }
+
+ public Action? OnCategoryPropertyChanged { get; set; }
+
+ private UserCategoryDefinition CategoryDefinition { get; set; }
+
+ public CategoryDefinitionConfigurationNode(UserCategoryDefinition categoryDefinition)
+ {
+ CategoryDefinition = categoryDefinition;
+
+ _sItemSheet ??= Services.DataManager.GetExcelSheet
- ();
+ _sUICategorySheet ??= Services.DataManager.GetExcelSheet();
+
+ FitContents = true;
+ ItemSpacing = 4.0f;
+
+ AddNode(CreateSectionHeader("Basic Settings"));
+
+ _enabledCheckbox = new CheckboxNode
+ {
+ Size = new Vector2(200, 20),
+ String = "Enabled",
+ IsChecked = CategoryDefinition.Enabled,
+ OnClick = isChecked =>
+ {
+ CategoryDefinition.Enabled = isChecked;
+ NotifyChanged();
+ NotifyCategoryPropertyChanged();
+ },
+ };
+ AddNode(_enabledCheckbox);
+
+ AddNode(new LabelTextNode { Size = new Vector2(80, 20), String = "Name:" });
+ _nameInputNode = new TextInputNode
+ {
+ Size = new Vector2(250, 28),
+ String = CategoryDefinition.Name,
+ PlaceholderString = CategoryDefinition.Name.IsNullOrWhitespace() ? "Category Name" : "",
+ OnInputReceived = name =>
+ {
+ CategoryDefinition.Name = name.ExtractText();
+ NotifyChanged();
+ NotifyCategoryPropertyChanged();
+ },
+ };
+ AddNode(_nameInputNode);
+
+ AddNode(new LabelTextNode { Size = new Vector2(80, 20), String = "Description:" });
+ _descriptionInputNode = new TextInputNode
+ {
+ Size = new Vector2(250, 28),
+ String = CategoryDefinition.Description,
+ PlaceholderString = CategoryDefinition.Description.IsNullOrWhitespace() ? "Optional description" : "",
+ OnInputReceived = desc =>
+ {
+ CategoryDefinition.Description = desc.ExtractText();
+ NotifyChanged();
+ },
+ };
+ AddNode(_descriptionInputNode);
+
+ _colorInputNode = new ColorInputRow
+ {
+ Label = "Color",
+ Size = new Vector2(300, 28),
+ CurrentColor = CategoryDefinition.Color,
+ DefaultColor = new UserCategoryDefinition().Color,
+ OnColorConfirmed = color =>
+ {
+ CategoryDefinition.Color = color;
+ NotifyChanged();
+ },
+ OnColorCanceled = color =>
+ {
+ CategoryDefinition.Color = color;
+ NotifyChanged();
+ },
+ };
+ AddNode(_colorInputNode);
+
+ AddNode(new LabelTextNode { Size = new Vector2(80, 20), String = "Priority:" });
+ _priorityInputNode = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = 0,
+ Max = 1000,
+ Step = 1,
+ Value = CategoryDefinition.Priority,
+ OnValueUpdate = val =>
+ {
+ CategoryDefinition.Priority = val;
+ NotifyChanged();
+ },
+ };
+ AddNode(_priorityInputNode);
+
+ AddNode(new LabelTextNode { Size = new Vector2(80, 20), String = "Order:" });
+ _orderInputNode = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = 0,
+ Max = 9999,
+ Step = 1,
+ Value = CategoryDefinition.Order,
+ OnValueUpdate = val =>
+ {
+ CategoryDefinition.Order = val;
+ NotifyChanged();
+ NotifyCategoryPropertyChanged();
+ },
+ };
+ AddNode(_orderInputNode);
+
+ AddNode(CreateSectionHeader("Range Filters"));
+
+ (_levelEnabledCheckbox, _levelMinNode, _levelMaxNode) = CreateRangeFilter(
+ "Level",
+ CategoryDefinition.Rules.Level,
+ 0, 200,
+ (enabled, min, max) =>
+ {
+ CategoryDefinition.Rules.Level.Enabled = enabled;
+ CategoryDefinition.Rules.Level.Min = min;
+ CategoryDefinition.Rules.Level.Max = max;
+ NotifyChanged();
+ }
+ );
+
+ (_itemLevelEnabledCheckbox, _itemLevelMinNode, _itemLevelMaxNode) = CreateRangeFilter(
+ "Item Level",
+ CategoryDefinition.Rules.ItemLevel,
+ 0, 2000,
+ (enabled, min, max) =>
+ {
+ CategoryDefinition.Rules.ItemLevel.Enabled = enabled;
+ CategoryDefinition.Rules.ItemLevel.Min = min;
+ CategoryDefinition.Rules.ItemLevel.Max = max;
+ NotifyChanged();
+ }
+ );
+
+ (_vendorPriceEnabledCheckbox, _vendorPriceMinNode, _vendorPriceMaxNode) = CreateRangeFilterUint(
+ "Vendor Price",
+ CategoryDefinition.Rules.VendorPrice,
+ 0, 9_999_999
+ );
+
+ AddNode(CreateSectionHeader("State Filters"));
+
+ _untradableFilter = new StateFilterRowNode("Untradable", CategoryDefinition.Rules.Untradable, NotifyChanged);
+ AddNode(_untradableFilter);
+
+ _uniqueFilter = new StateFilterRowNode("Unique", CategoryDefinition.Rules.Unique, NotifyChanged);
+ AddNode(_uniqueFilter);
+
+ _collectableFilter = new StateFilterRowNode("Collectable", CategoryDefinition.Rules.Collectable, NotifyChanged);
+ AddNode(_collectableFilter);
+
+ _dyeableFilter = new StateFilterRowNode("Dyeable", CategoryDefinition.Rules.Dyeable, NotifyChanged);
+ AddNode(_dyeableFilter);
+
+ _repairableFilter = new StateFilterRowNode("Repairable", CategoryDefinition.Rules.Repairable, NotifyChanged);
+ AddNode(_repairableFilter);
+
+ AddNode(CreateSectionHeader("List Filters"));
+
+ _allowedItemIdsEditor = new UintListEditorNode(
+ "Allowed Item IDs:",
+ CategoryDefinition.Rules.AllowedItemIds,
+ OnListChanged,
+ ResolveItemName
+ );
+ AddNode(_allowedItemIdsEditor);
+
+ _allowedNamePatternsEditor = new StringListEditorNode(
+ "Name Patterns (Regex):",
+ CategoryDefinition.Rules.AllowedItemNamePatterns,
+ OnListChanged
+ );
+ AddNode(_allowedNamePatternsEditor);
+
+ _allowedUiCategoriesEditor = new UintListEditorNode(
+ "UI Categories:",
+ CategoryDefinition.Rules.AllowedUiCategoryIds,
+ OnListChanged,
+ ResolveUiCategoryName
+ );
+ AddNode(_allowedUiCategoriesEditor);
+
+ _allowedRaritiesEditor = new RarityEditorNode(
+ CategoryDefinition.Rules.AllowedRarities,
+ NotifyChanged
+ );
+ AddNode(_allowedRaritiesEditor);
+
+ _isInitialized = true;
+ }
+
+ private void OnListChanged()
+ {
+ NotifyChanged();
+ RecalculateLayout();
+ OnLayoutChanged?.Invoke();
+ }
+
+ private static string ResolveItemName(uint itemId)
+ {
+ try
+ {
+ var item = _sItemSheet?.GetRow(itemId);
+ return item?.Name.ToString() ?? "Unknown";
+ }
+ catch
+ {
+ return "Unknown";
+ }
+ }
+
+ private static string ResolveUiCategoryName(uint categoryId)
+ {
+ try
+ {
+ var category = _sUICategorySheet?.GetRow(categoryId);
+ return category?.Name.ToString() ?? "Unknown";
+ }
+ catch
+ {
+ return "Unknown";
+ }
+ }
+
+ private static void NotifyChanged()
+ {
+ System.AddonInventoryWindow?.ManualInventoryRefresh();
+ }
+
+ private void NotifyCategoryPropertyChanged()
+ {
+ OnCategoryPropertyChanged?.Invoke();
+ }
+
+ private static LabelTextNode CreateSectionHeader(string text)
+ {
+ return new LabelTextNode
+ {
+ Size = new Vector2(300, 22),
+ String = text,
+ TextColor = ColorHelper.GetColor(2),
+ TextOutlineColor = ColorHelper.GetColor(0),
+ };
+ }
+
+ private (CheckboxNode enabled, NumericInputNode min, NumericInputNode max) CreateRangeFilter(
+ string label,
+ RangeFilter filter,
+ int minBound,
+ int maxBound,
+ Action onUpdate)
+ {
+ var enabledCheckbox = new CheckboxNode
+ {
+ Size = new Vector2(200, 20),
+ String = $"{label} Filter",
+ IsChecked = filter.Enabled,
+ };
+ AddNode(enabledCheckbox);
+
+ var minNode = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = minBound,
+ Max = maxBound,
+ Value = filter.Min,
+ IsEnabled = filter.Enabled,
+ };
+
+ var maxNode = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = minBound,
+ Max = maxBound,
+ Value = filter.Max,
+ IsEnabled = filter.Enabled,
+ };
+
+ var rangeRow = new HorizontalListNode { Size = new Vector2(300, 28), ItemSpacing = 8.0f };
+ rangeRow.AddNode(new LabelTextNode { Size = new Vector2(30, 28), String = "Min:" });
+ rangeRow.AddNode(minNode);
+ rangeRow.AddNode(new LabelTextNode { Size = new Vector2(30, 28), String = "Max:" });
+ rangeRow.AddNode(maxNode);
+ AddNode(rangeRow);
+
+ enabledCheckbox.OnClick = isChecked =>
+ {
+ minNode.IsEnabled = isChecked;
+ maxNode.IsEnabled = isChecked;
+ onUpdate(isChecked, minNode.Value, maxNode.Value);
+ };
+
+ minNode.OnValueUpdate = val => onUpdate(enabledCheckbox.IsChecked, val, maxNode.Value);
+ maxNode.OnValueUpdate = val => onUpdate(enabledCheckbox.IsChecked, minNode.Value, val);
+
+ return (enabledCheckbox, minNode, maxNode);
+ }
+
+ private (CheckboxNode enabled, NumericInputNode min, NumericInputNode max) CreateRangeFilterUint(
+ string label,
+ RangeFilter filter,
+ int minBound,
+ int maxBound)
+ {
+ var enabledCheckbox = new CheckboxNode
+ {
+ Size = new Vector2(200, 20),
+ String = $"{label} Filter",
+ IsChecked = filter.Enabled,
+ };
+ AddNode(enabledCheckbox);
+
+ var minNode = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = minBound,
+ Max = maxBound,
+ Value = (int)filter.Min,
+ IsEnabled = filter.Enabled,
+ };
+
+ var maxNode = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = minBound,
+ Max = maxBound,
+ Value = (int)Math.Min(filter.Max, maxBound),
+ IsEnabled = filter.Enabled,
+ };
+
+ var rangeRow = new HorizontalListNode { Size = new Vector2(300, 28), ItemSpacing = 8.0f };
+ rangeRow.AddNode(new LabelTextNode { Size = new Vector2(30, 28), String = "Min:" });
+ rangeRow.AddNode(minNode);
+ rangeRow.AddNode(new LabelTextNode { Size = new Vector2(30, 28), String = "Max:" });
+ rangeRow.AddNode(maxNode);
+ AddNode(rangeRow);
+
+ enabledCheckbox.OnClick = isChecked =>
+ {
+ minNode.IsEnabled = isChecked;
+ maxNode.IsEnabled = isChecked;
+ CategoryDefinition.Rules.VendorPrice.Enabled = isChecked;
+ NotifyChanged();
+ };
+
+ minNode.OnValueUpdate = value =>
+ {
+ CategoryDefinition.Rules.VendorPrice.Min = (uint)value;
+ NotifyChanged();
+ };
+
+ maxNode.OnValueUpdate = value =>
+ {
+ CategoryDefinition.Rules.VendorPrice.Max = (uint)value;
+ NotifyChanged();
+ };
+
+ return (enabledCheckbox, minNode, maxNode);
+ }
+
+ public void SetCategory(UserCategoryDefinition newCategory)
+ {
+ CategoryDefinition = newCategory;
+ RefreshValues();
+ }
+
+ private void RefreshValues()
+ {
+ if (! _isInitialized) return;
+
+ _enabledCheckbox.IsChecked = CategoryDefinition.Enabled;
+ _colorInputNode.CurrentColor = CategoryDefinition.Color;
+ _nameInputNode.String = CategoryDefinition.Name;
+ _descriptionInputNode.String = CategoryDefinition.Description;
+ _priorityInputNode.Value = CategoryDefinition.Priority;
+ _orderInputNode.Value = CategoryDefinition.Order;
+
+ RefreshRangeFilter(_levelEnabledCheckbox, _levelMinNode, _levelMaxNode, CategoryDefinition.Rules.Level);
+ RefreshRangeFilter(_itemLevelEnabledCheckbox, _itemLevelMinNode, _itemLevelMaxNode, CategoryDefinition.Rules.ItemLevel);
+
+ _vendorPriceEnabledCheckbox.IsChecked = CategoryDefinition.Rules.VendorPrice.Enabled;
+ _vendorPriceMinNode.Value = (int)CategoryDefinition.Rules.VendorPrice.Min;
+ _vendorPriceMaxNode.Value = (int)Math.Min(CategoryDefinition.Rules.VendorPrice.Max, int.MaxValue);
+ _vendorPriceMinNode.IsEnabled = CategoryDefinition.Rules.VendorPrice.Enabled;
+ _vendorPriceMaxNode.IsEnabled = CategoryDefinition.Rules.VendorPrice.Enabled;
+
+ _untradableFilter.SetState(CategoryDefinition.Rules.Untradable);
+ _uniqueFilter.SetState(CategoryDefinition.Rules.Unique);
+ _collectableFilter.SetState(CategoryDefinition.Rules.Collectable);
+ _dyeableFilter.SetState(CategoryDefinition.Rules.Dyeable);
+ _repairableFilter.SetState(CategoryDefinition.Rules.Repairable);
+
+ _allowedItemIdsEditor.SetList(CategoryDefinition.Rules.AllowedItemIds);
+ _allowedNamePatternsEditor.SetList(CategoryDefinition.Rules.AllowedItemNamePatterns);
+ _allowedUiCategoriesEditor.SetList(CategoryDefinition.Rules.AllowedUiCategoryIds);
+ _allowedRaritiesEditor.SetList(CategoryDefinition.Rules.AllowedRarities);
+
+ RecalculateLayout();
+ OnLayoutChanged?.Invoke();
+ }
+
+ private static void RefreshRangeFilter(CheckboxNode enabled, NumericInputNode min, NumericInputNode max, RangeFilter filter)
+ {
+ enabled.IsChecked = filter.Enabled;
+ min.Value = filter.Min;
+ max.Value = filter.Max;
+ min.IsEnabled = filter.Enabled;
+ max.IsEnabled = filter.Enabled;
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs
new file mode 100644
index 0000000..7d489c9
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs
@@ -0,0 +1,34 @@
+using System.Numerics;
+using AetherBags.Addons;
+using KamiToolKit.Nodes;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public class CategoryScrollingAreaNode : ScrollingAreaNode
+{
+ private AddonCategoryConfigurationWindow? _categoryConfigurationAddon;
+ private readonly TextButtonNode _categoryConfigurationButtonNode;
+
+ public CategoryScrollingAreaNode()
+ {
+ InitializeCategoryAddon();
+
+ _categoryConfigurationButtonNode = new TextButtonNode
+ {
+ Size = new Vector2(300, 28),
+ String = "Configure Categories",
+ OnClick = () => _categoryConfigurationAddon?.Toggle(),
+ };
+ _categoryConfigurationButtonNode.AttachNode(this);
+ }
+
+ private void InitializeCategoryAddon() {
+ if (_categoryConfigurationAddon is not null) return;
+
+ _categoryConfigurationAddon = new AddonCategoryConfigurationWindow {
+ Size = new Vector2(700.0f, 500.0f),
+ InternalName = "AetherBags_CategoryConfig",
+ Title = "Category Configuration Window",
+ };
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs b/AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs
new file mode 100644
index 0000000..3182704
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using KamiToolKit.Classes;
+using KamiToolKit.Nodes;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public sealed class RarityEditorNode : VerticalListNode
+{
+ private static readonly string[] RarityNames = { "Common (White)", "Uncommon (Green)", "Rare (Blue)", "Relic (Purple)", "Aetherial (Pink)" };
+
+ private List _list;
+ private readonly List _checkboxes = new();
+ private readonly Action? _onChanged;
+
+ public RarityEditorNode(List list, Action? onChanged = null)
+ {
+ _list = list;
+ _onChanged = onChanged;
+
+ FitContents = true;
+ ItemSpacing = 2.0f;
+
+ var headerLabel = new LabelTextNode
+ {
+ Size = new Vector2(280, 18),
+ String = "Allowed Rarities:",
+ TextColor = ColorHelper.GetColor(8),
+ };
+ AddNode(headerLabel);
+
+ for (int i = 0; i < RarityNames.Length; i++)
+ {
+ var rarity = i;
+ var checkbox = new CheckboxNode
+ {
+ Size = new Vector2(200, 20),
+ String = RarityNames[i],
+ IsChecked = _list.Contains(i),
+ OnClick = isChecked =>
+ {
+ if (isChecked && !_list.Contains(rarity))
+ {
+ _list.Add(rarity);
+ _list.Sort();
+ }
+ else if (!isChecked && _list.Contains(rarity))
+ {
+ _list.Remove(rarity);
+ }
+ _onChanged?.Invoke();
+ },
+ };
+ _checkboxes.Add(checkbox);
+ AddNode(checkbox);
+ }
+ }
+
+ public void SetList(List newList)
+ {
+ _list = newList;
+ Refresh();
+ }
+
+ public void Refresh()
+ {
+ for (int i = 0; i < _checkboxes.Count; i++)
+ {
+ _checkboxes[i].IsChecked = _list.Contains(i);
+ }
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs b/AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs
new file mode 100644
index 0000000..f96a972
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Numerics;
+using AetherBags.Configuration;
+using KamiToolKit.Classes;
+using KamiToolKit.Nodes;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public sealed class StateFilterRowNode : HorizontalListNode
+{
+ private readonly LabelTextNode _labelNode;
+ private readonly TextButtonNode _stateButton;
+ private readonly Action? _onChanged;
+ private StateFilter _filter;
+
+ private static readonly string[] StateLabels = { "Ignored", "Allow", "Disallow" };
+
+ public StateFilterRowNode(string label, StateFilter filter, Action? onChanged = null)
+ {
+ _filter = filter;
+ _onChanged = onChanged;
+ Size = new Vector2(280, 24);
+ ItemSpacing = 8.0f;
+
+ _labelNode = new LabelTextNode
+ {
+ Size = new Vector2(100, 24),
+ String = $"{label}:",
+ TextColor = ColorHelper.GetColor(8),
+ };
+ AddNode(_labelNode);
+
+ _stateButton = new TextButtonNode
+ {
+ Size = new Vector2(100, 24),
+ String = StateLabels[_filter.State],
+ OnClick = CycleState,
+ };
+ AddNode(_stateButton);
+ }
+
+ private void CycleState()
+ {
+ _filter.State = (_filter.State + 1) % 3;
+ _stateButton.String = StateLabels[_filter.State];
+ _onChanged?.Invoke();
+ }
+
+ public void SetState(StateFilter newFilter)
+ {
+ _filter = newFilter;
+ _stateButton.String = StateLabels[_filter.State];
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs b/AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs
new file mode 100644
index 0000000..e8378b2
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using KamiToolKit.Classes;
+using KamiToolKit.Nodes;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public sealed class StringListEditorNode : VerticalListNode
+{
+ private List _list;
+ private readonly TextInputNode _addInput;
+ private readonly VerticalListNode _itemsContainer;
+ private readonly Action? _onChanged;
+
+ public StringListEditorNode(string label, List list, Action? onChanged = null)
+ {
+ _list = list;
+ _onChanged = onChanged;
+
+ FitContents = true;
+ ItemSpacing = 4.0f;
+
+ var headerLabel = new LabelTextNode
+ {
+ Size = new Vector2(280, 18),
+ String = label,
+ TextColor = ColorHelper.GetColor(8),
+ };
+ AddNode(headerLabel);
+
+ _itemsContainer = new VerticalListNode
+ {
+ FitContents = true,
+ ItemSpacing = 2.0f,
+ };
+ AddNode(_itemsContainer);
+
+ var addRow = new HorizontalListNode { Size = new Vector2(300, 28), ItemSpacing = 4.0f };
+
+ _addInput = new TextInputNode
+ {
+ Size = new Vector2(200, 28),
+ PlaceholderString = "Add new...",
+ OnInputComplete = text =>
+ {
+ var value = text.ExtractText();
+ if (!string.IsNullOrWhiteSpace(value) && ! _list.Contains(value))
+ {
+ _list.Add(value);
+ _addInput.String = "";
+ RefreshItems();
+ _onChanged?.Invoke();
+ }
+ },
+ };
+ addRow.AddNode(_addInput);
+
+ var addButton = new TextButtonNode
+ {
+ Size = new Vector2(60, 28),
+ String = "Add",
+ OnClick = () =>
+ {
+ var value = _addInput.String;
+ if (!string.IsNullOrWhiteSpace(value) && !_list.Contains(value))
+ {
+ _list.Add(value);
+ _addInput.String = "";
+ RefreshItems();
+ _onChanged?.Invoke();
+ }
+ },
+ };
+ addRow.AddNode(addButton);
+
+ AddNode(addRow);
+
+ RefreshItems();
+ }
+
+ public void SetList(List newList)
+ {
+ _list = newList;
+ RefreshItems();
+ }
+
+ private void RefreshItems()
+ {
+ _itemsContainer.SyncWithListData(
+ _list,
+ node => node.Value,
+ value => new StringListItemNode(value)
+ {
+ Size = new Vector2(280, 22),
+ OnRemove = () =>
+ {
+ _list.Remove(value);
+ RefreshItems();
+ _onChanged?.Invoke();
+ },
+ }
+ );
+
+ _itemsContainer.RecalculateLayout();
+ RecalculateLayout();
+ }
+
+ public void Refresh()
+ {
+ RefreshItems();
+ }
+}
+
+public sealed class StringListItemNode : HorizontalListNode
+{
+ public string Value { get; }
+ public Action? OnRemove { get; init; }
+
+ public StringListItemNode(string value)
+ {
+ Value = value;
+ ItemSpacing = 4.0f;
+
+ var itemLabel = new LabelTextNode
+ {
+ Size = new Vector2(220, 22),
+ String = value,
+ TextColor = ColorHelper.GetColor(3),
+ };
+ AddNode(itemLabel);
+
+ var removeButton = new TextButtonNode
+ {
+ Size = new Vector2(50, 22),
+ String = "X",
+ OnClick = () => OnRemove?.Invoke(),
+ };
+ AddNode(removeButton);
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs b/AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs
new file mode 100644
index 0000000..6aae6fb
--- /dev/null
+++ b/AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using KamiToolKit.Classes;
+using KamiToolKit.Nodes;
+
+namespace AetherBags.Nodes.Configuration.Category;
+
+public sealed class UintListEditorNode : VerticalListNode
+{
+ private List _list;
+ private readonly NumericInputNode _addInput;
+ private readonly VerticalListNode _itemsContainer;
+ private readonly Action? _onChanged;
+ private readonly Func? _labelResolver;
+
+ public UintListEditorNode(string label, List list, Action? onChanged = null, Func? labelResolver = null)
+ {
+ _list = list;
+ _onChanged = onChanged;
+ _labelResolver = labelResolver;
+
+ FitContents = true;
+ ItemSpacing = 4.0f;
+
+ var headerLabel = new LabelTextNode
+ {
+ Size = new Vector2(280, 18),
+ String = label,
+ TextColor = ColorHelper.GetColor(8),
+ };
+ AddNode(headerLabel);
+
+ _itemsContainer = new VerticalListNode
+ {
+ FitContents = true,
+ ItemSpacing = 2.0f,
+ };
+ AddNode(_itemsContainer);
+
+ var addRow = new HorizontalListNode { Size = new Vector2(300, 28), ItemSpacing = 4.0f };
+
+ _addInput = new NumericInputNode
+ {
+ Size = new Vector2(120, 28),
+ Min = 0,
+ Max = int.MaxValue,
+ Value = 0,
+ };
+ addRow.AddNode(_addInput);
+
+ var addButton = new TextButtonNode
+ {
+ Size = new Vector2(60, 28),
+ String = "Add",
+ OnClick = () =>
+ {
+ var value = (uint)_addInput.Value;
+ if (! _list.Contains(value))
+ {
+ _list.Add(value);
+ RefreshItems();
+ _onChanged?.Invoke();
+ }
+ },
+ };
+ addRow.AddNode(addButton);
+
+ AddNode(addRow);
+
+ RefreshItems();
+ }
+
+ public void SetList(List newList)
+ {
+ _list = newList;
+ RefreshItems();
+ }
+
+ private void RefreshItems()
+ {
+ _itemsContainer.SyncWithListData(
+ _list,
+ node => node.Value,
+ value => new UintListItemNode(value, _labelResolver)
+ {
+ Size = new Vector2(280, 22),
+ OnRemove = () =>
+ {
+ _list.Remove(value);
+ RefreshItems();
+ _onChanged?.Invoke();
+ },
+ }
+ );
+
+ _itemsContainer.RecalculateLayout();
+ RecalculateLayout();
+ }
+
+ public void Refresh()
+ {
+ RefreshItems();
+ }
+}
+
+public sealed class UintListItemNode : HorizontalListNode
+{
+ public uint Value { get; }
+ public Action? OnRemove { get; init; }
+
+ public UintListItemNode(uint value, Func? labelResolver = null)
+ {
+ Value = value;
+ ItemSpacing = 4.0f;
+
+ var displayText = labelResolver != null ? $"{value} - {labelResolver(value)}" : value.ToString();
+ var itemLabel = new LabelTextNode
+ {
+ Size = new Vector2(220, 22),
+ String = displayText,
+ TextColor = ColorHelper.GetColor(3),
+ };
+ AddNode(itemLabel);
+
+ var removeButton = new TextButtonNode
+ {
+ Size = new Vector2(50, 22),
+ String = "X",
+ OnClick = () => OnRemove?.Invoke(),
+ };
+ AddNode(removeButton);
+ }
+}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/CategoryScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/CategoryScrollingAreaNode.cs
deleted file mode 100644
index fd33e4c..0000000
--- a/AetherBags/Nodes/Configuration/CategoryScrollingAreaNode.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using KamiToolKit.Nodes;
-
-namespace AetherBags.Nodes.Configuration;
-
-public class CategoryScrollingAreaNode : ScrollingAreaNode
-{
-}
\ No newline at end of file
diff --git a/AetherBags/Nodes/Configuration/Currency/CurrencyConfigurationNode.cs b/AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs
similarity index 96%
rename from AetherBags/Nodes/Configuration/Currency/CurrencyConfigurationNode.cs
rename to AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs
index 0d2814b..f8c1006 100644
--- a/AetherBags/Nodes/Configuration/Currency/CurrencyConfigurationNode.cs
+++ b/AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs
@@ -6,9 +6,9 @@ using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Configuration.Currency;
-public sealed class CurrencyConfigurationNode : TabbedVerticalListNode
+public sealed class CurrencyGeneralConfigurationNode : TabbedVerticalListNode
{
- public CurrencyConfigurationNode()
+ public CurrencyGeneralConfigurationNode()
{
CurrencySettings config = System.Config.Currency;
diff --git a/AetherBags/Nodes/Configuration/CurrencyScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs
similarity index 58%
rename from AetherBags/Nodes/Configuration/CurrencyScrollingAreaNode.cs
rename to AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs
index 70847d4..f0e074f 100644
--- a/AetherBags/Nodes/Configuration/CurrencyScrollingAreaNode.cs
+++ b/AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs
@@ -1,13 +1,12 @@
-using AetherBags.Nodes.Configuration.Currency;
using KamiToolKit.Nodes;
-namespace AetherBags.Nodes.Configuration;
+namespace AetherBags.Nodes.Configuration.Currency;
public sealed class CurrencyScrollingAreaNode : ScrollingAreaNode
{
public CurrencyScrollingAreaNode()
{
- ContentNode.AddNode(new CurrencyConfigurationNode
+ ContentNode.AddNode(new CurrencyGeneralConfigurationNode
{
Size = Size
});
diff --git a/AetherBags/Nodes/Configuration/GeneralScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs
similarity index 90%
rename from AetherBags/Nodes/Configuration/GeneralScrollingAreaNode.cs
rename to AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs
index 1d2544c..f744cb6 100644
--- a/AetherBags/Nodes/Configuration/GeneralScrollingAreaNode.cs
+++ b/AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs
@@ -1,11 +1,11 @@
-using AetherBags.Configuration;
-using AetherBags.Nodes.Configuration.Layout;
-using KamiToolKit.Nodes;
using System;
using System.Linq;
using System.Numerics;
+using AetherBags.Configuration;
+using AetherBags.Nodes.Configuration.Layout;
+using KamiToolKit.Nodes;
-namespace AetherBags.Nodes.Configuration;
+namespace AetherBags.Nodes.Configuration.General;
public sealed class GeneralScrollingAreaNode : ScrollingAreaNode
{
@@ -28,8 +28,10 @@ public sealed class GeneralScrollingAreaNode : ScrollingAreaNode
{
if (Enum.TryParse(selected, out var parsed))
+ {
config.StackMode = parsed;
- RefreshInventory();
+ System.AddonInventoryWindow.ManualInventoryRefresh();
+ }
}
};
ContentNode.AddNode(_stackDropDown);
diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs
index be9c151..f8fbd36 100644
--- a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs
+++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs
@@ -1,16 +1,16 @@
using System;
using System.Numerics;
+using AetherBags.Extensions;
+using AetherBags.Helpers;
using AetherBags.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.Game;
-using FFXIVClientStructs.FFXIV.Client.UI;
-using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
+
// TODO: Switch back to CS version when Dalamud Updated
using DragDropFixedNode = AetherBags.Nodes.DragDropNode;
-using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace AetherBags.Nodes.Inventory;
@@ -24,13 +24,10 @@ public class InventoryCategoryNode : SimpleComponentNode
private const float MinWidth = 40;
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;
@@ -77,7 +74,6 @@ public class InventoryCategoryNode : SimpleComponentNode
_categoryNameTextNode.String = _fullHeaderText;
_categoryNameTextNode.TextColor = value.Category.Color;
-
_categoryNameTextNode.TooltipString = value.Category.Description;
UpdateItemGrid();
@@ -114,7 +110,6 @@ public class InventoryCategoryNode : SimpleComponentNode
_headerExpanded = true;
ApplyHeaderVisualStateAndSize();
-
HeaderHoverChanged?.Invoke(this, true);
}
@@ -127,7 +122,6 @@ public class InventoryCategoryNode : SimpleComponentNode
_headerExpanded = false;
ApplyHeaderVisualStateAndSize();
-
HeaderHoverChanged?.Invoke(this, false);
}
@@ -140,12 +134,11 @@ public class InventoryCategoryNode : SimpleComponentNode
private void ApplyHeaderVisualStateAndSize()
{
- _categoryNameTextNode.IsVisible = !_headerSuppressed;
+ _categoryNameTextNode.IsVisible = ! _headerSuppressed;
if (_headerSuppressed)
return;
var flags = _categoryNameTextNode.TextFlags;
-
flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine);
if (_headerExpanded)
@@ -153,7 +146,7 @@ public class InventoryCategoryNode : SimpleComponentNode
flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis);
_categoryNameTextNode.TextFlags = flags;
- if (!string.IsNullOrEmpty(_fullHeaderText))
+ if (! string.IsNullOrEmpty(_fullHeaderText))
_categoryNameTextNode.String = _fullHeaderText;
Vector2 drawSize = _categoryNameTextNode.GetTextDrawSize();
@@ -167,7 +160,7 @@ public class InventoryCategoryNode : SimpleComponentNode
if (!string.IsNullOrEmpty(_fullHeaderText))
_categoryNameTextNode.String = _fullHeaderText;
- flags |= (TextFlags.OverflowHidden | TextFlags.Ellipsis);
+ flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis;
_categoryNameTextNode.TextFlags = flags;
}
}
@@ -179,58 +172,30 @@ public class InventoryCategoryNode : SimpleComponentNode
if (itemCount == 0)
{
float width = _fixedWidth ?? MinWidth;
-
Size = new Vector2(width, HeaderHeight);
-
_baseHeaderWidth = width;
-
_itemGridNode.Position = new Vector2(0, HeaderHeight);
_itemGridNode.Size = new Vector2(width, 0);
-
ApplyHeaderVisualStateAndSize();
return;
}
- int itemsPerLine = _itemGridNode.ItemsPerLine;
- if (itemsPerLine < 1) itemsPerLine = 1;
-
+ int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine);
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 cellW = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Width : FallbackItemSize;
+ float cellH = _itemGridNode.Nodes.Count > 0 ? _itemGridNode.Nodes[0].Height : FallbackItemSize;
float hPad = _itemGridNode.HorizontalPadding;
float vPad = _itemGridNode.VerticalPadding;
- float calculatedWidth;
- if (_fixedWidth.HasValue)
- {
- calculatedWidth = _fixedWidth.Value;
- }
- else
- {
- calculatedWidth = actualColumns * cellW + (actualColumns - 1) * hPad;
- if (calculatedWidth < MinWidth) calculatedWidth = MinWidth;
- }
-
+ float calculatedWidth = _fixedWidth ?? Math.Max(MinWidth, actualColumns * cellW + (actualColumns - 1) * hPad);
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);
-
_baseHeaderWidth = calculatedWidth;
ApplyHeaderVisualStateAndSize();
@@ -248,7 +213,7 @@ public class InventoryCategoryNode : SimpleComponentNode
{
InventoryItem item = data.Item;
- var node = new InventoryDragDropNode
+ return new InventoryDragDropNode
{
Size = new Vector2(42, 46),
IsVisible = true,
@@ -258,14 +223,11 @@ public class InventoryCategoryNode : SimpleComponentNode
Payload = new DragDropPayload
{
Type = DragDropType.Inventory_Item,
- Int1 = (int)item.GetInventoryType(),
+ Int1 = (int)item.Container,
Int2 = item.Slot,
},
IsClickable = true,
- OnEnd = _ =>
- {
- System.AddonInventoryWindow.ManualInventoryRefresh();
- },
+ OnEnd = _ => System.AddonInventoryWindow.ManualInventoryRefresh(),
OnPayloadAccepted = (n, p) => OnPayloadAccepted(n, p, data),
OnRollOver = n =>
{
@@ -277,54 +239,54 @@ public class InventoryCategoryNode : SimpleComponentNode
EndHeaderHover();
n.HideTooltip();
},
-
ItemInfo = data
};
-
- return node;
}
- private unsafe void OnPayloadAccepted(DragDropNode node, DragDropPayload payload, ItemInfo itemInfo)
+ private void OnPayloadAccepted(DragDropNode node, DragDropPayload payload, ItemInfo targetItemInfo)
{
- if (payload.Type != DragDropType.Item) return;
- InventoryItem item = itemInfo.Item;
- Services.Logger.Debug($"Inventory DragDropNode Payload Accepted: {payload.Type} Int1: {payload.Int1} Int2: {payload.Int2} ReferenceIndex: {payload.ReferenceIndex}");
- InventoryType inventoryType = InventoryType.GetInventoryTypeFromContainerId(payload.Int1);
- ushort sourceSlot = (ushort)payload.Int2;
- ItemOrderModuleSorterItemEntry* itemEntry = item.GetItemOrderData();
- Services.Logger.Debug($"{item.Slot} vs {item.GetSlot()}: entry: {itemEntry->Slot}");
- Services.Logger.Info($"[OnPayload] Moving {inventoryType}@{sourceSlot} -> {item.Container}@{item.Slot} -> {item.Name.ExtractText()}");
- InventoryManager.Instance()->MoveItemSlot(inventoryType, sourceSlot, item.Container, item.GetSlot(), true);
+ if (payload.Type != DragDropType.Item && payload.Type != DragDropType.Inventory_Item)
+ return;
+ var (sourceContainer, sourceSlot) = ResolveSourceFromPayload(payload);
- // System.AddonInventoryWindow.ManualInventoryRefresh();
-
- // Should work for swapping item but need a fake empty slot to put new items in probably.
- // Services.Logger.Debug($"Moving Item from {inventoryType} Slot {sourceSlot} to {itemInfo.Item.Container} Slot {itemInfo.Item.GetSlot()}");
- //MoveItem(inventoryType, sourceSlot, itemInfo.Item.Container, itemInfo.Item.GetSlot());
- }
-
- // Possibly still use this
- private unsafe void MoveItem(InventoryType sourceInventory, uint sourceSlot, InventoryType destinationInventory, uint destinationSlot)
- {
- var sourceContainerId = sourceInventory.AgentItemContainerId;
- var destinationContainerId = destinationInventory.AgentItemContainerId;
-
- if (sourceContainerId != 0 && destinationContainerId != 0) {
- var atkValues = stackalloc AtkValue[4];
- for (var i = 0; i < 4; i++) atkValues[i].Type = ValueType.UInt;
-
- atkValues[0].UInt = sourceContainerId;
- atkValues[1].UInt = sourceSlot;
- atkValues[2].UInt = destinationContainerId;
- atkValues[3].UInt = destinationSlot;
-
- var retVal = stackalloc AtkValue[1];
-
- RaptureAtkModule* atkModule = RaptureAtkModule.Instance();
- // (RaptureAtkModule* a1, void* outValue, AtkValue* atkValues);
- // (AtkValue* returnValue, AtkValue* values, uint valueCount)
- atkModule->HandleItemMove(retVal, atkValues, 4);
+ if (sourceContainer == 0)
+ {
+ Services.Logger.Warning($"[OnPayload] Could not resolve source from payload");
+ return;
}
+
+ InventoryType targetContainer = targetItemInfo.Item.Container;
+ ushort targetSlot = (ushort)targetItemInfo.Item.Slot;
+
+ Services.Logger.Debug($"[OnPayload] Moving {sourceContainer}@{sourceSlot} -> {targetContainer}@{targetSlot}");
+
+ InventoryMoveHelper.MoveItem(sourceContainer, sourceSlot, targetContainer, targetSlot);
}
-}
+
+ private static (InventoryType Container, ushort Slot) ResolveSourceFromPayload(DragDropPayload payload)
+ {
+ if (payload.Type == DragDropType.Inventory_Item)
+ {
+ return ((InventoryType)payload.Int1, (ushort)payload.Int2);
+ }
+
+ int containerId = payload.Int1;
+ int slotIndex = payload.Int2;
+
+ InventoryType sourceContainer = InventoryType.GetInventoryTypeFromContainerId(containerId);
+
+ if (sourceContainer == 0)
+ return (0, 0);
+
+ // For main inventory, resolve the real slot via ItemOrderModule
+ if (sourceContainer.IsMainInventory)
+ {
+ var (realContainer, realSlot) = sourceContainer.GetRealItemLocation(slotIndex);
+ return (realContainer, realSlot);
+ }
+
+ // For other containers (saddlebags, armory, etc.), use the slot directly
+ return (sourceContainer, (ushort)slotIndex);
+ }
+}
\ No newline at end of file