Initial commit: AetherBags + KamiToolKit for FC Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "KamiToolKit"]
|
||||
path = KamiToolKit
|
||||
url = https://github.com/MidoriKami/KamiToolKit
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using KamiToolKit.Premade.ListItemNodes;
|
||||
using KamiToolKit.Premade.SearchAddons;
|
||||
|
||||
namespace AetherBags.Addons;
|
||||
|
||||
public class AddonItemPicker : ItemSearchAddonBase<ItemListItemNode> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
global using KamiToolKit.Extensions;
|
||||
global using AetherBags.Extensions;
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user