Initial commit: AetherBags + KamiToolKit for FC Gitea
Debug Build and Test / Build against Latest Dalamud (push) Has been cancelled
Debug Build and Test / Build against Staging Dalamud (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 14:46:31 -05:00
commit 8db4ce6094
375 changed files with 34124 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: zeffuro
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+57
View File
@@ -0,0 +1,57 @@
name: Bug
description: For when you have found a bug
title: "[Bug]: "
labels: [bug]
body:
- type: textarea
id: what-happened
attributes:
label: What are you trying to do?
description: What are you trying to do?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: expected-behaviors
attributes:
label: What is the expected behavior?
description: What do you think should happen?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: actually-happened
attributes:
label: What actually happened?
description: Please try to be as descriptive as possible.
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: suggested-solution
attributes:
label: Suggested solution
description: If you have any idea how we could solve it let me know.
placeholder: Tell us what you see!
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs
description: If you have any errors in the log please put them here.
render: shell
- type: textarea
id: export
attributes:
label: Export
description: If you have an export for the aura that's causing issues please provide it here.
render: shell
- type: checkboxes
id: terms
attributes:
label: FFXIV Update
description: Whenever Final Fantasy has an update, XIVLauncher needs an update so please don't open issues during that window.
options:
- label: I have confirmed that I have the latest version of XIVLauncher and AetherBags.
required: true
+12
View File
@@ -0,0 +1,12 @@
name: Suggestion
description: For when you want to suggest new features for AetherBags.
title: "[Suggestion]: "
labels: [suggestion]
body:
- type: textarea
id: suggestion
attributes:
label: What's your suggestion?
description: Please try to be detailed explaining what you want.
validations:
required: true
+120
View File
@@ -0,0 +1,120 @@
name: Debug Build and Test
on: [push, pull_request]
jobs:
build-latest:
name: Build against Latest Dalamud
runs-on: windows-2022
# Define the plugin name and Dalamud version variables for this job
env:
PLUGIN_NAME: AetherBags
DALAMUD_VERSION_NAME: "Latest"
DALAMUD_VERSION_URL: "https://goatcorp.github.io/dalamud-distrib/latest.zip"
steps:
# Checkout the repository code
- name: Checkout and Initialise
uses: actions/checkout@v4
with:
submodules: true
# Install the required .NET SDK
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x.x'
# Cache the nuget packages.
- name: Cache Dependencies
id: cache-dependencies
uses: actions/cache@v4
with:
path: |
~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/${{ env.PLUGIN_NAME }}.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
# Create the required directory structure and download/extract Dalamud.
- name: Download and extract Dalamud (${{ env.DALAMUD_VERSION_NAME }})
run: |
mkdir -p "$env:AppData\XIVLauncher\addon\Hooks\dev"
Invoke-WebRequest -Uri "${{ env.DALAMUD_VERSION_URL }}" -OutFile "dalamud.zip"
Expand-Archive -Path "dalamud.zip" -DestinationPath "$env:AppData\XIVLauncher\addon\Hooks\dev" -Force
# Restore, build, and test.
- name: Build Debug (${{ env.DALAMUD_VERSION_NAME }})
id: build_step
run: |
dotnet restore `
&& dotnet build --no-restore --configuration Debug `
&& dotnet test --no-build --configuration Debug
# Upload the build artifact. This step will only run if the build_step succeeded.
- name: Upload Artifact (${{ env.DALAMUD_VERSION_NAME }})
if: steps.build_step.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: ${{ env.PLUGIN_NAME }}-debug-${{ env.DALAMUD_VERSION_NAME }}-${{ github.sha }}
path: |
${{ env.PLUGIN_NAME }}/bin/x64/Debug/
build-staging:
name: Build against Staging Dalamud
runs-on: windows-2022
# Define the plugin name and Dalamud version variables for this job
env:
PLUGIN_NAME: AetherBags
DALAMUD_VERSION_NAME: "Staging"
DALAMUD_VERSION_URL: "https://goatcorp.github.io/dalamud-distrib/stg/latest.zip"
steps:
# Checkout the repository code
- name: Checkout and Initialise
uses: actions/checkout@v4
with:
submodules: true
# Install the required .NET SDK
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x.x'
# Cache the nuget packages
- name: Cache Dependencies
id: cache-dependencies
uses: actions/cache@v4
with:
path: |
~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/${{ env.PLUGIN_NAME }}.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
# Create the required directory structure and download/extract Dalamud.
- name: Download and extract Dalamud (${{ env.DALAMUD_VERSION_NAME }})
run: |
mkdir -p "$env:AppData\XIVLauncher\addon\Hooks\dev"
Invoke-WebRequest -Uri "${{ env.DALAMUD_VERSION_URL }}" -OutFile "dalamud.zip"
Expand-Archive -Path "dalamud.zip" -DestinationPath "$env:AppData\XIVLauncher\addon\Hooks\dev" -Force
# Restore, build, and test.
- name: Build Debug (${{ env.DALAMUD_VERSION_NAME }})
id: build_step
run: |
dotnet restore `
&& dotnet build --no-restore --configuration Debug `
&& dotnet test --no-build --configuration Debug
# Upload the build artifact.
- name: Upload Artifact (${{ env.DALAMUD_VERSION_NAME }})
if: steps.build_step.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: ${{ env.PLUGIN_NAME }}-debug-${{ env.DALAMUD_VERSION_NAME }}-${{ github.sha }}
path: |
${{ env.PLUGIN_NAME }}/bin/x64/Debug/
+404
View File
@@ -0,0 +1,404 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
*.DS_Store
.idea/
*.meteor/
+3
View File
@@ -0,0 +1,3 @@
[submodule "KamiToolKit"]
path = KamiToolKit
url = https://github.com/MidoriKami/KamiToolKit
+22
View File
@@ -0,0 +1,22 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AetherBags", "AetherBags\AetherBags.csproj", "{5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KamiToolKit", "KamiToolKit\KamiToolKit.csproj", "{0907374F-93F8-427F-AD0A-49DB4B0A3DD4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Debug|x64.ActiveCfg = Debug|x64
{5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Debug|x64.Build.0 = Debug|x64
{5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Release|x64.ActiveCfg = Release|x64
{5BBE4215-8189-4A8A-AFD0-C5C6074DB47E}.Release|x64.Build.0 = Release|x64
{0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Debug|x64.ActiveCfg = Debug|x64
{0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Debug|x64.Build.0 = Debug|x64
{0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Release|x64.ActiveCfg = Release|x64
{0907374F-93F8-427F-AD0A-49DB4B0A3DD4}.Release|x64.Build.0 = Release|x64
EndGlobalSection
EndGlobal
+1
View File
@@ -0,0 +1 @@
/.idea/
@@ -0,0 +1,163 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AetherBags.Configuration;
using AetherBags.Inventory;
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<CategoryWrapper, CategoryListItemNode>? _selectionListNode;
private VerticalLineNode? _separatorLine;
private CategoryConfigurationNode? _configNode;
private TextNode? _nothingSelectedTextNode;
private List<CategoryWrapper> _categoryWrappers = new();
private bool _suppressSelectionListRefresh;
private bool _pendingSelectionListRefresh;
protected override unsafe void OnSetup(AtkUnitBase* addon)
{
_categoryWrappers = CreateCategoryWrappers();
_selectionListNode = new ModifyListNode<CategoryWrapper, CategoryListItemNode>
{
Position = ContentStartPosition,
Size = ContentSize with { X = 250.0f },
Options = _categoryWrappers,
SelectionChanged = OnOptionChanged,
AddNewEntry = OnAddNewCategory,
RemoveEntry = OnRemoveCategory,
SortOptions = [ "Order" ],
ItemComparer = (left, right, mode) => left.Compare(right, mode),
IsSearchMatch = (data, search) => data.GetLabel().Contains(search, global::System.StringComparison.OrdinalIgnoreCase)
};
_selectionListNode.AttachNode(this);
_separatorLine = new VerticalLineNode
{
Position = ContentStartPosition + new Vector2(250.0f + 8.0f, 0.0f),
Size = ContentSize with { X = 4.0f },
};
_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<CategoryWrapper> CreateCategoryWrappers()
{
return System.Config.Categories.UserCategories
.Select(categoryDefinition => new CategoryWrapper(categoryDefinition))
.ToList();
}
private void OnAddNewCategory()
{
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);
RefreshSelectionList();
_selectionListNode?.RefreshList();
InventoryOrchestrator.RefreshAll(updateMaps: true);
}
private void OnOptionChanged(CategoryWrapper? newOption)
{
if (_configNode is null) return;
_suppressSelectionListRefresh = true;
try
{
_configNode.IsVisible = newOption is not null;
if (_nothingSelectedTextNode is not null)
_nothingSelectedTextNode.IsVisible = newOption is null;
_configNode.ConfigurationOption = newOption;
}
finally
{
_suppressSelectionListRefresh = false;
if (_pendingSelectionListRefresh)
{
_pendingSelectionListRefresh = false;
_selectionListNode?.RefreshList();
}
}
}
private void OnRemoveCategory(CategoryWrapper categoryWrapper)
{
if (categoryWrapper.CategoryDefinition is null) return;
System.Config.Categories.UserCategories.Remove(categoryWrapper.CategoryDefinition);
_categoryWrappers.Remove(categoryWrapper);
RefreshSelectionList();
if (_configNode is not null && ReferenceEquals(_configNode.ConfigurationOption, categoryWrapper))
{
OnOptionChanged(null);
}
InventoryOrchestrator.RefreshAll(updateMaps: true);
}
private void RefreshSelectionList()
{
if (_suppressSelectionListRefresh)
{
_pendingSelectionListRefresh = true;
return;
}
_selectionListNode?.RefreshList();
}
protected override unsafe void OnFinalize(AtkUnitBase* addon)
{
_selectionListNode = null;
_configNode = null;
_separatorLine = null;
_nothingSelectedTextNode = null;
base.OnFinalize(addon);
}
}
@@ -0,0 +1,89 @@
using System.Collections.Generic;
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;
namespace AetherBags.Addons;
public class AddonConfigurationWindow : NativeAddon
{
private TabBarNode? _tabBarNode;
private GeneralScrollingAreaNode? _generalScrollingAreaNode;
private CategoryScrollingAreaNode? _categoryScrollingAreaNode;
private CurrencyScrollingAreaNode? _currencyScrollingAreaNode;
private readonly List<NodeBase> _tabContent = new();
protected override unsafe void OnSetup(AtkUnitBase* addon)
{
var tabContentY = ContentStartPosition.Y + 40;
var tabContentHeight = ContentSize.Y - 40;
_tabContent.Clear();
_tabBarNode = new TabBarNode
{
Position = ContentStartPosition,
Size = ContentSize with { Y = 24 },
IsVisible = true
};
_tabBarNode.AttachNode(this);
_generalScrollingAreaNode = new GeneralScrollingAreaNode
{
Position = ContentStartPosition with { Y = tabContentY },
Size = ContentSize with { Y = tabContentHeight },
IsVisible = true,
};
_generalScrollingAreaNode.AttachNode(this);
_categoryScrollingAreaNode = new CategoryScrollingAreaNode
{
Position = ContentStartPosition with { Y = tabContentY },
Size = ContentSize with { Y = tabContentHeight },
IsVisible = false,
};
_categoryScrollingAreaNode.AttachNode(this);
_currencyScrollingAreaNode = new CurrencyScrollingAreaNode
{
Position = ContentStartPosition with { Y = tabContentY },
Size = ContentSize with { Y = tabContentHeight },
IsVisible = false,
};
_currencyScrollingAreaNode.AttachNode(this);
_tabContent.Add(_generalScrollingAreaNode);
_tabContent.Add(_categoryScrollingAreaNode);
_tabContent.Add(_currencyScrollingAreaNode);
_tabBarNode.AddTab("General", () => SwitchTab(0));
_tabBarNode.AddTab("Categories", () => SwitchTab(1));
_tabBarNode.AddTab("Currency", () => SwitchTab(2));
base.OnSetup(addon);
}
private void SwitchTab(int index)
{
for (var i = 0; i < _tabContent.Count; i++)
_tabContent[i].IsVisible = i == index;
}
protected override unsafe void OnFinalize(AtkUnitBase* addon)
{
_tabBarNode?.Dispose();
_tabBarNode = null;
_generalScrollingAreaNode?.Dispose();
_generalScrollingAreaNode = null;
_categoryScrollingAreaNode?.Dispose();
_categoryScrollingAreaNode = null;
_currencyScrollingAreaNode?.Dispose();
_currencyScrollingAreaNode = null;
base.OnFinalize(addon);
}
}
+28
View File
@@ -0,0 +1,28 @@
using System;
using System.Linq;
using AetherBags.Currency;
using KamiToolKit.Premade.ListItemNodes;
using KamiToolKit.Premade.SearchAddons;
using Lumina.Excel.Sheets;
namespace AetherBags.Addons;
public class AddonCurrencyPicker : BaseSearchAddon<Item, ItemListItemNode> {
public AddonCurrencyPicker() {
var allItems = Services.DataManager.GetExcelSheet<Item>();
var obsoleteTomes = Services.DataManager.GetExcelSheet<TomestonesItem>()
.Where(t => t.Tomestones.RowId == 0)
.Select(t => t.Item.RowId).ToHashSet();
var currentTomestones = CurrencyState.GetCurrentTomestoneIds();
SearchOptions = allItems
.Where(i => (i.ItemUICategory.RowId == 100 || (i.RowId >= 1 && i.RowId < 100)) && !i.Name.IsEmpty)
.Where(i => !obsoleteTomes.Contains(i.RowId))
.Where(i => i.RowId != currentTomestones.Limited && i.RowId != currentTomestones.NonLimited)
.ToList();
}
protected override bool IsMatch(Item item, string search) => item.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase);
protected override int Comparer(Item l, Item r, string s, bool rev) => string.CompareOrdinal(l.Name.ToString(), r.Name.ToString());
}
+197
View File
@@ -0,0 +1,197 @@
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Inventory.Context;
using AetherBags.Inventory.Items;
using AetherBags.Inventory.State;
using AetherBags.Nodes.Input;
using AetherBags.Nodes.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Nodes;
namespace AetherBags.Addons;
public unsafe class AddonInventoryWindow : InventoryAddonBase
{
private readonly MainBagState _inventoryState = new();
private InventoryNotificationNode _notificationNode = null!;
private LootedItemsCategoryNode _lootedCategoryNode = null!;
protected override InventoryStateBase InventoryState => _inventoryState;
protected override void OnSetup(AtkUnitBase* addon)
{
InitializeBackgroundDropTarget();
ScrollableCategories = new ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>>
{
Position = ContentStartPosition,
Size = ContentSize,
ContentHeight = 0f,
AutoHideScrollBar = true,
};
ScrollableCategories.AttachNode(this);
CategoriesNode = ScrollableCategories.ContentNode;
CategoriesNode.HorizontalSpacing = CategorySpacing;
CategoriesNode.VerticalSpacing = CategorySpacing;
CategoriesNode.TopPadding = 4.0f;
CategoriesNode.BottomPadding = 4.0f;
_lootedCategoryNode = new LootedItemsCategoryNode
{
ItemsPerLine = 10,
OnDismissItem = OnDismissLootedItem,
OnClearAll = OnClearAllLootedItems,
};
var header = CalculateHeaderLayout(addon);
_notificationNode = new InventoryNotificationNode
{
Position = new Vector2(WindowNode!.X - 4f, WindowNode!.Y - 32f),
Size = new Vector2(header.HeaderWidth, 28f),
};
_notificationNode.AttachNode(this);
SearchInputNode = new TextInputWithButtonNode
{
Position = header.SearchPosition,
Size = header.SearchSize,
OnInputReceived = _ => ItemRefresh(),
OnButtonClicked = () => InventoryAddonContextMenu.OpenMain(this)
};
SearchInputNode.AttachNode(this);
SettingsButtonNode = new CircleButtonNode
{
Position = new Vector2(header.HeaderWidth - SettingsButtonOffset, header.HeaderY),
Size = new Vector2(28f),
Icon = ButtonIcon.GearCog,
OnClick = System.AddonConfigurationWindow.Toggle
};
SettingsButtonNode.AttachNode(this);
FooterNode = new InventoryFooterNode
{
Size = ContentSize with { Y = FooterHeight },
SlotAmountText = _inventoryState.GetEmptySlotsString(),
};
FooterNode.AttachNode(this);
LayoutContent();
addon->SubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
System.LootedItemsTracker.OnLootedItemsChanged += OnLootedItemsChanged;
IsSetupComplete = true;
_inventoryState.RefreshFromGame();
var existingLoot = System.LootedItemsTracker.LootedItems;
if (existingLoot.Count > 0)
{
UpdateLootedCategory(existingLoot);
}
RefreshCategoriesCore(autosize: true);
base.OnSetup(addon);
}
private void OnLootedItemsChanged(IReadOnlyList<LootedItemInfo> lootedItems)
{
if (!IsOpen || !IsSetupComplete) return;
UpdateLootedCategory(lootedItems);
}
private void UpdateLootedCategory(IReadOnlyList<LootedItemInfo> lootedItems)
{
_lootedCategoryNode.UpdateLootedItems(lootedItems);
if (lootedItems.Count > 0)
{
if (CategoriesNode.HoistedNode != _lootedCategoryNode)
{
CategoriesNode.SetHoistedNode(_lootedCategoryNode);
}
AutoSizeWindow();
}
else
{
using (CategoriesNode.DeferRecalculateLayout())
{
if (CategoriesNode.HoistedNode == _lootedCategoryNode)
{
CategoriesNode.SetHoistedNode(null);
}
CategoriesNode.RemoveNode(_lootedCategoryNode);
}
CategoriesNode.InvalidateLayout();
AutoSizeWindow();
}
}
private void OnDismissLootedItem(int index)
{
System.LootedItemsTracker.RemoveByIndex(index);
System.LootedItemsTracker.FlushPendingChanges();
}
private void OnClearAllLootedItems()
{
System.LootedItemsTracker.Clear();
System.LootedItemsTracker.FlushPendingChanges();
}
public void ManualCurrencyRefresh()
{
if (!Services.ClientState.IsLoggedIn) return;
FooterNode.RefreshCurrencies();
}
protected override void UpdateHeaderLayout()
{
base.UpdateHeaderLayout();
AtkUnitBase* addon = this;
if (addon == null) return;
var header = CalculateHeaderLayout(addon);
if (_notificationNode != null)
{
_notificationNode.Size = new Vector2(header.HeaderWidth, 28f);
}
}
public void SetNotification(InventoryNotificationInfo info)
{
Services.Framework.RunOnTick(() =>
{
if (IsOpen) _notificationNode.NotificationInfo = info;
}, delayTicks: 3);
}
protected override void OnFinalize(AtkUnitBase* addon)
{
System.LootedItemsTracker.OnLootedItemsChanged -= OnLootedItemsChanged;
ref var blockingAddonId = ref AgentInventoryContext.Instance()->BlockingAddonId;
if (blockingAddonId != 0)
{
RaptureAtkModule.Instance()->CloseAddon(blockingAddonId);
}
addon->UnsubscribeAtkArrayData(1, (int)NumberArrayType.Inventory);
_lootedCategoryNode?.Dispose();
IsSetupComplete = false;
base.OnFinalize(addon);
}
}
+7
View File
@@ -0,0 +1,7 @@
using KamiToolKit.Premade.ListItemNodes;
using KamiToolKit.Premade.SearchAddons;
namespace AetherBags.Addons;
public class AddonItemPicker : ItemSearchAddonBase<ItemListItemNode> {
}
+191
View File
@@ -0,0 +1,191 @@
using System.Linq;
using System.Numerics;
using AetherBags.Inventory;
using AetherBags.Inventory.State;
using AetherBags.Nodes.Input;
using AetherBags.Nodes.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Addons;
public unsafe class AddonRetainerWindow : InventoryAddonBase
{
private readonly RetainerState _inventoryState = new();
private TextNode _slotCounterNode = null!;
private TextNode _retainerNameNode = null!;
private TextButtonNode _entrustDuplicatesButton = null!;
protected override InventoryStateBase InventoryState => _inventoryState;
protected override bool HasFooter => false;
protected override bool HasSlotCounter => true;
private readonly Vector3 _tintColor = new(8f / 255f, -8f / 255f, -4f / 255f);
protected override float MinWindowWidth => 500;
protected override float MaxWindowWidth => 700;
private readonly string[] _retainerAddonNames = { "InventoryRetainer", "InventoryRetainerLarge" };
protected override void OnSetup(AtkUnitBase* addon)
{
InitializeBackgroundDropTarget();
WindowNode?.AddColor = _tintColor;
ScrollableCategories = new ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>>
{
Position = ContentStartPosition,
Size = ContentSize,
ContentHeight = 0f,
AutoHideScrollBar = true,
};
ScrollableCategories.AttachNode(this);
CategoriesNode = ScrollableCategories.ContentNode;
CategoriesNode.HorizontalSpacing = CategorySpacing;
CategoriesNode.VerticalSpacing = CategorySpacing;
CategoriesNode.TopPadding = 4.0f;
CategoriesNode.BottomPadding = 4.0f;
var header = CalculateHeaderLayout(addon);
SearchInputNode = new TextInputWithButtonNode
{
Position = header.SearchPosition,
Size = header.SearchSize,
OnInputReceived = _ => ItemRefresh(),
OnButtonClicked = () => InventoryAddonContextMenu.OpenMain(this)
};
SearchInputNode.AttachNode(this);
SettingsButtonNode = new CircleButtonNode
{
Position = new Vector2(header.HeaderWidth - SettingsButtonOffset, header.HeaderY),
Size = new Vector2(28f),
Icon = ButtonIcon.GearCog,
OnClick = System.AddonConfigurationWindow.Toggle
};
SettingsButtonNode.AttachNode(this);
_retainerNameNode = new TextNode
{
Position = new Vector2(8f, 0),
Size = new Vector2(200, 20),
AlignmentType = AlignmentType.Left,
FontType = FontType.MiedingerMed,
TextFlags = TextFlags.Glare,
TextColor = ColorHelper.GetColor(50),
TextOutlineColor = ColorHelper.GetColor(32),
};
_retainerNameNode.AttachNode(this);
_entrustDuplicatesButton = new TextButtonNode
{
Size = new Vector2(120, 28),
AddColor = _tintColor,
String = "Entrust Duplicates",
OnClick = OnEntrustDuplicates,
};
_entrustDuplicatesButton.AttachNode(this);
_slotCounterNode = new TextNode
{
Position = new Vector2(Size.X - 10, 0),
Size = new Vector2(82, 20),
AlignmentType = AlignmentType.Right,
FontType = FontType.MiedingerMed,
TextFlags = TextFlags.Glare,
TextColor = ColorHelper.GetColor(50),
TextOutlineColor = ColorHelper.GetColor(32),
};
_slotCounterNode.AttachNode(this);
SlotCounterNode = _slotCounterNode;
LayoutContent();
_inventoryState.RefreshFromGame();
IsSetupComplete = true;
RefreshCategoriesCore(autosize: true);
base.OnSetup(addon);
}
protected override void RefreshCategoriesCore(bool autosize)
{
if (!IsSetupComplete)
return;
_slotCounterNode.String = _inventoryState.GetEmptySlotsString();
_retainerNameNode.String = RetainerState.CurrentRetainerName;
base.RefreshCategoriesCore(autosize);
}
protected override void LayoutContent()
{
base.LayoutContent();
Vector2 contentPos = ContentStartPosition;
Vector2 contentSize = ContentSize;
float footerY = contentPos.Y + contentSize.Y - FooterHeight + 4f;
_retainerNameNode.Position = new Vector2(contentPos.X + 8f, footerY);
float buttonWidth = _entrustDuplicatesButton.Width;
float buttonX = contentPos.X + (contentSize.X - buttonWidth) / 2f;
_entrustDuplicatesButton.Position = new Vector2(buttonX, footerY - 2f);
if (SlotCounterNode != null)
SlotCounterNode.Position = new Vector2(contentSize.X - 80f, footerY);
}
private void CloseRetainerWindows()
{
var manager = RaptureAtkUnitManager.Instance();
foreach (var name in _retainerAddonNames)
{
var addon = manager->GetAddonByName(name);
if (addon != null)
{
addon->IsVisible = true;
addon->Close(true);
}
}
}
private bool IsAnyRetainerWindowLoaded()
{
return _retainerAddonNames.Any(name => RaptureAtkUnitManager.Instance()->GetAddonByName(name) != null);
}
protected override void OnShow(AtkUnitBase* addon)
{
base.OnShow(addon);
InventoryOrchestrator.RefreshAll(updateMaps: true);
}
private void OnEntrustDuplicates()
{
if (!IsAnyRetainerWindowLoaded()) return;
var agent = AgentModule.Instance()->GetAgentByInternalId(AgentId.Retainer);
agent->SendCommand(0, [0]);
}
protected override void OnFinalize(AtkUnitBase* addon)
{
IsSetupComplete = false;
CloseRetainerWindows();
base.OnFinalize(addon);
}
}
+120
View File
@@ -0,0 +1,120 @@
using System.Numerics;
using AetherBags.Inventory.State;
using AetherBags.Nodes.Input;
using AetherBags.Nodes.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Addons;
public unsafe class AddonSaddleBagWindow : InventoryAddonBase
{
private readonly SaddleBagState _inventoryState = new();
private TextNode _slotCounterNode = null!;
protected override InventoryStateBase InventoryState => _inventoryState;
protected override bool HasFooter => false;
protected override bool HasSlotCounter => true;
private readonly Vector3 _tintColor = new (-16f / 255f, -4f / 255f, 8f / 255f);
protected override float MinWindowWidth => 500;
protected override float MaxWindowWidth => 600;
protected override void OnSetup(AtkUnitBase* addon)
{
InitializeBackgroundDropTarget();
WindowNode?.AddColor = _tintColor;
ScrollableCategories = new ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>>
{
Position = ContentStartPosition,
Size = ContentSize,
ContentHeight = 0f,
AutoHideScrollBar = true,
};
ScrollableCategories.AttachNode(this);
CategoriesNode = ScrollableCategories.ContentNode;
CategoriesNode.HorizontalSpacing = CategorySpacing;
CategoriesNode.VerticalSpacing = CategorySpacing;
CategoriesNode.TopPadding = 4.0f;
CategoriesNode.BottomPadding = 4.0f;
var header = CalculateHeaderLayout(addon);
SearchInputNode = new TextInputWithButtonNode
{
Position = header.SearchPosition,
Size = header.SearchSize,
OnInputReceived = _ => ItemRefresh(),
OnButtonClicked = () => InventoryAddonContextMenu.OpenMain(this)
};
SearchInputNode.AttachNode(this);
SettingsButtonNode = new CircleButtonNode
{
Position = new Vector2(header.HeaderWidth - SettingsButtonOffset, header.HeaderY),
Size = new Vector2(28f),
AddColor = _tintColor,
Icon = ButtonIcon.GearCog,
OnClick = System.AddonConfigurationWindow.Toggle
};
SettingsButtonNode.AttachNode(this);
_slotCounterNode = new TextNode
{
Position = new Vector2(Size.X - 10, 0),
Size = new Vector2(82, 20),
AlignmentType = AlignmentType.Right,
FontType = FontType.MiedingerMed,
TextFlags = TextFlags.Glare,
TextColor = ColorHelper.GetColor(50),
TextOutlineColor = ColorHelper.GetColor(32)
};
_slotCounterNode.AttachNode(this);
SlotCounterNode = _slotCounterNode;
LayoutContent();
_inventoryState.RefreshFromGame();
IsSetupComplete = true;
RefreshCategoriesCore(autosize: true);
base.OnSetup(addon);
}
protected override void RefreshCategoriesCore(bool autosize)
{
if (!IsSetupComplete)
return;
_slotCounterNode.String = _inventoryState.GetEmptySlotsString();
base.RefreshCategoriesCore(autosize);
}
protected override void OnFinalize(AtkUnitBase* addon)
{
IsSetupComplete = false;
if (System.Config.General.HideGameSaddleBags)
{
var saddleAddon = RaptureAtkUnitManager.Instance()->GetAddonByName("InventoryBuddy");
if (saddleAddon != null)
{
saddleAddon->IsVisible = true;
saddleAddon->Close(true);
}
}
base.OnFinalize(addon);
}
}
@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using AetherBags.Nodes.Configuration.Category;
using KamiToolKit.Premade.SearchAddons;
using Lumina.Excel.Sheets;
namespace AetherBags.Addons;
public class AddonUICategoryPicker : BaseSearchAddon<ItemUICategory, UICategoryListItemNode> {
protected override int Comparer(ItemUICategory left, ItemUICategory right, string sort, bool rev)
=> string.CompareOrdinal(left.Name.ToString(), right.Name.ToString());
protected override bool IsMatch(ItemUICategory item, string search)
=> item.Name.ToString().Contains(search, global::System.StringComparison.OrdinalIgnoreCase);
}
+14
View File
@@ -0,0 +1,14 @@
using KamiToolKit.Premade.GenericListItemNodes;
namespace AetherBags.Addons;
public class CategoryListItemNode : GenericListItemNode<CategoryWrapper>
{
protected override uint GetIconId(CategoryWrapper data) => data.GetIconId() ?? 0;
protected override string GetLabelText(CategoryWrapper data) => data.GetLabel();
protected override string GetSubLabelText(CategoryWrapper data) => data.GetSubLabel();
protected override uint? GetId(CategoryWrapper data) => data.GetId();
}
+25
View File
@@ -0,0 +1,25 @@
using AetherBags.Configuration;
using AetherBags.Inventory.Categories;
namespace AetherBags.Addons;
// Removed IInfoNodeData implementation
public class CategoryWrapper(UserCategoryDefinition categoryDefinition)
{
public UserCategoryDefinition? CategoryDefinition { get; } = categoryDefinition;
public string GetLabel() => CategoryDefinition!.Name;
public string GetSubLabel() {
if(UserCategoryMatcher.IsCatchAll(CategoryDefinition!)) return " No valid rules!";
return CategoryDefinition!.Enabled ? "✓ Enabled" : " Disabled";
}
public uint? GetId() => null;
public uint? GetIconId() => 0;
public int Compare(CategoryWrapper other, string sortingMode) {
return CategoryDefinition!.Order.CompareTo(other.CategoryDefinition!.Order);
}
}
+14
View File
@@ -0,0 +1,14 @@
using AetherBags.Inventory.Items;
namespace AetherBags.Addons;
public interface IInventoryWindow
{
bool IsOpen { get; }
void Toggle();
void Close();
void ManualRefresh();
void ItemRefresh();
void SetSearchText(string searchText);
InventoryStats GetStats();
}
+713
View File
@@ -0,0 +1,713 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Configuration;
using AetherBags.Helpers;
using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context;
using AetherBags.Inventory.Items;
using AetherBags.Inventory.Scanning;
using AetherBags.Inventory.State;
using AetherBags.Monitoring;
using AetherBags.Nodes.Input;
using AetherBags.Nodes.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit;
using KamiToolKit.Classes;
using KamiToolKit.ContextMenu;
using KamiToolKit.Nodes;
namespace AetherBags.Addons;
public abstract unsafe class InventoryAddonBase : NativeAddon, IInventoryWindow
{
protected readonly InventoryCategoryHoverCoordinator HoverCoordinator = new();
protected readonly InventoryCategoryPinCoordinator PinCoordinator = new();
protected readonly HashSet<InventoryCategoryNode> HoverSubscribed = new();
protected DragDropNode BackgroundDropTarget = null!;
protected ScrollingAreaNode<WrappingGridNode<InventoryCategoryNodeBase>> ScrollableCategories = null!;
protected WrappingGridNode<InventoryCategoryNodeBase> CategoriesNode = null!;
protected TextInputWithButtonNode SearchInputNode = null!;
protected InventoryFooterNode FooterNode = null!;
protected TextNode? SlotCounterNode { get; set; }
protected CircleButtonNode SettingsButtonNode = null!;
internal ContextMenu ContextMenu = null!;
protected readonly SharedNodePool<InventoryDragDropNode> SharedItemNodePool = new(
maxSize: 256,
factory: null,
resetAction: node => node.ResetForReuse());
protected readonly SharedNodePool<InventoryCategoryNode> SharedCategoryNodePool = new(
maxSize: 32,
factory: null,
resetAction: node => node.ResetForReuse());
protected readonly VirtualizationState CategoryVirtualization = new() { BufferSize = 200f };
protected virtual float MinWindowWidth => 600;
protected virtual float MaxWindowWidth => 800;
protected virtual float MinWindowHeight => 200;
protected virtual float MaxWindowHeight => 1000;
protected const float CategorySpacing = 12;
protected const float ItemSize = 42;
protected const float ItemPadding = 5;
protected const float FooterHeight = 28f;
protected const float FooterTopSpacing = 4f;
protected const float SettingsButtonOffset = 62f;
protected const float ScrollBarWidth = 16f;
protected const float ContentHeightOffset = 4f;
protected bool RefreshQueued;
protected bool RefreshAutosizeQueued;
protected bool IsSetupComplete;
private bool _deferredPopulationInProgress;
private bool _initialPopulationComplete;
private const int ItemsPerFrame = 50;
protected abstract InventoryStateBase InventoryState { get; }
protected virtual bool HasFooter => true;
protected virtual bool HasPinning => true;
protected virtual bool HasSlotCounter => false;
private readonly HashSet<uint> _searchMatchScratch = new();
private bool _isRefreshing;
private string _lastSearchText = string.Empty;
private int _requestedUpdateCount;
private int _refreshFromLifecycleCount;
private long _lastLogTick;
public void ManualRefresh() => ExecuteRefresh(true);
public string GetSearchText() => SearchInputNode?.SearchString.ExtractText() ?? string.Empty;
public InventoryStats GetStats() => InventoryState.GetStats();
public IReadOnlyList<CategorizedInventory>? GetVisibleCategories()
{
if (!IsSetupComplete) return null;
string filter = GetSearchText();
return InventoryState.GetCategories(filter);
}
public virtual void SetSearchText(string searchText)
{
Services.Framework.RunOnTick(() =>
{
if (IsOpen) SearchInputNode.SearchString = searchText;
RefreshCategoriesCore(autosize: true);
}, delayTicks: 3);
}
private void ExecuteRefresh(bool autosize)
{
if (!IsSetupComplete || !IsOpen || _isRefreshing) return;
try
{
_isRefreshing = true;
InventoryState.RefreshFromGame();
System.LootedItemsTracker.FlushPendingChanges();
RefreshCategoriesCore(autosize);
}
finally
{
_isRefreshing = false;
}
}
public void RefreshFromLifecycle() => ExecuteRefresh(autosize: true);
protected virtual void RefreshCategoriesCore(bool autosize)
{
if (!IsSetupComplete)
return;
var config = System.Config.General;
string searchText = SearchInputNode.SearchString.ExtractText();
bool isSearching = !string.IsNullOrWhiteSpace(searchText);
if (searchText != _lastSearchText)
{
_lastSearchText = searchText;
System.AetherBagsAPI?.API.RaiseSearchChanged(searchText);
}
if (config.SearchMode == SearchMode.Highlight && isSearching)
{
_searchMatchScratch.Clear();
var allData = InventoryState.GetCategories(string.Empty);
for (int i = 0; i < allData.Count; i++)
{
var cat = allData[i];
for (int j = 0; j < cat.Items.Count; j++)
{
var item = cat.Items[j];
if (item.IsRegexMatch(searchText))
{
_searchMatchScratch.Add(item.Item.ItemId);
}
}
}
HighlightState.SetFilter(HighlightSource.Search, _searchMatchScratch);
}
else
{
HighlightState.ClearFilter(HighlightSource.Search);
}
if (SearchInputNode != null)
{
bool atActive = !string.IsNullOrEmpty(HighlightState.SelectedAllaganToolsFilterKey);
SearchInputNode.HintAddColor = (atActive)
? new Vector3(0.0f, 0.3f, 0.3f)
: Vector3.Zero;
}
if (HasFooter)
{
FooterNode.SlotAmountText = InventoryState.GetEmptySlotsString();
FooterNode.RefreshCurrencies();
}
string dataFilter = config.SearchMode == SearchMode.Filter ? searchText : string.Empty;
var categories = InventoryState.GetCategories(dataFilter);
float maxContentWidth = CategoriesNode.Width > 0 ? CategoriesNode.Width : ContentSize.X;
int maxItemsPerLine = CalculateOptimalItemsPerLine(maxContentWidth);
bool deferItems = !_deferredPopulationInProgress && !_initialPopulationComplete;
CategoriesNode.SyncWithListDataByKey<CategorizedInventory, InventoryCategoryNode, uint>(
dataList: categories,
getKeyFromData: categorizedInventory => categorizedInventory.Key,
getKeyFromNode: node => node.CategorizedInventory.Key,
updateNode: (node, data) =>
{
node.SetCategoryData(data, Math.Min(data.Items.Count, maxItemsPerLine), deferItemCreation: deferItems);
if (!deferItems) node.RefreshNodeVisuals();
},
createNodeMethod: _ => CreateCategoryNode(),
resetNodeForReuse: ResetCategoryNodeForReuse,
externalPool: SharedCategoryNodePool);
if (HasPinning)
{
bool pinsChanged = PinCoordinator.ApplyPinnedStates(CategoriesNode);
if (pinsChanged) HoverCoordinator.ResetAll(CategoriesNode);
}
WireHoverHandlers();
CategoriesNode.InvalidateLayout();
if (autosize)
AutoSizeWindow();
else
{
LayoutContent();
CategoriesNode.RecalculateLayout();
}
if (deferItems && !_deferredPopulationInProgress)
{
StartDeferredItemPopulation();
}
else if (!deferItems && !_initialPopulationComplete)
{
_initialPopulationComplete = true;
}
System.AetherBagsAPI?.API.RaiseCategoriesRefreshed();
}
private void StartDeferredItemPopulation()
{
_deferredPopulationInProgress = true;
Services.Framework.RunOnTick(PopulateCategoryBatch, delayTicks: 1);
}
private void PopulateCategoryBatch()
{
if (!IsOpen)
{
_deferredPopulationInProgress = false;
return;
}
UpdateCategoryVisibility();
int itemsPopulated = 0;
using (CategoriesNode.DeferRecalculateLayout())
{
var nodes = CategoriesNode.Nodes;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is not InventoryCategoryNode categoryNode || !categoryNode.NeedsItemPopulation)
continue;
if (!CategoryVirtualization.IsVisible(i))
continue;
int categoryItemCount = categoryNode.CategorizedInventory.Items.Count;
if (itemsPopulated > 0 && itemsPopulated + categoryItemCount > ItemsPerFrame)
break;
categoryNode.PopulateItems();
categoryNode.RefreshNodeVisuals();
itemsPopulated += categoryItemCount;
if (itemsPopulated >= ItemsPerFrame)
break;
}
if (itemsPopulated < ItemsPerFrame)
{
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is not InventoryCategoryNode categoryNode || !categoryNode.NeedsItemPopulation)
continue;
if (CategoryVirtualization.IsVisible(i))
continue;
int categoryItemCount = categoryNode.CategorizedInventory.Items.Count;
if (itemsPopulated > 0 && itemsPopulated + categoryItemCount > ItemsPerFrame)
break;
categoryNode.PopulateItems();
categoryNode.RefreshNodeVisuals();
itemsPopulated += categoryItemCount;
if (itemsPopulated >= ItemsPerFrame)
break;
}
}
}
bool hasMore = false;
foreach (var node in CategoriesNode.Nodes)
{
if (node is InventoryCategoryNode categoryNode && categoryNode.NeedsItemPopulation)
{
hasMore = true;
break;
}
}
if (hasMore)
{
Services.Framework.RunOnTick(PopulateCategoryBatch);
}
else
{
_deferredPopulationInProgress = false;
_initialPopulationComplete = true;
}
}
protected readonly struct HeaderLayout
{
public Vector2 SearchPosition { get; init; }
public Vector2 SearchSize { get; init; }
public float HeaderWidth { get; init; }
public float HeaderY { get; init; }
}
protected HeaderLayout CalculateHeaderLayout(AtkUnitBase* addon)
{
var header = addon->WindowHeaderCollisionNode;
float headerW = header->Width;
float itemY = header->Y + (header->Height - 28f) * 0.5f;
// Reserve space for close button (~50px) and settings button (~48px + gap)
const float closeButtonReserve = 50f;
const float settingsButtonWidth = 28f;
const float minGap = 16f;
const float minSearchWidth = 150f;
const float maxSearchWidth = 350f;
// Calculate max available width for search bar
// Layout from right: [closeButton 50px] [settings 28px] [gap 16px] [searchBar] [gap 16px] [leftContent]
float rightReserve = closeButtonReserve + settingsButtonWidth + minGap;
float leftReserve = 220f; // Space for title (e.g. "Chocobo Saddlebag" is ~200px)
float availableForSearch = headerW - rightReserve - leftReserve;
// Search bar width: prefer 45% of header, but clamp to available space and min/max
float desiredSearchWidth = headerW * 0.45f;
float searchWidth = Math.Clamp(desiredSearchWidth, minSearchWidth, Math.Min(maxSearchWidth, availableForSearch));
// Center the search bar, but ensure it doesn't extend past the safe right boundary
float maxSearchRight = headerW - rightReserve;
float centeredSearchX = (headerW - searchWidth) * 0.5f;
float searchRight = centeredSearchX + searchWidth;
// If centered position would overlap with right elements, shift left
float searchX = searchRight > maxSearchRight
? maxSearchRight - searchWidth
: centeredSearchX;
// Ensure search bar doesn't go past left reserve
if (searchX < leftReserve)
searchX = leftReserve;
return new HeaderLayout
{
SearchPosition = new Vector2(searchX, itemY),
SearchSize = new Vector2(searchWidth, 28f),
HeaderWidth = headerW,
HeaderY = itemY
};
}
protected void InitializeBackgroundDropTarget()
{
BackgroundDropTarget = new DragDropNode
{
Position = ContentStartPosition,
Size = ContentSize,
IconId = 0,
IsDraggable = false,
IsClickable = false,
AcceptedType = DragDropType.Item,
};
BackgroundDropTarget.DragDropBackgroundNode.IsVisible = false;
BackgroundDropTarget.IconNode.IsVisible = false;
BackgroundDropTarget.OnPayloadAccepted = OnBackgroundPayloadAccepted;
BackgroundDropTarget.AttachNode(this);
}
protected virtual InventoryCategoryNode CreateCategoryNode()
{
var node = SharedCategoryNodePool.TryRent();
if (node == null)
{
node = new InventoryCategoryNode
{
Size = ContentSize with { Y = 120 },
SharedItemPool = SharedItemNodePool,
};
}
node.OnRefreshRequested = ManualRefresh;
node.OnDragEnd = () => InventoryOrchestrator.RefreshAll(updateMaps: true);
node.SharedItemPool = SharedItemNodePool;
return node;
}
private static void ResetCategoryNodeForReuse(InventoryCategoryNode node)
{
node.ResetForReuse();
}
private void OnBackgroundPayloadAccepted(DragDropNode node, DragDropPayload acceptedPayload)
{
if (!acceptedPayload.IsValidInventoryPayload) return;
InventoryLocation emptyLocation = InventoryScanner.GetFirstEmptySlot(InventoryState.SourceType);
if (!emptyLocation.IsValid)
{
Services.Logger.Error("No empty slots available to receive drop.");
return;
}
InventoryMappedLocation visualLocation = InventoryContextState.GetVisualLocation(emptyLocation.Container, emptyLocation.Slot);
var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container);
int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot;
var targetPayload = new DragDropPayload
{
Type = DragDropType.Item,
Int1 = visualLocation.Container,
Int2 = visualLocation.Slot,
ReferenceIndex = (short)absoluteIndex
};
Services.Logger.DebugOnly($"[BackgroundDrop] Target: {emptyLocation} -> Visual: {visualLocation} (Ref: {absoluteIndex})");
InventoryMoveHelper.HandleItemMovePayload(acceptedPayload, targetPayload);
ManualRefresh();
}
protected void WireHoverHandlers()
{
var nodes = CategoriesNode.Nodes;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is not InventoryCategoryNode node)
continue;
if (!HoverSubscribed.Add(node))
continue;
node.HeaderHoverChanged += (src, hovering) =>
{
HoverCoordinator.OnCategoryHoverChanged(CategoriesNode, src, hovering);
};
}
}
protected int CalculateOptimalItemsPerLine(float availableWidth)
=> Math.Clamp((int)MathF.Floor((availableWidth + ItemPadding) / (ItemSize + ItemPadding)), 1, 15);
protected virtual void LayoutContent()
{
Vector2 contentPos = ContentStartPosition;
Vector2 contentSize = ContentSize;
float footerH = HasFooter || HasSlotCounter ? FooterHeight : 0;
if (HasFooter)
{
FooterNode.Position = new Vector2(contentPos.X, contentPos.Y + contentSize.Y - footerH);
FooterNode.Size = new Vector2(contentSize.X, footerH);
}
else if (HasSlotCounter && SlotCounterNode != null)
{
SlotCounterNode.Position = new Vector2(contentSize.X -80f, contentPos.Y + contentSize.Y - footerH + 4f);
}
float gridH = contentSize.Y - (HasFooter ? FooterHeight + FooterTopSpacing : 0);
if (gridH < 0) gridH = 0;
ScrollableCategories.Position = contentPos;
ScrollableCategories.Size = new Vector2(contentSize.X, gridH);
float categoriesWidth = contentSize.X - ScrollBarWidth;
CategoriesNode.Width = categoriesWidth;
UpdateCategoryMaxWidths(categoriesWidth);
}
private void UpdateCategoryMaxWidths(float maxWidth)
{
foreach (var node in CategoriesNode.Nodes)
{
if (node is InventoryCategoryNodeBase categoryNode && categoryNode.MaxWidth != maxWidth)
{
categoryNode.MaxWidth = maxWidth;
categoryNode.RecalculateSize();
}
}
}
protected virtual void AutoSizeWindow()
{
var nodes = CategoriesNode.Nodes;
float maxChildWidth = 0f;
int childCount = 0;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is not InventoryCategoryNodeBase cat)
continue;
childCount++;
float w = cat.Width;
if (w > maxChildWidth) maxChildWidth = w;
}
if (childCount == 0)
{
ResizeWindow(MinWindowWidth, MinWindowHeight, recalcLayout: true);
UpdateScrollParameters();
return;
}
float footerSpace = HasFooter || HasSlotCounter ? FooterHeight + FooterTopSpacing : 0;
float requiredWidth = maxChildWidth + ScrollBarWidth + (ContentStartPosition.X * 2);
float finalWidth = Math.Clamp(requiredWidth, MinWindowWidth, MaxWindowWidth);
if (SettingsButtonNode != null)
{
SettingsButtonNode.X = finalWidth - SettingsButtonOffset;
}
float contentWidth = finalWidth - (ContentStartPosition.X * 2);
float categoriesWidth = contentWidth - ScrollBarWidth;
CategoriesNode.Width = categoriesWidth;
UpdateCategoryMaxWidths(categoriesWidth);
CategoriesNode.RecalculateLayout();
float requiredGridHeight = CategoriesNode.GetRequiredHeight();
float requiredContentHeight = requiredGridHeight + footerSpace;
float requiredWindowHeight = requiredContentHeight + ContentStartPosition.Y + ContentStartPosition.X + ContentHeightOffset;
float finalHeight = Math.Clamp(requiredWindowHeight, MinWindowHeight, MaxWindowHeight);
ResizeWindow(finalWidth, finalHeight, recalcLayout: false);
UpdateScrollParameters();
}
protected void UpdateScrollParameters()
{
if (ScrollableCategories == null) return;
float requiredHeight = CategoriesNode.GetRequiredHeight();
ScrollableCategories.ContentHeight = requiredHeight;
CategoryVirtualization.ViewportHeight = ScrollableCategories.Size.Y;
UpdateCategoryVisibility();
}
private void OnScrollValueChanged(int scrollPosition)
{
CategoryVirtualization.ScrollPosition = scrollPosition;
}
private void UpdateCategoryVisibility()
{
var nodes = CategoriesNode.Nodes;
CategoryVirtualization.SetItemCount(nodes.Count);
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] is InventoryCategoryNodeBase cat)
{
CategoryVirtualization.SetItemLayout(i, cat.Y, cat.Height);
}
}
CategoryVirtualization.UpdateVisibility();
}
protected void ResizeWindow(float width, float height, bool recalcLayout)
{
SetWindowSize(width, height);
if (BackgroundDropTarget != null)
{
BackgroundDropTarget.Size = ContentSize;
}
UpdateHeaderLayout();
LayoutContent();
if (recalcLayout)
CategoriesNode.RecalculateLayout();
UpdateScrollParameters();
}
protected virtual void UpdateHeaderLayout()
{
AtkUnitBase* addon = this;
if (addon == null) return;
var header = CalculateHeaderLayout(addon);
if (SearchInputNode != null)
{
SearchInputNode.Position = header.SearchPosition;
SearchInputNode.Size = header.SearchSize;
}
if (SettingsButtonNode != null)
{
SettingsButtonNode.Position = new Vector2(header.HeaderWidth - SettingsButtonOffset, header.HeaderY);
}
}
protected void ResizeWindow(float width, float height)
=> ResizeWindow(width, height, recalcLayout: true);
public void ItemRefresh()
{
if (!IsOpen) return;
if (!IsSetupComplete) return;
RefreshCategoriesCore(false);
}
private void LogRefreshStats()
{
long now = Environment.TickCount64;
if (now - _lastLogTick > 1000) // Log every second
{
Services.Logger.DebugOnly($"[Perf] Last 1s: OnRequestedUpdate={_requestedUpdateCount}, RefreshFromLifecycle={_refreshFromLifecycleCount}");
_requestedUpdateCount = 0;
_refreshFromLifecycleCount = 0;
_lastLogTick = now;
}
}
protected override void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData)
{
base.OnRequestedUpdate(addon, numberArrayData, stringArrayData);
if (DragDropState.IsDragging) return;
ExecuteRefresh(autosize: true);
}
protected override void OnSetup(AtkUnitBase* addon)
{
ContextMenu = new ContextMenu();
System.AetherBagsAPI?.API.RaiseInventoryOpened();
if (ScrollableCategories != null)
{
ScrollableCategories.ScrollBarNode.OnValueChanged = OnScrollValueChanged;
}
base.OnSetup(addon);
}
protected override void OnUpdate(AtkUnitBase* addon)
{
if (RefreshQueued)
{
bool doAutosize = RefreshAutosizeQueued;
RefreshQueued = false;
RefreshAutosizeQueued = false;
RefreshCategoriesCore(doAutosize);
}
base.OnUpdate(addon);
}
protected override void OnFinalize(AtkUnitBase* addon)
{
System.AetherBagsAPI?.API.RaiseInventoryClosed();
ContextMenu?.Dispose();
HoverSubscribed.Clear();
RefreshQueued = false;
RefreshAutosizeQueued = false;
_deferredPopulationInProgress = false;
_initialPopulationComplete = false;
SharedItemNodePool.Clear();
SharedCategoryNodePool.Clear();
CategoryVirtualization.ClearLayout();
base.OnFinalize(addon);
}
}
@@ -0,0 +1,83 @@
using AetherBags.Configuration;
using AetherBags.Inventory;
using AetherBags.Inventory.Context;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using KamiToolKit.ContextMenu;
namespace AetherBags.Addons;
public static class InventoryAddonContextMenu
{
private static ContextMenuItem Separator => new()
{
Name = "---------------------------",
IsEnabled = false,
OnClick = () => { }
};
public static void OpenMain(InventoryAddonBase parent)
{
if (parent?.ContextMenu == null || System.Config == null) return;
var menu = parent.ContextMenu;
menu.Clear();
bool hasActiveAtFilter = !string.IsNullOrEmpty(HighlightState.SelectedAllaganToolsFilterKey);
string searchText = parent.GetSearchText();
if (HighlightState.IsFilterActive || hasActiveAtFilter || !string.IsNullOrEmpty(searchText))
{
menu.AddItem("Clear All Filters", () =>
{
HighlightState.ClearAll();
parent.SetSearchText(string.Empty);
InventoryOrchestrator.RefreshAll(updateMaps: false);
});
menu.AddItem(Separator);
}
var currentMode = System.Config.General.SearchMode;
string modeLabel = currentMode == SearchMode.Filter ? "Mode: Hide Non-Matches" : "Mode: Fade Non-Matches";
menu.AddItem(modeLabel, () =>
{
System.Config.General.SearchMode = currentMode == SearchMode.Filter ? SearchMode.Highlight : SearchMode.Filter;
parent.ManualRefresh();
});
if (System.IPC.AllaganTools is { IsReady: true } && System.Config.Categories.AllaganToolsCategoriesEnabled)
{
var atFilters = System.IPC.AllaganTools.GetSearchFilters();
if (atFilters is { Count: > 0 })
{
var subMenu = new ContextMenuSubItem
{
Name = "Allagan Tools Filters...",
OnClick = () => { }
};
foreach (var (key, name) in atFilters)
{
var capturedKey = key;
bool isActive = HighlightState.SelectedAllaganToolsFilterKey == key;
subMenu.AddItem(isActive ?$"✓ {name}" : $" {name}", () =>
{
HighlightState.SelectedAllaganToolsFilterKey = isActive ? string.Empty : capturedKey;
InventoryOrchestrator.RefreshAll(updateMaps: false);
});
}
menu.AddItem(subMenu);
}
}
menu.Open();
}
public static unsafe void Close()
{
var agent = AgentContext.Instance();
if (agent != null)
{
agent->ClearMenu();
}
}
}
@@ -0,0 +1,48 @@
using AetherBags.Inventory.Items;
using AetherBags.IPC.ExternalCategorySystem;
using KamiToolKit.ContextMenu;
namespace AetherBags.Addons;
public static class ItemContextMenuHandler
{
private static ContextMenu? _itemMenu;
public static void Initialize()
{
_itemMenu = new ContextMenu();
}
public static void Dispose()
{
_itemMenu?.Dispose();
_itemMenu = null;
}
public static bool TryShowExternalMenu(ItemInfo item)
{
if (_itemMenu == null) return false;
if (!System.Config.General.UseUnifiedExternalCategories) return false;
var entries = ExternalCategoryManager.GetContextMenuEntries(item.Item.ItemId);
if (entries == null || entries.Count == 0) return false;
_itemMenu.Clear();
var context = new ContextMenuContext(
item.Item.ItemId,
(int)item.Item.Container,
item.Item.Slot
);
foreach (var entry in entries)
{
var capturedEntry = entry;
var capturedContext = context;
_itemMenu.AddItem(entry.Label, () => capturedEntry.OnClick(capturedContext));
}
_itemMenu.Open();
return true;
}
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
<PropertyGroup>
<Version>1.0.0.0</Version>
</PropertyGroup>
<PropertyGroup>
<Author>Zeffuro, Pie Lover</Author>
<Name>AetherBags</Name>
<InternalName>AetherBags</InternalName>
<Punchline>Never think too hard about your bags again!</Punchline>
<Description>This plugin replaces your inventory with it's own categorified inventory addon.</Description>
<RepoUrl>https://github.com/Zeffuro/AetherBags</RepoUrl>
<Tags>ui</Tags>
<AcceptsFeedback>true</AcceptsFeedback>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\KamiToolKit\KamiToolKit.csproj"/>
</ItemGroup>
<ItemGroup>
<Content Include=".gitignore" />
<Content Include="changelog.md" />
<Content Include="Assets\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>false</Visible>
</Content>
<Content Include="Assets\Icons\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>false</Visible>
</Content>
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+196
View File
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using AetherBags.Addons;
using AetherBags.Helpers;
using AetherBags.Inventory;
using AetherBags.Inventory.Items;
using Dalamud.Game.Command;
namespace AetherBags.Commands;
public class CommandHandler : IDisposable
{
private const string MainCommand = "/aetherbags";
private const string ShortCommand = "/ab";
private const string HelpDescription = "Opens your inventory. Use '/ab help' for more options.";
public CommandHandler()
{
Services.CommandManager.AddHandler(MainCommand, new CommandInfo(OnCommand)
{
DisplayOrder = 1,
ShowInHelp = true,
HelpMessage = HelpDescription
});
Services.CommandManager.AddHandler(ShortCommand, new CommandInfo(OnCommand)
{
DisplayOrder = 2,
ShowInHelp = true,
HelpMessage = HelpDescription
});
}
private void OnCommand(string command, string args)
{
var argsParts = args.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var subCommand = argsParts.Length > 0 ? argsParts[0].ToLowerInvariant() : string.Empty;
var subArgs = argsParts.Length > 1 ? argsParts[1] : string.Empty;
switch (subCommand)
{
case "":
case "toggle":
System.AddonInventoryWindow.Toggle();
break;
case "config":
case "settings":
System.AddonConfigurationWindow.Toggle();
break;
case "show":
case "open":
System.AddonInventoryWindow.Open();
break;
case "hide":
case "close":
System.AddonInventoryWindow.Close();
break;
case "refresh":
InventoryOrchestrator.RefreshAll(updateMaps: true);
PrintChat("Inventory refreshed.");
break;
case "search":
HandleSearch(subArgs);
break;
case "import-sk":
ImportExportResetHelper.TryImportSortaKindaFromClipboard(true);
InventoryOrchestrator.RefreshAll(updateMaps: true);
break;
case "export":
HandleExport();
break;
case "import":
ImportExportResetHelper.TryImportConfigFromClipboard();
InventoryOrchestrator.RefreshAll(updateMaps: true);
break;
case "reset":
ImportExportResetHelper.TryResetConfig();
InventoryOrchestrator.RefreshAll(updateMaps: true);
break;
case "count":
case "stats":
PrintInventoryStats();
break;
case "saddle":
System.AddonSaddleBagWindow.Toggle();
break;
case "retainer":
System.AddonRetainerWindow.Toggle();
break;
case "help":
case "?":
PrintHelp();
break;
default:
PrintChat($"Unknown command: {subCommand}. Use '/ab help' for available commands.");
break;
}
}
private void PrintInventoryStats()
{
var openWindows = new List<(string Name, IInventoryWindow Window)>();
if (System.AddonInventoryWindow.IsOpen)
openWindows.Add(("Main", System.AddonInventoryWindow));
if (System.AddonSaddleBagWindow.IsOpen)
openWindows.Add(("Saddle", System.AddonSaddleBagWindow));
if (System.AddonRetainerWindow.IsOpen)
openWindows.Add(("Retainer", System.AddonRetainerWindow));
if (openWindows.Count == 0)
{
PrintChat("No inventory windows are open. Open an inventory to see stats.");
return;
}
foreach (var (name, window) in openWindows)
{
var stats = window.GetStats();
PrintChat($"[{name}] {stats.UsedSlots}/{stats.TotalSlots} slots ({stats.UsagePercent:F0}%) | {stats.TotalItems} items | {stats.CategoryCount} categories");
}
if (openWindows.Count > 1)
{
var combined = new InventoryStats();
foreach (var (_, window) in openWindows)
{
combined += window.GetStats();
}
PrintChat($"[Total] {combined.UsedSlots}/{combined.TotalSlots} slots ({combined.UsagePercent:F0}%) | {combined.TotalItems} items | {combined.CategoryCount} categories");
}
}
private void HandleSearch(string searchTerm)
{
if (!System.AddonInventoryWindow.IsOpen)
{
System.AddonInventoryWindow.Open();
}
if (!string.IsNullOrWhiteSpace(searchTerm))
{
System.AddonInventoryWindow.SetSearchText(searchTerm);
}
PrintChat($"Searching for: {searchTerm}");
}
private void HandleExport()
{
ImportExportResetHelper.TryExportConfigToClipboard(System.Config);
}
private void PrintHelp()
{
var helpText = @"AetherBags Commands:
/ab - Toggle inventory window
/ab config - Toggle configuration window
/ab show - Open inventory window
/ab hide - Close inventory window
/ab refresh - Force refresh inventory
/ab search <term> - Open and search for items
/ab import - Import config from clipboard (hold Shift)
/ab import-sk - Import from SortaKinda clipboard
/ab export - Export config to clipboard
/ab reset - Reset config to default
/ab help - Show this help message";
PrintChat(helpText);
}
private static void PrintChat(string message)
{
Services.ChatGui.Print(message, "AetherBags");
}
public void Dispose()
{
Services.CommandManager.RemoveHandler(MainCommand);
Services.CommandManager.RemoveHandler(ShortCommand);
}
}
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Numerics;
using System.Text.Json.Serialization;
using KamiToolKit.Classes;
namespace AetherBags.Configuration;
public class CategorySettings
{
public bool CategoriesEnabled { get; set; } = true;
public bool GameCategoriesEnabled { get; set; } = true;
public bool UserCategoriesEnabled { get; set; } = true;
public bool BisBuddyEnabled { get; set; } = true;
public PluginFilterMode BisBuddyMode { get; set; } = PluginFilterMode.Highlight;
public bool AllaganToolsCategoriesEnabled { get; set; } = false;
public PluginFilterMode AllaganToolsFilterMode { get; set; } = PluginFilterMode.Highlight;
public List<UserCategoryDefinition> UserCategories { get; set; } = new();
}
public class UserCategoryDefinition
{
public bool Enabled { get; set; } = true;
public bool Pinned { get; set; } = false;
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = "New Category";
public string Description { get; set; } = string.Empty;
public int Order { get; set; }
public int Priority { get; set; } = 100;
public Vector4 Color { get; set; } = ColorHelper.GetColor(50);
public CategoryRuleSet Rules { get; set; } = new();
}
public class CategoryRuleSet
{
public List<uint> AllowedItemIds { get; set; } = new();
public List<string> AllowedItemNamePatterns { get; set; } = new();
public List<uint> AllowedUiCategoryIds { get; set; } = new();
public List<int> AllowedRarities { get; set; } = new();
public RangeFilter<int> Level { get; set; } = new() { Enabled = false, Min = 0, Max = 200 };
public RangeFilter<int> ItemLevel { get; set; } = new() { Enabled = false, Min = 0, Max = 2000 };
public RangeFilter<uint> VendorPrice { get; set; } = new() { Enabled = false, Min = 0, Max = 9_999_999 };
public StateFilter Untradable { get; set; } = new();
public StateFilter Unique { get; set; } = new();
public StateFilter Collectable { get; set; } = new();
public StateFilter Dyeable { get; set; } = new();
public StateFilter Repairable { get; set; } = new();
public StateFilter HighQuality { get; set; } = new();
public StateFilter Desynthesizable { get; set; } = new();
public StateFilter Glamourable { get; set; } = new();
public StateFilter FullySpiritbonded { get; set; } = new();
}
public class RangeFilter<T> where T : struct, IComparable<T>
{
public bool Enabled { get; set; }
public T Min { get; set; }
public T Max { get; set; }
}
public class StateFilter
{
public int State { get; set; } = 0;
public int Filter { get; set; } = 0;
[JsonIgnore]
public ToggleFilterState ToggleState
{
get => Enum.IsDefined(typeof(ToggleFilterState), State) ? (ToggleFilterState)State : ToggleFilterState.Ignored;
set => State = (int)value;
}
}
public enum ToggleFilterState
{
Ignored = 0,
Allow = 1,
Disallow = 2,
}
public enum PluginFilterMode
{
[Description("Create New Categories")]
Categorize = 0,
[Description("Apply Highlight Only")]
Highlight = 1,
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Numerics;
using System.Text.Json.Serialization;
using KamiToolKit.Classes;
namespace AetherBags.Configuration;
public class CurrencySettings
{
[JsonIgnore]
public const uint LimitedTomestoneId = 0xFFFF_FFFE;
[JsonIgnore]
public const uint NonLimitedTomestoneId = 0xFFFF_FFFD;
public bool Enabled { get; set; } = true;
public List<uint> DisplayedCurrencies { get; set; } = new() { 1, LimitedTomestoneId, NonLimitedTomestoneId };
public bool ColorWhenCapped { get; set; } = true;
public bool ColorWhenLimited { get; set; } = true;
public Vector4 DefaultColor { get; set; } = ColorHelper.GetColor(8);
public Vector4 CappedColor { get; set; } = ColorHelper.GetColor(43);
public Vector4 LimitColor { get; set; } = ColorHelper.GetColor(17);
}
@@ -0,0 +1,41 @@
using System.ComponentModel;
namespace AetherBags.Configuration;
public class GeneralSettings
{
public InventoryStackMode StackMode { get; set; } = InventoryStackMode.AggregateByItemId;
public SearchMode SearchMode { get; set; } = SearchMode.Highlight;
public bool DebugEnabled { get; set; } = false;
public bool CompactPackingEnabled { get; set; } = true;
public int CompactLookahead { get; set; } = 24;
public bool CompactPreferLargestFit { get; set; } = true;
public bool CompactStableInsert { get; set; } = true;
public bool OpenWithGameInventory { get; set; } = true;
public bool HideGameInventory { get; set; } = false;
public bool OpenSaddleBagsWithGameInventory { get; set; } = true;
public bool HideGameSaddleBags { get; set; } = false;
public bool OpenRetainerWithGameInventory { get; set; } = true;
public bool HideGameRetainer { get; set; } = false;
public bool ShowCategoryItemCount { get; set; } = false;
public bool LinkItemEnabled { get; set; } = false;
public bool UseUnifiedExternalCategories { get; set; } = false;
}
public enum InventoryStackMode : byte
{
[Description("Split Stacks (Game Default)")]
NaturalStacks = 0,
[Description("Merge Stacks (By Item ID)")]
AggregateByItemId = 1,
}
public enum SearchMode : byte
{
[Description("Filter (Hide non-matches)")]
Filter = 0,
[Description("Highlight (Dim non-matches)")]
Highlight = 1,
}
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Numerics;
namespace AetherBags.Configuration.Import;
public sealed class SortaKindaImportFile
{
public List<SortaKindaCategory> Rules { get; set; } = new();
public object? MainInventory { get; set; }
}
public sealed class SortaKindaCategory
{
public Vector4 Color { get; set; }
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int Index { get; set; }
public List<string> AllowedItemNames { get; set; } = new();
public List<AllowedNameRegexDto> AllowedNameRegexes { get; set; } = new();
// Common
public List<uint> AllowedItemTypes { get; set; } = new();
public List<int> AllowedItemRarities { get; set; } = new();
public ExternalRangeFilterDto<int>? LevelFilter { get; set; }
public ExternalRangeFilterDto<int> ItemLevelFilter { get; set; } = new();
public ExternalRangeFilterDto<uint> VendorPriceFilter { get; set; } = new();
public ExternalStateFilterDto? UntradableFilter { get; set; }
public ExternalStateFilterDto? UniqueFilter { get; set; }
public ExternalStateFilterDto? CollectableFilter { get; set; }
public ExternalStateFilterDto? DyeableFilter { get; set; }
public ExternalStateFilterDto? RepairableFilter { get; set; }
public int Direction { get; set; }
public int FillMode { get; set; }
public int SortMode { get; set; }
public bool InclusiveAnd { get; set; }
}
public sealed class AllowedNameRegexDto
{
public string Text { get; set; } = string.Empty;
}
public sealed class ExternalStateFilterDto
{
public int State { get; set; }
public int Filter { get; set; }
}
public sealed class ExternalRangeFilterDto<T> where T : struct
{
public bool Enable { get; set; }
public string Label { get; set; } = string.Empty;
public T MinValue { get; set; }
public T MaxValue { get; set; }
}
@@ -0,0 +1,39 @@
namespace AetherBags.Configuration;
public class SystemConfiguration
{
public const string FileName = "AetherBags.json";
private GeneralSettings _general = new();
private CategorySettings _categories = new();
private CurrencySettings _currency = new();
public GeneralSettings General
{
get => _general;
set => _general = value ?? new();
}
public CategorySettings Categories
{
get => _categories;
set => _categories = value ?? new();
}
public CurrencySettings Currency
{
get => _currency;
set => _currency = value ?? new();
}
/// <summary>
/// Ensures all nested config objects are initialized. Call after deserialization.
/// </summary>
public void EnsureInitialized()
{
_general ??= new();
_categories ??= new();
_currency ??= new();
_categories.UserCategories ??= new();
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace AetherBags.Currency;
public class CurrencyInfo
{
public required uint Amount { get; set; }
public required uint MaxAmount { get; set; }
public required uint ItemId { get; set; }
public required uint IconId { get; set; }
public required bool LimitReached { get; set; }
public required bool IsCapped { get; set; }
}
+188
View File
@@ -0,0 +1,188 @@
using FFXIVClientStructs.FFXIV.Client.Game;
using Lumina.Excel.Sheets;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace AetherBags.Currency;
/// <summary>
/// Manages currency lookups, caching, and retrieval from the game.
/// </summary>
public static unsafe class CurrencyState
{
private const uint CurrencyIdLimitedTomestone = 0xFFFF_FFFE;
private const uint CurrencyIdNonLimitedTomestone = 0xFFFF_FFFD;
private static readonly Dictionary<uint, CurrencyItem> CurrencyItemByCurrencyIdCache = new(capacity: 32);
private static readonly Dictionary<uint, CurrencyStaticInfo> CurrencyStaticByItemIdCache = new(capacity: 64);
private static readonly List<CurrencyInfo> CurrencyInfoScratch = new(capacity: 8);
private static uint? _cachedLimitedTomestoneItemId;
private static uint? _cachedNonLimitedTomestoneItemId;
public static void InvalidateCaches()
{
CurrencyItemByCurrencyIdCache.Clear();
CurrencyStaticByItemIdCache.Clear();
_cachedLimitedTomestoneItemId = null;
_cachedNonLimitedTomestoneItemId = null;
}
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(uint[] currencyIds)
=> GetCurrencyInfoListCore(currencyIds.AsSpan());
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(List<uint> currencyIds)
=> GetCurrencyInfoListCore(CollectionsMarshal.AsSpan(currencyIds));
private static IReadOnlyList<CurrencyInfo> GetCurrencyInfoListCore(ReadOnlySpan<uint> currencyIds)
{
if (currencyIds.Length == 0)
return Array.Empty<CurrencyInfo>();
InventoryManager* inventoryManager = InventoryManager.Instance();
if (inventoryManager == null)
return Array.Empty<CurrencyInfo>();
CurrencyInfoScratch.Clear();
for (int i = 0; i < currencyIds.Length; i++)
{
CurrencyItem currencyItem = ResolveCurrencyItemIdCached(currencyIds[i]);
if (currencyItem.ItemId == 0)
continue;
CurrencyStaticInfo staticInfo = GetCurrencyStaticInfoCached(currencyItem.ItemId);
uint amount = (uint)inventoryManager->GetInventoryItemCount(currencyItem.ItemId);
bool isCapped = false;
if (currencyItem.IsLimited)
{
int weeklyLimit = InventoryManager.GetLimitedTomestoneWeeklyLimit();
int weeklyAcquired = inventoryManager->GetWeeklyAcquiredTomestoneCount();
isCapped = weeklyAcquired >= weeklyLimit;
}
CurrencyInfoScratch.Add(new CurrencyInfo
{
Amount = amount,
MaxAmount = staticInfo.MaxAmount,
ItemId = staticInfo.ItemId,
IconId = staticInfo.IconId,
LimitReached = amount >= staticInfo.MaxAmount,
IsCapped = isCapped
});
}
return CurrencyInfoScratch;
}
public static (uint Limited, uint NonLimited) GetCurrentTomestoneIds()
{
var tomestonesItemSheet = Services.DataManager.GetExcelSheet<TomestonesItem>();
uint limitedId = 0;
uint nonLimitedId = 0;
foreach (var row in tomestonesItemSheet)
{
var tomeSheetRef = row.Tomestones.ValueNullable;
if (tomeSheetRef == null || tomeSheetRef.Value.RowId == 0) continue;
var itemId = row.Item.RowId;
if (itemId == 0 || itemId == 28) continue;
if (tomeSheetRef.Value.WeeklyLimit > 0)
limitedId = itemId;
else
nonLimitedId = itemId;
}
return (limitedId, nonLimitedId);
}
/*
private static uint? GetLimitedTomestoneItemIdCached()
{
if (_cachedLimitedTomestoneItemId.HasValue)
return _cachedLimitedTomestoneItemId.Value;
uint? itemId = Services.DataManager.GetExcelSheet<TomestonesItem>()
.FirstOrDefault(t => t.Tomestones.RowId == 3)
.Item.RowId;
_cachedLimitedTomestoneItemId = itemId;
return itemId;
}
private static uint? GetNonLimitedTomestoneItemIdCached()
{
if (_cachedNonLimitedTomestoneItemId.HasValue)
return _cachedNonLimitedTomestoneItemId.Value;
uint? itemId = Services.DataManager.GetExcelSheet<TomestonesItem>()
.FirstOrDefault(t => t.Tomestones.RowId == 2)
.Item.RowId;
_cachedNonLimitedTomestoneItemId = itemId;
return itemId;
}
*/
private static uint? GetLimitedTomestoneItemIdCached()
=> _cachedLimitedTomestoneItemId ??= GetCurrentTomestoneIds().Limited;
private static uint? GetNonLimitedTomestoneItemIdCached()
=> _cachedNonLimitedTomestoneItemId ??= GetCurrentTomestoneIds().NonLimited;
private static CurrencyItem ResolveCurrencyItemIdCached(uint currencyId)
{
if (CurrencyItemByCurrencyIdCache.TryGetValue(currencyId, out var cached))
return cached;
uint itemId = currencyId;
bool isLimited = false;
if (currencyId == CurrencyIdLimitedTomestone)
{
itemId = GetLimitedTomestoneItemIdCached() ?? 0;
isLimited = true;
}
else if (currencyId == CurrencyIdNonLimitedTomestone)
{
itemId = GetNonLimitedTomestoneItemIdCached() ?? 0;
}
var resolved = new CurrencyItem(itemId, isLimited);
CurrencyItemByCurrencyIdCache[currencyId] = resolved;
return resolved;
}
private static CurrencyStaticInfo GetCurrencyStaticInfoCached(uint itemId)
{
if (CurrencyStaticByItemIdCache.TryGetValue(itemId, out CurrencyStaticInfo cached))
return cached;
var item = Services.DataManager.GetExcelSheet<Item>().GetRow(itemId);
var info = new CurrencyStaticInfo
{
ItemId = itemId,
IconId = item.Icon,
MaxAmount = item.StackSize,
};
CurrencyStaticByItemIdCache[itemId] = info;
return info;
}
private struct CurrencyStaticInfo
{
public uint ItemId;
public uint IconId;
public uint MaxAmount;
}
private record CurrencyItem(uint ItemId, bool IsLimited);
}
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Linq;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace AetherBags.Extensions;
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<string, List<AddonEvent>> ActiveLoggers = [];
private static void Logger(AddonEvent type, AddonArgs args) {
switch (args) {
case AddonReceiveEventArgs receiveEventArgs:
Services.Logger.DebugOnly($"[{args.AddonName}] {(AtkEventType)receiveEventArgs.AtkEventType}: {receiveEventArgs.EventParam}");
break;
default:
Services.Logger.DebugOnly($"{args.AddonName} called {type.ToString().Replace("Post", string.Empty)}");
break;
}
}
}
@@ -0,0 +1,23 @@
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace AetherBags.Extensions;
public static unsafe class AgentInterfaceExtensions {
extension(ref AgentInterface agent)
{
public void SendCommand(uint eventKind, int[] commandValues)
{
using var returnValue = new AtkValue();
var command = stackalloc AtkValue[commandValues.Length];
for (var index = 0; index < commandValues.Length; index++)
{
command[index].SetInt(commandValues[index]);
}
agent.ReceiveEvent(&returnValue, command, (uint)commandValues.Length, eventKind);
}
}
}
@@ -0,0 +1,34 @@
using FFXIVClientStructs.FFXIV.Client.Enums;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace AetherBags.Extensions;
public static unsafe class AtkStageExtensions
{
extension(ref AtkStage stage)
{
public void ShowInventoryItemTooltip(AtkResNode* node, InventoryType container, short slot)
{
var tooltipArgs = stackalloc AtkTooltipManager.AtkTooltipArgs[1];
tooltipArgs->Ctor();
tooltipArgs->ItemArgs.Kind = DetailKind.InventoryItem;
tooltipArgs->ItemArgs.InventoryType = container;
tooltipArgs->ItemArgs.Slot = slot;
tooltipArgs->ItemArgs.BuyQuantity = -1;
tooltipArgs->ItemArgs.Flag1 = 0;
var addon = RaptureAtkUnitManager.Instance()->GetAddonByNode(node);
if (addon is null) return;
stage.TooltipManager.ShowTooltip(
AtkTooltipManager.AtkTooltipType.Item,
addon->Id,
node,
tooltipArgs
);
}
}
}
@@ -0,0 +1,73 @@
using AetherBags.Inventory;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
namespace AetherBags.Extensions;
public static class DragDropPayloadExtensions
{
extension(DragDropPayload payload)
{
public bool IsValidInventoryPayload =>
payload.Type is DragDropType.Inventory_Item
or DragDropType.Inventory_Crystal
or DragDropType.RemoteInventory_Item
or DragDropType.Item;
public bool IsSameBaseContainer(DragDropPayload otherPayload) {
if (payload.InventoryLocation.Container.IsSameContainerGroup(otherPayload.InventoryLocation.Container))
{
return true;
}
return false;
}
public InventoryLocation InventoryLocation
{
get
{
if (!payload.IsValidInventoryPayload) return default;
if (payload.Type == DragDropType.Inventory_Item)
{
return new InventoryLocation((InventoryType)payload.Int1, (ushort)payload.Int2);
}
int containerId = payload.Int1;
int uiSlot = payload.Int2;
InventoryType sourceContainer = InventoryType.GetInventoryTypeFromContainerId(containerId);
if (sourceContainer == 0)
return new InventoryLocation(0, 0);
// Retainers have special handling: UI has 5 tabs × 35 slots, data has 7 pages × 25 slots
if (sourceContainer.IsRetainer)
{
// Container IDs 52-56 = UI tabs 0-4
int uiTabIndex = containerId - 52;
// Convert to global data index
int globalDataIndex = (uiTabIndex * 35) + uiSlot;
// Calculate data page and slot
int dataPage = globalDataIndex / 25;
int dataSlot = globalDataIndex % 25;
InventoryType dataContainer = InventoryType.RetainerPage1 + (uint)dataPage;
// Now resolve through sorter for the actual storage location
var (realContainer, realSlot) = dataContainer.GetRealItemLocation(dataSlot);
return new InventoryLocation(realContainer, realSlot);
}
// For non-retainers, use the standard resolution
var (container, slot) = sourceContainer.GetRealItemLocation(uiSlot);
return new InventoryLocation(container, slot);
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.ComponentModel;
using System.Numerics;
using System.Runtime.CompilerServices;
using Dalamud.Utility;
namespace AetherBags.Extensions;
internal static class EnumExtensions {
extension(Enum enumValue) {
public string Description => enumValue.GetDescription();
private string GetDescription() {
var attribute = enumValue.GetAttribute<DescriptionAttribute>();
return attribute?.Description ?? enumValue.ToString();
}
}
extension<T>(ref T flagValue) where T : unmanaged, Enum {
public void SetFlags(params T[] flags) {
foreach (var flag in flags) {
flagValue.SetFlag(flag, true);
}
}
public void ClearFlags(params T[] flags) {
foreach (var flag in flags) {
flagValue.SetFlag(flag, false);
}
}
private unsafe void SetFlag(T flag, bool enable) {
switch (sizeof(T)) {
case 1: flagValue.SetFlag<T, byte>(flag, enable); break;
case 2: flagValue.SetFlag<T, ushort>(flag, enable); break;
case 4: flagValue.SetFlag<T, uint>(flag, enable); break;
case 8: flagValue.SetFlag<T, ulong>(flag, enable); break;
default: throw new NotSupportedException("Unsupported enum size");
}
}
private void SetFlag<TUnderlying>(T flag, bool enable) where TUnderlying : unmanaged, IBinaryInteger<TUnderlying> {
ref var value = ref Unsafe.As<T, TUnderlying>(ref flagValue);
var mask = Unsafe.As<T, TUnderlying>(ref flag);
if (enable)
value |= mask;
else
value &= ~mask;
}
}
}
@@ -0,0 +1,124 @@
using System.Text.RegularExpressions;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using Lumina.Excel.Sheets;
using Lumina.Text.ReadOnly;
namespace AetherBags.Extensions;
public static unsafe class InventoryItemExtensions {
extension(ref InventoryItem item) {
public uint IconId => item.GetIconId();
public ReadOnlySeString Name => item.GetItemName();
private uint GetIconId() {
uint iconId = 0;
if (item.GetEventItem() is { } eventItem) {
iconId = eventItem.Icon;
}
else if (item.GetItem() is { } regularItem) {
iconId = regularItem.Icon;
if (item.IsHighQuality()) {
iconId += 1_000_000;
}
}
return iconId;
}
private ReadOnlySeString GetItemName() {
var itemId = item.GetItemId();
var itemName = ItemUtil.GetItemName(itemId);
return new Lumina.Text.SeStringBuilder()
.PushColorType(ItemUtil.GetItemRarityColorType(itemId))
.Append(itemName)
.PopColorType()
.ToReadOnlySeString();
}
private Item? GetItem() {
var baseItemId = item.GetBaseItemId();
if (ItemUtil.IsNormalItem(baseItemId) &&
Services.DataManager.GetExcelSheet<Item>().TryGetRow(baseItemId, out var baseItem)) {
return baseItem;
}
return null;
}
private EventItem? GetEventItem() {
var baseItemId = item.GetBaseItemId();
if (ItemUtil.IsEventItem(baseItemId) &&
Services.DataManager.GetExcelSheet<EventItem>().TryGetRow(baseItemId, out var eventItem)) {
return eventItem;
}
return null;
}
public ItemOrderModuleSorterItemEntry* GetItemOrderData()
{
InventoryType type = item.GetInventoryType();
int slot = item.GetSlot();
return type.GetInventorySorter->Items[slot + type.GetInventoryStartIndex];
}
public bool IsRegexMatch(string searchString) {
// Skip any data access if string is empty
if (searchString.IsNullOrEmpty()) return true;
var isDescriptionSearch = searchString.StartsWith('$');
if (isDescriptionSearch) {
searchString = searchString[1..];
}
try {
var regex = new Regex(searchString,RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
if (ItemUtil.IsEventItem(item.GetBaseItemId())) {
if (!Services.DataManager.GetExcelSheet<EventItem>().TryGetRow(item.GetBaseItemId(), out var itemData)) return false;
if (regex.IsMatch(item.ItemId.ToString())) return true;
if (regex.IsMatch(itemData.Name.ToString())) return true;
}
else if (ItemUtil.IsNormalItem(item.GetBaseItemId())) {
if (!Services.DataManager.GetExcelSheet<Item>().TryGetRow(item.GetBaseItemId(), out var itemData)) return false;
if (regex.IsMatch(item.ItemId.ToString())) return true;
if (regex.IsMatch(itemData.Name.ToString())) return true;
if (regex.IsMatch(itemData.Description.ToString()) && isDescriptionSearch) return true;
if (regex.IsMatch(itemData.LevelEquip.ToString())) return true;
if (regex.IsMatch(itemData.LevelItem.RowId.ToString())) return true;
}
}
catch (RegexParseException) { }
return false;
}
public void UseItem()
{
uint itemId = item.ItemId;
InventoryType type = item.GetInventoryType() == InventoryType.KeyItems
? InventoryType.KeyItems
: InventoryType.Invalid;
if (InventoryManager.Instance()->GetInventoryItemCount(itemId, true) > 0)
itemId += 1_000_000;
if (!item.Container.IsMainInventory)
return;
AgentInventoryContext.Instance()->UseItem(itemId, type);
}
}
}
@@ -0,0 +1,218 @@
using AetherBags.Inventory;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using InventoryManager = FFXIVClientStructs.FFXIV.Client.Game.InventoryManager;
namespace AetherBags.Extensions;
public static unsafe class InventoryTypeExtensions
{
extension(InventoryType inventoryType)
{
public uint AgentItemContainerId =>
inventoryType switch
{
InventoryType.EquippedItems => 4,
InventoryType.KeyItems => 7,
InventoryType.Inventory1 => 48,
InventoryType.Inventory2 => 49,
InventoryType.Inventory3 => 50,
InventoryType.Inventory4 => 51,
// It's possible that these are actually UI IDs
InventoryType.RetainerPage1 => 52,
InventoryType.RetainerPage2 => 53,
InventoryType.RetainerPage3 => 54,
InventoryType.RetainerPage4 => 55,
InventoryType.RetainerPage5 => 56,
InventoryType.ArmoryMainHand => 57,
InventoryType.ArmoryHead => 58,
InventoryType.ArmoryBody => 59,
InventoryType.ArmoryHands => 60,
InventoryType.ArmoryLegs => 61,
InventoryType.ArmoryFeets => 62,
InventoryType.ArmoryOffHand => 63,
InventoryType.ArmoryEar => 64,
InventoryType.ArmoryNeck => 65,
InventoryType.ArmoryWrist => 66,
InventoryType.ArmoryRings => 67,
InventoryType.ArmorySoulCrystal => 68,
InventoryType.SaddleBag1 => 69,
InventoryType.SaddleBag2 => 70,
InventoryType.PremiumSaddleBag1 => 71,
InventoryType.PremiumSaddleBag2 => 72,
_ => 0
};
public static InventoryType GetInventoryTypeFromContainerId(int id) =>
id switch
{
4 => InventoryType.EquippedItems,
7 => InventoryType.KeyItems,
48 => InventoryType.Inventory1,
49 => InventoryType.Inventory2,
50 => InventoryType.Inventory3,
51 => InventoryType.Inventory4,
52 => InventoryType.RetainerPage1,
53 => InventoryType.RetainerPage2,
54 => InventoryType.RetainerPage3,
55 => InventoryType.RetainerPage4,
56 => InventoryType.RetainerPage5,
57 => InventoryType.ArmoryMainHand,
58 => InventoryType.ArmoryHead,
59 => InventoryType.ArmoryBody,
60 => InventoryType.ArmoryHands,
61 => InventoryType.ArmoryLegs,
62 => InventoryType.ArmoryFeets,
63 => InventoryType.ArmoryOffHand,
64 => InventoryType.ArmoryEar,
65 => InventoryType.ArmoryNeck,
66 => InventoryType.ArmoryWrist,
67 => InventoryType.ArmoryRings,
68 => InventoryType.ArmorySoulCrystal,
69 => InventoryType.SaddleBag1,
70 => InventoryType.SaddleBag2,
71 => InventoryType.PremiumSaddleBag1,
72 => InventoryType.PremiumSaddleBag2,
_ => (InventoryType)0
};
public ItemOrderModuleSorter* GetInventorySorter => inventoryType switch {
InventoryType.Inventory1 => ItemOrderModule.Instance()->InventorySorter,
InventoryType.Inventory2 => ItemOrderModule.Instance()->InventorySorter,
InventoryType.Inventory3 => ItemOrderModule.Instance()->InventorySorter,
InventoryType.Inventory4 => ItemOrderModule.Instance()->InventorySorter,
InventoryType.ArmoryMainHand => ItemOrderModule.Instance()->ArmouryMainHandSorter,
InventoryType.ArmoryOffHand => ItemOrderModule.Instance()->ArmouryOffHandSorter,
InventoryType.ArmoryHead => ItemOrderModule.Instance()->ArmouryHeadSorter,
InventoryType.ArmoryBody => ItemOrderModule.Instance()->ArmouryBodySorter,
InventoryType.ArmoryHands => ItemOrderModule.Instance()->ArmouryHandsSorter,
InventoryType.ArmoryLegs => ItemOrderModule.Instance()->ArmouryLegsSorter,
InventoryType.ArmoryFeets => ItemOrderModule.Instance()->ArmouryFeetSorter,
InventoryType.ArmoryEar => ItemOrderModule.Instance()->ArmouryEarsSorter,
InventoryType.ArmoryNeck => ItemOrderModule.Instance()->ArmouryNeckSorter,
InventoryType.ArmoryWrist => ItemOrderModule.Instance()->ArmouryWristsSorter,
InventoryType.ArmoryRings => ItemOrderModule.Instance()->ArmouryRingsSorter,
InventoryType.ArmorySoulCrystal => ItemOrderModule.Instance()->ArmourySoulCrystalSorter,
InventoryType.SaddleBag1 => ItemOrderModule.Instance()->SaddleBagSorter,
InventoryType.SaddleBag2 => ItemOrderModule.Instance()->SaddleBagSorter,
InventoryType.PremiumSaddleBag1 => ItemOrderModule.Instance()->PremiumSaddleBagSorter,
InventoryType.PremiumSaddleBag2 => ItemOrderModule.Instance()->PremiumSaddleBagSorter,
InventoryType.RetainerPage1 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
InventoryType.RetainerPage2 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
InventoryType.RetainerPage3 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
InventoryType.RetainerPage4 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
InventoryType.RetainerPage5 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
InventoryType.RetainerPage6 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
InventoryType.RetainerPage7 => ItemOrderModule.Instance()->GetActiveRetainerSorter(),
_ => null,
};
public int GetInventoryStartIndex => inventoryType switch {
InventoryType.Inventory2 => inventoryType.UIPageSize,
InventoryType.Inventory3 => inventoryType.UIPageSize * 2,
InventoryType.Inventory4 => inventoryType.UIPageSize * 3,
InventoryType.SaddleBag2 => inventoryType.UIPageSize,
InventoryType.PremiumSaddleBag2 => inventoryType.UIPageSize,
InventoryType.RetainerPage2 => inventoryType.UIPageSize,
InventoryType.RetainerPage3 => inventoryType.UIPageSize * 2,
InventoryType.RetainerPage4 => inventoryType.UIPageSize * 3,
InventoryType.RetainerPage5 => inventoryType.UIPageSize * 4,
InventoryType.RetainerPage6 => inventoryType.UIPageSize * 5,
InventoryType.RetainerPage7 => inventoryType.UIPageSize * 6,
_ => 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 bool IsRetainer => inventoryType is
InventoryType.RetainerPage1 or
InventoryType.RetainerPage2 or
InventoryType.RetainerPage3 or
InventoryType.RetainerPage4 or
InventoryType.RetainerPage5 or
InventoryType.RetainerPage6 or
InventoryType.RetainerPage7;
public int UIPageSize => inventoryType switch
{
_ when (inventoryType.IsMainInventory || inventoryType.IsRetainer) => 35,
_ when inventoryType.IsSaddleBag => 70,
_ when inventoryType.IsArmory => 50,
_ => 0,
};
public int ContainerGroup => inventoryType switch
{
_ when inventoryType.IsMainInventory => 1,
_ when inventoryType.IsSaddleBag => 2,
_ when inventoryType.IsArmory => 3,
_ when inventoryType.IsRetainer => 4,
_ => 0,
};
public bool IsLoaded => InventoryManager.Instance()->GetInventoryContainer(inventoryType)->IsLoaded;
public bool IsSameContainerGroup(InventoryType other)
=> inventoryType.ContainerGroup == other.ContainerGroup;
/// <summary>
/// Resolves the real container and slot for this inventory type using ItemOrderModule.
/// For sorted inventories, the visual slot differs from the actual storage slot.
/// </summary>
public InventoryLocation GetRealItemLocation(int visualSlot)
{
var sorter = inventoryType.GetInventorySorter;
if (sorter == null)
return new InventoryLocation(inventoryType, (ushort)visualSlot);
int startIndex = inventoryType.GetInventoryStartIndex;
int sorterIndex = startIndex + visualSlot;
if (sorterIndex < 0 || sorterIndex >= sorter->Items.LongCount)
return new InventoryLocation(inventoryType, (ushort)visualSlot);
var entry = sorter->Items[sorterIndex].Value;
if (entry == null)
return new InventoryLocation(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,
_ when inventoryType.IsRetainer => InventoryType.RetainerPage1,
_ => inventoryType,
};
InventoryType realContainer = baseType + entry->Page;
ushort realSlot = entry->Slot;
return new InventoryLocation(realContainer, realSlot);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Numerics;
using KamiToolKit.Classes;
using Lumina.Excel.Sheets;
namespace AetherBags.Extensions;
public static class ItemExtensions {
extension(Item item) {
public Vector4 RarityColor => item.Rarity switch {
7 => ColorHelper.GetColor(561),
4 => ColorHelper.GetColor(555),
3 => ColorHelper.GetColor(553),
2 => ColorHelper.GetColor(551),
1 => ColorHelper.GetColor(549),
_ => Vector4.One,
};
}
}
@@ -0,0 +1,26 @@
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
namespace AetherBags.Extensions;
public static unsafe class ItemOrderModuleSorterExtensions {
extension(ref ItemOrderModuleSorter sorter) {
public long GetSlotIndex(ItemOrderModuleSorterItemEntry* entry)
=> entry->Slot + sorter.ItemsPerPage * entry->Page;
public InventoryItem* GetInventoryItem(ItemOrderModuleSorterItemEntry* entry)
=> sorter.GetInventoryItem(sorter.GetSlotIndex(entry));
public InventoryItem* GetInventoryItem(long slotIndex) {
if (sorter.Items.LongCount <= slotIndex) return null;
var item = sorter.Items[slotIndex].Value;
if (item == null) return null;
var container = InventoryManager.Instance()->GetInventoryContainer(sorter.InventoryType + item->Page);
if (container == null) return null;
return container->GetInventorySlot(item->Slot);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using System.Diagnostics;
using Dalamud.Plugin.Services;
namespace AetherBags.Extensions;
public static class LoggerExtensions
{
extension(IPluginLog logger)
{
[Conditional("DEBUG")]
public void DebugOnly(string message)
{
if (System.Config?.General?.DebugEnabled == true)
{
logger.Debug(message);
}
}
[Conditional("DEBUG")]
public void DebugOnly(string message, params object[] args)
{
if (System.Config?.General?.DebugEnabled == true)
{
logger.Debug(message, args);
}
}
}
}
@@ -0,0 +1,12 @@
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit;
namespace AetherBags.Extensions;
public static unsafe class NodeBaseExtensions {
extension(NodeBase node) {
public void ShowInventoryItemTooltip(InventoryType container, short slot)
=> AtkStage.Instance()->ShowInventoryItemTooltip(node, container, slot);
}
}
+2
View File
@@ -0,0 +1,2 @@
global using KamiToolKit.Extensions;
global using AetherBags.Extensions;
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using Dalamud.Plugin;
namespace AetherBags.Helpers;
// Taken and adapted for StatusTimers using zips from https://github.com/Caraxi/SimpleHeels/blob/0a0fe3c02a0a2c5a7c96b3304952d5078cd338aa/Plugin.cs#L392
// Thanks Caraxi
public static class BackupHelper {
private const int MaxBackups = 10;
private const string Name = "AetherBags";
public static void DoConfigBackup(IDalamudPluginInterface pluginInterface) {
Services.Logger.DebugOnly("Backup configuration start.");
try {
var configDirectory = pluginInterface.ConfigDirectory;
if (!configDirectory.Exists) {
return;
}
var backupDir = Path.Join(configDirectory.Parent!.Parent!.FullName, "backups", Name);
var dir = new DirectoryInfo(backupDir);
if (!dir.Exists) {
dir.Create();
}
if (!dir.Exists) {
throw new Exception("Backup Directory does not exist");
}
var latestFile = new FileInfo(Path.Join(backupDir, $"{Name}.latest.zip"));
var tempFile = Path.Join(backupDir, $"{Name}.tmp.zip");
var needsBackup = false;
if (latestFile.Exists) {
string lastBackupHash = ZipJsonHash(latestFile.FullName);
string currentConfigDirHash = DirJsonHash(configDirectory.FullName);
if (currentConfigDirHash != lastBackupHash) {
needsBackup = true;
}
} else {
needsBackup = true;
}
if (!needsBackup) {
return;
}
ZipFile.CreateFromDirectory(configDirectory.FullName, tempFile);
if (latestFile.Exists) {
var t = latestFile.LastWriteTime;
string archiveName = $"{Name}.{t.Year}{t.Month:00}{t.Day:00}{t.Hour:00}{t.Minute:00}{t.Second:00}.zip";
string archivePath = Path.Join(backupDir, archiveName);
bool moved = false;
for (int i = 0; i < 5 && !moved; i++) {
try {
File.Move(latestFile.FullName, archivePath);
moved = true;
} catch (IOException ioEx) when (i < 4) {
Services.Logger.DebugOnly($"Move failed, retrying in 100ms: {ioEx.Message}");
global::System.Threading.Thread.Sleep(100);
}
}
if (!moved) {
throw new IOException($"Could not move {latestFile.FullName} after several retries.");
}
}
if (File.Exists(latestFile.FullName)) {
File.Delete(latestFile.FullName);
}
File.Move(tempFile, latestFile.FullName);
var allBackups = dir.GetFiles().Where(f => f.Name.StartsWith($"{Name}.2") && f.Name.EndsWith(".zip"))
.OrderBy(f => f.LastWriteTime.Ticks).ToList();
if (allBackups.Count > MaxBackups) {
Services.Logger.DebugOnly($"Removing Oldest Backup: {allBackups[0].FullName}");
File.Delete(allBackups[0].FullName);
}
} catch (Exception exception) {
Services.Logger.Warning(exception, "Backup Skipped");
}
}
private static string ComputeCombinedJsonHash(IEnumerable<(string name, byte[] contents)> files) {
using var sha256 = SHA256.Create();
foreach (var file in files.OrderBy(f => f.name, StringComparer.OrdinalIgnoreCase)) {
sha256.TransformBlock(file.contents, 0, file.contents.Length, null, 0);
}
sha256.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
return sha256.Hash != null ? BitConverter.ToString(sha256.Hash).Replace("-", "") : string.Empty;
}
private static string DirJsonHash(string dirPath) =>
ComputeCombinedJsonHash(
new DirectoryInfo(dirPath)
.GetFiles("*.json", SearchOption.TopDirectoryOnly)
.Where(f => !f.Name.EndsWith(".addon.json", StringComparison.OrdinalIgnoreCase))
.Select(f => (f.Name, File.ReadAllBytes(f.FullName)))
);
private static string ZipJsonHash(string zipPath) {
byte[] zipBytes = File.ReadAllBytes(zipPath);
using var msZip = new MemoryStream(zipBytes);
using var zip = new ZipArchive(msZip, ZipArchiveMode.Read);
var files = zip.Entries
.Where(e => e.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !e.FullName.EndsWith(".addon.json", StringComparison.OrdinalIgnoreCase)
&& !e.FullName.Contains("/"))
.Select(e => {
using var ms = new MemoryStream();
using (var s = e.Open()) {
s.CopyTo(ms);
}
return (e.FullName, ms.ToArray());
});
return ComputeCombinedJsonHash(files);
}
}
@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using AetherBags.Configuration;
using AetherBags.Configuration.Import;
namespace AetherBags.Helpers.Import;
public static class SortaKindaImportExport
{
private static readonly JsonSerializerOptions ExternalJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
IncludeFields = true
};
public static bool TryImportFromClipboard(
SystemConfiguration targetConfig,
bool replaceExisting,
out string error)
{
error = string.Empty;
string clipboard;
try
{
clipboard = Dalamud.Bindings.ImGui.ImGui.GetClipboardText();
}
catch (Exception ex)
{
error = $"Failed to read clipboard: {ex.Message}";
return false;
}
return TryImportFromJson(clipboard, targetConfig, replaceExisting, out error);
}
public static bool TryImportFromJson(
string input,
SystemConfiguration targetConfig,
bool replaceExisting,
out string error)
{
error = string.Empty;
if (string.IsNullOrWhiteSpace(input))
{
error = "Input was empty.";
return false;
}
string trimmed = input.Trim();
SortaKindaCategory[]? external = null;
SortaKindaImportFile? file = Util.DeserializeCompressed<SortaKindaImportFile>(trimmed, ExternalJsonOptions);
if (file?.Rules is { Count: > 0 })
{
external = file.Rules.ToArray();
}
else
{
external = Util.DeserializeCompressed<SortaKindaCategory[]>(trimmed, ExternalJsonOptions);
}
if (external is null)
{
error = "Failed to parse SortaKinda input.";
return false;
}
var mapped = external
.Select(MapToUserCategory)
.OrderBy(c => c.Order)
.ToList();
var dest = targetConfig.Categories.UserCategories;
if (replaceExisting)
{
dest.Clear();
dest.AddRange(mapped);
}
else
{
var byId = dest
.Where(c => !string.IsNullOrWhiteSpace(c.Id))
.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
foreach (var incoming in mapped)
{
if (!string.IsNullOrWhiteSpace(incoming.Id) && byId.TryGetValue(incoming.Id, out var existing))
{
existing.Name = incoming.Name;
existing.Description = incoming.Description;
existing.Order = incoming.Order;
existing.Priority = incoming.Priority;
existing.Color = incoming.Color;
existing.Rules = incoming.Rules;
}
else
{
dest.Add(incoming);
if (!string.IsNullOrWhiteSpace(incoming.Id))
byId[incoming.Id] = incoming;
}
}
}
targetConfig.Categories.UserCategoriesEnabled = true;
return true;
}
public static string ExportToJson(SystemConfiguration sourceConfig)
{
var exported = new SortaKindaImportFile
{
Rules = sourceConfig.Categories.UserCategories
.OrderBy(c => c.Priority)
.Select(MapToExternal)
.ToList(),
// MainInventory = new { InventoryConfigs = new[] { new { } } }
};
return Util.SerializeCompressed(exported, ExternalJsonOptions);
}
public static void ExportToClipboard(SystemConfiguration sourceConfig)
=> Dalamud.Bindings.ImGui.ImGui.SetClipboardText(ExportToJson(sourceConfig));
private static UserCategoryDefinition MapToUserCategory(SortaKindaCategory external)
=> new()
{
Id = string.IsNullOrWhiteSpace(external.Id) ? Guid.NewGuid().ToString("N") : external.Id,
Name = external.Name,
Description = string.Empty,
Order = external.Index,
Priority = external.Index,
Color = external.Color,
Rules = new CategoryRuleSet
{
AllowedItemIds = new List<uint>(),
AllowedItemNamePatterns =
(external.AllowedItemNames ?? new List<string>())
.Concat((external.AllowedNameRegexes ?? new List<AllowedNameRegexDto>())
.Select(r => r.Text)
.Where(t => !string.IsNullOrWhiteSpace(t)))
.ToList(),
AllowedUiCategoryIds = external.AllowedItemTypes?.ToList() ?? new List<uint>(),
AllowedRarities = external.AllowedItemRarities?.ToList() ?? new List<int>(),
Level = new RangeFilter<int>
{
Enabled = external.LevelFilter?.Enable ?? false,
Min = external.LevelFilter?.MinValue ?? 0,
Max = external.LevelFilter?.MaxValue ?? 200,
},
ItemLevel = new RangeFilter<int>
{
Enabled = external.ItemLevelFilter?.Enable ?? false,
Min = external.ItemLevelFilter?.MinValue ?? 0,
Max = external.ItemLevelFilter?.MaxValue ?? 2000,
},
VendorPrice = new RangeFilter<uint>
{
Enabled = external.VendorPriceFilter?.Enable ?? false,
Min = external.VendorPriceFilter?.MinValue ?? 0u,
Max = external.VendorPriceFilter?.MaxValue ?? 9_999_999u,
},
Untradable = new StateFilter { State = external.UntradableFilter?.State ?? 0, Filter = external.UntradableFilter?.Filter ?? 0 },
Unique = new StateFilter { State = external.UniqueFilter?.State ?? 0, Filter = external.UniqueFilter?.Filter ?? 0 },
Collectable= new StateFilter { State = external.CollectableFilter?.State ?? 0,Filter = external.CollectableFilter?.Filter ?? 0 },
Dyeable = new StateFilter { State = external.DyeableFilter?.State ?? 0, Filter = external.DyeableFilter?.Filter ?? 0 },
Repairable = new StateFilter { State = external.RepairableFilter?.State ?? 0, Filter = external.RepairableFilter?.Filter ?? 0 },
}
};
private static SortaKindaCategory MapToExternal(UserCategoryDefinition internalCat)
=> new()
{
Color = internalCat.Color,
Id = internalCat.Id,
Name = internalCat.Name,
Index = internalCat.Priority,
AllowedItemNames = new List<string>(),
AllowedNameRegexes =
(internalCat.Rules.AllowedItemNamePatterns ?? new List<string>())
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => new AllowedNameRegexDto { Text = s })
.ToList(),
AllowedItemTypes = internalCat.Rules.AllowedUiCategoryIds?.ToList() ?? new List<uint>(),
AllowedItemRarities = internalCat.Rules.AllowedRarities?.ToList() ?? new List<int>(),
LevelFilter = new ExternalRangeFilterDto<int>
{
Enable = internalCat.Rules.Level.Enabled,
Label = "Level Filter",
MinValue = internalCat.Rules.Level.Min,
MaxValue = internalCat.Rules.Level.Max
},
ItemLevelFilter = new ExternalRangeFilterDto<int>
{
Enable = internalCat.Rules.ItemLevel.Enabled,
Label = "Item Level Filter",
MinValue = internalCat.Rules.ItemLevel.Min,
MaxValue = internalCat.Rules.ItemLevel.Max
},
VendorPriceFilter = new ExternalRangeFilterDto<uint>
{
Enable = internalCat.Rules.VendorPrice.Enabled,
Label = "Vendor Price Filter",
MinValue = internalCat.Rules.VendorPrice.Min,
MaxValue = internalCat.Rules.VendorPrice.Max
},
UntradableFilter = new ExternalStateFilterDto { State = internalCat.Rules.Untradable.State, Filter = internalCat.Rules.Untradable.Filter },
UniqueFilter = new ExternalStateFilterDto { State = internalCat.Rules.Unique.State, Filter = internalCat.Rules.Unique.Filter },
CollectableFilter= new ExternalStateFilterDto { State = internalCat.Rules.Collectable.State,Filter = internalCat.Rules.Collectable.Filter },
DyeableFilter = new ExternalStateFilterDto { State = internalCat.Rules.Dyeable.State, Filter = internalCat.Rules.Dyeable.Filter },
RepairableFilter = new ExternalStateFilterDto { State = internalCat.Rules.Repairable.State, Filter = internalCat.Rules.Repairable.Filter },
Direction = 0,
FillMode = 0,
SortMode = 0,
InclusiveAnd = false,
};
}
@@ -0,0 +1,89 @@
using AetherBags.Configuration;
using AetherBags.Helpers.Import;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.ImGuiNotification;
namespace AetherBags.Helpers;
public abstract class ImportExportResetHelper {
public static void TryImportConfigFromClipboard()
{
var clipboard = ImGui.GetClipboardText();
var notification = new Notification { Content = "Configuration imported from clipboard.", Type = NotificationType.Success };
if (!string.IsNullOrWhiteSpace(clipboard))
{
var imported = Util.DeserializeConfig(clipboard);
if (imported != null)
{
System.Config = imported;
Util.SaveConfig(System.Config);
Services.Logger.Info("Configuration imported from clipboard.");
}
else
{
notification.Content = "Clipboard data was invalid or could not be imported.";
notification.Type = NotificationType.Error;
Services.Logger.Warning("Clipboard data was invalid or could not be imported.");
}
}
else
{
notification.Content = "Clipboard is empty or invalid for import.";
notification.Type = NotificationType.Warning;
Services.Logger.Warning("Clipboard is empty or invalid for import.");
}
Services.NotificationManager.AddNotification(notification);
}
public static void TryExportConfigToClipboard(
SystemConfiguration config)
{
var exportString = Util.SerializeConfig(config);
ImGui.SetClipboardText(exportString);
Services.NotificationManager.AddNotification(
new Notification { Content = "Configuration exported to clipboard.", Type = NotificationType.Success }
);
Services.Logger.Info("Configuration exported to clipboard.");
}
public static void TryResetConfig()
{
System.Config = Util.ResetConfig();
Util.SaveConfig(System.Config);
Services.NotificationManager.AddNotification(
new Notification { Content = "Configuration reset to default.", Type = NotificationType.Success }
);
Services.Logger.Info("Configuration reset to default.");
}
public static void TryImportSortaKindaFromClipboard(bool replaceExisting)
{
var notification = new Notification { Content = "SortaKinda categories imported.", Type = NotificationType.Success };
if (!SortaKindaImportExport.TryImportFromClipboard(System.Config, replaceExisting, out var error))
{
notification.Content = error;
notification.Type = NotificationType.Error;
Services.Logger.Warning(error);
}
else
{
Util.SaveConfig(System.Config);
Services.Logger.Info("SortaKinda categories imported from clipboard.");
}
Services.NotificationManager.AddNotification(notification);
}
public static void TryExportSortaKindaToClipboard()
{
SortaKindaImportExport.ExportToClipboard(System.Config);
Services.NotificationManager.AddNotification(
new Notification { Content = "SortaKinda JSON exported to clipboard.", Type = NotificationType.Success }
);
Services.Logger.Info("SortaKinda JSON exported to clipboard.");
}
}
+50
View File
@@ -0,0 +1,50 @@
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
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)
{
Services.Logger.DebugOnly($"[MoveItem] {sourceContainer}@{sourceSlot} -> {destContainer}@{destSlot}");
InventoryManager.Instance()->MoveItemSlot(sourceContainer, sourceSlot, destContainer, destSlot, true);
Services.Framework.DelayTicks(3);
Services.Framework.RunOnFrameworkThread(System.AddonInventoryWindow.ManualRefresh);
}
public static void HandleItemMovePayload(DragDropPayload source, DragDropPayload target)
{
uint srcContainer = (uint)source.Int1;
uint dstContainer = (uint)target.Int1;
uint srcSlot = (uint)source.Int2;
uint dstSlot = (uint)target.Int2;
short srcRi = source.ReferenceIndex;
short dstRi = target.ReferenceIndex;
if (srcContainer == 0 || dstContainer == 0) return;
Services.Logger.DebugOnly($"[MoveItemViaAgent] {srcContainer}:{srcSlot}:{srcRi} -> {dstContainer}:{dstSlot}:{dstRi}");
var atkValues = stackalloc AtkValue[4];
for (var i = 0; i < 4; i++)
{
atkValues[i].Type = ValueType.UInt;
}
atkValues[0].UInt = srcContainer;
atkValues[1].UInt = srcSlot;
atkValues[2].UInt = dstContainer;
atkValues[3].UInt = dstSlot;
var retVal = stackalloc AtkValue[1];
RaptureAtkModule* atkModule = RaptureAtkModule.Instance();
atkModule->HandleItemMove(retVal, atkValues, 4);
}
}
+70
View File
@@ -0,0 +1,70 @@
using System;
using System.IO;
using System.Text.Json;
using Dalamud.Utility;
namespace AetherBags.Helpers;
public static class JsonFileHelper {
private static readonly JsonSerializerOptions SerializerOptions = new() {
WriteIndented = true,
IncludeFields = true,
};
public static T LoadFile<T>(string filePath) where T : new() {
var fileInfo = new FileInfo(filePath);
if (fileInfo is { Exists: true }) {
try {
var fileText = File.ReadAllText(fileInfo.FullName);
var dataObject = JsonSerializer.Deserialize<T>(fileText, SerializerOptions);
// If deserialize result is null, create a new instance instead and save it.
if (dataObject is null) {
dataObject = new T();
SaveFile(dataObject, filePath);
}
return dataObject;
}
catch (Exception e) {
// If there is any kind of error loading the file, generate a new one instead and save it.
Services.Logger.Error(e, $"Error trying to load file {filePath}, creating a new one instead.");
SaveFile(new T(), filePath);
}
}
var newFile = new T();
SaveFile(newFile, filePath);
return newFile;
}
public static void SaveFile<T>(T? file, string filePath) {
try {
if (file is null) {
Services.Logger.Error("Null file provided.");
return;
}
var fileText = JsonSerializer.Serialize(file, file.GetType(), SerializerOptions);
FilesystemUtil.WriteAllTextSafe(filePath, fileText);
}
catch (Exception e) {
Services.Logger.Error(e, $"Error trying to save file {filePath}");
}
}
public static FileInfo GetFileInfo(params string[] path) {
var directory = Services.PluginInterface.ConfigDirectory;
for (var index = 0; index < path.Length - 1; index++) {
directory = new DirectoryInfo(Path.Combine(directory.FullName, path[index]));
if (!directory.Exists) {
directory.Create();
}
}
return new FileInfo(Path.Combine(directory.FullName, path[^1]));
}
}
+47
View File
@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace AetherBags.Helpers;
/// <summary>
/// Thread-safe cache for compiled Regex objects to avoid repeated compilation overhead.
/// </summary>
internal static class RegexCache
{
private const int MaxCacheSize = 128;
private static readonly ConcurrentDictionary<string, Regex> Cache = new();
/// <summary>
/// Gets or creates a compiled Regex for the given pattern with case-insensitive matching.
/// Returns null if the pattern is invalid.
/// </summary>
public static Regex? GetOrCreate(string pattern)
{
if (string.IsNullOrEmpty(pattern))
return null;
if (Cache.TryGetValue(pattern, out var cached))
return cached;
try
{
var regex = new Regex(pattern, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled);
if (Cache.Count < MaxCacheSize)
{
Cache.TryAdd(pattern, regex);
}
return regex;
}
catch
{
return null;
}
}
/// <summary>
/// Clears the regex cache. Call when configuration changes significantly.
/// </summary>
public static void Clear() => Cache.Clear();
}
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using AetherBags.Configuration;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace AetherBags.Helpers;
public static class Util
{
private static readonly JsonSerializerOptions ConfigJsonOptions = new()
{
WriteIndented = true,
IncludeFields = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string SerializeUIntSet(HashSet<uint> set)
=> string.Join(",", set.OrderBy(x => x));
public static HashSet<uint> DeserializeUIntSet(string data)
=> data
.Split([','], StringSplitOptions.RemoveEmptyEntries)
.Select(s => uint.TryParse(s, out var val) ? val : (uint?)null)
.Where(v => v.HasValue)
.Select(v => v!.Value)
.ToHashSet();
private static string CompressToBase64(string str)
=> Convert.ToBase64String(Dalamud.Utility.Util.CompressString(str));
private static string DecompressFromBase64(string base64)
=> Dalamud.Utility.Util.DecompressString(Convert.FromBase64String(base64));
public static string SerializeHashSet(HashSet<uint> hashSet)
=> CompressToBase64(SerializeUIntSet(hashSet));
public static HashSet<uint> DeserializeHashSet(string input)
{
try
{
return DeserializeUIntSet(DecompressFromBase64(input));
}
catch
{
return new HashSet<uint>();
}
}
public static string SerializeCompressed<T>(T value, JsonSerializerOptions? options = null)
{
var json = JsonSerializer.Serialize(value, options ?? ConfigJsonOptions);
return CompressToBase64(json);
}
public static T? DeserializeCompressed<T>(string input, JsonSerializerOptions? options = null)
{
try
{
var json = DecompressFromBase64(input);
return JsonSerializer.Deserialize<T>(json, options ?? ConfigJsonOptions);
}
catch
{
return default;
}
}
public static string SerializeConfig(SystemConfiguration config)
=> SerializeCompressed(config, ConfigJsonOptions);
public static SystemConfiguration? DeserializeConfig(string input)
=> DeserializeCompressed<SystemConfiguration>(input, ConfigJsonOptions);
public static void SaveConfig(SystemConfiguration config)
{
FileInfo file = JsonFileHelper.GetFileInfo(SystemConfiguration.FileName);
JsonFileHelper.SaveFile(config, file.FullName);
}
private static SystemConfiguration LoadConfig()
{
FileInfo file = JsonFileHelper.GetFileInfo(SystemConfiguration.FileName);
var config = JsonFileHelper.LoadFile<SystemConfiguration>(file.FullName);
config.EnsureInitialized();
return config;
}
public static SystemConfiguration LoadConfigOrDefault()
{
var config = LoadConfig() ?? new SystemConfiguration();
config.EnsureInitialized();
return config;
}
public static SystemConfiguration ResetConfig()
=> new SystemConfiguration();
}
+140
View File
@@ -0,0 +1,140 @@
using System;
using Dalamud.Hooking;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace AetherBags.Hooks;
/// <summary>
/// Manages hooks related to inventory operations.
/// </summary>
public sealed unsafe class InventoryHooks : IDisposable
{
private delegate int MoveItemSlotDelegate(
InventoryManager* inventoryManager,
InventoryType srcContainer,
ushort srcSlot,
InventoryType dstContainer,
ushort dstSlot,
bool unk);
private delegate void HandleInventoryEventDelegate(AgentInterface* eventInterface, AtkValue* atkValue, int valueCount);
private readonly Hook<MoveItemSlotDelegate>? _moveItemSlotHook;
/*
private readonly Hook<UIModule.Delegates.OpenInventory>? _openInventoryHook;
private readonly Hook<HandleInventoryEventDelegate>? _handleInventoryEventHook;
private readonly Hook<RaptureAtkModule.Delegates.OpenAddon>? _openAddonHook;
*/
public InventoryHooks()
{
try
{
_moveItemSlotHook = Services.GameInteropProvider.HookFromSignature<MoveItemSlotDelegate>(
"E8 ?? ?? ?? ?? 48 8B 03 66 FF C5",
MoveItemSlotDetour);
_moveItemSlotHook.Enable();
Services.Logger.DebugOnly("MoveItemSlot hooked successfully.");
}
catch (Exception e)
{
Services.Logger.Error(e, "Failed to hook MoveItemSlot");
}
/*
try
{
_openInventoryHook = Services.GameInteropProvider.HookFromAddress<UIModule.Delegates.OpenInventory>(
UIModule.Instance()->VirtualTable->OpenInventory,
OpenInventoryDetour);
_openInventoryHook.Enable();
Services.Logger.DebugOnly("OpenInventory hooked successfully.");
}
catch (Exception e)
{
Services.Logger.Error(e, "Failed to hook OpenInventory");
}
try
{
_handleInventoryEventHook = Services.GameInteropProvider.HookFromSignature<HandleInventoryEventDelegate>(
"E8 ?? ?? ?? ?? 48 8B 74 24 ?? 33 C0 ?? ?? 89 43",
HandleInventoryEventDetour);
_handleInventoryEventHook.Enable();
Services.Logger.DebugOnly("HandleInventoryEvent hooked successfully.");
}
catch (Exception e)
{
Services.Logger.Error(e, "Failed to hook HandleInventoryEvent");
}
try
{
_openAddonHook = Services.GameInteropProvider.HookFromAddress<RaptureAtkModule.Delegates.OpenAddon>(
RaptureAtkModule.MemberFunctionPointers.OpenAddon,
OpenAddonDetour);
_openAddonHook.Enable();
Services.Logger.DebugOnly("OpenAddon hooked successfully.");
}
catch (Exception e)
{
Services.Logger.Error(e, "Failed to hook MoveItemSlot");
}
*/
}
private int MoveItemSlotDetour(InventoryManager* manager,
InventoryType srcType,
ushort srcSlot,
InventoryType dstType,
ushort dstSlot,
bool unk)
{
//InventoryItem* sourceItem = InventoryManager.Instance()->GetInventorySlot(srcType, srcSlot);
//InventoryItem* destItem = InventoryManager.Instance()->GetInventorySlot(dstType, dstSlot);
Services.Logger.DebugOnly($"[MoveItemSlot Hook] Moving {srcType}@{srcSlot} -> {dstType}@{dstSlot} I Unk: {unk}");
//Services.Logger.DebugOnly($"[MoveItemSlot Hook] Moving {srcType}@{srcSlot} ID:{sourceItem->ItemId} -> {dstType}@{dstSlot} ID:{destItem->ItemId} Unk: {unk}");
return _moveItemSlotHook!.Original(manager, srcType, srcSlot, dstType, dstSlot, unk);
}
/*
private void OpenInventoryDetour(UIModule* uiModule, byte type)
{
Services.Logger.DebugOnly($"[OpenInventory Hook] Opening inventory of type {type}");
_openInventoryHook?.Original(uiModule, type);
}
private void HandleInventoryEventDetour(AgentInterface* eventInterface, AtkValue* atkValue, int valueCount)
{
for(int i = 0; i < valueCount; i++)
{
Services.Logger.DebugOnly($"[HandleInventoryEvent Hook] AtkValue[{i}]: Type={atkValue[i].Type}, ToString: {atkValue[i].ToString()} ");
}
_handleInventoryEventHook?.Original(eventInterface, atkValue, valueCount);
}
private ushort OpenAddonDetour(RaptureAtkModule* thisPtr, uint addonNameId, uint valueCount, AtkValue* values, AtkModuleInterface.AtkEventInterface* eventInterface, ulong eventKind, ushort parentAddonId, int depthLayer)
{
for(int i = 0; i < valueCount; i++)
{
Services.Logger.DebugOnly($"[OpenAddon Hook] AtkValue[{i}]: ToString: {values[i].ToString()} ");
}
return _openAddonHook!.Original(thisPtr, addonNameId, valueCount, values, eventInterface, eventKind, parentAddonId, depthLayer);
}
*/
public void Dispose()
{
_moveItemSlotHook?.Dispose();
/*
_openInventoryHook?.Dispose();
_handleInventoryEventHook?.Dispose();
_openAddonHook?.Dispose();
*/
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AetherBags.IPC.ExternalCategorySystem;
namespace AetherBags.IPC.AetherBagsAPI;
public class AetherBagsAPIImpl : IAetherBagsAPI
{
public event Action<uint>? OnItemHovered;
public event Action<uint>? OnItemUnhovered;
public event Action<uint>? OnItemClicked;
public event Action<string>? OnSearchChanged;
public event Action? OnInventoryOpened;
public event Action? OnInventoryClosed;
public event Action? OnCategoriesRefreshed;
public bool IsInventoryOpen => System.AddonInventoryWindow?.IsOpen ?? false;
public IReadOnlyList<uint> GetVisibleItemIds()
{
var window = System.AddonInventoryWindow;
if (window == null || !window.IsOpen) return Array.Empty<uint>();
var categories = window.GetVisibleCategories();
if (categories == null) return Array.Empty<uint>();
var result = new List<uint>();
foreach (var category in categories)
{
foreach (var item in category.Items)
{
result.Add(item.Item.ItemId);
}
}
return result;
}
public IReadOnlyList<uint> GetItemsInCategory(uint categoryKey)
{
var window = System.AddonInventoryWindow;
if (window == null || !window.IsOpen) return Array.Empty<uint>();
var categories = window.GetVisibleCategories();
if (categories == null) return Array.Empty<uint>();
var category = categories.FirstOrDefault(c => c.Key == categoryKey);
if (category.Items == null) return Array.Empty<uint>();
return category.Items.Select(i => i.Item.ItemId).ToList();
}
public bool IsItemVisible(uint itemId)
{
var window = System.AddonInventoryWindow;
if (window == null || !window.IsOpen) return false;
var categories = window.GetVisibleCategories();
if (categories == null) return false;
foreach (var category in categories)
{
if (category.Items.Any(i => i.Item.ItemId == itemId))
return true;
}
return false;
}
public string GetCurrentSearchFilter()
{
return System.AddonInventoryWindow?.GetSearchText() ?? string.Empty;
}
public void RegisterSource(IExternalItemSource source)
{
ExternalCategoryManager.RegisterSource(source);
}
public void UnregisterSource(string sourceName)
{
ExternalCategoryManager.UnregisterSource(sourceName);
}
public IReadOnlyList<string> GetRegisteredSourceNames()
{
return ExternalCategoryManager.RegisteredSources.Select(s => s.SourceName).ToList();
}
public void RaiseItemHovered(uint itemId) => OnItemHovered?.Invoke(itemId);
public void RaiseItemUnhovered(uint itemId) => OnItemUnhovered?.Invoke(itemId);
public void RaiseItemClicked(uint itemId) => OnItemClicked?.Invoke(itemId);
public void RaiseSearchChanged(string search) => OnSearchChanged?.Invoke(search);
public void RaiseInventoryOpened() => OnInventoryOpened?.Invoke();
public void RaiseInventoryClosed() => OnInventoryClosed?.Invoke();
public void RaiseCategoriesRefreshed() => OnCategoriesRefreshed?.Invoke();
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using Dalamud.Plugin.Ipc;
namespace AetherBags.IPC.AetherBagsAPI;
public class AetherBagsIPCProvider : IDisposable
{
private const string IpcPrefix = "AetherBags.";
private readonly AetherBagsAPIImpl _api;
private readonly ICallGateProvider<bool> _isInventoryOpen;
private readonly ICallGateProvider<List<uint>> _getVisibleItemIds;
private readonly ICallGateProvider<uint, List<uint>> _getItemsInCategory;
private readonly ICallGateProvider<uint, bool> _isItemVisible;
private readonly ICallGateProvider<string> _getSearchFilter;
private readonly ICallGateProvider<List<string>> _getRegisteredSources;
private readonly ICallGateProvider<uint, bool> _onItemHovered;
private readonly ICallGateProvider<uint, bool> _onItemUnhovered;
private readonly ICallGateProvider<uint, bool> _onItemClicked;
private readonly ICallGateProvider<string, bool> _onSearchChanged;
private readonly ICallGateProvider<bool> _onInventoryOpened;
private readonly ICallGateProvider<bool> _onInventoryClosed;
private readonly ICallGateProvider<bool> _onCategoriesRefreshed;
public AetherBagsAPIImpl API => _api;
public AetherBagsIPCProvider()
{
_api = new AetherBagsAPIImpl();
_isInventoryOpen = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}IsInventoryOpen");
_getVisibleItemIds = Services.PluginInterface.GetIpcProvider<List<uint>>($"{IpcPrefix}GetVisibleItemIds");
_getItemsInCategory = Services.PluginInterface.GetIpcProvider<uint, List<uint>>($"{IpcPrefix}GetItemsInCategory");
_isItemVisible = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}IsItemVisible");
_getSearchFilter = Services.PluginInterface.GetIpcProvider<string>($"{IpcPrefix}GetSearchFilter");
_getRegisteredSources = Services.PluginInterface.GetIpcProvider<List<string>>($"{IpcPrefix}GetRegisteredSources");
_onItemHovered = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}OnItemHovered");
_onItemUnhovered = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}OnItemUnhovered");
_onItemClicked = Services.PluginInterface.GetIpcProvider<uint, bool>($"{IpcPrefix}OnItemClicked");
_onSearchChanged = Services.PluginInterface.GetIpcProvider<string, bool>($"{IpcPrefix}OnSearchChanged");
_onInventoryOpened = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}OnInventoryOpened");
_onInventoryClosed = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}OnInventoryClosed");
_onCategoriesRefreshed = Services.PluginInterface.GetIpcProvider<bool>($"{IpcPrefix}OnCategoriesRefreshed");
RegisterFunctions();
SubscribeEvents();
}
private void RegisterFunctions()
{
_isInventoryOpen.RegisterFunc(() => _api.IsInventoryOpen);
_getVisibleItemIds.RegisterFunc(() => new List<uint>(_api.GetVisibleItemIds()));
_getItemsInCategory.RegisterFunc(key => new List<uint>(_api.GetItemsInCategory(key)));
_isItemVisible.RegisterFunc(itemId => _api.IsItemVisible(itemId));
_getSearchFilter.RegisterFunc(() => _api.GetCurrentSearchFilter());
_getRegisteredSources.RegisterFunc(() => new List<string>(_api.GetRegisteredSourceNames()));
}
private void SubscribeEvents()
{
_api.OnItemHovered += itemId => _onItemHovered.SendMessage(itemId);
_api.OnItemUnhovered += itemId => _onItemUnhovered.SendMessage(itemId);
_api.OnItemClicked += itemId => _onItemClicked.SendMessage(itemId);
_api.OnSearchChanged += search => _onSearchChanged.SendMessage(search);
_api.OnInventoryOpened += () => _onInventoryOpened.SendMessage();
_api.OnInventoryClosed += () => _onInventoryClosed.SendMessage();
_api.OnCategoriesRefreshed += () => _onCategoriesRefreshed.SendMessage();
}
public void Dispose()
{
_isInventoryOpen.UnregisterFunc();
_getVisibleItemIds.UnregisterFunc();
_getItemsInCategory.UnregisterFunc();
_isItemVisible.UnregisterFunc();
_getSearchFilter.UnregisterFunc();
_getRegisteredSources.UnregisterFunc();
}
}
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using AetherBags.IPC.ExternalCategorySystem;
namespace AetherBags.IPC.AetherBagsAPI;
public interface IAetherBagsAPI
{
IReadOnlyList<uint> GetVisibleItemIds();
IReadOnlyList<uint> GetItemsInCategory(uint categoryKey);
bool IsItemVisible(uint itemId);
string GetCurrentSearchFilter();
bool IsInventoryOpen { get; }
event Action<uint>? OnItemHovered;
event Action<uint>? OnItemUnhovered;
event Action<uint>? OnItemClicked;
event Action<string>? OnSearchChanged;
event Action? OnInventoryOpened;
event Action? OnInventoryClosed;
event Action? OnCategoriesRefreshed;
void RegisterSource(IExternalItemSource source);
void UnregisterSource(string sourceName);
IReadOnlyList<string> GetRegisteredSourceNames();
}
+310
View File
@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context;
using AetherBags.IPC.ExternalCategorySystem;
using Dalamud.Plugin.Ipc;
using KamiToolKit.Classes;
namespace AetherBags.IPC;
public class AllaganToolsIPC : IDisposable
{
private ICallGateSubscriber<bool>? _isInitialized;
private ICallGateSubscriber<bool, bool>? _initialized;
private ICallGateSubscriber<string, Dictionary<uint, uint>>? _getFilterItems;
private ICallGateSubscriber<Dictionary<string, string>>? _getSearchFilters;
private ICallGateSubscriber<string, bool>? _enableUiFilter;
private ICallGateSubscriber<string, bool>? _toggleUiFilter;
public bool IsReady { get; private set; }
/// <summary>
/// Cached filter items. Key = filterKey, Value = (ItemId -> Quantity).
/// </summary>
public Dictionary<string, Dictionary<uint, uint>> CachedFilterItems { get; } = new();
/// <summary>
/// Cached search filters. Key -> Name.
/// </summary>
public Dictionary<string, string> CachedSearchFilters { get; } = new();
/// <summary>
/// Quick lookup: ItemId -> List of filter keys that contain this item.
/// </summary>
public Dictionary<uint, List<string>> ItemToFilters { get; } = new();
public event Action? OnInitialized;
public event Action? OnFiltersRefreshed;
public AllaganToolsIPC()
{
try
{
_isInitialized = Services.PluginInterface.GetIpcSubscriber<bool>("AllaganTools.IsInitialized");
_initialized = Services.PluginInterface.GetIpcSubscriber<bool, bool>("AllaganTools.Initialized");
_getFilterItems = Services.PluginInterface.GetIpcSubscriber<string, Dictionary<uint, uint>>("AllaganTools.GetFilterItems");
_getSearchFilters = Services.PluginInterface.GetIpcSubscriber<Dictionary<string, string>>("AllaganTools.GetSearchFilters");
_enableUiFilter = Services.PluginInterface.GetIpcSubscriber<string, bool>("AllaganTools.EnableUiFilter");
_toggleUiFilter = Services.PluginInterface.GetIpcSubscriber<string, bool>("AllaganTools.ToggleUiFilter");
_initialized.Subscribe(OnAllaganInitialized);
try
{
IsReady = _isInitialized.InvokeFunc();
if (IsReady)
{
RefreshFilters();
}
}
catch
{
IsReady = false;
}
}
catch (Exception ex)
{
Services.Logger.DebugOnly($"Allagan Tools not available: {ex.Message}");
IsReady = false;
}
}
private void OnAllaganInitialized(bool initialized)
{
IsReady = initialized;
if (initialized)
{
Services.Logger.Information("Allagan Tools IPC connected");
RefreshFilters();
OnInitialized?.Invoke();
}
}
/// <summary>
/// Refreshes all cached filter data from Allagan Tools.
/// Call this when you need updated filter information.
/// </summary>
public void RefreshFilters()
{
if (!IsReady) return;
try
{
CachedSearchFilters.Clear();
CachedFilterItems.Clear();
ItemToFilters.Clear();
var filters = _getSearchFilters?.InvokeFunc();
if (filters == null) return;
foreach (var (key, name) in filters)
{
CachedSearchFilters[key] = name;
var items = _getFilterItems?.InvokeFunc(key);
if (items != null && items.Count > 0)
{
CachedFilterItems[key] = items;
// Build reverse lookup
foreach (var itemId in items.Keys)
{
if (!ItemToFilters.TryGetValue(itemId, out var filterList))
{
filterList = new List<string>(capacity: 4);
ItemToFilters[itemId] = filterList;
}
filterList.Add(key);
}
}
}
Services.Logger.DebugOnly($"Refreshed {CachedSearchFilters.Count} Allagan Tools filters, {ItemToFilters.Count} unique items");
OnFiltersRefreshed?.Invoke();
}
catch (Exception ex)
{
Services.Logger.Warning($"Failed to refresh Allagan Tools filters: {ex.Message}");
}
}
/// <summary>
/// Checks if an item is in any Allagan Tools filter.
/// </summary>
public bool IsItemInAnyFilter(uint itemId)
=> ItemToFilters.ContainsKey(itemId);
/// <summary>
/// Gets all filter keys that contain this item.
/// </summary>
public IReadOnlyList<string>? GetFiltersForItem(uint itemId)
=> ItemToFilters.TryGetValue(itemId, out var list) ? list : null;
/// <summary>
/// Gets items from a specific filter. Returns ItemId -> Quantity.
/// </summary>
public Dictionary<uint, uint>? GetFilterItems(string filterKey)
{
// Try cache first
if (CachedFilterItems.TryGetValue(filterKey, out var cached))
return cached;
if (!IsReady) return null;
try
{
return _getFilterItems?.InvokeFunc(filterKey);
}
catch (Exception ex)
{
Services.Logger.Warning($"GetFilterItems failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Gets all available search filters. Returns Key -> Name.
/// </summary>
public Dictionary<string, string>? GetSearchFilters()
{
if (CachedSearchFilters.Count > 0)
return CachedSearchFilters;
if (!IsReady) return null;
try
{
return _getSearchFilters?.InvokeFunc();
}
catch (Exception ex)
{
Services.Logger.Warning($"GetSearchFilters failed: {ex.Message}");
return null;
}
}
public void SelectFilter(string filterKey)
{
HighlightState.SelectedAllaganToolsFilterKey = filterKey;
InventoryOrchestrator.RefreshHighlights();
}
private AllaganToolsSource? _source;
public void EnableExternalCategorySupport()
{
if (_source != null) return;
_source = new AllaganToolsSource(this);
ExternalCategoryManager.RegisterSource(_source);
}
public void DisableExternalCategorySupport()
{
if (_source == null) return;
ExternalCategoryManager.UnregisterSource(_source.SourceName);
_source = null;
}
public void Dispose()
{
DisableExternalCategorySupport();
_initialized?.Unsubscribe(OnAllaganInitialized);
}
private sealed class AllaganToolsSource : IExternalItemSource
{
private readonly AllaganToolsIPC _ipc;
private int _version;
public string SourceName => "AllaganTools";
public string DisplayName => "Allagan Tools";
public int Priority => 50;
public bool IsReady => _ipc.IsReady;
public int Version => _version;
public event Action? OnDataChanged;
public SourceCapabilities Capabilities =>
SourceCapabilities.Categories |
SourceCapabilities.SearchTags;
public ConflictBehavior ConflictBehavior => ConflictBehavior.Defer;
public AllaganToolsSource(AllaganToolsIPC ipc)
{
_ipc = ipc;
_ipc.OnFiltersRefreshed += OnIpcRefreshed;
}
private void OnIpcRefreshed()
{
_version++;
OnDataChanged?.Invoke();
}
public IReadOnlyDictionary<uint, ExternalCategoryAssignment>? GetCategoryAssignments()
{
if (_ipc.CachedFilterItems.Count == 0) return null;
var result = new Dictionary<uint, ExternalCategoryAssignment>();
int filterIndex = 0;
foreach (var (filterKey, filterName) in _ipc.CachedSearchFilters)
{
if (!_ipc.CachedFilterItems.TryGetValue(filterKey, out var itemIds))
{
filterIndex++;
continue;
}
uint categoryKey = CategoryBucketManager.MakeAllaganFilterKey(filterIndex);
foreach (var itemId in itemIds.Keys)
{
result.TryAdd(itemId, new ExternalCategoryAssignment(
CategoryKey: categoryKey,
CategoryName: $"[AT] {filterName}",
CategoryDescription: $"Allagan Tools filter: {filterName}",
CategoryColor: ColorHelper.GetColor(32),
ItemOverlayColor: null,
SubPriority: filterIndex
));
}
filterIndex++;
}
return result;
}
public IReadOnlyDictionary<uint, ItemDecoration>? GetItemDecorations() => null;
public IReadOnlyList<ContextMenuEntry>? GetContextMenuEntries(uint itemId) => null;
public IReadOnlyDictionary<uint, string[]>? GetSearchTags()
{
if (_ipc.ItemToFilters.Count == 0) return null;
var result = new Dictionary<uint, string[]>();
foreach (var (itemId, filterKeys) in _ipc.ItemToFilters)
{
var tags = new List<string>(filterKeys.Count + 1) { "at", "allagantools" };
foreach (var key in filterKeys)
{
if (_ipc.CachedSearchFilters.TryGetValue(key, out var name))
{
tags.Add(name.ToLowerInvariant());
}
}
result[itemId] = tags.ToArray();
}
return result;
}
public IReadOnlyList<ItemRelationship>? GetItemRelationships(uint itemId) => null;
}
}
+349
View File
@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Inventory;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context;
using AetherBags.IPC.ExternalCategorySystem;
using Dalamud.Plugin.Ipc;
using KamiToolKit.Classes;
namespace AetherBags.IPC;
public record BisItemEntry(uint ItemId, Vector4 Color);
public record BisItemFilter(
bool IncludePrereqs = true,
bool IncludeMateria = true,
bool IncludeCollected = false,
bool IncludeObtainable = true,
bool IncludeCollectedPrereqs = true
);
public class BisBuddyIPC : IDisposable
{
private ICallGateSubscriber<bool>? _isInitialized;
private ICallGateSubscriber<bool, bool>? _initialized;
private ICallGateSubscriber<List<BisItemEntry>>? _getInventoryHighlightItems;
private ICallGateSubscriber<List<BisItemEntry>, bool>? _inventoryHighlightItemsChanged;
private ICallGateSubscriber<BisItemFilter, List<BisItemEntry>>? _getBisItemsFiltered;
public bool IsReady { get; private set; }
public List<BisItemEntry> CachedBisItems { get; } = new();
public Dictionary<uint, BisItemEntry> ItemLookup { get; } = new();
public BisItemFilter? CurrentFilter { get; private set; }
public event Action? OnItemsRefreshed;
public BisBuddyIPC()
{
try
{
_isInitialized = Services.PluginInterface.GetIpcSubscriber<bool>("BisBuddy.IsInitialized");
_initialized = Services.PluginInterface.GetIpcSubscriber<bool, bool>("BisBuddy.Initialized");
_getInventoryHighlightItems = Services.PluginInterface.GetIpcSubscriber<List<BisItemEntry>>("BisBuddy.GetInventoryHighlightItems");
_inventoryHighlightItemsChanged = Services.PluginInterface.GetIpcSubscriber<List<BisItemEntry>, bool>("BisBuddy.InventoryHighlightItemsChanged");
_getBisItemsFiltered = Services.PluginInterface.GetIpcSubscriber<BisItemFilter, List<BisItemEntry>>("BisBuddy.GetBisItemsFiltered");
_initialized.Subscribe(OnBisBuddyInitialized);
_inventoryHighlightItemsChanged.Subscribe(OnInventoryHighlightItemsChanged);
try
{
IsReady = _isInitialized.InvokeFunc();
if (IsReady) RefreshItems();
}
catch
{
IsReady = false;
}
}
catch (Exception ex)
{
Services.Logger.DebugOnly($"BisBuddy not available: {ex.Message}");
IsReady = false;
}
}
private void OnBisBuddyInitialized(bool ready)
{
IsReady = ready;
if (ready)
{
Services.Logger.Information("BisBuddy IPC connected");
RefreshItems();
}
else
{
ClearHighlights();
}
}
private void OnInventoryHighlightItemsChanged(List<BisItemEntry> items)
{
if (CurrentFilter == null)
{
UpdateCacheAndHighlights(items);
}
}
public void RefreshItems()
{
if (!IsReady) return;
try
{
List<BisItemEntry>? items;
if (CurrentFilter != null)
{
items = _getBisItemsFiltered?.InvokeFunc(CurrentFilter);
}
else
{
items = _getInventoryHighlightItems?.InvokeFunc();
}
if (items != null)
{
UpdateCacheAndHighlights(items);
}
}
catch (Exception ex)
{
Services.Logger.Warning($"Failed to refresh BisBuddy items: {ex.Message}");
IsReady = false;
}
}
public void SetFilter(BisItemFilter? filter)
{
CurrentFilter = filter;
RefreshItems();
}
public void ShowAllItems()
{
SetFilter(new BisItemFilter(IncludeCollected: true));
}
public void ShowUncollectedOnly()
{
SetFilter(new BisItemFilter(IncludeCollected: false));
}
public void UseInventoryConfig()
{
SetFilter(null);
}
private void UpdateCacheAndHighlights(List<BisItemEntry> items)
{
CachedBisItems.Clear();
ItemLookup.Clear();
foreach (var item in items)
{
CachedBisItems.Add(item);
ItemLookup[item.ItemId] = item;
}
Services.Logger.DebugOnly($"Refreshed {CachedBisItems.Count} BisBuddy items");
ApplyHighlights();
OnItemsRefreshed?.Invoke();
}
private void ApplyHighlights()
{
if (!System.Config.Categories.BisBuddyEnabled || CachedBisItems.Count == 0)
{
HighlightState.ClearLabel(HighlightSource.BiSBuddy);
}
else
{
var highlights = new Dictionary<uint, Vector4>(CachedBisItems.Count);
foreach (var item in CachedBisItems)
{
highlights[item.ItemId] = item.Color;
}
HighlightState.SetLabelWithColors(HighlightSource.BiSBuddy, highlights);
}
InventoryOrchestrator.RefreshHighlights();
}
private void ClearHighlights()
{
CachedBisItems.Clear();
ItemLookup.Clear();
HighlightState.ClearLabel(HighlightSource.BiSBuddy);
InventoryOrchestrator.RefreshHighlights();
}
public bool IsBisItem(uint itemId)
=> ItemLookup.ContainsKey(itemId);
public BisItemEntry? GetBisItem(uint itemId)
=> ItemLookup.GetValueOrDefault(itemId);
public Vector4? GetItemColor(uint itemId)
=> GetBisItem(itemId)?.Color;
private BisBuddySource? _source;
public void EnableExternalCategorySupport()
{
if (_source != null) return;
_source = new BisBuddySource(this);
ExternalCategoryManager.RegisterSource(_source);
}
public void DisableExternalCategorySupport()
{
if (_source == null) return;
ExternalCategoryManager.UnregisterSource(_source.SourceName);
_source = null;
}
public void Dispose()
{
DisableExternalCategorySupport();
_initialized?.Unsubscribe(OnBisBuddyInitialized);
_inventoryHighlightItemsChanged?.Unsubscribe(OnInventoryHighlightItemsChanged);
}
private sealed class BisBuddySource : IExternalItemSource
{
private readonly BisBuddyIPC _ipc;
private int _version;
public string SourceName => "BisBuddy";
public string DisplayName => "Best in Slot";
public int Priority => 100;
public bool IsReady => _ipc.IsReady;
public int Version => _version;
public event Action? OnDataChanged;
public SourceCapabilities Capabilities =>
SourceCapabilities.Categories |
SourceCapabilities.ItemColors |
SourceCapabilities.SearchTags |
SourceCapabilities.Relationships;
public ConflictBehavior ConflictBehavior => ConflictBehavior.Replace;
public BisBuddySource(BisBuddyIPC ipc)
{
_ipc = ipc;
_ipc.OnItemsRefreshed += OnIpcRefreshed;
}
private void OnIpcRefreshed()
{
_version++;
OnDataChanged?.Invoke();
}
public IReadOnlyDictionary<uint, ExternalCategoryAssignment>? GetCategoryAssignments()
{
var items = _ipc.ItemLookup;
if (items.Count == 0) return null;
var result = new Dictionary<uint, ExternalCategoryAssignment>();
var colorGroups = new Dictionary<Vector4, List<(uint itemId, BisItemEntry entry)>>();
foreach (var (itemId, entry) in items)
{
if (!colorGroups.TryGetValue(entry.Color, out var list))
{
list = new List<(uint, BisItemEntry)>();
colorGroups[entry.Color] = list;
}
list.Add((itemId, entry));
}
uint subKey = 0;
foreach (var (color, groupItems) in colorGroups)
{
uint categoryKey = CategoryBucketManager.MakeBisBuddyKey() | subKey++;
foreach (var (itemId, entry) in groupItems)
{
result[itemId] = new ExternalCategoryAssignment(
CategoryKey: categoryKey,
CategoryName: "[BiS] Gearset",
CategoryDescription: "Items needed for Best in Slot",
CategoryColor: color,
ItemOverlayColor: new Vector3(color.X, color.Y, color.Z),
SubPriority: 0
);
}
}
return result;
}
public IReadOnlyDictionary<uint, ItemDecoration>? GetItemDecorations()
{
var items = _ipc.ItemLookup;
if (items.Count == 0) return null;
var result = new Dictionary<uint, ItemDecoration>();
foreach (var (itemId, entry) in items)
{
result[itemId] = new ItemDecoration
{
OverlayColor = new Vector3(entry.Color.X, entry.Color.Y, entry.Color.Z),
};
}
return result;
}
public IReadOnlyList<ContextMenuEntry>? GetContextMenuEntries(uint itemId) => null;
public IReadOnlyDictionary<uint, string[]>? GetSearchTags()
{
var items = _ipc.ItemLookup;
if (items.Count == 0) return null;
var result = new Dictionary<uint, string[]>();
foreach (var itemId in items.Keys)
{
result[itemId] = new[] { "bis", "bestinslot", "gearset" };
}
return result;
}
public IReadOnlyList<ItemRelationship>? GetItemRelationships(uint itemId)
{
if (!_ipc.ItemLookup.TryGetValue(itemId, out var entry)) return null;
var sameSetItems = new List<uint>();
foreach (var (otherId, otherEntry) in _ipc.ItemLookup)
{
if (otherId != itemId && otherEntry.Color == entry.Color)
{
sameSetItems.Add(otherId);
}
}
if (sameSetItems.Count == 0) return null;
return new[]
{
new ItemRelationship(
Type: RelationshipType.SameSet,
RelatedItemIds: sameSetItems.ToArray(),
GroupLabel: "Same Gearset",
HighlightColor: new Vector3(entry.Color.X, entry.Color.Y, entry.Color.Z)
)
};
}
}
}
@@ -0,0 +1,297 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Items;
namespace AetherBags.IPC.ExternalCategorySystem;
public static class ExternalCategoryManager
{
private static readonly List<IExternalItemSource> Sources = new();
private static readonly Dictionary<uint, ExternalCategoryAssignment> CategoryCache = new();
private static readonly Dictionary<uint, ItemDecoration> DecorationCache = new();
private static readonly Dictionary<uint, List<string>> SearchTagCache = new();
private static int _lastCombinedVersion;
public static IReadOnlyList<IExternalItemSource> RegisteredSources => Sources;
public static void RegisterSource(IExternalItemSource source)
{
if (Sources.Any(s => s.SourceName == source.SourceName))
return;
Sources.Add(source);
Sources.Sort((a, b) => b.Priority.CompareTo(a.Priority));
source.OnDataChanged += InvalidateCache;
InvalidateCache();
Services.Logger.Information($"Registered external category source: {source.SourceName}");
}
public static void UnregisterSource(string sourceName)
{
var source = Sources.FirstOrDefault(s => s.SourceName == sourceName);
if (source == null) return;
source.OnDataChanged -= InvalidateCache;
Sources.Remove(source);
InvalidateCache();
Services.Logger.Information($"Unregistered external category source: {sourceName}");
}
public static void InvalidateCache()
{
_lastCombinedVersion = -1;
CategoryCache.Clear();
DecorationCache.Clear();
SearchTagCache.Clear();
}
private static int ComputeCombinedVersion()
{
int version = 0;
foreach (var source in Sources)
version = unchecked(version * 31 + source.Version);
return version;
}
public static void RebuildCacheIfNeeded()
{
int currentVersion = ComputeCombinedVersion();
if (currentVersion == _lastCombinedVersion && CategoryCache.Count > 0)
return;
_lastCombinedVersion = currentVersion;
CategoryCache.Clear();
DecorationCache.Clear();
SearchTagCache.Clear();
foreach (var source in Sources)
{
if (!source.IsReady) continue;
if (source.Capabilities.HasFlag(SourceCapabilities.Categories))
{
var categories = source.GetCategoryAssignments();
if (categories != null)
{
foreach (var (itemId, assignment) in categories)
{
CategoryCache.TryAdd(itemId, assignment);
}
}
}
if (source.Capabilities.HasFlag(SourceCapabilities.ItemColors) ||
source.Capabilities.HasFlag(SourceCapabilities.Badges))
{
var decorations = source.GetItemDecorations();
if (decorations != null)
{
foreach (var (itemId, decoration) in decorations)
{
if (DecorationCache.TryGetValue(itemId, out var existing))
{
DecorationCache[itemId] = MergeDecorations(existing, decoration, source.ConflictBehavior);
}
else
{
DecorationCache[itemId] = decoration;
}
}
}
}
if (source.Capabilities.HasFlag(SourceCapabilities.SearchTags))
{
var searchTags = source.GetSearchTags();
if (searchTags != null)
{
foreach (var (itemId, tags) in searchTags)
{
if (!SearchTagCache.TryGetValue(itemId, out var existingTags))
{
existingTags = new List<string>(tags.Length);
SearchTagCache[itemId] = existingTags;
}
existingTags.AddRange(tags);
}
}
}
}
}
private static ItemDecoration MergeDecorations(ItemDecoration existing, ItemDecoration incoming, ConflictBehavior behavior)
{
return behavior switch
{
ConflictBehavior.Replace => incoming,
ConflictBehavior.Defer => existing,
ConflictBehavior.Merge => new ItemDecoration
{
OverlayColor = incoming.OverlayColor ?? existing.OverlayColor,
Opacity = incoming.Opacity ?? existing.Opacity,
Badge = incoming.Badge ?? existing.Badge,
Border = incoming.Border != BorderStyle.None ? incoming.Border : existing.Border,
TooltipLine = CombineTooltips(existing.TooltipLine, incoming.TooltipLine),
},
_ => incoming
};
}
private static string? CombineTooltips(string? a, string? b)
{
if (string.IsNullOrEmpty(a)) return b;
if (string.IsNullOrEmpty(b)) return a;
return $"{a}\n{b}";
}
public static void BucketItems(
Dictionary<ulong, ItemInfo> itemInfoByKey,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> claimedKeys)
{
RebuildCacheIfNeeded();
if (CategoryCache.Count == 0) return;
foreach (var (itemKey, item) in itemInfoByKey)
{
if (claimedKeys.Contains(itemKey)) continue;
if (!CategoryCache.TryGetValue(item.Item.ItemId, out var assignment))
continue;
ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, assignment.CategoryKey, out bool exists);
if (!exists)
{
bucketRef = new CategoryBucket
{
Key = assignment.CategoryKey,
Category = new CategoryInfo
{
Name = assignment.CategoryName,
Description = assignment.CategoryDescription ?? string.Empty,
Color = assignment.CategoryColor,
},
Items = new List<ItemInfo>(16),
FilteredItems = new List<ItemInfo>(16),
Used = true,
};
}
else
{
bucketRef!.Used = true;
bucketRef.Category.Name = assignment.CategoryName;
bucketRef.Category.Description = assignment.CategoryDescription ?? string.Empty;
bucketRef.Category.Color = assignment.CategoryColor;
}
bucketRef!.Items.Add(item);
claimedKeys.Add(itemKey);
}
}
public static ItemDecoration? GetDecoration(uint itemId)
{
RebuildCacheIfNeeded();
return DecorationCache.TryGetValue(itemId, out var dec) ? dec : null;
}
public static Vector3? GetItemOverlayColor(uint itemId)
{
if (CategoryCache.TryGetValue(itemId, out var assignment))
return assignment.ItemOverlayColor;
if (DecorationCache.TryGetValue(itemId, out var decoration))
return decoration.OverlayColor;
return null;
}
public static List<ContextMenuEntry>? GetContextMenuEntries(uint itemId)
{
List<ContextMenuEntry>? result = null;
foreach (var source in Sources)
{
if (!source.IsReady) continue;
if (!source.Capabilities.HasFlag(SourceCapabilities.ContextMenu)) continue;
var entries = source.GetContextMenuEntries(itemId);
if (entries == null || entries.Count == 0) continue;
foreach (var entry in entries)
{
if (entry.IsVisible != null && !entry.IsVisible(itemId)) continue;
result ??= new List<ContextMenuEntry>(4);
result.Add(entry);
}
}
result?.Sort((a, b) => a.Order.CompareTo(b.Order));
return result;
}
public static IReadOnlyList<string>? GetSearchTags(uint itemId)
{
RebuildCacheIfNeeded();
return SearchTagCache.TryGetValue(itemId, out var tags) ? tags : null;
}
public static bool MatchesSearchTag(uint itemId, string searchText)
{
RebuildCacheIfNeeded();
if (!SearchTagCache.TryGetValue(itemId, out var tags)) return false;
foreach (var tag in tags)
{
if (tag.Contains(searchText, global::System.StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
public static List<ItemRelationship>? GetItemRelationships(uint itemId)
{
List<ItemRelationship>? result = null;
foreach (var source in Sources)
{
if (!source.IsReady) continue;
if (!source.Capabilities.HasFlag(SourceCapabilities.Relationships)) continue;
var relationships = source.GetItemRelationships(itemId);
if (relationships == null || relationships.Count == 0) continue;
result ??= new List<ItemRelationship>(4);
result.AddRange(relationships);
}
return result;
}
public static HashSet<uint>? GetRelatedItemIds(uint itemId, RelationshipType? filterType = null)
{
var relationships = GetItemRelationships(itemId);
if (relationships == null || relationships.Count == 0) return null;
var result = new HashSet<uint>();
foreach (var rel in relationships)
{
if (filterType.HasValue && rel.Type != filterType.Value) continue;
foreach (var relatedId in rel.RelatedItemIds)
{
result.Add(relatedId);
}
}
return result.Count > 0 ? result : null;
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AetherBags.IPC.ExternalCategorySystem;
public interface IExternalItemSource
{
string SourceName { get; }
string DisplayName { get; }
int Priority { get; }
bool IsReady { get; }
int Version { get; }
event Action? OnDataChanged;
SourceCapabilities Capabilities { get; }
ConflictBehavior ConflictBehavior { get; }
IReadOnlyDictionary<uint, ExternalCategoryAssignment>? GetCategoryAssignments();
IReadOnlyDictionary<uint, ItemDecoration>? GetItemDecorations();
IReadOnlyList<ContextMenuEntry>? GetContextMenuEntries(uint itemId);
IReadOnlyDictionary<uint, string[]>? GetSearchTags();
IReadOnlyList<ItemRelationship>? GetItemRelationships(uint itemId);
}
[Flags]
public enum SourceCapabilities
{
None = 0,
Categories = 1,
ItemColors = 2,
Badges = 4,
ContextMenu = 8,
SearchTags = 16,
Relationships = 32,
Tooltips = 64
}
public enum ConflictBehavior
{
Replace,
Merge,
Defer
}
public readonly record struct ExternalCategoryAssignment(
uint CategoryKey,
string CategoryName,
string? CategoryDescription,
Vector4 CategoryColor,
Vector3? ItemOverlayColor,
int SubPriority
);
public record struct ItemDecoration
{
public Vector3? OverlayColor { get; init; }
public float? Opacity { get; init; }
public BadgeInfo? Badge { get; init; }
public BorderStyle Border { get; init; }
public string? TooltipLine { get; init; }
}
public record struct BadgeInfo(
uint IconId,
BadgePosition Position,
Vector4? TintColor
);
public enum BadgePosition { TopLeft, TopRight, BottomLeft, BottomRight }
public enum BorderStyle { None, Solid, Glow, Pulse }
public record struct ContextMenuEntry(
string Label,
uint? IconId,
Action<ContextMenuContext> OnClick,
int Order,
Func<uint, bool>? IsVisible = null
);
public record struct ContextMenuContext(
uint ItemId,
int Container,
int Slot
);
public record struct ItemRelationship(
RelationshipType Type,
uint[] RelatedItemIds,
string? GroupLabel,
Vector3? HighlightColor
);
public enum RelationshipType
{
SameSet,
Upgrades,
UpgradedFrom,
CraftedFrom,
CraftsInto,
Alternative
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using AetherBags.Configuration;
namespace AetherBags.IPC;
public class IPCService : IDisposable
{
public AllaganToolsIPC AllaganTools { get; } = new();
public WotsItIPC WotsIt { get; } = new();
public BisBuddyIPC BisBuddy { get; } = new();
private bool _unifiedEnabled;
public void UpdateUnifiedCategorySupport(bool enabled)
{
_unifiedEnabled = enabled;
RefreshExternalSources();
}
public void RefreshExternalSources()
{
var config = System.Config?.Categories;
if (config == null) return;
bool categoriesEnabled = config.CategoriesEnabled;
bool allaganShouldBeActive = _unifiedEnabled &&
categoriesEnabled &&
config.AllaganToolsCategoriesEnabled &&
config.AllaganToolsFilterMode == PluginFilterMode.Categorize;
if (allaganShouldBeActive)
AllaganTools.EnableExternalCategorySupport();
else
AllaganTools.DisableExternalCategorySupport();
bool bisBuddyShouldBeActive = _unifiedEnabled &&
categoriesEnabled &&
config.BisBuddyEnabled &&
config.BisBuddyMode == PluginFilterMode.Categorize;
if (bisBuddyShouldBeActive)
BisBuddy.EnableExternalCategorySupport();
else
BisBuddy.DisableExternalCategorySupport();
}
public void Dispose()
{
AllaganTools.Dispose();
WotsIt.Dispose();
BisBuddy.Dispose();
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using Dalamud.Plugin.Ipc;
namespace AetherBags.IPC;
public class WotsItIPC : IDisposable
{
private ICallGateSubscriber<string, string, string, uint, string>? _registerWithSearch;
private ICallGateSubscriber<string, bool>? _invoke;
private ICallGateSubscriber<string, bool>? _unregisterAll;
private string? _searchGuid;
public WotsItIPC()
{
try
{
_registerWithSearch = Services.PluginInterface.GetIpcSubscriber<string, string, string, uint, string>("FA.RegisterWithSearch");
_unregisterAll = Services.PluginInterface.GetIpcSubscriber<string, bool>("FA.UnregisterAll");
_invoke = Services.PluginInterface.GetIpcSubscriber<string, bool>("FA.Invoke");
_invoke.Subscribe(OnInvoke);
Register();
}
catch (Exception ex)
{
Services.Logger.DebugOnly($"WotsIt not available: {ex.Message}");
}
}
private void Register()
{
try
{
UnregisterAll();
_searchGuid = _registerWithSearch?.InvokeFunc(
Services.PluginInterface.InternalName,
"AetherBags: Search Inventory",
"AetherBags Search",
66472 // Icon ID
);
}
catch (Exception ex)
{
Services.Logger.DebugOnly($"Failed to register with WotsIt: {ex.Message}");
}
}
private void OnInvoke(string guid)
{
if (guid == _searchGuid)
{
if (! System.AddonInventoryWindow.IsOpen)
{
System.AddonInventoryWindow.Open();
}
}
}
private bool UnregisterAll()
{
try
{
_unregisterAll?.InvokeFunc(Services.PluginInterface.InternalName);
return true;
}
catch
{
return false;
}
}
public void Dispose()
{
_invoke?.Unsubscribe(OnInvoke);
UnregisterAll();
}
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
using AetherBags.Inventory.Items;
namespace AetherBags.Inventory.Categories;
public readonly record struct CategorizedInventory(uint Key, CategoryInfo Category, List<ItemInfo> Items);
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using AetherBags.Inventory.Items;
namespace AetherBags.Inventory.Categories;
public sealed class CategoryBucket
{
public uint Key;
public CategoryInfo Category = null!;
public List<ItemInfo> Items = null!;
public List<ItemInfo> FilteredItems = null!;
public bool Used;
public bool NeedsSorting = true;
}
public sealed class ItemCountDescComparer : IComparer<ItemInfo>
{
public static readonly ItemCountDescComparer Instance = new();
public int Compare(ItemInfo? left, ItemInfo? right)
{
if (ReferenceEquals(left, right)) return 0;
if (left is null) return 1;
if (right is null) return -1;
int leftCount = left.ItemCount;
int rightCount = right.ItemCount;
if (leftCount > rightCount) return -1;
if (leftCount < rightCount) return 1;
return 0;
}
}
@@ -0,0 +1,481 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AetherBags.Configuration;
using AetherBags.Inventory.Items;
using KamiToolKit.Classes;
namespace AetherBags.Inventory.Categories;
public static class CategoryBucketManager
{
private const uint UserCategoryKeyFlag = 0x8000_0000;
private static readonly Dictionary<uint, CategoryInfo> CategoryInfoCache = new(capacity: 256);
public static uint MakeUserCategoryKey(int order)
=> UserCategoryKeyFlag | (uint)(order & 0x7FFF_FFFF);
public static bool IsUserCategoryKey(uint key)
=> (key & UserCategoryKeyFlag) != 0;
private const uint AllaganFilterKeyFlag = 0x4000_0000;
private const uint BisBuddyKeyFlag = 0x2000_0000;
public static uint MakeAllaganFilterKey(int index)
=> AllaganFilterKeyFlag | (uint)(index & 0x3FFF_FFFF);
public static uint MakeBisBuddyKey()
=> BisBuddyKeyFlag;
public static bool IsBisBuddyKey(uint key)
=> (key & BisBuddyKeyFlag) != 0
&& (key & AllaganFilterKeyFlag) == 0
&& (key & UserCategoryKeyFlag) == 0;
public static bool IsAllaganFilterKey(uint key)
=> (key & AllaganFilterKeyFlag) != 0 && (key & UserCategoryKeyFlag) == 0;
/// <summary>
/// Resets all buckets for a new refresh cycle.
/// </summary>
public static void ResetBuckets(Dictionary<uint, CategoryBucket> bucketsByKey)
{
foreach (var kvp in bucketsByKey)
{
CategoryBucket bucket = kvp.Value;
bucket.Used = false;
bucket.Items.Clear();
bucket.FilteredItems.Clear();
bucket.NeedsSorting = true;
}
}
public static void BucketByUserCategories(
Dictionary<ulong, ItemInfo> itemInfoByKey,
List<UserCategoryDefinition> userCategories,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> claimedKeys,
List<UserCategoryDefinition> sortedScratch)
{
sortedScratch.Clear();
sortedScratch.AddRange(userCategories);
sortedScratch.Sort(UserCategoryComparer.Instance);
var activeBuckets = new (uint key, CategoryBucket bucket, UserCategoryDefinition def)[sortedScratch.Count];
int activeCount = 0;
for (int i = 0; i < sortedScratch.Count; i++)
{
UserCategoryDefinition category = sortedScratch[i];
if (!category.Enabled || UserCategoryMatcher.IsCatchAll(category))
continue;
uint bucketKey = MakeUserCategoryKey(category.Order);
ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists);
if (!exists)
{
bucketRef = new CategoryBucket
{
Key = bucketKey,
Category = new CategoryInfo
{
Name = category.Name,
Description = category.Description,
Color = category.Color,
IsPinned = category.Pinned,
},
Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true,
};
}
else
{
bucketRef!.Used = true;
bucketRef.Category.Name = category.Name;
bucketRef.Category.Description = category.Description;
bucketRef.Category.Color = category.Color;
bucketRef.Category.IsPinned = category.Pinned;
}
activeBuckets[activeCount++] = (bucketKey, bucketRef!, category);
}
foreach (var itemKvp in itemInfoByKey)
{
ulong itemKey = itemKvp.Key;
if (claimedKeys.Contains(itemKey))
continue;
ItemInfo item = itemKvp.Value;
for (int i = 0; i < activeCount; i++)
{
ref var entry = ref activeBuckets[i];
if (UserCategoryMatcher.Matches(item, entry.def))
{
entry.bucket.Items.Add(item);
claimedKeys.Add(itemKey);
break;
}
}
}
for (int i = 0; i < activeCount; i++)
{
ref var entry = ref activeBuckets[i];
if (entry.bucket.Items.Count == 0)
entry.bucket.Used = false;
}
}
private sealed class UserCategoryComparer : IComparer<UserCategoryDefinition>
{
public static readonly UserCategoryComparer Instance = new();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int Compare(UserCategoryDefinition? left, UserCategoryDefinition? right)
{
if (left is null || right is null) return 0;
int priority = left.Priority.CompareTo(right.Priority);
if (priority != 0) return priority;
int order = left.Order.CompareTo(right.Order);
if (order != 0) return order;
return string.Compare(left.Id, right.Id, StringComparison.OrdinalIgnoreCase);
}
}
public static void BucketByGameCategories(
Dictionary<ulong, ItemInfo> itemInfoByKey,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> claimedKeys,
bool userCategoriesEnabled)
{
foreach (var itemKvp in itemInfoByKey)
{
ulong itemKey = itemKvp.Key;
ItemInfo info = itemKvp.Value;
if (userCategoriesEnabled && claimedKeys.Contains(itemKey))
continue;
uint categoryKey = info.UiCategory.RowId;
ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, categoryKey, out bool exists);
if (!exists)
{
bucketRef = new CategoryBucket
{
Key = categoryKey,
Category = GetCategoryInfoCached(categoryKey, info),
Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true,
};
}
else
{
bucketRef!.Used = true;
}
bucketRef!.Items.Add(info);
}
}
public static void BucketByAllaganFilters(
Dictionary<ulong, ItemInfo> itemInfoByKey,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> claimedKeys,
bool allaganCategoriesEnabled)
{
if (!allaganCategoriesEnabled) return;
if (!System.IPC.AllaganTools.IsReady) return;
var filters = System.IPC.AllaganTools.CachedSearchFilters;
var itemToFilters = System.IPC.AllaganTools.ItemToFilters;
if (filters.Count == 0 || itemToFilters.Count == 0) return;
var filterKeyToIndex = new Dictionary<string, int>(filters.Count);
int index = 0;
foreach (var filterKey in filters.Keys)
{
filterKeyToIndex[filterKey] = index++;
}
index = 0;
foreach (var (filterKey, filterName) in filters)
{
uint bucketKey = MakeAllaganFilterKey(index);
ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists);
if (!exists)
{
bucketRef = new CategoryBucket
{
Key = bucketKey,
Category = new CategoryInfo
{
Name = $"[AT] {filterName}",
Description = $"Allagan Tools filter: {filterName}",
Color = ColorHelper.GetColor(32),
},
Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true,
};
}
else
{
bucketRef!.Used = true;
bucketRef.Category.Name = $"[AT] {filterName}";
}
index++;
}
foreach (var itemKvp in itemInfoByKey)
{
ulong itemKey = itemKvp.Key;
if (claimedKeys.Contains(itemKey))
continue;
ItemInfo item = itemKvp.Value;
if (!itemToFilters.TryGetValue(item.Item.ItemId, out var filterKeys))
continue;
if (filterKeys.Count > 0 && filterKeyToIndex.TryGetValue(filterKeys[0], out int filterIndex))
{
uint bucketKey = MakeAllaganFilterKey(filterIndex);
if (bucketsByKey.TryGetValue(bucketKey, out var bucket))
{
bucket.Items.Add(item);
claimedKeys.Add(itemKey);
}
}
}
index = 0;
foreach (var _ in filters)
{
uint bucketKey = MakeAllaganFilterKey(index++);
if (bucketsByKey.TryGetValue(bucketKey, out var bucket) && bucket.Items.Count == 0)
bucket.Used = false;
}
}
public static void BucketByBisBuddyItems(
Dictionary<ulong, ItemInfo> itemInfoByKey,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> claimedKeys,
bool bisCategoriesEnabled)
{
if (!bisCategoriesEnabled) return;
if (!System.IPC.BisBuddy.IsReady) return;
var bisItems = System.IPC.BisBuddy.ItemLookup;
if (bisItems.Count == 0) return;
uint bucketKey = MakeBisBuddyKey();
ref var bucketRef = ref CollectionsMarshal.GetValueRefOrAddDefault(bucketsByKey, bucketKey, out bool exists);
if (!exists)
{
bucketRef = new CategoryBucket
{
Key = bucketKey,
Category = new CategoryInfo
{
Name = "[BiS] Best in Slot",
Description = "Items needed for your BiS gearsets",
Color = ColorHelper.GetColor(50),
},
Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true,
};
}
else
{
bucketRef!.Used = true;
}
var bucket = bucketRef!;
foreach (var itemKvp in itemInfoByKey)
{
ulong itemKey = itemKvp.Key;
if (claimedKeys.Contains(itemKey))
continue;
ItemInfo item = itemKvp.Value;
if (bisItems.ContainsKey(item.Item.ItemId))
{
bucket.Items.Add(item);
claimedKeys.Add(itemKey);
}
}
if (bucket.Items.Count == 0)
bucket.Used = false;
}
public static void BucketUnclaimedToMisc(
Dictionary<ulong, ItemInfo> itemInfoByKey,
Dictionary<uint, CategoryBucket> bucketsByKey,
HashSet<ulong> claimedKeys,
bool userCategoriesEnabled)
{
if (!bucketsByKey.TryGetValue(0u, out CategoryBucket? miscBucket))
{
CategoryInfo miscInfo;
if (itemInfoByKey.Count > 0)
{
using var enumerator = itemInfoByKey.Values.GetEnumerator();
enumerator.MoveNext();
miscInfo = GetCategoryInfoCached(0u, enumerator.Current);
}
else
{
miscInfo = new CategoryInfo { Name = "Misc", Description = "Uncategorized items" };
}
miscBucket = new CategoryBucket
{
Key = 0u,
Category = miscInfo,
Items = new List<ItemInfo>(capacity: 16),
FilteredItems = new List<ItemInfo>(capacity: 16),
Used = true,
};
bucketsByKey.Add(0u, miscBucket);
}
else
{
miscBucket.Used = true;
}
foreach (var itemKvp in itemInfoByKey)
{
ulong itemKey = itemKvp.Key;
ItemInfo info = itemKvp.Value;
if (userCategoriesEnabled && claimedKeys.Contains(itemKey))
continue;
miscBucket.Items.Add(info);
}
if (miscBucket.Items.Count == 0)
miscBucket.Used = false;
}
public static void SortBucketsAndBuildKeyList(
Dictionary<uint, CategoryBucket> bucketsByKey,
List<uint> sortedCategoryKeys)
{
sortedCategoryKeys.Clear();
foreach (var kvp in bucketsByKey)
{
CategoryBucket bucket = kvp.Value;
if (!bucket.Used)
continue;
// TODO: Make configurable
// Only sort if items changed
if (bucket.NeedsSorting)
{
bucket.Items.Sort(ItemCountDescComparer.Instance);
bucket.NeedsSorting = false;
}
sortedCategoryKeys.Add(bucket.Key);
}
// TODO: Make sortable by user
sortedCategoryKeys.Sort((left, right) =>
{
int GetPriority(uint key)
{
if (IsUserCategoryKey(key)) return 1;
if (IsBisBuddyKey(key)) return 2;
if (IsAllaganFilterKey(key)) return 3;
if (key == 0) return 99;
return 10;
}
int leftPrio = GetPriority(left);
int rightPrio = GetPriority(right);
return leftPrio != rightPrio ? leftPrio.CompareTo(rightPrio) : left.CompareTo(right);
});
}
public static void BuildCategorizedList(
Dictionary<uint, CategoryBucket> bucketsByKey,
List<uint> sortedCategoryKeys,
List<CategorizedInventory> allCategories)
{
allCategories.Clear();
allCategories.Capacity = Math.Max(allCategories.Capacity, sortedCategoryKeys.Count);
for (int i = 0; i < sortedCategoryKeys.Count; i++)
{
uint key = sortedCategoryKeys[i];
CategoryBucket bucket = bucketsByKey[key];
allCategories.Add(new CategorizedInventory(bucket.Key, bucket.Category, bucket.Items));
}
int displayed = 0;
for (int i = 0; i < allCategories.Count; i++)
displayed += allCategories[i].Items.Count;
Services.Logger.DebugOnly($"AllCategories={allCategories.Count} DisplayedItemsTotal={displayed}");
}
private static CategoryInfo GetCategoryInfoCached(uint key, ItemInfo sample)
{
if (CategoryInfoCache.TryGetValue(key, out var cached))
return cached;
CategoryInfo info = GetCategoryInfoSlow(key, sample);
CategoryInfoCache[key] = info;
return info;
}
private static CategoryInfo GetCategoryInfoSlow(uint key, ItemInfo sample)
{
if (key == 0)
{
return new CategoryInfo
{
Name = "Misc",
Description = "Uncategorized items",
};
}
var uiCat = sample.UiCategory.Value;
string name = uiCat.Name.ToString();
if (string.IsNullOrWhiteSpace(name))
name = $"Category {key}";
return new CategoryInfo
{
Name = name,
};
}
}
@@ -0,0 +1,12 @@
using System.Numerics;
using KamiToolKit.Classes;
namespace AetherBags.Inventory.Categories;
public class CategoryInfo
{
public required string Name { get; set; }
public Vector4 Color { get; set; } = ColorHelper.GetColor(2);
public string Description { get; set; } = string.Empty;
public bool IsPinned { get; set; } = false;
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using AetherBags.Helpers;
using AetherBags.Inventory.Items;
using AetherBags.IPC.ExternalCategorySystem;
namespace AetherBags.Inventory.Categories;
public static class InventoryFilter
{
public static IReadOnlyList<CategorizedInventory> FilterCategories(
IReadOnlyList<CategorizedInventory> allCategories,
Dictionary<uint, CategoryBucket> bucketsByKey,
List<CategorizedInventory> filteredCategories,
string filterString,
bool invert = false)
{
if (string.IsNullOrEmpty(filterString))
return allCategories;
Regex? re = RegexCache.GetOrCreate(filterString);
bool regexValid = re != null;
filteredCategories.Clear();
for (int i = 0; i < allCategories.Count; i++)
{
CategorizedInventory cat = allCategories[i];
CategoryBucket bucket = bucketsByKey[cat.Key];
var filtered = bucket.FilteredItems;
filtered.Clear();
var src = bucket.Items;
for (int j = 0; j < src.Count; j++)
{
ItemInfo info = src[j];
bool isMatch;
if (regexValid)
{
isMatch = info.IsRegexMatch(re!);
}
else
{
isMatch = info.Name.Contains(filterString, StringComparison.OrdinalIgnoreCase) || info.DescriptionContains(filterString);
}
if (!isMatch)
{
isMatch = ExternalCategoryManager.MatchesSearchTag(info.Item.ItemId, filterString);
}
if (isMatch != invert)
filtered.Add(info);
}
if (filtered.Count != 0)
filteredCategories.Add(new CategorizedInventory(bucket.Key, bucket.Category, filtered));
}
return filteredCategories;
}
}
@@ -0,0 +1,115 @@
using System;
using System.Runtime.CompilerServices;
using AetherBags.Configuration;
using AetherBags.Helpers;
using AetherBags.Inventory.Items;
namespace AetherBags.Inventory.Categories;
internal static class UserCategoryMatcher
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Matches(ItemInfo item, UserCategoryDefinition userCategory)
{
var rules = userCategory.Rules;
if (!MatchesToggle(rules.Untradable, item.IsUntradable)) return false;
if (!MatchesToggle(rules.Unique, item.IsUnique)) return false;
if (!MatchesToggle(rules.Collectable, item.IsCollectable)) return false;
if (!MatchesToggle(rules.Dyeable, item.IsDyeable)) return false;
if (!MatchesToggle(rules.HighQuality, item.IsHq)) return false;
if (!MatchesToggle(rules.Repairable, item.IsRepairable)) return false;
if (!MatchesToggle(rules.Desynthesizable, item.IsDesynthesizable)) return false;
if (!MatchesToggle(rules.Glamourable, item.IsGlamourable)) return false;
if (!MatchesToggle(rules.FullySpiritbonded, item.IsSpiritbonded)) return false;
if (rules.Level.Enabled && !InRange(item.Level, rules.Level.Min, rules.Level.Max))
return false;
if (rules.ItemLevel.Enabled && !InRange(item.ItemLevel, rules.ItemLevel.Min, rules.ItemLevel.Max))
return false;
if (rules.VendorPrice.Enabled && !InRange(item.VendorPrice, rules.VendorPrice.Min, rules.VendorPrice.Max))
return false;
if (rules.AllowedRarities.Count > 0 && !rules.AllowedRarities.Contains(item.Rarity))
return false;
if (rules.AllowedUiCategoryIds.Count > 0 && !rules.AllowedUiCategoryIds.Contains(item.UiCategory.RowId))
return false;
bool hasIdentificationFilters = rules.AllowedItemIds.Count > 0 || rules.AllowedItemNamePatterns.Count > 0;
if (hasIdentificationFilters)
{
if (rules.AllowedItemIds.Count > 0 && rules.AllowedItemIds.Contains(item.Item.ItemId))
return true;
if (rules.AllowedItemNamePatterns.Count > 0)
{
for (int i = 0; i < rules.AllowedItemNamePatterns.Count; i++)
{
string pattern = rules.AllowedItemNamePatterns[i];
if (string.IsNullOrWhiteSpace(pattern))
continue;
var regex = RegexCache.GetOrCreate(pattern);
if (regex != null && regex.IsMatch(item.Name))
return true;
}
}
return false;
}
return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool InRange<T>(T value, T min, T max) where T : struct, IComparable<T>
=> value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0;
public static bool IsCatchAll(UserCategoryDefinition userCategory)
{
var rules = userCategory.Rules;
if (rules.AllowedItemIds.Count > 0)
return false;
if (rules.AllowedItemNamePatterns.Count > 0)
return false;
if (rules.AllowedUiCategoryIds.Count > 0)
return false;
if (rules.AllowedRarities.Count > 0)
return false;
if (rules.Level.Enabled)
return false;
if (rules.ItemLevel.Enabled)
return false;
if (rules.VendorPrice.Enabled)
return false;
if (rules.Untradable.ToggleState != ToggleFilterState.Ignored)
return false;
if (rules.Unique.ToggleState != ToggleFilterState.Ignored)
return false;
if (rules.Collectable.ToggleState != ToggleFilterState.Ignored)
return false;
if (rules.Dyeable.ToggleState != ToggleFilterState.Ignored)
return false;
if (rules.Repairable.ToggleState != ToggleFilterState.Ignored)
return false;
return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool MatchesToggle(StateFilter filter, bool itemHasProperty)
{
var state = filter.ToggleState;
if (state == ToggleFilterState.Ignored) return true;
if (state == ToggleFilterState.Allow) return itemHasProperty;
if (state == ToggleFilterState.Disallow) return !itemHasProperty;
return true;
}
}
@@ -0,0 +1,188 @@
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace AetherBags.Inventory.Context;
public enum HighlightSource
{
Search,
AllaganTools,
BiSBuddy,
Relationship,
}
public record HighlightEntry(uint ItemId, Vector3 Color);
public static class HighlightState
{
private static readonly Dictionary<HighlightSource, HashSet<uint>> Filters = new();
private static readonly Dictionary<HighlightSource, (HashSet<uint> ids, Vector3 color)> Labels = new();
private static readonly Dictionary<HighlightSource, Dictionary<uint, HighlightEntry>> PerItemLabels = new();
// Flat cache for O(1) lookups
private static readonly Dictionary<uint, HighlightEntry> CachedEntries = new(capacity: 512);
private static bool _cacheValid;
private static int _version;
/// <summary>
/// Version counter that increments when highlight state changes.
/// Used by ItemInfo to detect when cached visual state is stale.
/// </summary>
public static int Version => _version;
public static string? SelectedAllaganToolsFilterKey { get; set; } = string.Empty;
public static string? SelectedBisBuddyFilterKey { get; set; } = string.Empty;
public static bool IsFilterActive => Filters.Count > 0;
public static void SetFilter(HighlightSource source, IEnumerable<uint> ids)
{
Filters[source] = new HashSet<uint>(ids);
_version++;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsInActiveFilters(uint itemId)
{
if (Filters.Count == 0) return true;
foreach (var filter in Filters.Values)
if (filter.Contains(itemId)) return true;
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static HighlightEntry? GetHighlightEntry(uint itemId)
{
EnsureCacheValid();
return CachedEntries.TryGetValue(itemId, out var entry) ? entry : null;
}
private static void EnsureCacheValid()
{
if (_cacheValid) return;
CachedEntries.Clear();
// PerItemLabels have priority - add them first
foreach (var perItemLabel in PerItemLabels.Values)
{
foreach (var (id, entry) in perItemLabel)
{
CachedEntries.TryAdd(id, entry);
}
}
// Labels are fallback - only add if not already present
foreach (var label in Labels.Values)
{
var color = label.color;
foreach (var id in label.ids)
{
CachedEntries.TryAdd(id, new HighlightEntry(id, color));
}
}
_cacheValid = true;
}
private static void InvalidateCache()
{
_cacheValid = false;
_version++;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector3? GetLabelColor(uint itemId)
=> GetHighlightEntry(itemId)?.Color;
public static void SetLabel(HighlightSource source, IEnumerable<uint> ids, Vector3 color)
{
PerItemLabels.Remove(source);
Labels[source] = (new HashSet<uint>(ids), color);
InvalidateCache();
}
public static void SetLabelWithColors(HighlightSource source, Dictionary<uint, Vector4> itemColors)
{
Labels.Remove(source);
var entries = new Dictionary<uint, HighlightEntry>(itemColors.Count);
foreach (var (itemId, color) in itemColors)
{
var rgb = new Vector3(
color.X * color.W,
color.Y * color.W,
color.Z * color.W
);
entries[itemId] = new HighlightEntry(itemId, rgb);
}
PerItemLabels[source] = entries;
InvalidateCache();
}
public static void SetLabelWithColors(HighlightSource source, IEnumerable<HighlightEntry> entries)
{
Labels.Remove(source);
var dict = new Dictionary<uint, HighlightEntry>();
foreach (var entry in entries)
{
dict[entry.ItemId] = entry;
}
PerItemLabels[source] = dict;
InvalidateCache();
}
public static void SetLabelWithColors(HighlightSource source, Dictionary<uint, Vector3> itemColors)
{
Labels.Remove(source);
var entries = new Dictionary<uint, HighlightEntry>(itemColors.Count);
foreach (var (itemId, color) in itemColors)
{
entries[itemId] = new HighlightEntry(itemId, color);
}
PerItemLabels[source] = entries;
InvalidateCache();
}
public static void ClearAll()
{
Filters.Clear();
Labels.Clear();
PerItemLabels.Clear();
CachedEntries.Clear();
_cacheValid = true; // Empty cache is valid
_version++;
SelectedAllaganToolsFilterKey = string.Empty;
}
public static void ClearFilter(HighlightSource source)
{
Filters.Remove(source);
_version++;
}
public static void ClearLabel(HighlightSource source)
{
Labels.Remove(source);
PerItemLabels.Remove(source);
InvalidateCache();
}
public static void SetRelationshipHighlight(HashSet<uint>? relatedItemIds, Vector3? color)
{
if (relatedItemIds == null || relatedItemIds.Count == 0)
{
ClearLabel(HighlightSource.Relationship);
return;
}
var highlightColor = color ?? new Vector3(0.3f, 0.6f, 0.9f);
SetLabel(HighlightSource.Relationship, relatedItemIds, highlightColor);
}
}
@@ -0,0 +1,157 @@
using System.Collections.Generic;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Arrays;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
namespace AetherBags.Inventory.Context;
public static unsafe class InventoryContextState
{
private static readonly HashSet<(int page, int slot)> EligibleSlots = new();
private static readonly HashSet<(InventoryType container, int slot)> BlockedSlots = new();
private static readonly Dictionary<InventoryMappedLocation, InventoryMappedLocation> VisualLocationMap = new();
private static readonly Dictionary<int, Dictionary<InventoryMappedLocation, InventoryMappedLocation>> GroupedLocationMaps = new();
private static uint _lastContextId;
public static uint ActiveContextId => _lastContextId;
public static bool HasActiveContext => _lastContextId != 0;
public static void RefreshMaps()
{
EligibleSlots.Clear();
VisualLocationMap.Clear();
GroupedLocationMaps.Clear();
var itemOrderModule = ItemOrderModule.Instance();
if (itemOrderModule == null) return;
var agentInventory = AgentInventory.Instance();
bool hasContext = agentInventory != null && agentInventory->OpenTitleId != 0;
_lastContextId = hasContext ? agentInventory->OpenTitleId : 0;
var invArray = hasContext ? InventoryNumberArray.Instance() : null;
// Helper local to process any sorter
void ProcessSorter(ItemOrderModuleSorter* sorter)
{
if (sorter == null) return;
// Determine actual page size.
// We prefer the physical container size over the sorter's 'ItemsPerPage'
var baseInventoryType = sorter->InventoryType;
var inventoryManager = InventoryManager.Instance();
var container = inventoryManager != null ? inventoryManager->GetInventoryContainer(baseInventoryType) : null;
// Fallback to sorter value if container isn't loaded, but default to 35 for main/retainer
int itemsPerPage = baseInventoryType.UIPageSize;
if (itemsPerPage <= 0) itemsPerPage = 35;
var baseAgentId = (int)baseInventoryType.AgentItemContainerId;
if (baseAgentId == 0) return;
long count = sorter->Items.LongCount;
for (int displayIdx = 0; displayIdx < count; displayIdx++)
{
var entry = sorter->Items[displayIdx].Value;
if (entry == null) continue;
var realContainer = (InventoryType)((int)baseInventoryType + entry->Page);
int realSlot = entry->Slot;
int visualPage = displayIdx / itemsPerPage;
int visualSlot = displayIdx % itemsPerPage;
int visualContainerId = baseAgentId + visualPage;
var realKey = new InventoryMappedLocation((int)realContainer, realSlot);
var visualValue = new InventoryMappedLocation(visualContainerId, visualSlot);
VisualLocationMap[realKey] = visualValue;
if (hasContext && invArray != null && baseInventoryType.IsMainInventory)
{
var itemData = invArray->Items[displayIdx];
if (itemData.IconId != 0)
{
bool eligible = itemData.ItemFlags.MirageFlag == 0;
if (eligible)
EligibleSlots.Add(((int)realContainer - (int)InventoryType.Inventory1, realSlot));
}
}
}
}
ProcessSorter(itemOrderModule->InventorySorter);
ProcessSorter(itemOrderModule->ArmouryMainHandSorter);
ProcessSorter(itemOrderModule->ArmouryOffHandSorter);
ProcessSorter(itemOrderModule->ArmouryHeadSorter);
ProcessSorter(itemOrderModule->ArmouryBodySorter);
ProcessSorter(itemOrderModule->ArmouryHandsSorter);
ProcessSorter(itemOrderModule->ArmouryLegsSorter);
ProcessSorter(itemOrderModule->ArmouryFeetSorter);
ProcessSorter(itemOrderModule->ArmouryEarsSorter);
ProcessSorter(itemOrderModule->ArmouryNeckSorter);
ProcessSorter(itemOrderModule->ArmouryWristsSorter);
ProcessSorter(itemOrderModule->ArmouryRingsSorter);
ProcessSorter(itemOrderModule->ArmourySoulCrystalSorter);
ProcessSorter(itemOrderModule->SaddleBagSorter);
ProcessSorter(itemOrderModule->PremiumSaddleBagSorter);
try
{
var activeRetainerSorter = itemOrderModule->GetActiveRetainerSorter();
ProcessSorter(activeRetainerSorter);
}
catch
{
// GetActiveRetainerSorter is a member function — guard just in case
}
}
public static void RefreshBlockedSlots()
{
BlockedSlots.Clear();
var inventoryManager = InventoryManager.Instance();
if (inventoryManager == null) return;
var blockedContainer = inventoryManager->GetInventoryContainer(InventoryType.BlockedItems);
if (blockedContainer == null) return;
for (int i = 0; i < blockedContainer->Size; i++)
{
ref var item = ref blockedContainer->Items[i];
if (item.ItemId == 0) continue;
BlockedSlots.Add((item.Container, item.Slot));
}
}
public static bool IsEligible(int page, int slot)
=> EligibleSlots.Contains((page, slot));
public static bool IsSlotBlocked(InventoryType container, int slot)
=> BlockedSlots.Contains((container, slot));
public static InventoryMappedLocation GetVisualLocation(InventoryType realContainer, int slot)
{
var key = new InventoryMappedLocation((int)realContainer, slot);
if (VisualLocationMap.TryGetValue(key, out var result))
return result;
// default fallback: use the agent container id for the real container (works for Inventory1..4, RetainerPageN, etc.)
var defaultAgentId = (int)realContainer.AgentItemContainerId;
if (defaultAgentId == 0)
{
// final fallback: Inventory1 base at 48
defaultAgentId = 48;
}
return new InventoryMappedLocation(defaultAgentId, slot);
}
}
@@ -0,0 +1,98 @@
using System.Collections.Generic;
using Lumina.Excel.Sheets;
using Lumina.Text.ReadOnly;
namespace AetherBags.Inventory.Context;
public class InventoryNotificationState
{
private readonly Dictionary<InventoryNotificationType, InventoryNotificationInfo> notificationCache;
public InventoryNotificationState()
{
var addonSheet = Services.DataManager.GetExcelSheet<Addon>();
notificationCache = new Dictionary<InventoryNotificationType, InventoryNotificationInfo>
{
{ InventoryNotificationType.Sell, new InventoryNotificationInfo(addonSheet.GetRow(530).Text, addonSheet.GetRow(3576).Text) },
{ InventoryNotificationType.Trade, new InventoryNotificationInfo(addonSheet.GetRow(531).Text, addonSheet.GetRow(3572).Text) },
{ InventoryNotificationType.Letters, new InventoryNotificationInfo(addonSheet.GetRow(549).Text, addonSheet.GetRow(3575).Text) },
{ InventoryNotificationType.Retainer, new InventoryNotificationInfo(addonSheet.GetRow(532).Text, addonSheet.GetRow(3573).Text) },
{ InventoryNotificationType.RetainerEquip, new InventoryNotificationInfo(addonSheet.GetRow(778).Text, addonSheet.GetRow(3585).Text) },
{ InventoryNotificationType.Equip, new InventoryNotificationInfo(addonSheet.GetRow(538).Text, addonSheet.GetRow(3577).Text) },
{ InventoryNotificationType.Armory, new InventoryNotificationInfo(addonSheet.GetRow(775).Text, addonSheet.GetRow(3578).Text) },
{ InventoryNotificationType.Markets, new InventoryNotificationInfo(addonSheet.GetRow(548).Text, addonSheet.GetRow(3574).Text) },
{ InventoryNotificationType.Trade2, new InventoryNotificationInfo(addonSheet.GetRow(531).Text, addonSheet.GetRow(3572).Text) },
{ InventoryNotificationType.CompanyChest, new InventoryNotificationInfo(addonSheet.GetRow(776).Text, addonSheet.GetRow(3579).Text) },
{ InventoryNotificationType.Exterior, new InventoryNotificationInfo(addonSheet.GetRow(3583).Text, addonSheet.GetRow(3581).Text) },
{ InventoryNotificationType.Interior, new InventoryNotificationInfo(addonSheet.GetRow(3584).Text, addonSheet.GetRow(3582).Text) },
{ InventoryNotificationType.Layout, new InventoryNotificationInfo(addonSheet.GetRow(6237).Text, addonSheet.GetRow(3580).Text) },
{ InventoryNotificationType.Plant, new InventoryNotificationInfo(addonSheet.GetRow(6416).Text, addonSheet.GetRow(6418).Text) },
{ InventoryNotificationType.Fertilize, new InventoryNotificationInfo(addonSheet.GetRow(6417).Text, addonSheet.GetRow(6419).Text) },
{ InventoryNotificationType.Transmutation, new InventoryNotificationInfo(addonSheet.GetRow(3911).Text, addonSheet.GetRow(3901).Text) },
{ InventoryNotificationType.Reward, new InventoryNotificationInfo(addonSheet.GetRow(6503).Text, addonSheet.GetRow(6502).Text) },
{ InventoryNotificationType.Feed, new InventoryNotificationInfo(addonSheet.GetRow(6519).Text, addonSheet.GetRow(6518).Text) },
{ InventoryNotificationType.Charge, new InventoryNotificationInfo(addonSheet.GetRow(8638).Text, addonSheet.GetRow(8637).Text) },
{ InventoryNotificationType.Convert, new InventoryNotificationInfo(addonSheet.GetRow(8647).Text, addonSheet.GetRow(8646).Text) },
{ InventoryNotificationType.Covering, new InventoryNotificationInfo(addonSheet.GetRow(9029).Text, addonSheet.GetRow(9028).Text) },
{ InventoryNotificationType.Feed2, new InventoryNotificationInfo(addonSheet.GetRow(9041).Text, addonSheet.GetRow(9040).Text) },
{ InventoryNotificationType.Manual, new InventoryNotificationInfo(addonSheet.GetRow(9044).Text, addonSheet.GetRow(9043).Text) },
{ InventoryNotificationType.Chocobo, new InventoryNotificationInfo(addonSheet.GetRow(9073).Text, addonSheet.GetRow(9072).Text) },
{ InventoryNotificationType.Outfit, new InventoryNotificationInfo(addonSheet.GetRow(6578).Text, addonSheet.GetRow(6579).Text) },
{ InventoryNotificationType.Outfit2, new InventoryNotificationInfo(addonSheet.GetRow(6578).Text, addonSheet.GetRow(6579).Text) },
{ InventoryNotificationType.Plant2, new InventoryNotificationInfo(addonSheet.GetRow(6416).Text, addonSheet.GetRow(6418).Text) },
{ InventoryNotificationType.Aquarium, new InventoryNotificationInfo(addonSheet.GetRow(6808).Text, addonSheet.GetRow(6807).Text) },
{ InventoryNotificationType.SaddleBag, new InventoryNotificationInfo(addonSheet.GetRow(891).Text, addonSheet.GetRow(892).Text) },
{ InventoryNotificationType.Donate, new InventoryNotificationInfo(addonSheet.GetRow(11595).Text, addonSheet.GetRow(11596).Text) },
{ InventoryNotificationType.Trade3, new InventoryNotificationInfo(addonSheet.GetRow(531).Text, addonSheet.GetRow(3572).Text) },
{ InventoryNotificationType.Trade4, new InventoryNotificationInfo(addonSheet.GetRow(531).Text, addonSheet.GetRow(3572).Text) },
{ InventoryNotificationType.Exterior2, new InventoryNotificationInfo(addonSheet.GetRow(3583).Text, addonSheet.GetRow(3581).Text) },
{ InventoryNotificationType.Interior2, new InventoryNotificationInfo(addonSheet.GetRow(6237).Text, addonSheet.GetRow(3580).Text) },
};
}
public InventoryNotificationInfo? GetNotificationInfo(uint openTitleId)
{
return notificationCache.GetValueOrDefault((InventoryNotificationType)openTitleId);
}
}
public record InventoryNotificationInfo(ReadOnlySeString Title, ReadOnlySeString Message);
public enum InventoryNotificationType : uint
{
None = 0,
Sell = 1,
Trade = 2,
Letters = 3,
Retainer = 4,
RetainerEquip = 5,
Equip = 6,
Armory = 7,
Markets = 8,
Trade2 = 9,
CompanyChest = 10,
Exterior = 11,
Interior = 12,
Layout = 13,
Plant = 14,
Fertilize = 15,
Transmutation = 16,
Reward = 17,
Feed = 18,
Charge = 19,
Convert = 20,
Covering = 21,
Feed2 = 22,
Manual = 23,
Chocobo = 24,
Outfit = 25,
Outfit2 = 26,
Plant2 = 27,
Aquarium = 28,
SaddleBag = 29,
Donate = 30,
Trade3 = 31,
Trade4 = 32,
Exterior2 = 33,
Interior2 = 34
}
+25
View File
@@ -0,0 +1,25 @@
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory;
public readonly record struct InventoryLocation(InventoryType Container, ushort Slot)
{
public static readonly InventoryLocation Invalid = new((InventoryType)uint.MaxValue, ushort.MaxValue);
public bool IsValid => Container.IsMainInventory ||
Container.IsSaddleBag ||
Container.IsArmory ||
Container.IsRetainer ||
Container == InventoryType.EquippedItems;
public override string ToString() => $"{Container}@{Slot}";
}
public readonly record struct InventoryMappedLocation(int Container, int Slot)
{
public static readonly InventoryMappedLocation Invalid = new(-1, -1);
public bool IsValid => Container != 0;
public override string ToString() => $"{Container}@{Slot}";
}
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using AetherBags.Addons;
using AetherBags.Inventory.Context;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
namespace AetherBags.Inventory;
public static unsafe class InventoryOrchestrator
{
private static readonly InventoryNotificationState NotificationState = new();
private static bool _isRefreshing;
public static void RefreshAll(bool updateMaps = true)
{
if (_isRefreshing)
return;
try
{
_isRefreshing = true;
if (updateMaps)
{
InventoryContextState.RefreshMaps();
InventoryContextState.RefreshBlockedSlots();
}
if (!HasAnyWindowOpen())
return;
var agent = AgentInventory.Instance();
var contextId = agent != null ? agent->OpenTitleId : 0;
var notification = NotificationState.GetNotificationInfo(contextId);
Services.Framework.RunOnTick(() =>
{
if (notification != null && System.AddonInventoryWindow.IsOpen)
System.AddonInventoryWindow.SetNotification(notification);
foreach (var window in GetAllWindows())
{
window.ManualRefresh();
}
});
}
finally
{
_isRefreshing = false;
}
}
public static void CloseAll()
{
foreach (var window in GetAllWindows())
{
window.Close();
}
}
public static void RefreshHighlights()
{
if (!HasAnyWindowOpen())
return;
Services.Framework.RunOnTick(() =>
{
foreach (var window in GetAllWindows())
{
window.ItemRefresh();
}
});
}
private static bool HasAnyWindowOpen()
{
foreach (var window in GetAllWindows())
{
if (window.IsOpen)
return true;
}
return false;
}
private static IEnumerable<IInventoryWindow> GetAllWindows()
{
if (System.AddonInventoryWindow != null)
yield return System.AddonInventoryWindow;
if (System.AddonSaddleBagWindow != null)
yield return System.AddonSaddleBagWindow;
if (System.AddonRetainerWindow != null)
yield return System.AddonRetainerWindow;
}
}
@@ -0,0 +1,21 @@
namespace AetherBags.Inventory.Items;
public readonly struct InventoryStats
{
public int TotalItems { get; init; }
public int TotalQuantity { get; init; }
public int EmptySlots { get; init; }
public int TotalSlots { get; init; }
public int CategoryCount { get; init; }
public int UsedSlots => TotalSlots - EmptySlots;
public float UsagePercent => TotalSlots > 0 ? (float)UsedSlots / TotalSlots * 100f : 0f;
public static InventoryStats operator +(InventoryStats a, InventoryStats b) => new()
{
TotalItems = a.TotalItems + b.TotalItems,
TotalQuantity = a.TotalQuantity + b.TotalQuantity,
EmptySlots = a.EmptySlots + b.EmptySlots,
TotalSlots = a.TotalSlots + b.TotalSlots,
CategoryCount = a.CategoryCount + b.CategoryCount,
};
}
+222
View File
@@ -0,0 +1,222 @@
using System;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using AetherBags.Helpers;
using AetherBags.Inventory.Context;
using AetherBags.IPC.ExternalCategorySystem;
using FFXIVClientStructs.FFXIV.Client.Game;
using Lumina.Excel;
using Lumina.Excel.Sheets;
namespace AetherBags.Inventory.Items;
public sealed class ItemInfo : IEquatable<ItemInfo>
{
public required ulong Key { get; set; }
public required InventoryItem Item { get; set; }
public required int ItemCount { get; set; }
private static ExcelSheet<Item>? s_itemSheet;
private static ExcelSheet<Item> ItemSheet => s_itemSheet ??= Services.DataManager.GetExcelSheet<Item>();
private bool _rowLoaded;
private Item _row;
private string? _name;
private string? _description;
private string? _levelString;
private string? _itemLevelString;
private int _cachedHighlightVersion = -1;
private float _cachedVisualAlpha;
private Vector3 _cachedHighlightColor;
private bool _cachedIsRelationshipHighlighted;
private ref readonly Item Row
{
get
{
if (!_rowLoaded)
{
_row = ItemSheet.GetRow(Item.ItemId);
_rowLoaded = true;
}
return ref _row;
}
}
public Vector4 RarityColor => Row.RarityColor;
public uint IconId => Row.Icon;
public string Name => _name ??= Row.Name.ToString();
public int Level => Row.LevelEquip;
public int ItemLevel => (int)Row.LevelItem.RowId;
private string LevelString => _levelString ??= Level.ToString();
private string ItemLevelString => _itemLevelString ??= ItemLevel.ToString();
public int Rarity => Row.Rarity;
public uint VendorPrice => Row.PriceLow;
public uint StackSize => Row.StackSize;
public RowRef<ItemUICategory> UiCategory => Row.ItemUICategory;
public bool IsUntradable => Row.IsUntradable;
public bool IsUnique => Row.IsUnique;
public bool IsCollectable => Row.IsCollectable;
public bool IsDyeable => Row.DyeCount > 0;
public bool IsRepairable => Row.ItemRepair.RowId != 0;
public bool IsHq => Item.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
public bool IsDesynthesizable => Row.Desynth > 0;
public bool IsCraftable => Row.ItemAction.RowId != 0 || Row.CanBeHq;
public bool IsGlamourable => Row.IsGlamorous;
public bool IsSpiritbonded => Item.SpiritbondOrCollectability >= 10000; // 100% = 10000
private string Description => _description ??= Row.Description.ToString();
public InventoryMappedLocation VisualLocation => InventoryContextState.GetVisualLocation(Item.Container, Item.Slot);
public int InventoryPage => Item.Container switch
{
InventoryType.Inventory1 => 0,
InventoryType.Inventory2 => 1,
InventoryType.Inventory3 => 2,
InventoryType.Inventory4 => 3,
_ => -1
};
public bool IsSlotBlocked => InventoryContextState.IsSlotBlocked(Item.Container, Item.Slot);
public bool IsEligibleForContext
{
get
{
if (IsSlotBlocked) return false;
if (!CheckNativeContextEligibility()) return false;
if (!HighlightState.IsInActiveFilters(Item.ItemId)) return false;
return true;
}
}
public float VisualAlpha
{
get
{
EnsureVisualStateCached();
return _cachedVisualAlpha;
}
}
public Vector3 HighlightOverlayColor
{
get
{
EnsureVisualStateCached();
return _cachedHighlightColor;
}
}
public bool IsRelationshipHighlighted
{
get
{
EnsureVisualStateCached();
return _cachedIsRelationshipHighlighted;
}
}
private void EnsureVisualStateCached()
{
int currentVersion = HighlightState.Version;
if (_cachedHighlightVersion == currentVersion)
return;
_cachedVisualAlpha = IsEligibleForContext ? 1.0f : 0.4f;
_cachedHighlightColor = System.Config.Categories.BisBuddyEnabled
? HighlightState.GetLabelColor(Item.ItemId) ?? Vector3.Zero
: Vector3.Zero;
var entry = HighlightState.GetHighlightEntry(Item.ItemId);
_cachedIsRelationshipHighlighted = entry != null;
_cachedHighlightVersion = currentVersion;
}
private bool CheckNativeContextEligibility()
{
uint contextId = InventoryContextState.ActiveContextId;
if (contextId == 0) return true;
bool isRetainerContext = contextId == 4;
bool isSaddlebagContext = contextId == 29;
bool isMainContext = !isRetainerContext && isSaddlebagContext == false;
if (IsMainInventory)
{
if (!isMainContext) return true;
return InventoryContextState.IsEligible(InventoryPage, Item.Slot);
}
if (Item.Container.IsRetainer)
{
if (!isRetainerContext) return true;
}
if (Item.Container.IsSaddleBag)
{
if (!isSaddlebagContext) return true;
}
return true;
}
public bool IsMainInventory => InventoryPage >= 0;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsRegexMatch(string searchTerms)
{
if (string.IsNullOrEmpty(searchTerms))
return true;
var re = RegexCache.GetOrCreate(searchTerms);
if (re == null)
return false;
if (re.IsMatch(Name)) return true;
if (re.IsMatch(LevelString)) return true;
if (re.IsMatch(ItemLevelString)) return true;
if (ExternalCategoryManager.MatchesSearchTag(Item.ItemId, searchTerms)) return true;
if (re.IsMatch(Description)) return true;
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsRegexMatch(Regex re)
{
if (re.IsMatch(Name)) return true;
if (re.IsMatch(LevelString)) return true;
if (re.IsMatch(ItemLevelString)) return true;
if (re.IsMatch(Description)) return true;
return false;
}
public bool DescriptionContains(string value)
=> Description.Contains(value, StringComparison.OrdinalIgnoreCase);
public bool Equals(ItemInfo? other)
=> other is not null && Key == other.Key;
public override bool Equals(object? obj)
=> obj is ItemInfo other && Equals(other);
public override int GetHashCode()
=> Key.GetHashCode();
}
@@ -0,0 +1,5 @@
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.Items;
public record LootedItemInfo(int Index, InventoryItem Item, int Quantity);
@@ -0,0 +1,9 @@
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.Scanning;
public struct AggregatedItem
{
public InventoryItem First;
public int Total;
}
@@ -0,0 +1,219 @@
using System.Collections.Generic;
using AetherBags.Configuration;
using AetherBags.Inventory.Items;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.Scanning;
public static unsafe class InventoryScanner
{
public static readonly InventoryType[] StandardInventories =
[
InventoryType.Inventory1,
InventoryType.Inventory2,
InventoryType.Inventory3,
InventoryType.Inventory4,
InventoryType.EquippedItems,
InventoryType.ArmoryMainHand,
InventoryType.ArmoryHead,
InventoryType.ArmoryBody,
InventoryType.ArmoryHands,
InventoryType.ArmoryWaist,
InventoryType.ArmoryLegs,
InventoryType.ArmoryFeets,
InventoryType.ArmoryOffHand,
InventoryType.ArmoryEar,
InventoryType.ArmoryNeck,
InventoryType.ArmoryWrist,
InventoryType.ArmoryRings,
InventoryType.Currency,
InventoryType.Crystals,
InventoryType.ArmorySoulCrystal,
];
private const ulong AggregatedKeyTag = 1UL << 63;
public static ulong MakeAggregatedItemKey(uint itemId, bool isHighQuality)
=> AggregatedKeyTag | ((ulong)itemId << 1) | (isHighQuality ? 1UL : 0UL);
public static ulong MakeNaturalSlotKey(InventoryType container, int slot)
=> ((ulong)(uint)container << 32) | (uint)slot;
public static void ScanInventories(
InventoryManager* inventoryManager,
InventoryStackMode stackMode,
Dictionary<ulong, AggregatedItem> aggByKey,
InventorySourceType source)
{
aggByKey.Clear();
var inventories = InventorySourceDefinitions.GetInventories(source);
int scannedSlots = 0;
int nonEmptySlots = 0;
int collisions = 0;
for (int inventoryIndex = 0; inventoryIndex < inventories.Length; inventoryIndex++)
{
var inventoryType = inventories[inventoryIndex];
var container = inventoryManager->GetInventoryContainer(inventoryType);
if (container == null)
{
Services.Logger.DebugOnly($"Container null: {inventoryType}");
continue;
}
int size = container->Size;
Services.Logger.DebugOnly($"Scanning {inventoryType} Size={size}");
for (int slot = 0; slot < size; slot++)
{
scannedSlots++;
ref var item = ref container->Items[slot];
uint id = item.ItemId;
if (id == 0)
continue;
nonEmptySlots++;
int quantity = item.Quantity;
bool isHq = (item.Flags & InventoryItem.ItemFlags.HighQuality) != 0;
ulong key = stackMode == InventoryStackMode.AggregateByItemId
? MakeAggregatedItemKey(id, isHq)
: MakeNaturalSlotKey(inventoryType, slot);
Services.Logger.DebugOnly($"Slot {inventoryType}[{slot}] ItemId={id} Qty={quantity} Key=0x{key: X16}");
if (aggByKey.TryGetValue(key, out AggregatedItem agg))
{
if (stackMode == InventoryStackMode.NaturalStacks)
{
collisions++;
Services.Logger.DebugOnly($"COLLISION Key=0x{key:X16}: existing ItemId={agg.First.ItemId} new ItemId={id}");
}
agg.Total += quantity;
aggByKey[key] = agg;
}
else
{
aggByKey.Add(key, new AggregatedItem { First = item, Total = quantity });
}
}
}
Services.Logger.DebugOnly($"ScannedSlots={scannedSlots} NonEmptySlots={nonEmptySlots} AggByKey.Count={aggByKey.Count} Collisions={collisions}");
}
public static void BuildItemInfos(
Dictionary<ulong, AggregatedItem> aggByKey,
Dictionary<ulong, ItemInfo> itemInfoByKey)
{
foreach (var kvp in aggByKey)
{
ulong key = kvp.Key;
AggregatedItem agg = kvp.Value;
if (!itemInfoByKey.TryGetValue(key, out ItemInfo? info))
{
info = new ItemInfo
{
Key = key,
Item = agg.First,
ItemCount = agg.Total,
};
itemInfoByKey.Add(key, info);
}
else
{
info.Item = agg.First;
info.ItemCount = agg.Total;
}
}
Services.Logger.DebugOnly($"ItemInfoByKey.Count={itemInfoByKey.Count}");
}
public static void PruneStaleItemInfos(
Dictionary<ulong, AggregatedItem> aggByKey,
Dictionary<ulong, ItemInfo> itemInfoByKey,
List<ulong> removeKeysScratch)
{
if (itemInfoByKey.Count == aggByKey.Count)
return;
removeKeysScratch.Clear();
foreach (var kvp in itemInfoByKey)
{
ulong key = kvp.Key;
if (!aggByKey.ContainsKey(key))
removeKeysScratch.Add(key);
}
for (int i = 0; i < removeKeysScratch.Count; i++)
itemInfoByKey.Remove(removeKeysScratch[i]);
}
public static InventoryContainer* GetInventoryContainer(InventoryType inventoryType)
=> InventoryManager.Instance()->GetInventoryContainer(inventoryType);
public static InventoryLocation GetFirstEmptySlot(InventorySourceType source)
{
var manager = InventoryManager.Instance();
var containers = InventorySourceDefinitions.GetContainersForSource(source);
foreach (var type in containers)
{
var container = manager->GetInventoryContainer(type);
if (container == null || container->Size == 0) continue;
for (int i = 0; i < container->Size; i++)
{
if (container->Items[i].ItemId == 0)
return new InventoryLocation(type, (ushort)i);
}
}
return InventoryLocation.Invalid;
}
public static int GetEmptySlots(InventorySourceType source) => (int)(source switch
{
InventorySourceType.MainBags => InventoryManager.Instance()->GetEmptySlotsInBag(),
InventorySourceType.SaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.SaddleBag),
InventorySourceType.PremiumSaddleBag => GetEmptySlotsInContainer(InventorySourceDefinitions.PremiumSaddleBag),
InventorySourceType.AllSaddleBags => GetEmptySlotsInContainer(InventorySourceDefinitions.AllSaddleBags),
InventorySourceType.Retainer => GetEmptySlotsInContainer(InventorySourceDefinitions.Retainer),
_ => 0u,
});
public static string GetEmptySlotsString(InventorySourceType source)
{
int total = InventorySourceDefinitions.GetTotalSlots(source);
int empty = GetEmptySlots(source);
int used = total - empty;
return $"{used}/{total}";
}
private static uint GetEmptySlotsInContainer(InventoryType[] inventories)
{
uint empty = 0;
var inventoryManager = InventoryManager.Instance();
foreach (var inv in inventories)
{
var container = inventoryManager->GetInventoryContainer(inv);
var containerSize = container->Size;
if (container == null) continue;
for (int i = 0; i < containerSize; i++)
{
if (container->Items[i]. ItemId == 0)
empty++;
}
}
return empty;
}
}
@@ -0,0 +1,84 @@
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.Scanning;
public enum InventorySourceType
{
MainBags,
SaddleBag,
PremiumSaddleBag,
AllSaddleBags,
Retainer,
}
public static class InventorySourceDefinitions
{
public static readonly InventoryType[] MainBags =
[
InventoryType.Inventory1,
InventoryType.Inventory2,
InventoryType.Inventory3,
InventoryType.Inventory4,
];
public static readonly InventoryType[] SaddleBag =
[
InventoryType.SaddleBag1,
InventoryType.SaddleBag2,
];
public static readonly InventoryType[] PremiumSaddleBag =
[
InventoryType.PremiumSaddleBag1,
InventoryType.PremiumSaddleBag2,
];
public static readonly InventoryType[] AllSaddleBags =
[
InventoryType.SaddleBag1,
InventoryType.SaddleBag2,
InventoryType.PremiumSaddleBag1,
InventoryType.PremiumSaddleBag2,
];
public static readonly InventoryType[] Retainer =
[
InventoryType.RetainerPage1,
InventoryType.RetainerPage2,
InventoryType.RetainerPage3,
InventoryType.RetainerPage4,
InventoryType.RetainerPage5,
InventoryType.RetainerPage6,
InventoryType.RetainerPage7,
];
public static InventoryType[] GetInventories(InventorySourceType source) => source switch
{
InventorySourceType.MainBags => MainBags,
InventorySourceType.SaddleBag => SaddleBag,
InventorySourceType.PremiumSaddleBag => PremiumSaddleBag,
InventorySourceType.AllSaddleBags => AllSaddleBags,
InventorySourceType.Retainer => Retainer,
_ => MainBags,
};
public static InventoryType[] GetContainersForSource(InventorySourceType source) => source switch
{
InventorySourceType.MainBags => MainBags,
InventorySourceType.SaddleBag => SaddleBag,
InventorySourceType.PremiumSaddleBag => PremiumSaddleBag,
InventorySourceType.AllSaddleBags => AllSaddleBags,
InventorySourceType.Retainer => Retainer,
_ => MainBags,
};
public static int GetTotalSlots(InventorySourceType source) => source switch
{
InventorySourceType.MainBags => 140, // 4 * 35
InventorySourceType.SaddleBag => 70, // 2 * 35
InventorySourceType.PremiumSaddleBag => 70, // 2 * 35
InventorySourceType.AllSaddleBags => 140, // 2 * 35
InventorySourceType.Retainer => Retainer.Length * 25, // 7 * 25
_ => 140,
};
}
@@ -0,0 +1,252 @@
using System.Collections.Generic;
using AetherBags.Configuration;
using AetherBags.Currency;
using AetherBags.Inventory.Categories;
using AetherBags.Inventory.Context;
using AetherBags.Inventory.Items;
using AetherBags.Inventory.Scanning;
using AetherBags.IPC.ExternalCategorySystem;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.State;
public abstract class InventoryStateBase
{
protected readonly Dictionary<ulong, AggregatedItem> AggByKey = new(capacity: 512);
protected readonly Dictionary<ulong, ItemInfo> ItemInfoByKey = new(capacity: 512);
protected readonly Dictionary<uint, CategoryBucket> BucketsByKey = new(capacity: 256);
protected readonly List<uint> SortedCategoryKeys = new(capacity: 256);
protected readonly List<CategorizedInventory> AllCategories = new(capacity: 256);
protected readonly List<CategorizedInventory> FilteredCategories = new(capacity: 256);
protected readonly List<UserCategoryDefinition> UserCategoriesSortedScratch = new(capacity: 64);
protected readonly List<UserCategoryDefinition> EnabledUserCategoriesScratch = new(capacity: 64);
protected readonly List<ulong> RemoveKeysScratch = new(capacity: 256);
protected readonly HashSet<ulong> ClaimedKeys = new(capacity: 512);
public abstract InventorySourceType SourceType { get; }
public abstract InventoryType[] Inventories { get; }
public virtual unsafe void RefreshFromGame()
{
InventoryManager* inventoryManager = InventoryManager.Instance();
if (inventoryManager == null)
{
ClearAll();
return;
}
var config = System.Config;
InventoryStackMode stackMode = config.General.StackMode;
AggByKey.Clear();
ItemInfoByKey.Clear();
SortedCategoryKeys.Clear();
AllCategories.Clear();
FilteredCategories.Clear();
ClaimedKeys.Clear();
InventoryScanner.ScanInventories(inventoryManager, stackMode, AggByKey, SourceType);
CategoryBucketManager.ResetBuckets(BucketsByKey);
InventoryScanner.BuildItemInfos(AggByKey, ItemInfoByKey);
OnPostScan();
ApplyCategories(config);
InventoryScanner.PruneStaleItemInfos(AggByKey, ItemInfoByKey, RemoveKeysScratch);
CategoryBucketManager.SortBucketsAndBuildKeyList(BucketsByKey, SortedCategoryKeys);
CategoryBucketManager.BuildCategorizedList(BucketsByKey, SortedCategoryKeys, AllCategories);
}
protected virtual void OnPostScan()
{
}
protected virtual void ApplyCategories(SystemConfiguration config)
{
bool categoriesEnabled = config.Categories.CategoriesEnabled;
bool userCategoriesEnabled = config.Categories.UserCategoriesEnabled && categoriesEnabled;
bool gameCategoriesEnabled = config.Categories.GameCategoriesEnabled && categoriesEnabled;
bool allaganCategoriesEnabled = config.Categories.AllaganToolsCategoriesEnabled && categoriesEnabled;
bool bisCategoriesEnabled = config.Categories.BisBuddyEnabled && categoriesEnabled;
// TODO: Cache this when config changes
EnabledUserCategoriesScratch.Clear();
foreach (var cat in config.Categories.UserCategories)
{
if (cat.Enabled)
EnabledUserCategoriesScratch.Add(cat);
}
if (userCategoriesEnabled && EnabledUserCategoriesScratch.Count > 0)
{
CategoryBucketManager.BucketByUserCategories(
ItemInfoByKey,
EnabledUserCategoriesScratch,
BucketsByKey,
ClaimedKeys,
UserCategoriesSortedScratch
);
}
bool useUnified = config.General.UseUnifiedExternalCategories;
if (useUnified)
{
ExternalCategoryManager.BucketItems(ItemInfoByKey, BucketsByKey, ClaimedKeys);
if (allaganCategoriesEnabled && config.Categories.AllaganToolsFilterMode == PluginFilterMode.Highlight)
UpdateAllaganHighlight(HighlightState.SelectedAllaganToolsFilterKey);
else
HighlightState.ClearFilter(HighlightSource.AllaganTools);
if (bisCategoriesEnabled && config.Categories.BisBuddyMode == PluginFilterMode.Highlight)
UpdateBisBuddyHighlight(HighlightState.SelectedBisBuddyFilterKey);
else
HighlightState.ClearFilter(HighlightSource.BiSBuddy);
}
else
{
if (allaganCategoriesEnabled)
{
if (config.Categories.AllaganToolsFilterMode == PluginFilterMode.Categorize)
{
CategoryBucketManager.BucketByAllaganFilters(ItemInfoByKey, BucketsByKey, ClaimedKeys, true);
HighlightState.ClearFilter(HighlightSource.AllaganTools);
}
else
{
UpdateAllaganHighlight(HighlightState.SelectedAllaganToolsFilterKey);
}
}
else
{
HighlightState.ClearFilter(HighlightSource.AllaganTools);
}
if (bisCategoriesEnabled)
{
if (config.Categories.BisBuddyMode == PluginFilterMode.Categorize)
{
CategoryBucketManager.BucketByBisBuddyItems(ItemInfoByKey, BucketsByKey, ClaimedKeys, true);
HighlightState.ClearFilter(HighlightSource.BiSBuddy);
}
else
{
UpdateBisBuddyHighlight(HighlightState.SelectedBisBuddyFilterKey);
}
}
else
{
HighlightState.ClearFilter(HighlightSource.BiSBuddy);
}
}
if (gameCategoriesEnabled)
{
CategoryBucketManager.BucketByGameCategories(
ItemInfoByKey, BucketsByKey, ClaimedKeys, userCategoriesEnabled);
}
else
{
CategoryBucketManager.BucketUnclaimedToMisc(
ItemInfoByKey, BucketsByKey, ClaimedKeys, userCategoriesEnabled);
}
}
private void UpdateAllaganHighlight(string? filterKey)
{
if (string.IsNullOrEmpty(filterKey) || !System.IPC.AllaganTools.IsReady)
{
HighlightState.ClearFilter(HighlightSource.AllaganTools);
return;
}
var filterItems = System.IPC.AllaganTools.GetFilterItems(filterKey);
if (filterItems != null)
{
HighlightState.SetFilter(HighlightSource.AllaganTools, filterItems.Keys);
}
else
{
HighlightState.ClearFilter(HighlightSource.AllaganTools);
}
}
private void UpdateBisBuddyHighlight(string? filterKey)
{
if (string.IsNullOrEmpty(filterKey) || !System.IPC.BisBuddy.IsReady)
{
HighlightState.ClearFilter(HighlightSource.BiSBuddy);
return;
}
var bisItems = System.IPC.BisBuddy.ItemLookup;
if (bisItems.Count > 0)
{
HighlightState.SetFilter(HighlightSource.BiSBuddy, bisItems.Keys);
}
else
{
HighlightState.ClearFilter(HighlightSource.BiSBuddy);
}
}
public IReadOnlyList<CategorizedInventory> GetCategories(string filter = "", bool invert = false)
=> InventoryFilter.FilterCategories(AllCategories, BucketsByKey, FilteredCategories, filter, invert);
public string GetEmptySlotsString() => InventoryScanner.GetEmptySlotsString(SourceType);
public InventoryStats GetStats()
{
int totalItems = ItemInfoByKey.Count;
int totalQuantity = 0;
foreach (var kvp in ItemInfoByKey)
{
totalQuantity += kvp.Value.ItemCount;
}
int totalSlots = InventorySourceDefinitions.GetTotalSlots(SourceType);
int emptySlots = InventoryScanner.GetEmptySlots(SourceType);
var categories = GetCategories(string.Empty);
int categoryCount = categories.Count;
return new InventoryStats
{
TotalItems = totalItems,
TotalQuantity = totalQuantity,
EmptySlots = emptySlots,
TotalSlots = totalSlots,
CategoryCount = categoryCount,
};
}
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(uint[] currencyIds)
=> CurrencyState.GetCurrencyInfoList(currencyIds);
public static IReadOnlyList<CurrencyInfo> GetCurrencyInfoList(List<uint> currencyIds)
=> CurrencyState.GetCurrencyInfoList(currencyIds);
public static void InvalidateCurrencyCaches()
=> CurrencyState.InvalidateCaches();
protected virtual void ClearAll()
{
AggByKey.Clear();
ItemInfoByKey.Clear();
foreach (var kvp in BucketsByKey)
{
kvp.Value.Items.Clear();
kvp.Value.FilteredItems.Clear();
kvp.Value.Used = false;
}
SortedCategoryKeys.Clear();
AllCategories.Clear();
FilteredCategories.Clear();
RemoveKeysScratch.Clear();
ClaimedKeys.Clear();
}
}
@@ -0,0 +1,17 @@
using AetherBags.Inventory.Context;
using AetherBags.Inventory.Scanning;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags.Inventory.State;
public class MainBagState : InventoryStateBase
{
public override InventorySourceType SourceType => InventorySourceType.MainBags;
public override InventoryType[] Inventories => InventorySourceDefinitions.MainBags;
protected override void OnPostScan()
{
InventoryContextState.RefreshMaps();
InventoryContextState.RefreshBlockedSlots();
}
}
@@ -0,0 +1,65 @@
using AetherBags. Inventory.Scanning;
using FFXIVClientStructs.FFXIV.Client.Game;
namespace AetherBags. Inventory.State;
public class RetainerState : InventoryStateBase
{
public override InventorySourceType SourceType => InventorySourceType.Retainer;
public override InventoryType[] Inventories => InventorySourceDefinitions.Retainer;
public static unsafe ulong CurrentRetainerId
{
get
{
var retainerManager = RetainerManager.Instance();
if (retainerManager == null) return 0;
return retainerManager->LastSelectedRetainerId;
}
}
public static unsafe string CurrentRetainerName
{
get
{
var retainerManager = RetainerManager.Instance();
if (retainerManager == null) return string.Empty;
var retainer = retainerManager->GetActiveRetainer();
if (retainer == null) return string.Empty;
return retainer->NameString;
}
}
public static unsafe bool IsRetainerActive
{
get
{
if (! Services.ClientState.IsLoggedIn) return false;
var retainerManager = RetainerManager. Instance();
if (retainerManager == null) return false;
return retainerManager->LastSelectedRetainerId != 0;
}
}
public static unsafe bool AreContainersLoaded
{
get
{
if (!IsRetainerActive) return false;
var inventoryManager = FFXIVClientStructs.FFXIV.Client.Game.InventoryManager.Instance();
if (inventoryManager == null) return false;
var container = inventoryManager->GetInventoryContainer(InventoryType.RetainerPage1);
return container != null && container->Size > 0;
}
}
public static bool CanMoveItems => AreContainersLoaded;
}
@@ -0,0 +1,27 @@
using AetherBags.Inventory.Scanning;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.Game.UI;
namespace AetherBags.Inventory.State;
public class SaddleBagState : InventoryStateBase
{
public override InventorySourceType SourceType => HasPremiumSaddlebag
? InventorySourceType.AllSaddleBags
: InventorySourceType.SaddleBag;
public override InventoryType[] Inventories => HasPremiumSaddlebag
? InventorySourceDefinitions.AllSaddleBags
: InventorySourceDefinitions.SaddleBag;
private static unsafe bool HasPremiumSaddlebag
{
get
{
if (!Services.ClientState.IsLoggedIn) return false;
var playerState = PlayerState.Instance();
return playerState != null && playerState->HasPremiumSaddlebag;
}
}
}
+255
View File
@@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AetherBags.Configuration;
using AetherBags.Inventory.Context;
using AetherBags.Inventory.Scanning;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.Inventory.InventoryEventArgTypes;
using Dalamud.Game.NativeWrapper;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Text.ReadOnly;
using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace AetherBags.Monitoring;
public static unsafe class DragDropState
{
/// <summary>
/// Returns true if the game's drag-drop manager is currently dragging.
/// </summary>
public static bool IsDragging => AtkStage.Instance()->DragDropManager.IsDragging;
}
public class InventoryMonitor : IDisposable
{
public InventoryMonitor()
{
var bags = new[] { "Inventory", "InventoryLarge", "InventoryExpansion" };
var saddle = new[] { "InventoryBuddy" };
var retainer = new[] { "InventoryRetainer", "InventoryRetainerLarge" };
Services.AddonLifecycle.RegisterListener(AddonEvent.PostSetup, saddle, OnPostSetup);
Services.AddonLifecycle.RegisterListener(AddonEvent.PostSetup, retainer, OnPostSetup);
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, saddle, OnPreFinalize);
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, retainer, OnPreFinalize);
Services.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, bags, OnInventoryPreFinalize);
Services.AddonLifecycle.RegisterListener(AddonEvent.PreHide, bags, OnInventoryPreHide);
// PreRefresh Handlers
Services.AddonLifecycle.RegisterListener(AddonEvent.PreRefresh, bags, InventoryPreRefreshHandler);
// PostRequestedUpdate
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "Inventory", OnInventoryUpdate);
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "InventoryBuddy", OnSaddleBagUpdate);
Services.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, retainer, OnRetainerInventoryUpdate);
// Dalamud raw event for raw inventory changes (scans once per frame)
Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw;
Services.Logger.Verbose("InventoryLifecycles initialized");
}
private void OnPreFinalize(AddonEvent type, AddonArgs args)
{
CloseInventories(args.AddonName);
}
private void OnPostSetup(AddonEvent type, AddonArgs args)
{
OpenInventories(args.AddonName);
}
private void OnInventoryPreFinalize(AddonEvent type, AddonArgs args)
{
System.AddonInventoryWindow.Close();
}
private void OnInventoryPreHide(AddonEvent type, AddonArgs args)
{
if (System.Config.General.OpenWithGameInventory)
{
System.AddonInventoryWindow.Close();
}
}
private unsafe void OpenInventories(string name)
{
GeneralSettings config = System.Config.General;
if (name.Contains("Retainer") && config.OpenRetainerWithGameInventory)
{
System.AddonRetainerWindow.Open();
if (config.HideGameRetainer)
{
var addon = RaptureAtkUnitManager.Instance()->GetAddonByName("InventoryRetainer");
if (addon != null)
{
addon->IsVisible = false;
}
addon = RaptureAtkUnitManager.Instance()->GetAddonByName("InventoryRetainerLarge");
if (addon != null)
{
addon->IsVisible = false;
}
}
}
if (name.Contains("InventoryBuddy") && config.OpenSaddleBagsWithGameInventory)
{
System.AddonSaddleBagWindow.Open();
if (config.HideGameSaddleBags)
{
var addon = RaptureAtkUnitManager.Instance()->GetAddonByName("InventoryBuddy");
if (addon != null)
{
addon->IsVisible = false;
}
}
}
}
private void CloseInventories(string name)
{
if (name.Contains("Retainer")) System.AddonRetainerWindow.Close();
if (name.Contains("InventoryBuddy")) System.AddonSaddleBagWindow.Close();
}
private static bool IsInUnsafeState()
{
if (!Services.ClientState.IsLoggedIn)
return true;
return Services.Condition.Any(ConditionFlag.BetweenAreas, ConditionFlag.BetweenAreas51);
}
/*
values[0] = OpenType
values[1] = OpenTitleId
values[2] = tab index
values[3] = InventoryAddonId | (OpenerAddonId << 16)
values[4] = focus
values[5] = title
values[6] = upper title
values[7] = can use Saddlebags (Agent InventoryBuddy IsActivatable)
*/
private void OnInventoryChangedRaw(IReadOnlyCollection<InventoryEventArgs> events)
{
bool needsRefresh = false;
foreach (var inventoryEventArgs in events)
{
if (InventoryScanner.StandardInventories.Contains((InventoryType)inventoryEventArgs.Item.ContainerType))
{
needsRefresh = true;
break;
}
}
if (needsRefresh)
{
Services.Framework.RunOnTick(() =>
{
if (IsInUnsafeState() || DragDropState.IsDragging) return;
System.LootedItemsTracker.FlushPendingChanges();
System.AddonInventoryWindow?.RefreshFromLifecycle();
System.AddonSaddleBagWindow?.RefreshFromLifecycle();
System.AddonRetainerWindow?.RefreshFromLifecycle();
});
}
}
private unsafe void InventoryPreRefreshHandler(AddonEvent type, AddonArgs args)
{
if (args is not AddonRefreshArgs refreshArgs)
return;
if (IsInUnsafeState())
return;
GeneralSettings config = System.Config.General;
Services.Logger.DebugOnly("PreRefresh event for Inventory detected");
AtkValuePtr[] atkValues = refreshArgs.AtkValueEnumerable.ToArray();
if (atkValues.Length < 7) return;
AtkValue* value5 = (AtkValue*)atkValues[5].Address;
AtkValue* value6 = (AtkValue*)atkValues[6].Address;
if (value5->Type != ValueType.ManagedString || value6->Type != ValueType.ManagedString)
return;
ReadOnlySeString title = value5->String.AsReadOnlySeString();
ReadOnlySeString upperTitle = value6->String.AsReadOnlySeString();
System.AddonInventoryWindow.SetNotification(new InventoryNotificationInfo(title, upperTitle));
if (config.HideGameInventory)
{
refreshArgs.AtkValueCount = 0;
}
if (config.OpenWithGameInventory)
{
var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(args.AddonName);
bool isCurrentlyVisible = addon != null && addon->IsVisible;
if (!isCurrentlyVisible)
{
System.AddonInventoryWindow.Open();
}
}
}
private void OnInventoryUpdate(AddonEvent type, AddonArgs args)
{
if (IsInUnsafeState())
return;
if (DragDropState.IsDragging)
return;
System.LootedItemsTracker.FlushPendingChanges();
System.AddonInventoryWindow?.RefreshFromLifecycle();
}
private void OnSaddleBagUpdate(AddonEvent type, AddonArgs args)
{
if (IsInUnsafeState())
return;
if (DragDropState.IsDragging)
return;
System.LootedItemsTracker.FlushPendingChanges();
System.AddonSaddleBagWindow?.RefreshFromLifecycle();
}
private void OnRetainerInventoryUpdate(AddonEvent type, AddonArgs args)
{
if (IsInUnsafeState())
return;
if (DragDropState.IsDragging)
return;
System.LootedItemsTracker.FlushPendingChanges();
System.AddonRetainerWindow?.RefreshFromLifecycle();
}
public void Dispose()
{
Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw;
Services.AddonLifecycle.UnregisterListener(OnPostSetup, OnPreFinalize, OnInventoryUpdate, OnSaddleBagUpdate, OnRetainerInventoryUpdate, OnInventoryPreFinalize, OnInventoryPreHide, InventoryPreRefreshHandler);
}
}
+229
View File
@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AetherBags.Inventory.Items;
using AetherBags.Inventory.Scanning;
using Dalamud.Game.Inventory;
using Dalamud.Game.Inventory.InventoryEventArgTypes;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game;
using Lumina.Excel.Sheets;
namespace AetherBags.Monitoring;
public sealed unsafe class LootedItemsTracker : IDisposable
{
private static IReadOnlyList<InventoryType> StandardInventories => InventoryScanner.StandardInventories;
private const int BatchDelayMs = 300;
private readonly List<LootedItemInfo> _lootedItems = new(capacity: 64);
private readonly Dictionary<(uint ItemId, bool IsHq), (InventoryItem Item, int Quantity)> _pendingChanges = new(capacity: 32);
private static HashSet<uint>? _filteredCategoryItems;
private bool _isEnabled;
private long _batchStartTick;
private bool _hasPendingRemoval;
private int _nextIndex;
public event Action<IReadOnlyList<LootedItemInfo>>? OnLootedItemsChanged;
public IReadOnlyList<LootedItemInfo> LootedItems => _lootedItems;
public bool HasPendingChanges => _pendingChanges.Count > 0 || _hasPendingRemoval;
public void Enable()
{
if (_isEnabled) return;
_isEnabled = true;
_lootedItems.Clear();
_pendingChanges.Clear();
_batchStartTick = 0;
_hasPendingRemoval = false;
_nextIndex = 0;
Services.GameInventory.InventoryChangedRaw += OnInventoryChangedRaw;
Services.Framework.Update += OnFrameworkUpdate;
}
public void Disable()
{
if (!_isEnabled) return;
_isEnabled = false;
Services.GameInventory.InventoryChangedRaw -= OnInventoryChangedRaw;
Services.Framework.Update -= OnFrameworkUpdate;
_lootedItems.Clear();
_pendingChanges.Clear();
_batchStartTick = 0;
_hasPendingRemoval = false;
_nextIndex = 0;
}
public void Clear()
{
_lootedItems.Clear();
_hasPendingRemoval = true;
_nextIndex = 0;
}
public void RemoveByIndex(int index)
{
for (int i = 0; i < _lootedItems.Count; i++)
{
if (_lootedItems[i].Index == index)
{
_lootedItems.RemoveAt(i);
_hasPendingRemoval = true;
return;
}
}
}
public void FlushPendingChanges()
{
if (_pendingChanges.Count == 0 && !_hasPendingRemoval) return;
ProcessPendingChanges();
_hasPendingRemoval = false;
OnLootedItemsChanged?.Invoke(_lootedItems);
}
public void Dispose()
{
Disable();
}
private void ProcessPendingChanges()
{
if (_pendingChanges.Count == 0) return;
foreach (var ((itemId, isHq), (item, delta)) in _pendingChanges)
{
int existingIndex = FindExistingItemIndex(itemId, isHq);
if (existingIndex >= 0)
{
var current = _lootedItems[existingIndex];
int newQty = current.Quantity + delta;
if (newQty <= 0)
_lootedItems.RemoveAt(existingIndex);
else
_lootedItems[existingIndex] = current with { Quantity = newQty };
}
else if (delta > 0)
{
_lootedItems.Add(new LootedItemInfo(_nextIndex++, item, delta));
}
}
_pendingChanges.Clear();
}
private int FindExistingItemIndex(uint itemId, bool isHq)
{
for (int i = 0; i < _lootedItems.Count; i++)
{
var info = _lootedItems[i];
if (info.Item.ItemId == itemId &&
info.Item.Flags.HasFlag(InventoryItem.ItemFlags.HighQuality) == isHq)
{
return i;
}
}
return -1;
}
private void OnInventoryChangedRaw(IReadOnlyCollection<InventoryEventArgs> events)
{
if (!_isEnabled || !Services.ClientState.IsLoggedIn) return;
bool anyChanged = false;
foreach (var eventData in events)
{
if (!StandardInventories.Contains((InventoryType)eventData.Item.ContainerType))
continue;
if (eventData.Item.ContainerType == GameInventoryType.DamagedGear)
continue;
int changeAmount = eventData switch
{
InventoryItemAddedArgs added => added.Item.Quantity,
InventoryItemRemovedArgs removed => -removed.Item.Quantity,
InventoryItemChangedArgs changed => changed.Item.Quantity - changed.OldItemState.Quantity,
_ => 0
};
if (changeAmount == 0) continue;
if (ShouldFilterItem(eventData.Item.ItemId))
continue;
uint itemId = eventData.Item.ItemId;
bool isHq = eventData.Item.IsHq;
var key = (itemId, isHq);
if (_pendingChanges.TryGetValue(key, out var existing))
{
InventoryItem itemStruct = existing.Item;
if (changeAmount > 0 && itemStruct.ItemId == 0)
{
itemStruct = *(InventoryItem*)eventData.Item.Address;
}
_pendingChanges[key] = (itemStruct, existing.Quantity + changeAmount);
}
else
{
InventoryItem itemStruct = default;
if (changeAmount > 0)
{
itemStruct = *(InventoryItem*)eventData.Item.Address;
}
_pendingChanges[key] = (itemStruct, changeAmount);
}
anyChanged = true;
}
if (anyChanged && _batchStartTick == 0)
{
_batchStartTick = Environment.TickCount64;
}
}
private void OnFrameworkUpdate(IFramework framework)
{
if (_batchStartTick == 0)
return;
if (Environment.TickCount64 < _batchStartTick + BatchDelayMs)
return;
_batchStartTick = 0;
FlushPendingChanges();
}
private static bool ShouldFilterItem(uint itemId)
{
if (_filteredCategoryItems == null)
{
_filteredCategoryItems = new HashSet<uint>();
var sheet = Services.DataManager.GetExcelSheet<Item>();
foreach (var row in sheet)
{
if (row.ItemUICategory.RowId == 62)
_filteredCategoryItems.Add(row.RowId);
}
Services.Logger.DebugOnly($"[LootedItemsTracker] Built filter cache with {_filteredCategoryItems.Count} items");
}
return _filteredCategoryItems.Contains(itemId);
}
}
+107
View File
@@ -0,0 +1,107 @@
using System;
using System.Numerics;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Nodes;
using KamiToolKit.Premade.Addons;
using KamiToolKit.Premade.Color;
namespace AetherBags.Nodes.Color;
public class ColorInputRow : HorizontalListNode
{
private ColorPickerAddon? _colorPickerAddon;
private readonly LabelTextNode _labelTextNode;
private readonly ColorPreviewButtonNode _colorPreview;
public ColorInputRow()
{
InitializeColorPicker();
_colorPreview = new ColorPreviewButtonNode { Size = new Vector2(28) };
_labelTextNode = new LabelTextNode
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Position = new Vector2(28, 0),
Height = 28,
};
var node = _colorPreview;
node.OnClick = () =>
{
var snapshot = CurrentColor;
if (_colorPickerAddon is not null)
{
_colorPickerAddon.InitialColor = snapshot;
_colorPickerAddon.DefaultColor = DefaultColor;
_colorPickerAddon.Toggle();
_colorPickerAddon.OnColorConfirmed = color =>
{
CurrentColor = color;
node.Color = color;
OnColorConfirmed?.Invoke(color);
};
_colorPickerAddon.OnColorPreviewed = color =>
{
node.Color = color;
OnColorPreviewed?.Invoke(color);
};
_colorPickerAddon.OnColorCancelled = () =>
{
CurrentColor = snapshot;
node.Color = snapshot;
OnColorCanceled?.Invoke(snapshot);
};
}
};
_colorPreview.AttachNode(this);
_labelTextNode.AttachNode(this);
}
private void InitializeColorPicker() {
if (_colorPickerAddon is not null) return;
_colorPickerAddon = new ColorPickerAddon {
InternalName = "ColorPicker_AetherBags",
Title = "Pick a color",
};
}
protected override void Dispose(bool disposing, bool isNativeDestructor) {
base.Dispose();
_colorPickerAddon?.Dispose();
_colorPickerAddon = null;
}
public required string Label
{
get;
set
{
field = value;
_labelTextNode.String = value;
}
}
public required Vector4 CurrentColor
{
get;
set
{
field = value;
_colorPreview.Color = value;
}
}
public required Vector4 DefaultColor { get; set; }
public Action<Vector4>? OnColorConfirmed { get; set; }
public Action<Vector4>? OnColorCanceled { get; set; }
public Action<Vector4>? OnColorChange { get; set; }
public Action<Vector4>? OnColorPreviewed { get; set; }
}
@@ -0,0 +1,41 @@
using System.Numerics;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Color;
public class ColorPreviewButtonNode : ButtonBase {
private readonly ColorPreviewNode _colorPreview;
public ColorPreviewButtonNode() {
_colorPreview = new ColorPreviewNode {
IsVisible = true,
Position = Vector2.Zero,
Size = base.Size,
};
_colorPreview.AttachNode(this);
LoadTimelines();
InitializeComponentEvents();
}
public override Vector4 Color
{
get => _colorPreview.Color;
set => _colorPreview.Color = value;
}
public override Vector2 Size
{
get => base.Size;
set
{
base.Size = value;
_colorPreview.Size = value;
}
}
private void LoadTimelines()
=> LoadTwoPartTimelines(this, _colorPreview);
}
+112
View File
@@ -0,0 +1,112 @@
using System.Drawing;
using System.IO;
using System.Numerics;
using Dalamud.Interface;
using KamiToolKit.Enums;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Color;
public class ColorPreviewNode : ResNode
{
private readonly BackgroundImageNode _colorBackground;
private readonly ImGuiImageNode _alphaLayer;
private readonly BackgroundImageNode _colorForeground;
private bool _isDisposed;
public ColorPreviewNode()
{
base.Size = new Vector2(64, 64);
_colorBackground = new BackgroundImageNode
{
IsVisible = true,
Color = KnownColor.Black.Vector(),
FitTexture = true,
};
_colorBackground.AttachNode(this);
_alphaLayer = new ImGuiImageNode
{
IsVisible = true,
TexturePath = GetAlphaTexturePath(),
WrapMode = WrapMode.Stretch,
};
_alphaLayer.AttachNode(this);
_colorForeground = new BackgroundImageNode
{
IsVisible = true,
Color = KnownColor.White.Vector(),
FitTexture = true,
};
_colorForeground.AttachNode(this);
UpdateLayout();
}
public override Vector4 Color
{
get => _colorForeground.Color;
set => _colorForeground.Color = value;
}
public override Vector2 Size
{
get => base.Size;
set
{
base.Size = value;
UpdateLayout();
}
}
public BackgroundImageNode BackgroundNode => _colorBackground;
public BackgroundImageNode ForegroundNode => _colorForeground;
private void UpdateLayout()
{
const float backgroundPadding = 6f;
const float alphaPadding = 8f;
const float foregroundPadding = 8f;
var bgSize = base.Size - new Vector2(backgroundPadding * 2f);
var alphaSize = base.Size - new Vector2(alphaPadding * 2f);
var fgSize = base.Size - new Vector2(foregroundPadding * 2f);
_colorBackground.Size = bgSize;
_colorBackground.Position = new Vector2(backgroundPadding, backgroundPadding);
_alphaLayer.Size = alphaSize;
_alphaLayer.Position = new Vector2(alphaPadding, alphaPadding);
_colorForeground.Size = fgSize;
_colorForeground.Position = new Vector2(foregroundPadding, foregroundPadding);
}
private static string GetAlphaTexturePath()
{
var baseDir = Services.PluginInterface.AssemblyLocation.Directory!.FullName;
return Path.Combine(baseDir, "Assets", "alpha_background.png");
}
protected override void Dispose(bool disposing, bool isNativeDestructor)
{
if (_isDisposed)
{
base.Dispose(disposing, isNativeDestructor);
return;
}
_isDisposed = true;
if (disposing)
{
_colorBackground.Dispose();
_alphaLayer.Dispose();
_colorForeground.Dispose();
}
base.Dispose(disposing, isNativeDestructor);
}
}
@@ -0,0 +1,141 @@
using System;
using System.Numerics;
using AetherBags.Configuration;
using AetherBags.Nodes.Color;
using Dalamud.Utility;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class BasicSettingsSection(Func<UserCategoryDefinition> getCategoryDefinition) : ConfigurationSection(getCategoryDefinition)
{
public Action? OnPropertyChanged { get; init; }
private CheckboxNode? _enabledCheckbox;
private CheckboxNode? _pinnedCheckbox;
private TextInputNode? _nameInput;
private TextInputNode? _descriptionInput;
private ColorInputRow? _colorInput;
private NumericInputNode? _priorityInput;
private NumericInputNode? _orderInput;
private bool _initialized;
private void EnsureInitialized()
{
if (_initialized) return;
_initialized = true;
_enabledCheckbox = new CheckboxNode
{
Size = new Vector2(Width, 20),
String = "Enabled",
OnClick = isChecked =>
{
CategoryDefinition.Enabled = isChecked;
OnPropertyChanged?.Invoke();
},
};
AddNode(_enabledCheckbox);
_pinnedCheckbox = new CheckboxNode
{
Size = new Vector2(Width, 20),
String = "Pinned",
OnClick = isChecked =>
{
CategoryDefinition.Pinned = isChecked;
OnPropertyChanged?.Invoke();
},
};
AddNode(_pinnedCheckbox);
AddNode(CreateLabel("Name: "));
_nameInput = new TextInputNode
{
Size = new Vector2(250, 28),
PlaceholderString = "Category Name",
OnInputReceived = input =>
{
CategoryDefinition.Name = input.ExtractText();
OnPropertyChanged?.Invoke();
},
};
AddNode(_nameInput);
AddNode(CreateLabel("Description:"));
_descriptionInput = new TextInputNode
{
Size = new Vector2(250, 28),
PlaceholderString = "Optional description",
OnInputReceived = input =>
{
CategoryDefinition.Description = input.ExtractText();
OnValueChanged?.Invoke();
},
};
AddNode(_descriptionInput);
_colorInput = new ColorInputRow
{
Label = "Color",
Size = new Vector2(300, 28),
CurrentColor = new UserCategoryDefinition().Color,
DefaultColor = new UserCategoryDefinition().Color,
OnColorConfirmed = color => { CategoryDefinition.Color = color; OnValueChanged?.Invoke(); },
OnColorCanceled = color => { CategoryDefinition.Color = color; OnValueChanged?.Invoke(); },
OnColorPreviewed = color => { CategoryDefinition.Color = color; OnValueChanged?.Invoke(); },
OnColorChange = color => { CategoryDefinition.Color = color; OnValueChanged?.Invoke(); },
};
AddNode(_colorInput);
AddNode(CreateLabel("Priority:"));
_priorityInput = new NumericInputNode
{
Size = new Vector2(120, 28),
Min = 0,
Max = 1000,
Step = 1,
OnValueUpdate = value =>
{
CategoryDefinition.Priority = value;
OnValueChanged?.Invoke();
},
};
AddNode(_priorityInput);
AddNode(CreateLabel("Order: "));
_orderInput = new NumericInputNode
{
Size = new Vector2(120, 28),
Min = 0,
Max = 9999,
Step = 1,
OnValueUpdate = val =>
{
CategoryDefinition.Order = val;
OnPropertyChanged?.Invoke();
},
};
AddNode(_orderInput);
RecalculateLayout();
}
public override void Refresh()
{
EnsureInitialized();
_enabledCheckbox!.IsChecked = CategoryDefinition.Enabled;
_pinnedCheckbox!.IsChecked = CategoryDefinition.Pinned;
_nameInput!.String = CategoryDefinition.Name;
_nameInput.PlaceholderString = CategoryDefinition.Name.IsNullOrWhitespace() ? "Category Name" : "";
_descriptionInput!.String = CategoryDefinition.Description;
_descriptionInput.PlaceholderString = CategoryDefinition.Description.IsNullOrWhitespace() ? "Optional description" : "";
_colorInput!.CurrentColor = CategoryDefinition.Color;
_priorityInput!.Value = CategoryDefinition.Priority;
_orderInput!.Value = CategoryDefinition.Order;
RecalculateLayout();
}
}
@@ -0,0 +1,57 @@
using System;
using AetherBags.Addons;
using KamiToolKit.Premade.Nodes;
namespace AetherBags.Nodes.Configuration.Category;
public class CategoryConfigurationNode : ConfigNode<CategoryWrapper>
{
private CategoryDefinitionConfigurationNode? _activeNode;
public Action? OnCategoryChanged { get; set; }
public CategoryConfigurationNode()
{
}
protected override void OptionChanged(CategoryWrapper? option)
{
if (option?.CategoryDefinition is null)
{
if (_activeNode is not null)
{
_activeNode.IsVisible = false;
}
return;
}
if (_activeNode is null)
{
_activeNode = new CategoryDefinitionConfigurationNode
{
OnLayoutChanged = RecalculateLayout,
OnCategoryPropertyChanged = OnCategoryChanged,
};
_activeNode.AttachNode(this);
}
_activeNode.IsVisible = true;
_activeNode.Size = Size;
_activeNode.SetCategory(option.CategoryDefinition);
}
private void RecalculateLayout()
{
// Trigger parent layout update if needed
}
protected override void OnSizeChanged()
{
base.OnSizeChanged();
if (_activeNode is not null)
{
_activeNode.Size = Size;
}
}
}
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AetherBags.Configuration;
using AetherBags.Inventory;
using AetherBags.Nodes.Layout;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Nodes;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using Action = System.Action;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class CategoryDefinitionConfigurationNode : SimpleComponentNode
{
private static ExcelSheet<Item>? ItemSheet => Services.DataManager.GetExcelSheet<Item>();
private static ExcelSheet<ItemUICategory>? UICategorySheet => Services.DataManager.GetExcelSheet<ItemUICategory>();
public Action? OnLayoutChanged { get; init; }
public Action? OnCategoryPropertyChanged { get; init; }
private UserCategoryDefinition _categoryDefinition = new();
private readonly ScrollingAreaNode<VerticalListNode> _scrollingArea;
private readonly List<ConfigurationSection> _sections = new();
public CategoryDefinitionConfigurationNode()
{
_scrollingArea = new ScrollingAreaNode<VerticalListNode> {
AutoHideScrollBar = true,
ContentHeight = 100f
};
_scrollingArea.AttachNode(this);
var list = _scrollingArea.ContentAreaNode;
list.FitContents = true;
list.ItemSpacing = 4.0f;
_sections.Add(new BasicSettingsSection(() => _categoryDefinition) {
String = "Basic Settings", IsCollapsed = false,
OnPropertyChanged = () => { NotifyChanged(); OnCategoryPropertyChanged?.Invoke(); }
});
_sections.Add(new RangeFiltersSection(() => _categoryDefinition) { String = "Range Filters" });
_sections.Add(new StateFiltersSection(() => _categoryDefinition) { String = "State Filters" });
_sections.Add(new ListFiltersSection(() => _categoryDefinition) {
String = "List Filters",
OnListChanged = HandleLayoutChange
});
foreach (var section in _sections)
{
section.OnToggle = HandleLayoutChange;
section.OnValueChanged = NotifyChanged;
list.AddNode(section);
}
}
protected override void OnSizeChanged()
{
base.OnSizeChanged();
_scrollingArea.Size = Size;
foreach (var section in _sections)
{
section.Width = Width - 16.0f;
}
HandleLayoutChange();
}
public void SetCategory(UserCategoryDefinition newCategory)
{
_categoryDefinition = newCategory;
foreach (var section in _sections) section.Refresh();
HandleLayoutChange();
}
private void HandleLayoutChange()
{
_scrollingArea.ContentAreaNode.RecalculateLayout();
_scrollingArea.ContentHeight = _scrollingArea.ContentAreaNode.Height;
OnLayoutChanged?.Invoke();
}
private static void NotifyChanged() => InventoryOrchestrator.RefreshAll(updateMaps: true);
public static string ResolveItemName(uint itemId) => ItemSheet?.GetRow(itemId).Name.ToString() ?? "Unknown";
public static string ResolveUiCategoryName(uint categoryId) => UICategorySheet?.GetRow(categoryId).Name.ToString() ?? "Unknown";
}
public abstract class ConfigurationSection : CollapsibleSectionNode
{
private readonly Func<UserCategoryDefinition> _getCategoryDefinition;
public Action? OnValueChanged { get; set; }
protected UserCategoryDefinition CategoryDefinition => _getCategoryDefinition();
protected ConfigurationSection(Func<UserCategoryDefinition> getCategoryDefinition)
{
_getCategoryDefinition = getCategoryDefinition;
HeaderHeight = 30.0f;
AddTab();
}
public abstract void Refresh();
protected static LabelTextNode CreateLabel(string text) => new()
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Size = new Vector2(80, 20),
String = text,
};
}
@@ -0,0 +1,177 @@
using System;
using System.Linq;
using System.Numerics;
using AetherBags.Configuration;
using AetherBags.Inventory;
using AetherBags.Inventory.Context;
using AetherBags.Nodes.Color;
using AetherBags.Nodes.Input;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class CategoryGeneralConfigurationNode : TabbedVerticalListNode
{
private readonly CheckboxNode _allaganToolsCheckbox;
public CategoryGeneralConfigurationNode()
{
CategorySettings config = System.Config.Categories;
ItemVerticalSpacing = 2;
LabelTextNode titleNode = new LabelTextNode
{
Size = Size with { Y = 18 },
String = "Category Configuration",
TextColor = ColorHelper.GetColor(2),
TextOutlineColor = ColorHelper.GetColor(0),
};
AddNode(titleNode);
AddTab(1);
CheckboxNode categoriesEnabled = new CheckboxNode
{
Size = Size with { Y = 18 },
IsVisible = true,
String = "Categories Enabled",
IsChecked = config.CategoriesEnabled,
OnClick = isChecked =>
{
config.CategoriesEnabled = isChecked;
System.IPC?.RefreshExternalSources();
RefreshInventory();
}
};
AddNode(categoriesEnabled);
AddTab(1);
CheckboxNode gameCategoriesEnabled = new CheckboxNode
{
Size = Size with { Y = 18 },
IsVisible = true,
String = "Game Categories",
IsChecked = config.GameCategoriesEnabled,
TextTooltip = "Use the game's built-in item categories (e.g., Arms, Tools, Armor).",
OnClick = isChecked =>
{
config.GameCategoriesEnabled = isChecked;
RefreshInventory();
}
};
AddNode(gameCategoriesEnabled);
CheckboxNode userCategoriesEnabled = new CheckboxNode
{
Size = Size with { Y = 18 },
IsVisible = true,
String = "User Categories",
IsChecked = config.UserCategoriesEnabled,
TextTooltip = "Use your custom-defined categories.",
OnClick = isChecked =>
{
config.UserCategoriesEnabled = isChecked;
RefreshInventory();
}
};
AddNode(userCategoriesEnabled);
bool bisBuddyReady = System.IPC.BisBuddy?.IsReady ?? false;
LabeledEnumDropdownNode<PluginFilterMode>? bbModeDropdown = new LabeledEnumDropdownNode<PluginFilterMode>
{
Size = new Vector2(500, 20),
LabelText = "Filter Display Mode",
LabelTextFlags = TextFlags.AutoAdjustNodeSize,
IsEnabled = config.BisBuddyEnabled && bisBuddyReady,
Options = Enum.GetValues<PluginFilterMode>().ToList(),
SelectedOption = config.BisBuddyMode,
OnOptionSelected = selected =>
{
config.BisBuddyMode = selected;
if (selected == PluginFilterMode.Categorize)
HighlightState.ClearFilter(HighlightSource.BiSBuddy);
System.IPC?.RefreshExternalSources();
RefreshInventory();
}
};
CheckboxNode bisBuddyEnabled = new CheckboxNode
{
Size = Size with { Y = 18 },
IsVisible = true,
String = bisBuddyReady ? "BISBuddy" : "BISBuddy (Not Available)",
IsChecked = config.BisBuddyEnabled,
TextTooltip = "Allow BISBuddy to highlight items.",
OnClick = isChecked =>
{
config.BisBuddyEnabled = isChecked;
if (bbModeDropdown != null) bbModeDropdown.IsEnabled = isChecked;
if (isChecked)
System.IPC.BisBuddy?.RefreshItems();
else
HighlightState.ClearLabel(HighlightSource.BiSBuddy);
System.IPC?.RefreshExternalSources();
RefreshInventory();
}
};
AddNode(bisBuddyEnabled);
AddNode(1, bbModeDropdown);
bool allaganReady = System.IPC.AllaganTools?.IsReady ?? false;
LabeledEnumDropdownNode<PluginFilterMode>? atModeDropdown = new LabeledEnumDropdownNode<PluginFilterMode>
{
Size = new Vector2(500, 20),
LabelText = "Filter Display Mode",
LabelTextFlags = TextFlags.AutoAdjustNodeSize,
IsEnabled = config.AllaganToolsCategoriesEnabled && allaganReady,
Options = Enum.GetValues<PluginFilterMode>().ToList(),
SelectedOption = config.AllaganToolsFilterMode,
OnOptionSelected = selected =>
{
config.AllaganToolsFilterMode = selected;
if (selected == PluginFilterMode.Categorize)
{
HighlightState.ClearFilter(HighlightSource.AllaganTools);
}
System.IPC?.RefreshExternalSources();
RefreshInventory();
}
};
_allaganToolsCheckbox = new CheckboxNode
{
Size = Size with { Y = 18 },
IsVisible = true,
String = allaganReady ? "Allagan Tools Filters" : "Allagan Tools Filters (Not Available)",
IsChecked = config.AllaganToolsCategoriesEnabled,
IsEnabled = allaganReady,
TextTooltip = allaganReady
? "Use search filters from Allagan Tools as categories. Items matching a filter will be grouped together."
: "Allagan Tools is not installed or not initialized.",
OnClick = isChecked =>
{
config.AllaganToolsCategoriesEnabled = isChecked;
if (atModeDropdown != null) atModeDropdown.IsEnabled = isChecked;
if (isChecked)
System.IPC?.AllaganTools?.RefreshFilters();
else
HighlightState.ClearLabel(HighlightSource.AllaganTools);
System.IPC?.RefreshExternalSources();
RefreshInventory();
}
};
AddNode(_allaganToolsCheckbox);
AddNode(1, atModeDropdown);
SubtractTab(1);
}
private void RefreshInventory() => InventoryOrchestrator.RefreshAll(updateMaps: true);
}
@@ -0,0 +1,51 @@
using System.Numerics;
using AetherBags.Addons;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class CategoryScrollingAreaNode : ScrollingListNode
{
private AddonCategoryConfigurationWindow? _categoryConfigurationAddon;
public CategoryScrollingAreaNode()
{
InitializeCategoryAddon();
AddNode(new CategoryGeneralConfigurationNode());
AddNode(new ExperimentalConfigurationNode());
var categoryConfigurationButtonNode = new TextButtonNode
{
Size = new Vector2(300, 28),
String = "Configure Categories",
OnClick = () => _categoryConfigurationAddon?.Toggle(),
};
AddNode(categoryConfigurationButtonNode);
}
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",
};
}
protected override void Dispose(bool disposing, bool isNativeDestructor)
{
if (disposing)
{
if (_categoryConfigurationAddon != null)
{
_categoryConfigurationAddon.Close();
_categoryConfigurationAddon = null;
}
}
base.Dispose(disposing, isNativeDestructor);
}
}
@@ -0,0 +1,50 @@
using AetherBags.Configuration;
using AetherBags.Inventory;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Configuration.Category;
internal class ExperimentalConfigurationNode : TabbedVerticalListNode
{
public ExperimentalConfigurationNode()
{
GeneralSettings config = System.Config.General;
var titleNode = new CategoryTextNode
{
Height = 18,
String = "Experimental",
};
AddNode(titleNode);
AddTab(1);
var externalCategoryCheckbox = new CheckboxNode
{
Height = 18,
IsVisible = true,
String = "External Category Support",
IsChecked = config.UseUnifiedExternalCategories,
TextTooltip = "EXPERIMENTAL - Use at your own risk. This feature is not fully tested.\n\n" +
"Enables enhanced integration with external plugins like " +
"Allagan Tools and BisBuddy.\n\n" +
"Features:\n" +
"- Search by plugin tags (e.g. search 'bis' to find BiS items)\n" +
"- Relationship highlighting: hover an item to see related items\n" +
" (same gear set, upgrades, crafting materials)\n" +
"- Item badges showing plugin status icons\n" +
"- Custom borders and visual effects (glow, pulse)\n" +
"- Additional right-click menu options from plugins\n" +
"- Extra tooltip information from plugins\n\n" +
"When disabled, external plugins still provide categories and " +
"basic highlighting, but without these enhanced features.",
OnClick = isChecked =>
{
config.UseUnifiedExternalCategories = isChecked;
System.IPC?.UpdateUnifiedCategorySupport(isChecked);
InventoryOrchestrator.RefreshAll(updateMaps: true);
}
};
AddNode(externalCategoryCheckbox);
}
}
@@ -0,0 +1,114 @@
using System;
using System.Linq;
using AetherBags.Addons;
using AetherBags.Configuration;
using Lumina.Excel.Sheets;
using Action = System.Action;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class ListFiltersSection(Func<UserCategoryDefinition> getCategoryDefinition) : ConfigurationSection(getCategoryDefinition)
{
public Action? OnListChanged { get; init; }
private UintListEditorNode? _itemIdsEditor;
private StringListEditorNode? _namePatternsEditor;
private UintListEditorNode? _uiCategoriesEditor;
private RarityEditorNode? _raritiesEditor;
private bool _initialized;
private AddonItemPicker? _itemPicker;
private AddonUICategoryPicker? _categoryPicker;
private void EnsureInitialized()
{
if (_initialized) return;
_initialized = true;
_itemIdsEditor = new UintListEditorNode
{
Label = "Allowed Item IDs:",
LabelResolver = CategoryDefinitionConfigurationNode.ResolveItemName,
OnSearchButtonClicked = OpenItemPicker,
OnChanged = () =>
{
OnListChanged?.Invoke();
RefreshLayout();
},
};
AddNode(_itemIdsEditor);
_namePatternsEditor = new StringListEditorNode
{
Label = "Name Patterns (Regex):",
OnChanged = () =>
{
OnListChanged?.Invoke();
RefreshLayout();
},
};
AddNode(_namePatternsEditor);
_uiCategoriesEditor = new UintListEditorNode
{
Label = "UI Categories:",
LabelResolver = CategoryDefinitionConfigurationNode.ResolveUiCategoryName,
OnSearchButtonClicked = OpenCategoryPicker,
OnChanged = () =>
{
OnListChanged?.Invoke();
RefreshLayout();
},
};
AddNode(_uiCategoriesEditor);
_raritiesEditor = new RarityEditorNode
{
OnChanged = () => OnValueChanged?.Invoke(),
};
AddNode(_raritiesEditor);
RecalculateLayout();
}
private void OpenItemPicker() {
_itemPicker ??= new AddonItemPicker
{
Title = "Select Items to Add",
InternalName = "Aetherbags_ItemPicker",
SearchOptions = Services.DataManager.GetExcelSheet<Item>()
.Where(i => i.RowId > 0 && !i.Name.IsEmpty)
.ToList(),
SortingOptions = ["Alphabetical", "Id"],
ItemSpacing = 3.0f,
};
_itemPicker.SelectionResult = item => _itemIdsEditor?.AddValue(item.RowId);
_itemPicker.Open();
}
private void OpenCategoryPicker() {
_categoryPicker ??= new AddonUICategoryPicker {
Title = "Select Categories to Add",
InternalName = "Aetherbags_CategoryPicker",
SearchOptions = Services.DataManager.GetExcelSheet<ItemUICategory>()
.Where(i => i.RowId > 0)
.ToList()
};
_categoryPicker.SelectionResult = cat => _uiCategoriesEditor?.AddValue(cat.RowId);
_categoryPicker.Open();
}
public override void Refresh()
{
EnsureInitialized();
_itemIdsEditor!.SetList(CategoryDefinition.Rules.AllowedItemIds);
_namePatternsEditor!.SetList(CategoryDefinition.Rules.AllowedItemNamePatterns);
_uiCategoriesEditor!.SetList(CategoryDefinition.Rules.AllowedUiCategoryIds);
_raritiesEditor!.SetList(CategoryDefinition.Rules.AllowedRarities);
RecalculateLayout();
}
}
@@ -0,0 +1,207 @@
using System;
using System.Numerics;
using AetherBags.Configuration;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Nodes;
using Lumina.Text.ReadOnly;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class RangeFilterRow : VerticalListNode
{
private readonly CheckboxNode _enabledCheckbox;
private readonly NumericInputNode _minNode;
private readonly NumericInputNode _maxNode;
public Action<bool, int, int>? OnFilterChanged { get; set; }
public required ReadOnlySeString Label
{
get => _enabledCheckbox.String.ExtractText().Replace(" Filter", "");
init => _enabledCheckbox.String = $"{value} Filter";
}
public int MinBound
{
get => _minNode.Min;
init
{
_minNode.Min = value;
_maxNode.Min = value;
}
}
public int MaxBound
{
get => _minNode.Max;
init
{
_minNode.Max = value;
_maxNode.Max = value;
}
}
public RangeFilterRow()
{
FitContents = true;
ItemSpacing = 2.0f;
_enabledCheckbox = new CheckboxNode
{
Size = new Vector2(200, 20),
OnClick = isChecked =>
{
if (_minNode == null || _maxNode == null) return;
_minNode.IsEnabled = isChecked;
_maxNode.IsEnabled = isChecked;
OnFilterChanged?.Invoke(isChecked, _minNode.Value, _maxNode.Value);
},
};
AddNode(_enabledCheckbox);
var rangeRow = new HorizontalListNode { Size = new Vector2(300, 28), ItemSpacing = 8.0f };
rangeRow.AddNode(new LabelTextNode
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Size = new Vector2(30, 28),
String = "Min:",
});
_minNode = new NumericInputNode
{
Size = new Vector2(100, 28),
OnValueUpdate = val =>
{
if (_maxNode != null) OnFilterChanged?.Invoke(_enabledCheckbox.IsChecked, val, _maxNode.Value);
},
};
rangeRow.AddNode(_minNode);
rangeRow.AddNode(new LabelTextNode
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Size = new Vector2(30, 28),
String = "Max:",
});
_maxNode = new NumericInputNode
{
Size = new Vector2(100, 28),
OnValueUpdate = val => OnFilterChanged?.Invoke(_enabledCheckbox.IsChecked, _minNode.Value, val),
};
rangeRow.AddNode(_maxNode);
AddNode(rangeRow);
}
public void SetFilter(RangeFilter<int> filter)
{
_enabledCheckbox.IsChecked = filter.Enabled;
_minNode.Value = filter.Min;
_maxNode.Value = filter.Max;
_minNode.IsEnabled = filter.Enabled;
_maxNode.IsEnabled = filter.Enabled;
}
}
public sealed class RangeFilterRowUint : VerticalListNode
{
private readonly CheckboxNode _enabledCheckbox;
private readonly NumericInputNode _minNode;
private readonly NumericInputNode _maxNode;
private int _maxBound = int.MaxValue;
public Action<bool, uint, uint>? OnFilterChanged { get; set; }
public required ReadOnlySeString Label
{
get => _enabledCheckbox.String.ExtractText().Replace(" Filter", "");
init => _enabledCheckbox.String = $"{value} Filter";
}
public int MinBound
{
get => _minNode.Min;
init
{
_minNode.Min = value;
_maxNode.Min = value;
}
}
public int MaxBound
{
get => _maxBound;
init
{
_maxBound = value;
_minNode.Max = value;
_maxNode.Max = value;
}
}
public RangeFilterRowUint()
{
FitContents = true;
ItemSpacing = 2.0f;
_enabledCheckbox = new CheckboxNode
{
Size = new Vector2(200, 20),
OnClick = isChecked =>
{
if (_minNode == null || _maxNode == null) return;
_minNode.IsEnabled = isChecked;
_maxNode.IsEnabled = isChecked;
OnFilterChanged?.Invoke(isChecked, (uint)_minNode.Value, (uint)_maxNode.Value);
},
};
AddNode(_enabledCheckbox);
var rangeRow = new HorizontalListNode { Size = new Vector2(300, 28), ItemSpacing = 8.0f };
rangeRow.AddNode(new LabelTextNode
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Size = new Vector2(30, 28),
String = "Min:",
});
_minNode = new NumericInputNode
{
Size = new Vector2(100, 28),
OnValueUpdate = val =>
{
if (_maxNode != null)
OnFilterChanged?.Invoke(_enabledCheckbox.IsChecked, (uint)val, (uint)_maxNode.Value);
},
};
rangeRow.AddNode(_minNode);
rangeRow.AddNode(new LabelTextNode
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Size = new Vector2(30, 28),
String = "Max:",
});
_maxNode = new NumericInputNode
{
Size = new Vector2(100, 28),
OnValueUpdate = val => OnFilterChanged?.Invoke(_enabledCheckbox.IsChecked, (uint)_minNode.Value, (uint)val),
};
rangeRow.AddNode(_maxNode);
AddNode(rangeRow);
}
public void SetFilter(RangeFilter<uint> filter)
{
_enabledCheckbox.IsChecked = filter.Enabled;
_minNode.Value = (int)filter.Min;
_maxNode.Value = (int)Math.Min(filter.Max, _maxBound);
_minNode.IsEnabled = filter.Enabled;
_maxNode.IsEnabled = filter.Enabled;
}
}
@@ -0,0 +1,77 @@
using System;
using AetherBags.Configuration;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class RangeFiltersSection(Func<UserCategoryDefinition> getCategoryDefinition) : ConfigurationSection(getCategoryDefinition)
{
private RangeFilterRow? _levelFilter;
private RangeFilterRow? _itemLevelFilter;
private RangeFilterRowUint? _vendorPriceFilter;
private bool _initialized;
private void EnsureInitialized()
{
if (_initialized) return;
_initialized = true;
_levelFilter = new RangeFilterRow
{
Label = "Level",
MinBound = 0,
MaxBound = 200,
OnFilterChanged = (enabled, min, max) =>
{
CategoryDefinition.Rules.Level.Enabled = enabled;
CategoryDefinition.Rules.Level.Min = min;
CategoryDefinition.Rules.Level.Max = max;
OnValueChanged?.Invoke();
},
};
AddNode(_levelFilter);
_itemLevelFilter = new RangeFilterRow
{
Label = "Item Level",
MinBound = 0,
MaxBound = 2000,
OnFilterChanged = (enabled, min, max) =>
{
CategoryDefinition.Rules.ItemLevel.Enabled = enabled;
CategoryDefinition.Rules.ItemLevel.Min = min;
CategoryDefinition.Rules.ItemLevel.Max = max;
OnValueChanged?.Invoke();
},
};
AddNode(_itemLevelFilter);
_vendorPriceFilter = new RangeFilterRowUint
{
Label = "Vendor Price",
MinBound = 0,
MaxBound = 9_999_999,
OnFilterChanged = (enabled, min, max) =>
{
CategoryDefinition.Rules.VendorPrice.Enabled = enabled;
CategoryDefinition.Rules.VendorPrice.Min = min;
CategoryDefinition.Rules.VendorPrice.Max = max;
OnValueChanged?.Invoke();
},
};
AddNode(_vendorPriceFilter);
RecalculateLayout();
}
public override void Refresh()
{
EnsureInitialized();
_levelFilter!.SetFilter(CategoryDefinition.Rules.Level);
_itemLevelFilter!.SetFilter(CategoryDefinition.Rules.ItemLevel);
_vendorPriceFilter!.SetFilter(CategoryDefinition.Rules.VendorPrice);
RecalculateLayout();
}
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class RarityEditorNode :VerticalListNode
{
private const float LabelWidth = 120f;
private const float CheckboxWidth = 150f;
private static readonly string[] RarityNames =
[
"Common (White)",
"Uncommon (Green)",
"Rare (Blue)",
"Relic (Purple)",
"Aetherial (Pink)"
];
public Action? OnChanged { get; set; }
private List<int> _list = [];
private readonly List<CheckboxNode> _checkboxes = [];
public RarityEditorNode()
{
FitContents = true;
ItemSpacing = 2.0f;
var headerLabel = new LabelTextNode
{
TextFlags = TextFlags.AutoAdjustNodeSize,
Size = new Vector2(280, 18),
String = "Allowed Rarities:",
TextColor = ColorHelper.GetColor(8),
};
AddNode(headerLabel);
for (var i = 0; i < RarityNames.Length; i++)
{
var rarity = i;
var checkbox = new CheckboxNode
{
Size = new Vector2(LabelWidth + CheckboxWidth, 22),
String = RarityNames[i],
OnClick = isChecked => ToggleRarity(rarity, isChecked),
};
_checkboxes.Add(checkbox);
AddNode(checkbox);
}
}
private void ToggleRarity(int rarity, bool isChecked)
{
if (isChecked && !_list.Contains(rarity))
{
_list.Add(rarity);
_list.Sort();
}
else if (!isChecked && _list.Contains(rarity))
{
_list.Remove(rarity);
}
OnChanged?.Invoke();
}
public void SetList(List<int> newList)
{
_list = newList;
Refresh();
}
public void Refresh()
{
for (var i = 0; i < _checkboxes.Count; i++)
{
_checkboxes[i].IsChecked = _list.Contains(i);
}
}
}
@@ -0,0 +1,63 @@
using AetherBags.Configuration;
using FFXIVClientStructs.FFXIV.Component.GUI;
using KamiToolKit.Classes;
using KamiToolKit.Nodes;
using KamiToolKit.Premade.Nodes;
using System;
using System.Numerics;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class StateFilterRowNode : HorizontalListNode
{
private const float LabelWidth = 120f;
private const float ButtonWidth = 100f;
private readonly StateFilterButton _stateButton;
private readonly Action? _onChanged;
private StateFilter _filter;
public StateFilterRowNode(string label, StateFilter filter, Action?onChanged = null)
{
_filter = filter;
_onChanged = onChanged;
Size = new Vector2(LabelWidth + ButtonWidth + 8f, 24);
ItemSpacing = 8.0f;
var labelNode = new LabelTextNode
{
Size = new Vector2(LabelWidth, 24),
String = $"{label}:",
TextColor = ColorHelper.GetColor(8),
AlignmentType = AlignmentType.Right,
};
AddNode(labelNode);
_stateButton = new StateFilterButton
{
Size = new Vector2(ButtonWidth, 24),
States = [0, 1, 2],
SelectedState = _filter.State,
OnStateChanged = newState =>
{
_filter.State = newState;
_onChanged?.Invoke();
}
};
AddNode(_stateButton);
}
public void SetState(StateFilter newFilter)
{
_filter = newFilter;
_stateButton.SelectedState = _filter.State;
}
private sealed class StateFilterButton : MultiStateButtonNode<int>
{
private static readonly string[] StateLabels = ["Ignored", "Required", "Excluded"];
protected override string GetStateText(int state)
=> state >= 0 && state < StateLabels.Length ?StateLabels[state] : "Unknown";
}
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using AetherBags.Configuration;
namespace AetherBags.Nodes.Configuration.Category;
public sealed class StateFiltersSection(Func<UserCategoryDefinition> getCategoryDefinition)
: ConfigurationSection(getCategoryDefinition)
{
private readonly List<(StateFilterRowNode Node, Func<UserCategoryDefinition, StateFilter> GetFilter)> _filters = [];
private bool _initialized;
private void EnsureInitialized()
{
if (_initialized) return;
_initialized = true;
AddFilter("Untradable", def => def.Rules.Untradable);
AddFilter("Unique", def => def.Rules.Unique);
AddFilter("Collectable", def => def.Rules.Collectable);
AddFilter("Dyeable", def => def.Rules.Dyeable);
AddFilter("Repairable", def => def.Rules.Repairable);
AddFilter("High Quality", def => def.Rules.HighQuality);
AddFilter("Desynthesizable", def => def.Rules.Desynthesizable);
AddFilter("Glamourable", def => def.Rules.Glamourable);
AddFilter("Spiritbonded", def => def.Rules.FullySpiritbonded);
RecalculateLayout();
}
private void AddFilter(string label, Func<UserCategoryDefinition, StateFilter> getFilter)
{
var node = new StateFilterRowNode(label, new StateFilter(), () => OnValueChanged?.Invoke());
_filters.Add((node, getFilter));
AddNode(node);
}
public override void Refresh()
{
EnsureInitialized();
foreach (var (node, getFilter) in _filters)
{
node.SetState(getFilter(CategoryDefinition));
}
RecalculateLayout();
}
}

Some files were not shown because too many files have changed in this diff Show More