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