commit 8db4ce6094e5c20bf5afb959de526515640e1e95 Author: Knack117 Date: Sun Feb 8 14:46:31 2026 -0500 Initial commit: AetherBags + KamiToolKit for FC Gitea Co-authored-by: Cursor diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..700b3c6 --- /dev/null +++ b/.github/FUNDING.yml @@ -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'] diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml new file mode 100644 index 0000000..8b0cd30 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/SUGGESTION.yaml b/.github/ISSUE_TEMPLATE/SUGGESTION.yaml new file mode 100644 index 0000000..64dad27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/SUGGESTION.yaml @@ -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 diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml new file mode 100644 index 0000000..6b22930 --- /dev/null +++ b/.github/workflows/build-debug.yml @@ -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/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7232fb2 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..69a9f30 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "KamiToolKit"] + path = KamiToolKit + url = https://github.com/MidoriKami/KamiToolKit diff --git a/AetherBags.sln b/AetherBags.sln new file mode 100644 index 0000000..3e7404a --- /dev/null +++ b/AetherBags.sln @@ -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 diff --git a/AetherBags/.gitignore b/AetherBags/.gitignore new file mode 100644 index 0000000..57f1cb2 --- /dev/null +++ b/AetherBags/.gitignore @@ -0,0 +1 @@ +/.idea/ \ No newline at end of file diff --git a/AetherBags/Addons/AddonCategoryConfigurationWindow.cs b/AetherBags/Addons/AddonCategoryConfigurationWindow.cs new file mode 100644 index 0000000..6ea3348 --- /dev/null +++ b/AetherBags/Addons/AddonCategoryConfigurationWindow.cs @@ -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? _selectionListNode; + private VerticalLineNode? _separatorLine; + private CategoryConfigurationNode? _configNode; + private TextNode? _nothingSelectedTextNode; + + private List _categoryWrappers = new(); + + private bool _suppressSelectionListRefresh; + private bool _pendingSelectionListRefresh; + + protected override unsafe void OnSetup(AtkUnitBase* addon) + { + _categoryWrappers = CreateCategoryWrappers(); + + _selectionListNode = new ModifyListNode + { + 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 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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonConfigurationWindow.cs b/AetherBags/Addons/AddonConfigurationWindow.cs new file mode 100644 index 0000000..678ae0b --- /dev/null +++ b/AetherBags/Addons/AddonConfigurationWindow.cs @@ -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 _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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonCurrencyPicker.cs b/AetherBags/Addons/AddonCurrencyPicker.cs new file mode 100644 index 0000000..0804191 --- /dev/null +++ b/AetherBags/Addons/AddonCurrencyPicker.cs @@ -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 { + public AddonCurrencyPicker() { + var allItems = Services.DataManager.GetExcelSheet(); + var obsoleteTomes = Services.DataManager.GetExcelSheet() + .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()); +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs new file mode 100644 index 0000000..af0d6bf --- /dev/null +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -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> + { + 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 lootedItems) + { + if (!IsOpen || !IsSetupComplete) return; + UpdateLootedCategory(lootedItems); + } + + private void UpdateLootedCategory(IReadOnlyList 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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonItemPicker.cs b/AetherBags/Addons/AddonItemPicker.cs new file mode 100644 index 0000000..341f350 --- /dev/null +++ b/AetherBags/Addons/AddonItemPicker.cs @@ -0,0 +1,7 @@ +using KamiToolKit.Premade.ListItemNodes; +using KamiToolKit.Premade.SearchAddons; + +namespace AetherBags.Addons; + +public class AddonItemPicker : ItemSearchAddonBase { +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonRetainerWindow.cs b/AetherBags/Addons/AddonRetainerWindow.cs new file mode 100644 index 0000000..fc0f5a4 --- /dev/null +++ b/AetherBags/Addons/AddonRetainerWindow.cs @@ -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> + { + 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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonSaddleBagWindow.cs b/AetherBags/Addons/AddonSaddleBagWindow.cs new file mode 100644 index 0000000..81faf12 --- /dev/null +++ b/AetherBags/Addons/AddonSaddleBagWindow.cs @@ -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> + { + 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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/AddonUICategoryPicker.cs b/AetherBags/Addons/AddonUICategoryPicker.cs new file mode 100644 index 0000000..4f3fb91 --- /dev/null +++ b/AetherBags/Addons/AddonUICategoryPicker.cs @@ -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 { + 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); +} \ No newline at end of file diff --git a/AetherBags/Addons/CategoryListItemNode.cs b/AetherBags/Addons/CategoryListItemNode.cs new file mode 100644 index 0000000..b6d6a9c --- /dev/null +++ b/AetherBags/Addons/CategoryListItemNode.cs @@ -0,0 +1,14 @@ +using KamiToolKit.Premade.GenericListItemNodes; + +namespace AetherBags.Addons; + +public class CategoryListItemNode : GenericListItemNode +{ + 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(); +} \ No newline at end of file diff --git a/AetherBags/Addons/CategoryWrapper.cs b/AetherBags/Addons/CategoryWrapper.cs new file mode 100644 index 0000000..a3f78ad --- /dev/null +++ b/AetherBags/Addons/CategoryWrapper.cs @@ -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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/IInventoryWindow.cs b/AetherBags/Addons/IInventoryWindow.cs new file mode 100644 index 0000000..b3d9e1c --- /dev/null +++ b/AetherBags/Addons/IInventoryWindow.cs @@ -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(); +} \ No newline at end of file diff --git a/AetherBags/Addons/InventoryAddonBase.cs b/AetherBags/Addons/InventoryAddonBase.cs new file mode 100644 index 0000000..49b971b --- /dev/null +++ b/AetherBags/Addons/InventoryAddonBase.cs @@ -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 HoverSubscribed = new(); + + protected DragDropNode BackgroundDropTarget = null!; + protected ScrollingAreaNode> ScrollableCategories = null!; + protected WrappingGridNode 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 SharedItemNodePool = new( + maxSize: 256, + factory: null, + resetAction: node => node.ResetForReuse()); + + protected readonly SharedNodePool 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 _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? 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( + 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); + } +} \ No newline at end of file diff --git a/AetherBags/Addons/InventoryAddonContextMenu.cs b/AetherBags/Addons/InventoryAddonContextMenu.cs new file mode 100644 index 0000000..366be17 --- /dev/null +++ b/AetherBags/Addons/InventoryAddonContextMenu.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/AetherBags/Addons/ItemContextMenuHandler.cs b/AetherBags/Addons/ItemContextMenuHandler.cs new file mode 100644 index 0000000..f98ce17 --- /dev/null +++ b/AetherBags/Addons/ItemContextMenuHandler.cs @@ -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; + } +} diff --git a/AetherBags/AetherBags.csproj b/AetherBags/AetherBags.csproj new file mode 100644 index 0000000..0422e06 --- /dev/null +++ b/AetherBags/AetherBags.csproj @@ -0,0 +1,33 @@ + + + 1.0.0.0 + + + + Zeffuro, Pie Lover + AetherBags + AetherBags + Never think too hard about your bags again! + This plugin replaces your inventory with it's own categorified inventory addon. + https://github.com/Zeffuro/AetherBags + ui + true + + + + + + + + + + + PreserveNewest + false + + + PreserveNewest + false + + + diff --git a/AetherBags/Assets/Icons/download.png b/AetherBags/Assets/Icons/download.png new file mode 100644 index 0000000..1d02268 Binary files /dev/null and b/AetherBags/Assets/Icons/download.png differ diff --git a/AetherBags/Assets/Icons/upload.png b/AetherBags/Assets/Icons/upload.png new file mode 100644 index 0000000..68b36a6 Binary files /dev/null and b/AetherBags/Assets/Icons/upload.png differ diff --git a/AetherBags/Commands/CommandHandler.cs b/AetherBags/Commands/CommandHandler.cs new file mode 100644 index 0000000..2fb0198 --- /dev/null +++ b/AetherBags/Commands/CommandHandler.cs @@ -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 - 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); + } +} \ No newline at end of file diff --git a/AetherBags/Configuration/CategorySettings.cs b/AetherBags/Configuration/CategorySettings.cs new file mode 100644 index 0000000..0dedceb --- /dev/null +++ b/AetherBags/Configuration/CategorySettings.cs @@ -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 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 AllowedItemIds { get; set; } = new(); + public List AllowedItemNamePatterns { get; set; } = new(); + public List AllowedUiCategoryIds { get; set; } = new(); + public List AllowedRarities { get; set; } = new(); + + public RangeFilter Level { get; set; } = new() { Enabled = false, Min = 0, Max = 200 }; + public RangeFilter ItemLevel { get; set; } = new() { Enabled = false, Min = 0, Max = 2000 }; + public RangeFilter 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 where T : struct, IComparable +{ + 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, +} \ No newline at end of file diff --git a/AetherBags/Configuration/CurrencySettings.cs b/AetherBags/Configuration/CurrencySettings.cs new file mode 100644 index 0000000..a0e8236 --- /dev/null +++ b/AetherBags/Configuration/CurrencySettings.cs @@ -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 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); +} \ No newline at end of file diff --git a/AetherBags/Configuration/GeneralSettings.cs b/AetherBags/Configuration/GeneralSettings.cs new file mode 100644 index 0000000..b3d5e40 --- /dev/null +++ b/AetherBags/Configuration/GeneralSettings.cs @@ -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, +} \ No newline at end of file diff --git a/AetherBags/Configuration/Import/SortaKindaCategory.cs b/AetherBags/Configuration/Import/SortaKindaCategory.cs new file mode 100644 index 0000000..2241bba --- /dev/null +++ b/AetherBags/Configuration/Import/SortaKindaCategory.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AetherBags.Configuration.Import; + +public sealed class SortaKindaImportFile +{ + public List 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 AllowedItemNames { get; set; } = new(); + + public List AllowedNameRegexes { get; set; } = new(); + + // Common + public List AllowedItemTypes { get; set; } = new(); + public List AllowedItemRarities { get; set; } = new(); + + public ExternalRangeFilterDto? LevelFilter { get; set; } + public ExternalRangeFilterDto ItemLevelFilter { get; set; } = new(); + public ExternalRangeFilterDto 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 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; } +} \ No newline at end of file diff --git a/AetherBags/Configuration/SystemConfiguration.cs b/AetherBags/Configuration/SystemConfiguration.cs new file mode 100644 index 0000000..653c735 --- /dev/null +++ b/AetherBags/Configuration/SystemConfiguration.cs @@ -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(); + } + + /// + /// Ensures all nested config objects are initialized. Call after deserialization. + /// + public void EnsureInitialized() + { + _general ??= new(); + _categories ??= new(); + _currency ??= new(); + _categories.UserCategories ??= new(); + } +} \ No newline at end of file diff --git a/AetherBags/Currency/CurrencyInfo.cs b/AetherBags/Currency/CurrencyInfo.cs new file mode 100644 index 0000000..a26b8d0 --- /dev/null +++ b/AetherBags/Currency/CurrencyInfo.cs @@ -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; } +} \ No newline at end of file diff --git a/AetherBags/Currency/CurrencyState.cs b/AetherBags/Currency/CurrencyState.cs new file mode 100644 index 0000000..338853d --- /dev/null +++ b/AetherBags/Currency/CurrencyState.cs @@ -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; + +/// +/// Manages currency lookups, caching, and retrieval from the game. +/// +public static unsafe class CurrencyState +{ + private const uint CurrencyIdLimitedTomestone = 0xFFFF_FFFE; + private const uint CurrencyIdNonLimitedTomestone = 0xFFFF_FFFD; + + private static readonly Dictionary CurrencyItemByCurrencyIdCache = new(capacity: 32); + private static readonly Dictionary CurrencyStaticByItemIdCache = new(capacity: 64); + private static readonly List 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 GetCurrencyInfoList(uint[] currencyIds) + => GetCurrencyInfoListCore(currencyIds.AsSpan()); + + public static IReadOnlyList GetCurrencyInfoList(List currencyIds) + => GetCurrencyInfoListCore(CollectionsMarshal.AsSpan(currencyIds)); + + private static IReadOnlyList GetCurrencyInfoListCore(ReadOnlySpan currencyIds) + { + if (currencyIds.Length == 0) + return Array.Empty(); + + InventoryManager* inventoryManager = InventoryManager.Instance(); + if (inventoryManager == null) + return Array.Empty(); + + 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(); + 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() + .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() + .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().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); +} \ No newline at end of file diff --git a/AetherBags/Extensions/AddonLifecycleExtensions.cs b/AetherBags/Extensions/AddonLifecycleExtensions.cs new file mode 100644 index 0000000..7d51c06 --- /dev/null +++ b/AetherBags/Extensions/AddonLifecycleExtensions.cs @@ -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> 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; + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/AgentInterfaceExtensions.cs b/AetherBags/Extensions/AgentInterfaceExtensions.cs new file mode 100644 index 0000000..2236e0a --- /dev/null +++ b/AetherBags/Extensions/AgentInterfaceExtensions.cs @@ -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); + } + } +} diff --git a/AetherBags/Extensions/AtkStageExtensions.cs b/AetherBags/Extensions/AtkStageExtensions.cs new file mode 100644 index 0000000..63e7aea --- /dev/null +++ b/AetherBags/Extensions/AtkStageExtensions.cs @@ -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 + ); + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/DragDropPayloadExtensions.cs b/AetherBags/Extensions/DragDropPayloadExtensions.cs new file mode 100644 index 0000000..e4e4785 --- /dev/null +++ b/AetherBags/Extensions/DragDropPayloadExtensions.cs @@ -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); + } + } + } + +} \ No newline at end of file diff --git a/AetherBags/Extensions/EnumExtensions.cs b/AetherBags/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..258ba19 --- /dev/null +++ b/AetherBags/Extensions/EnumExtensions.cs @@ -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(); + return attribute?.Description ?? enumValue.ToString(); + } + } + + extension(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(flag, enable); break; + case 2: flagValue.SetFlag(flag, enable); break; + case 4: flagValue.SetFlag(flag, enable); break; + case 8: flagValue.SetFlag(flag, enable); break; + default: throw new NotSupportedException("Unsupported enum size"); + } + } + + private void SetFlag(T flag, bool enable) where TUnderlying : unmanaged, IBinaryInteger { + ref var value = ref Unsafe.As(ref flagValue); + var mask = Unsafe.As(ref flag); + + if (enable) + value |= mask; + else + value &= ~mask; + } + } +} diff --git a/AetherBags/Extensions/InventoryItemExtensions.cs b/AetherBags/Extensions/InventoryItemExtensions.cs new file mode 100644 index 0000000..1dbe710 --- /dev/null +++ b/AetherBags/Extensions/InventoryItemExtensions.cs @@ -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().TryGetRow(baseItemId, out var baseItem)) { + return baseItem; + } + + return null; + } + + private EventItem? GetEventItem() { + var baseItemId = item.GetBaseItemId(); + + if (ItemUtil.IsEventItem(baseItemId) && + Services.DataManager.GetExcelSheet().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().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().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); + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/InventoryTypeExtensions.cs b/AetherBags/Extensions/InventoryTypeExtensions.cs new file mode 100644 index 0000000..77c6e43 --- /dev/null +++ b/AetherBags/Extensions/InventoryTypeExtensions.cs @@ -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; + + /// + /// Resolves the real container and slot for this inventory type using ItemOrderModule. + /// For sorted inventories, the visual slot differs from the actual storage slot. + /// + public 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); + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/ItemExtensions.cs b/AetherBags/Extensions/ItemExtensions.cs new file mode 100644 index 0000000..1de9a4e --- /dev/null +++ b/AetherBags/Extensions/ItemExtensions.cs @@ -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, + }; + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/ItemOrderModuleSorterExtensions.cs b/AetherBags/Extensions/ItemOrderModuleSorterExtensions.cs new file mode 100644 index 0000000..11fbe06 --- /dev/null +++ b/AetherBags/Extensions/ItemOrderModuleSorterExtensions.cs @@ -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); + } + } +} diff --git a/AetherBags/Extensions/LoggerExtensions.cs b/AetherBags/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000..3758e25 --- /dev/null +++ b/AetherBags/Extensions/LoggerExtensions.cs @@ -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); + } + } + } +} \ No newline at end of file diff --git a/AetherBags/Extensions/NodeBaseExtensions.cs b/AetherBags/Extensions/NodeBaseExtensions.cs new file mode 100644 index 0000000..f43e2e1 --- /dev/null +++ b/AetherBags/Extensions/NodeBaseExtensions.cs @@ -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); + } +} \ No newline at end of file diff --git a/AetherBags/GlobalUsing.cs b/AetherBags/GlobalUsing.cs new file mode 100644 index 0000000..b691c20 --- /dev/null +++ b/AetherBags/GlobalUsing.cs @@ -0,0 +1,2 @@ +global using KamiToolKit.Extensions; +global using AetherBags.Extensions; \ No newline at end of file diff --git a/AetherBags/Helpers/BackupHelper.cs b/AetherBags/Helpers/BackupHelper.cs new file mode 100644 index 0000000..4446e13 --- /dev/null +++ b/AetherBags/Helpers/BackupHelper.cs @@ -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(), 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); + } +} diff --git a/AetherBags/Helpers/Import/SortaKindaImportExport.cs b/AetherBags/Helpers/Import/SortaKindaImportExport.cs new file mode 100644 index 0000000..84b28fc --- /dev/null +++ b/AetherBags/Helpers/Import/SortaKindaImportExport.cs @@ -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(trimmed, ExternalJsonOptions); + if (file?.Rules is { Count: > 0 }) + { + external = file.Rules.ToArray(); + } + else + { + external = Util.DeserializeCompressed(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(), + + AllowedItemNamePatterns = + (external.AllowedItemNames ?? new List()) + .Concat((external.AllowedNameRegexes ?? new List()) + .Select(r => r.Text) + .Where(t => !string.IsNullOrWhiteSpace(t))) + .ToList(), + + AllowedUiCategoryIds = external.AllowedItemTypes?.ToList() ?? new List(), + AllowedRarities = external.AllowedItemRarities?.ToList() ?? new List(), + + Level = new RangeFilter + { + Enabled = external.LevelFilter?.Enable ?? false, + Min = external.LevelFilter?.MinValue ?? 0, + Max = external.LevelFilter?.MaxValue ?? 200, + }, + ItemLevel = new RangeFilter + { + Enabled = external.ItemLevelFilter?.Enable ?? false, + Min = external.ItemLevelFilter?.MinValue ?? 0, + Max = external.ItemLevelFilter?.MaxValue ?? 2000, + }, + VendorPrice = new RangeFilter + { + 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(), + AllowedNameRegexes = + (internalCat.Rules.AllowedItemNamePatterns ?? new List()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => new AllowedNameRegexDto { Text = s }) + .ToList(), + + AllowedItemTypes = internalCat.Rules.AllowedUiCategoryIds?.ToList() ?? new List(), + AllowedItemRarities = internalCat.Rules.AllowedRarities?.ToList() ?? new List(), + + LevelFilter = new ExternalRangeFilterDto + { + Enable = internalCat.Rules.Level.Enabled, + Label = "Level Filter", + MinValue = internalCat.Rules.Level.Min, + MaxValue = internalCat.Rules.Level.Max + }, + + ItemLevelFilter = new ExternalRangeFilterDto + { + Enable = internalCat.Rules.ItemLevel.Enabled, + Label = "Item Level Filter", + MinValue = internalCat.Rules.ItemLevel.Min, + MaxValue = internalCat.Rules.ItemLevel.Max + }, + VendorPriceFilter = new ExternalRangeFilterDto + { + 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, + }; +} \ No newline at end of file diff --git a/AetherBags/Helpers/ImportExportResetHelper.cs b/AetherBags/Helpers/ImportExportResetHelper.cs new file mode 100644 index 0000000..49a4d5d --- /dev/null +++ b/AetherBags/Helpers/ImportExportResetHelper.cs @@ -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."); + } +} diff --git a/AetherBags/Helpers/InventoryMoveHelper.cs b/AetherBags/Helpers/InventoryMoveHelper.cs new file mode 100644 index 0000000..c73143c --- /dev/null +++ b/AetherBags/Helpers/InventoryMoveHelper.cs @@ -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); + } +} \ No newline at end of file diff --git a/AetherBags/Helpers/JsonFileHelper.cs b/AetherBags/Helpers/JsonFileHelper.cs new file mode 100644 index 0000000..7e9196f --- /dev/null +++ b/AetherBags/Helpers/JsonFileHelper.cs @@ -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(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(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? 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])); + } +} diff --git a/AetherBags/Helpers/RegexCache.cs b/AetherBags/Helpers/RegexCache.cs new file mode 100644 index 0000000..b6ee376 --- /dev/null +++ b/AetherBags/Helpers/RegexCache.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace AetherBags.Helpers; + +/// +/// Thread-safe cache for compiled Regex objects to avoid repeated compilation overhead. +/// +internal static class RegexCache +{ + private const int MaxCacheSize = 128; + private static readonly ConcurrentDictionary Cache = new(); + + /// + /// Gets or creates a compiled Regex for the given pattern with case-insensitive matching. + /// Returns null if the pattern is invalid. + /// + 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; + } + } + + /// + /// Clears the regex cache. Call when configuration changes significantly. + /// + public static void Clear() => Cache.Clear(); +} diff --git a/AetherBags/Helpers/Util.cs b/AetherBags/Helpers/Util.cs new file mode 100644 index 0000000..044e1a3 --- /dev/null +++ b/AetherBags/Helpers/Util.cs @@ -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 set) + => string.Join(",", set.OrderBy(x => x)); + + public static HashSet 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 hashSet) + => CompressToBase64(SerializeUIntSet(hashSet)); + + public static HashSet DeserializeHashSet(string input) + { + try + { + return DeserializeUIntSet(DecompressFromBase64(input)); + } + catch + { + return new HashSet(); + } + } + + public static string SerializeCompressed(T value, JsonSerializerOptions? options = null) + { + var json = JsonSerializer.Serialize(value, options ?? ConfigJsonOptions); + return CompressToBase64(json); + } + + public static T? DeserializeCompressed(string input, JsonSerializerOptions? options = null) + { + try + { + var json = DecompressFromBase64(input); + return JsonSerializer.Deserialize(json, options ?? ConfigJsonOptions); + } + catch + { + return default; + } + } + + public static string SerializeConfig(SystemConfiguration config) + => SerializeCompressed(config, ConfigJsonOptions); + + public static SystemConfiguration? DeserializeConfig(string input) + => DeserializeCompressed(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(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(); +} diff --git a/AetherBags/Hooks/InventoryHook.cs b/AetherBags/Hooks/InventoryHook.cs new file mode 100644 index 0000000..996b84d --- /dev/null +++ b/AetherBags/Hooks/InventoryHook.cs @@ -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; + +/// +/// Manages hooks related to inventory operations. +/// +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? _moveItemSlotHook; + /* + private readonly Hook? _openInventoryHook; + private readonly Hook? _handleInventoryEventHook; + private readonly Hook? _openAddonHook; + */ + + public InventoryHooks() + { + try + { + _moveItemSlotHook = Services.GameInteropProvider.HookFromSignature( + "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.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( + "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.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(); + */ + } +} \ No newline at end of file diff --git a/AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs b/AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs new file mode 100644 index 0000000..0c840a8 --- /dev/null +++ b/AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs @@ -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? OnItemHovered; + public event Action? OnItemUnhovered; + public event Action? OnItemClicked; + public event Action? OnSearchChanged; + public event Action? OnInventoryOpened; + public event Action? OnInventoryClosed; + public event Action? OnCategoriesRefreshed; + + public bool IsInventoryOpen => System.AddonInventoryWindow?.IsOpen ?? false; + + public IReadOnlyList GetVisibleItemIds() + { + var window = System.AddonInventoryWindow; + if (window == null || !window.IsOpen) return Array.Empty(); + + var categories = window.GetVisibleCategories(); + if (categories == null) return Array.Empty(); + + var result = new List(); + foreach (var category in categories) + { + foreach (var item in category.Items) + { + result.Add(item.Item.ItemId); + } + } + return result; + } + + public IReadOnlyList GetItemsInCategory(uint categoryKey) + { + var window = System.AddonInventoryWindow; + if (window == null || !window.IsOpen) return Array.Empty(); + + var categories = window.GetVisibleCategories(); + if (categories == null) return Array.Empty(); + + var category = categories.FirstOrDefault(c => c.Key == categoryKey); + if (category.Items == null) return Array.Empty(); + + 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 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(); +} diff --git a/AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs b/AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs new file mode 100644 index 0000000..4b3dbbe --- /dev/null +++ b/AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs @@ -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 _isInventoryOpen; + private readonly ICallGateProvider> _getVisibleItemIds; + private readonly ICallGateProvider> _getItemsInCategory; + private readonly ICallGateProvider _isItemVisible; + private readonly ICallGateProvider _getSearchFilter; + private readonly ICallGateProvider> _getRegisteredSources; + + private readonly ICallGateProvider _onItemHovered; + private readonly ICallGateProvider _onItemUnhovered; + private readonly ICallGateProvider _onItemClicked; + private readonly ICallGateProvider _onSearchChanged; + private readonly ICallGateProvider _onInventoryOpened; + private readonly ICallGateProvider _onInventoryClosed; + private readonly ICallGateProvider _onCategoriesRefreshed; + + public AetherBagsAPIImpl API => _api; + + public AetherBagsIPCProvider() + { + _api = new AetherBagsAPIImpl(); + + _isInventoryOpen = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}IsInventoryOpen"); + _getVisibleItemIds = Services.PluginInterface.GetIpcProvider>($"{IpcPrefix}GetVisibleItemIds"); + _getItemsInCategory = Services.PluginInterface.GetIpcProvider>($"{IpcPrefix}GetItemsInCategory"); + _isItemVisible = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}IsItemVisible"); + _getSearchFilter = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}GetSearchFilter"); + _getRegisteredSources = Services.PluginInterface.GetIpcProvider>($"{IpcPrefix}GetRegisteredSources"); + + _onItemHovered = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnItemHovered"); + _onItemUnhovered = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnItemUnhovered"); + _onItemClicked = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnItemClicked"); + _onSearchChanged = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnSearchChanged"); + _onInventoryOpened = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnInventoryOpened"); + _onInventoryClosed = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnInventoryClosed"); + _onCategoriesRefreshed = Services.PluginInterface.GetIpcProvider($"{IpcPrefix}OnCategoriesRefreshed"); + + RegisterFunctions(); + SubscribeEvents(); + } + + private void RegisterFunctions() + { + _isInventoryOpen.RegisterFunc(() => _api.IsInventoryOpen); + _getVisibleItemIds.RegisterFunc(() => new List(_api.GetVisibleItemIds())); + _getItemsInCategory.RegisterFunc(key => new List(_api.GetItemsInCategory(key))); + _isItemVisible.RegisterFunc(itemId => _api.IsItemVisible(itemId)); + _getSearchFilter.RegisterFunc(() => _api.GetCurrentSearchFilter()); + _getRegisteredSources.RegisterFunc(() => new List(_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(); + } +} diff --git a/AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs b/AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs new file mode 100644 index 0000000..5d9e9ad --- /dev/null +++ b/AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using AetherBags.IPC.ExternalCategorySystem; + +namespace AetherBags.IPC.AetherBagsAPI; + +public interface IAetherBagsAPI +{ + IReadOnlyList GetVisibleItemIds(); + IReadOnlyList GetItemsInCategory(uint categoryKey); + bool IsItemVisible(uint itemId); + string GetCurrentSearchFilter(); + bool IsInventoryOpen { get; } + + event Action? OnItemHovered; + event Action? OnItemUnhovered; + event Action? OnItemClicked; + event Action? OnSearchChanged; + event Action? OnInventoryOpened; + event Action? OnInventoryClosed; + event Action? OnCategoriesRefreshed; + + void RegisterSource(IExternalItemSource source); + void UnregisterSource(string sourceName); + IReadOnlyList GetRegisteredSourceNames(); +} diff --git a/AetherBags/IPC/AllaganToolsIPC.cs b/AetherBags/IPC/AllaganToolsIPC.cs new file mode 100644 index 0000000..9937259 --- /dev/null +++ b/AetherBags/IPC/AllaganToolsIPC.cs @@ -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? _isInitialized; + private ICallGateSubscriber? _initialized; + private ICallGateSubscriber>? _getFilterItems; + private ICallGateSubscriber>? _getSearchFilters; + private ICallGateSubscriber? _enableUiFilter; + private ICallGateSubscriber? _toggleUiFilter; + + public bool IsReady { get; private set; } + + /// + /// Cached filter items. Key = filterKey, Value = (ItemId -> Quantity). + /// + public Dictionary> CachedFilterItems { get; } = new(); + + /// + /// Cached search filters. Key -> Name. + /// + public Dictionary CachedSearchFilters { get; } = new(); + + /// + /// Quick lookup: ItemId -> List of filter keys that contain this item. + /// + public Dictionary> ItemToFilters { get; } = new(); + + public event Action? OnInitialized; + public event Action? OnFiltersRefreshed; + + public AllaganToolsIPC() + { + try + { + _isInitialized = Services.PluginInterface.GetIpcSubscriber("AllaganTools.IsInitialized"); + _initialized = Services.PluginInterface.GetIpcSubscriber("AllaganTools.Initialized"); + _getFilterItems = Services.PluginInterface.GetIpcSubscriber>("AllaganTools.GetFilterItems"); + _getSearchFilters = Services.PluginInterface.GetIpcSubscriber>("AllaganTools.GetSearchFilters"); + _enableUiFilter = Services.PluginInterface.GetIpcSubscriber("AllaganTools.EnableUiFilter"); + _toggleUiFilter = Services.PluginInterface.GetIpcSubscriber("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(); + } + } + + /// + /// Refreshes all cached filter data from Allagan Tools. + /// Call this when you need updated filter information. + /// + 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(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}"); + } + } + + /// + /// Checks if an item is in any Allagan Tools filter. + /// + public bool IsItemInAnyFilter(uint itemId) + => ItemToFilters.ContainsKey(itemId); + + /// + /// Gets all filter keys that contain this item. + /// + public IReadOnlyList? GetFiltersForItem(uint itemId) + => ItemToFilters.TryGetValue(itemId, out var list) ? list : null; + + /// + /// Gets items from a specific filter. Returns ItemId -> Quantity. + /// + public Dictionary? 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; + } + } + + /// + /// Gets all available search filters. Returns Key -> Name. + /// + public Dictionary? 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? GetCategoryAssignments() + { + if (_ipc.CachedFilterItems.Count == 0) return null; + + var result = new Dictionary(); + 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? GetItemDecorations() => null; + + public IReadOnlyList? GetContextMenuEntries(uint itemId) => null; + + public IReadOnlyDictionary? GetSearchTags() + { + if (_ipc.ItemToFilters.Count == 0) return null; + + var result = new Dictionary(); + foreach (var (itemId, filterKeys) in _ipc.ItemToFilters) + { + var tags = new List(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? GetItemRelationships(uint itemId) => null; + } +} \ No newline at end of file diff --git a/AetherBags/IPC/BisBuddyIPC.cs b/AetherBags/IPC/BisBuddyIPC.cs new file mode 100644 index 0000000..6ec8cb6 --- /dev/null +++ b/AetherBags/IPC/BisBuddyIPC.cs @@ -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? _isInitialized; + private ICallGateSubscriber? _initialized; + private ICallGateSubscriber>? _getInventoryHighlightItems; + private ICallGateSubscriber, bool>? _inventoryHighlightItemsChanged; + private ICallGateSubscriber>? _getBisItemsFiltered; + + public bool IsReady { get; private set; } + + public List CachedBisItems { get; } = new(); + + public Dictionary ItemLookup { get; } = new(); + + public BisItemFilter? CurrentFilter { get; private set; } + + public event Action? OnItemsRefreshed; + + public BisBuddyIPC() + { + try + { + _isInitialized = Services.PluginInterface.GetIpcSubscriber("BisBuddy.IsInitialized"); + _initialized = Services.PluginInterface.GetIpcSubscriber("BisBuddy.Initialized"); + _getInventoryHighlightItems = Services.PluginInterface.GetIpcSubscriber>("BisBuddy.GetInventoryHighlightItems"); + _inventoryHighlightItemsChanged = Services.PluginInterface.GetIpcSubscriber, bool>("BisBuddy.InventoryHighlightItemsChanged"); + _getBisItemsFiltered = Services.PluginInterface.GetIpcSubscriber>("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 items) + { + if (CurrentFilter == null) + { + UpdateCacheAndHighlights(items); + } + } + + public void RefreshItems() + { + if (!IsReady) return; + + try + { + List? 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 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(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? GetCategoryAssignments() + { + var items = _ipc.ItemLookup; + if (items.Count == 0) return null; + + var result = new Dictionary(); + + var colorGroups = new Dictionary>(); + 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? GetItemDecorations() + { + var items = _ipc.ItemLookup; + if (items.Count == 0) return null; + + var result = new Dictionary(); + 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? GetContextMenuEntries(uint itemId) => null; + + public IReadOnlyDictionary? GetSearchTags() + { + var items = _ipc.ItemLookup; + if (items.Count == 0) return null; + + var result = new Dictionary(); + foreach (var itemId in items.Keys) + { + result[itemId] = new[] { "bis", "bestinslot", "gearset" }; + } + return result; + } + + public IReadOnlyList? GetItemRelationships(uint itemId) + { + if (!_ipc.ItemLookup.TryGetValue(itemId, out var entry)) return null; + + var sameSetItems = new List(); + 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) + ) + }; + } + } +} \ No newline at end of file diff --git a/AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs b/AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs new file mode 100644 index 0000000..00d9ac2 --- /dev/null +++ b/AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs @@ -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 Sources = new(); + private static readonly Dictionary CategoryCache = new(); + private static readonly Dictionary DecorationCache = new(); + private static readonly Dictionary> SearchTagCache = new(); + private static int _lastCombinedVersion; + + public static IReadOnlyList 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(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 itemInfoByKey, + Dictionary bucketsByKey, + HashSet 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(16), + FilteredItems = new List(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? GetContextMenuEntries(uint itemId) + { + List? 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(4); + result.Add(entry); + } + } + + result?.Sort((a, b) => a.Order.CompareTo(b.Order)); + return result; + } + + public static IReadOnlyList? 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? GetItemRelationships(uint itemId) + { + List? 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(4); + result.AddRange(relationships); + } + + return result; + } + + public static HashSet? GetRelatedItemIds(uint itemId, RelationshipType? filterType = null) + { + var relationships = GetItemRelationships(itemId); + if (relationships == null || relationships.Count == 0) return null; + + var result = new HashSet(); + 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; + } +} diff --git a/AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs b/AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs new file mode 100644 index 0000000..5c509b6 --- /dev/null +++ b/AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs @@ -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? GetCategoryAssignments(); + IReadOnlyDictionary? GetItemDecorations(); + IReadOnlyList? GetContextMenuEntries(uint itemId); + IReadOnlyDictionary? GetSearchTags(); + IReadOnlyList? 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 OnClick, + int Order, + Func? 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 +} diff --git a/AetherBags/IPC/IPCService.cs b/AetherBags/IPC/IPCService.cs new file mode 100644 index 0000000..47cb258 --- /dev/null +++ b/AetherBags/IPC/IPCService.cs @@ -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(); + } +} \ No newline at end of file diff --git a/AetherBags/IPC/WotsItIPC.cs b/AetherBags/IPC/WotsItIPC.cs new file mode 100644 index 0000000..5e13b15 --- /dev/null +++ b/AetherBags/IPC/WotsItIPC.cs @@ -0,0 +1,80 @@ +using System; +using Dalamud.Plugin.Ipc; + +namespace AetherBags.IPC; + +public class WotsItIPC : IDisposable +{ + private ICallGateSubscriber? _registerWithSearch; + private ICallGateSubscriber? _invoke; + private ICallGateSubscriber? _unregisterAll; + + private string? _searchGuid; + + public WotsItIPC() + { + try + { + _registerWithSearch = Services.PluginInterface.GetIpcSubscriber("FA.RegisterWithSearch"); + _unregisterAll = Services.PluginInterface.GetIpcSubscriber("FA.UnregisterAll"); + _invoke = Services.PluginInterface.GetIpcSubscriber("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(); + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Categories/CategorizedInventory.cs b/AetherBags/Inventory/Categories/CategorizedInventory.cs new file mode 100644 index 0000000..8f42bff --- /dev/null +++ b/AetherBags/Inventory/Categories/CategorizedInventory.cs @@ -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 Items); \ No newline at end of file diff --git a/AetherBags/Inventory/Categories/CategoryBucket.cs b/AetherBags/Inventory/Categories/CategoryBucket.cs new file mode 100644 index 0000000..b1b9695 --- /dev/null +++ b/AetherBags/Inventory/Categories/CategoryBucket.cs @@ -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 Items = null!; + public List FilteredItems = null!; + public bool Used; + public bool NeedsSorting = true; +} + +public sealed class ItemCountDescComparer : IComparer +{ + 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; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Categories/CategoryBucketManager.cs b/AetherBags/Inventory/Categories/CategoryBucketManager.cs new file mode 100644 index 0000000..a28202f --- /dev/null +++ b/AetherBags/Inventory/Categories/CategoryBucketManager.cs @@ -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 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; + + + /// + /// Resets all buckets for a new refresh cycle. + /// + public static void ResetBuckets(Dictionary 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 itemInfoByKey, + List userCategories, + Dictionary bucketsByKey, + HashSet claimedKeys, + List 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(capacity: 16), + FilteredItems = new List(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 + { + 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 itemInfoByKey, + Dictionary bucketsByKey, + HashSet 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(capacity: 16), + FilteredItems = new List(capacity: 16), + Used = true, + }; + } + else + { + bucketRef!.Used = true; + } + + bucketRef!.Items.Add(info); + } + } + + public static void BucketByAllaganFilters( + Dictionary itemInfoByKey, + Dictionary bucketsByKey, + HashSet 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(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(capacity: 16), + FilteredItems = new List(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 itemInfoByKey, + Dictionary bucketsByKey, + HashSet 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(capacity: 16), + FilteredItems = new List(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 itemInfoByKey, + Dictionary bucketsByKey, + HashSet 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(capacity: 16), + FilteredItems = new List(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 bucketsByKey, + List 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 bucketsByKey, + List sortedCategoryKeys, + List 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, + }; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Categories/CategoryInfo.cs b/AetherBags/Inventory/Categories/CategoryInfo.cs new file mode 100644 index 0000000..5ac5588 --- /dev/null +++ b/AetherBags/Inventory/Categories/CategoryInfo.cs @@ -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; +} \ No newline at end of file diff --git a/AetherBags/Inventory/Categories/InventoryFilter.cs b/AetherBags/Inventory/Categories/InventoryFilter.cs new file mode 100644 index 0000000..6dddfde --- /dev/null +++ b/AetherBags/Inventory/Categories/InventoryFilter.cs @@ -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 FilterCategories( + IReadOnlyList allCategories, + Dictionary bucketsByKey, + List 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; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Categories/UserCategoryMatcher.cs b/AetherBags/Inventory/Categories/UserCategoryMatcher.cs new file mode 100644 index 0000000..ea1db6b --- /dev/null +++ b/AetherBags/Inventory/Categories/UserCategoryMatcher.cs @@ -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 value, T min, T max) where T : struct, IComparable + => 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; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Context/HighlightState.cs b/AetherBags/Inventory/Context/HighlightState.cs new file mode 100644 index 0000000..8a7b243 --- /dev/null +++ b/AetherBags/Inventory/Context/HighlightState.cs @@ -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> Filters = new(); + private static readonly Dictionary ids, Vector3 color)> Labels = new(); + private static readonly Dictionary> PerItemLabels = new(); + + // Flat cache for O(1) lookups + private static readonly Dictionary CachedEntries = new(capacity: 512); + private static bool _cacheValid; + private static int _version; + + /// + /// Version counter that increments when highlight state changes. + /// Used by ItemInfo to detect when cached visual state is stale. + /// + 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 ids) + { + Filters[source] = new HashSet(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 ids, Vector3 color) + { + PerItemLabels.Remove(source); + Labels[source] = (new HashSet(ids), color); + InvalidateCache(); + } + + public static void SetLabelWithColors(HighlightSource source, Dictionary itemColors) + { + Labels.Remove(source); + + var entries = new Dictionary(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 entries) + { + Labels.Remove(source); + + var dict = new Dictionary(); + foreach (var entry in entries) + { + dict[entry.ItemId] = entry; + } + + PerItemLabels[source] = dict; + InvalidateCache(); + } + + public static void SetLabelWithColors(HighlightSource source, Dictionary itemColors) + { + Labels.Remove(source); + + var entries = new Dictionary(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? 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); + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Context/InventoryContextState.cs b/AetherBags/Inventory/Context/InventoryContextState.cs new file mode 100644 index 0000000..1f1e8d6 --- /dev/null +++ b/AetherBags/Inventory/Context/InventoryContextState.cs @@ -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 VisualLocationMap = new(); + private static readonly Dictionary> 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); + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Context/InventoryNotificationState.cs b/AetherBags/Inventory/Context/InventoryNotificationState.cs new file mode 100644 index 0000000..02358cb --- /dev/null +++ b/AetherBags/Inventory/Context/InventoryNotificationState.cs @@ -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 notificationCache; + + public InventoryNotificationState() + { + var addonSheet = Services.DataManager.GetExcelSheet(); + notificationCache = new Dictionary + { + { 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 +} \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryLocation.cs b/AetherBags/Inventory/InventoryLocation.cs new file mode 100644 index 0000000..7dd5265 --- /dev/null +++ b/AetherBags/Inventory/InventoryLocation.cs @@ -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}"; +} \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryOrchestrator.cs b/AetherBags/Inventory/InventoryOrchestrator.cs new file mode 100644 index 0000000..4d52dd2 --- /dev/null +++ b/AetherBags/Inventory/InventoryOrchestrator.cs @@ -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 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; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Items/InventoryStats.cs b/AetherBags/Inventory/Items/InventoryStats.cs new file mode 100644 index 0000000..d43c9a4 --- /dev/null +++ b/AetherBags/Inventory/Items/InventoryStats.cs @@ -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, + }; +} \ No newline at end of file diff --git a/AetherBags/Inventory/Items/ItemInfo.cs b/AetherBags/Inventory/Items/ItemInfo.cs new file mode 100644 index 0000000..2f45dca --- /dev/null +++ b/AetherBags/Inventory/Items/ItemInfo.cs @@ -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 +{ + public required ulong Key { get; set; } + + public required InventoryItem Item { get; set; } + public required int ItemCount { get; set; } + + private static ExcelSheet? s_itemSheet; + private static ExcelSheet ItemSheet => s_itemSheet ??= Services.DataManager.GetExcelSheet(); + + 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 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(); +} diff --git a/AetherBags/Inventory/Items/LootedItemInfo.cs b/AetherBags/Inventory/Items/LootedItemInfo.cs new file mode 100644 index 0000000..4e3b1d8 --- /dev/null +++ b/AetherBags/Inventory/Items/LootedItemInfo.cs @@ -0,0 +1,5 @@ +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace AetherBags.Inventory.Items; + +public record LootedItemInfo(int Index, InventoryItem Item, int Quantity); \ No newline at end of file diff --git a/AetherBags/Inventory/Scanning/AggregatedItem.cs b/AetherBags/Inventory/Scanning/AggregatedItem.cs new file mode 100644 index 0000000..2f196b2 --- /dev/null +++ b/AetherBags/Inventory/Scanning/AggregatedItem.cs @@ -0,0 +1,9 @@ +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace AetherBags.Inventory.Scanning; + +public struct AggregatedItem +{ + public InventoryItem First; + public int Total; +} \ No newline at end of file diff --git a/AetherBags/Inventory/Scanning/InventoryScanner.cs b/AetherBags/Inventory/Scanning/InventoryScanner.cs new file mode 100644 index 0000000..4053f27 --- /dev/null +++ b/AetherBags/Inventory/Scanning/InventoryScanner.cs @@ -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 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 aggByKey, + Dictionary 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 aggByKey, + Dictionary itemInfoByKey, + List 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; + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/Scanning/InventorySource.cs b/AetherBags/Inventory/Scanning/InventorySource.cs new file mode 100644 index 0000000..b080406 --- /dev/null +++ b/AetherBags/Inventory/Scanning/InventorySource.cs @@ -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, + }; +} \ No newline at end of file diff --git a/AetherBags/Inventory/State/InventoryStateBase.cs b/AetherBags/Inventory/State/InventoryStateBase.cs new file mode 100644 index 0000000..c35afc6 --- /dev/null +++ b/AetherBags/Inventory/State/InventoryStateBase.cs @@ -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 AggByKey = new(capacity: 512); + protected readonly Dictionary ItemInfoByKey = new(capacity: 512); + protected readonly Dictionary BucketsByKey = new(capacity: 256); + protected readonly List SortedCategoryKeys = new(capacity: 256); + protected readonly List AllCategories = new(capacity: 256); + protected readonly List FilteredCategories = new(capacity: 256); + protected readonly List UserCategoriesSortedScratch = new(capacity: 64); + protected readonly List EnabledUserCategoriesScratch = new(capacity: 64); + protected readonly List RemoveKeysScratch = new(capacity: 256); + protected readonly HashSet 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 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 GetCurrencyInfoList(uint[] currencyIds) + => CurrencyState.GetCurrencyInfoList(currencyIds); + + public static IReadOnlyList GetCurrencyInfoList(List 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(); + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/State/MainBagState.cs b/AetherBags/Inventory/State/MainBagState.cs new file mode 100644 index 0000000..945d36a --- /dev/null +++ b/AetherBags/Inventory/State/MainBagState.cs @@ -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(); + } +} \ No newline at end of file diff --git a/AetherBags/Inventory/State/RetainerState.cs b/AetherBags/Inventory/State/RetainerState.cs new file mode 100644 index 0000000..d557ae1 --- /dev/null +++ b/AetherBags/Inventory/State/RetainerState.cs @@ -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; +} \ No newline at end of file diff --git a/AetherBags/Inventory/State/SaddleBagState.cs b/AetherBags/Inventory/State/SaddleBagState.cs new file mode 100644 index 0000000..608d229 --- /dev/null +++ b/AetherBags/Inventory/State/SaddleBagState.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/AetherBags/Monitoring/InventoryMonitor.cs b/AetherBags/Monitoring/InventoryMonitor.cs new file mode 100644 index 0000000..39de8ca --- /dev/null +++ b/AetherBags/Monitoring/InventoryMonitor.cs @@ -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 +{ + /// + /// Returns true if the game's drag-drop manager is currently dragging. + /// + 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 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); + } +} \ No newline at end of file diff --git a/AetherBags/Monitoring/LootedItemsTracker.cs b/AetherBags/Monitoring/LootedItemsTracker.cs new file mode 100644 index 0000000..7454b96 --- /dev/null +++ b/AetherBags/Monitoring/LootedItemsTracker.cs @@ -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 StandardInventories => InventoryScanner.StandardInventories; + + private const int BatchDelayMs = 300; + + private readonly List _lootedItems = new(capacity: 64); + private readonly Dictionary<(uint ItemId, bool IsHq), (InventoryItem Item, int Quantity)> _pendingChanges = new(capacity: 32); + + private static HashSet? _filteredCategoryItems; + + private bool _isEnabled; + private long _batchStartTick; + private bool _hasPendingRemoval; + private int _nextIndex; + + public event Action>? OnLootedItemsChanged; + + public IReadOnlyList 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 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(); + var sheet = Services.DataManager.GetExcelSheet(); + 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); + } +} diff --git a/AetherBags/Nodes/Color/ColorInputRow.cs b/AetherBags/Nodes/Color/ColorInputRow.cs new file mode 100644 index 0000000..582d2bd --- /dev/null +++ b/AetherBags/Nodes/Color/ColorInputRow.cs @@ -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? OnColorConfirmed { get; set; } + public Action? OnColorCanceled { get; set; } + public Action? OnColorChange { get; set; } + public Action? OnColorPreviewed { get; set; } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Color/ColorPreviewButtonNode.cs b/AetherBags/Nodes/Color/ColorPreviewButtonNode.cs new file mode 100644 index 0000000..28cf618 --- /dev/null +++ b/AetherBags/Nodes/Color/ColorPreviewButtonNode.cs @@ -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); +} diff --git a/AetherBags/Nodes/Color/ColorPreviewNode.cs b/AetherBags/Nodes/Color/ColorPreviewNode.cs new file mode 100644 index 0000000..d929efa --- /dev/null +++ b/AetherBags/Nodes/Color/ColorPreviewNode.cs @@ -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); + } +} diff --git a/AetherBags/Nodes/Configuration/Category/BasicSettingsSection.cs b/AetherBags/Nodes/Configuration/Category/BasicSettingsSection.cs new file mode 100644 index 0000000..91bea68 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/BasicSettingsSection.cs @@ -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 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(); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs new file mode 100644 index 0000000..b3b7821 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs @@ -0,0 +1,57 @@ +using System; +using AetherBags.Addons; +using KamiToolKit.Premade.Nodes; + +namespace AetherBags.Nodes.Configuration.Category; + +public class CategoryConfigurationNode : ConfigNode +{ + 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; + } + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs new file mode 100644 index 0000000..9add9c7 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs @@ -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? ItemSheet => Services.DataManager.GetExcelSheet(); + private static ExcelSheet? UICategorySheet => Services.DataManager.GetExcelSheet(); + + public Action? OnLayoutChanged { get; init; } + public Action? OnCategoryPropertyChanged { get; init; } + + private UserCategoryDefinition _categoryDefinition = new(); + + private readonly ScrollingAreaNode _scrollingArea; + private readonly List _sections = new(); + + public CategoryDefinitionConfigurationNode() + { + _scrollingArea = new ScrollingAreaNode { + 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 _getCategoryDefinition; + + public Action? OnValueChanged { get; set; } + + protected UserCategoryDefinition CategoryDefinition => _getCategoryDefinition(); + + protected ConfigurationSection(Func 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, + }; +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs new file mode 100644 index 0000000..d23e7ea --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs @@ -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? bbModeDropdown = new LabeledEnumDropdownNode + { + Size = new Vector2(500, 20), + LabelText = "Filter Display Mode", + LabelTextFlags = TextFlags.AutoAdjustNodeSize, + IsEnabled = config.BisBuddyEnabled && bisBuddyReady, + Options = Enum.GetValues().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? atModeDropdown = new LabeledEnumDropdownNode + { + Size = new Vector2(500, 20), + LabelText = "Filter Display Mode", + LabelTextFlags = TextFlags.AutoAdjustNodeSize, + IsEnabled = config.AllaganToolsCategoriesEnabled && allaganReady, + Options = Enum.GetValues().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); +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs new file mode 100644 index 0000000..e37ad02 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs @@ -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); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs b/AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs new file mode 100644 index 0000000..ff0e00f --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs @@ -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); + } +} diff --git a/AetherBags/Nodes/Configuration/Category/ListFiltersSection.cs b/AetherBags/Nodes/Configuration/Category/ListFiltersSection.cs new file mode 100644 index 0000000..76f0f6a --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/ListFiltersSection.cs @@ -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 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() + .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() + .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(); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/RangeFilterRow.cs b/AetherBags/Nodes/Configuration/Category/RangeFilterRow.cs new file mode 100644 index 0000000..a7c32df --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/RangeFilterRow.cs @@ -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? 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 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? 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 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; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/RangeFiltersSection.cs b/AetherBags/Nodes/Configuration/Category/RangeFiltersSection.cs new file mode 100644 index 0000000..8b7c1ad --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/RangeFiltersSection.cs @@ -0,0 +1,77 @@ +using System; +using AetherBags.Configuration; + +namespace AetherBags.Nodes.Configuration.Category; + +public sealed class RangeFiltersSection(Func 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(); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs b/AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs new file mode 100644 index 0000000..1dc10b4 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs @@ -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 _list = []; + private readonly List _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 newList) + { + _list = newList; + Refresh(); + } + + public void Refresh() + { + for (var i = 0; i < _checkboxes.Count; i++) + { + _checkboxes[i].IsChecked = _list.Contains(i); + } + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs b/AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs new file mode 100644 index 0000000..707a708 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs @@ -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 + { + private static readonly string[] StateLabels = ["Ignored", "Required", "Excluded"]; + + protected override string GetStateText(int state) + => state >= 0 && state < StateLabels.Length ?StateLabels[state] : "Unknown"; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/StateFiltersSection.cs b/AetherBags/Nodes/Configuration/Category/StateFiltersSection.cs new file mode 100644 index 0000000..b9f9e11 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/StateFiltersSection.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using AetherBags.Configuration; + +namespace AetherBags.Nodes.Configuration.Category; + +public sealed class StateFiltersSection(Func getCategoryDefinition) + : ConfigurationSection(getCategoryDefinition) +{ + private readonly List<(StateFilterRowNode Node, Func 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 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(); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs b/AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs new file mode 100644 index 0000000..6238a44 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Configuration.Category; + +public sealed class StringListEditorNode : VerticalListNode +{ + private const float LabelWidth = 300f; + private const float RowHeight = 28f; + + private List _list = []; + + private readonly LabelTextNode _headerLabel; + private readonly VerticalListNode _itemsContainer; + private readonly TextInputNode _addInput; + + public Action? OnChanged { get; set; } + + public required ReadOnlySeString Label + { + get => _headerLabel.String; + init => _headerLabel.String = value; + } + + public StringListEditorNode() + { + FitContents = true; + ItemSpacing = 4.0f; + + _headerLabel = new LabelTextNode + { + TextFlags = TextFlags.AutoAdjustNodeSize, + Size = new Vector2(280, 18), + TextColor = ColorHelper.GetColor(8), + }; + AddNode(_headerLabel); + + _itemsContainer = new VerticalListNode + { + Size = new Vector2(LabelWidth + 40f, 0), + ItemSpacing = 2.0f, + FitContents = true, + FirstItemSpacing = 2, + }; + AddNode(_itemsContainer); + + var addRow = new HorizontalListNode + { + Size = new Vector2(LabelWidth + 40f, RowHeight), + ItemSpacing = 4.0f, + }; + + _addInput = new TextInputNode + { + Size = new Vector2(200, RowHeight), + PlaceholderString = "Add new...", + OnInputComplete = _ => AddCurrentValue(), + }; + addRow.AddNode(_addInput); + + var addButton = new TextButtonNode + { + Size = new Vector2(60, RowHeight), + String = "Add", + OnClick = AddCurrentValue, + }; + addRow.AddNode(addButton); + + AddNode(addRow); + } + + public void SetList(List newList) + { + _list = newList; + RefreshItems(); + } + + private void AddCurrentValue() + { + var value = _addInput.String.ExtractText(); + if (!string.IsNullOrWhiteSpace(value) && !_list.Contains(value)) + { + _list.Add(value); + _addInput.String = ""; + RefreshItems(); + OnChanged?.Invoke(); + } + } + + private void RefreshItems() + { + _itemsContainer.Clear(); + + foreach (var value in _list) + { + _itemsContainer.AddNode(CreateItemNode(value)); + } + + if (_list.Count == 0) + { + _itemsContainer.Height = 0; + } + + _itemsContainer.RecalculateLayout(); + RecalculateLayout(); + } + + private StringListItemNode CreateItemNode(string value) => new(value) + { + Size = new Vector2(LabelWidth + 40f, RowHeight), + OnRemove = () => RemoveValue(value), + }; + + private void RemoveValue(string value) + { + _list.Remove(value); + RefreshItems(); + OnChanged?.Invoke(); + } +} + +public sealed class StringListItemNode : HorizontalListNode +{ + private const float LabelWidth = 300f; + + public string Value { get; } + public Action? OnRemove { get; init; } + + public StringListItemNode(string value) + { + Value = value; + ItemSpacing = 4.0f; + + AddNode(new LabelTextNode + { + Size = new Vector2(LabelWidth, 24), + String = value, + TextColor = ColorHelper.GetColor(3), + }); + + AddNode(new CircleButtonNode + { + Size = new Vector2(28, 28), + Icon = ButtonIcon.Cross, + OnClick = () => OnRemove?.Invoke(), + }); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/UICategoryListItemNode.cs b/AetherBags/Nodes/Configuration/Category/UICategoryListItemNode.cs new file mode 100644 index 0000000..a87dc47 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/UICategoryListItemNode.cs @@ -0,0 +1,31 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace AetherBags.Nodes.Configuration.Category; + +public class UICategoryListItemNode : ListItemNode { + public override float ItemHeight => 30.0f; + protected readonly TextNode LabelTextNode; + + public UICategoryListItemNode() { + LabelTextNode = new TextNode { + FontSize = 14, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(8), + }; + LabelTextNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + LabelTextNode.Size = Size with { X = Width - 10 }; + LabelTextNode.Position = new Vector2(5, 0); + } + + protected override void SetNodeData(ItemUICategory data) { + LabelTextNode.String = data.Name.ToString(); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs b/AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs new file mode 100644 index 0000000..2544f50 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AetherBags.Configuration; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Configuration.Category; + +public sealed class UintListEditorNode : VerticalListNode +{ + private const float LabelWidth = 300f; + private const float RowHeight = 28f; + + private List _list = []; + + public List GetList() => _list.ToList(); + + private readonly LabelTextNode _headerLabel; + private readonly VerticalListNode _itemsContainer; + private readonly NumericInputNode _addInput; + + public Action? OnSearchButtonClicked { get; init; } + + public Func? LabelResolver { get; init; } + public Action? OnChanged { get; set; } + + public required ReadOnlySeString Label + { + get => _headerLabel.String; + init => _headerLabel.String = value; + } + + public UintListEditorNode() + { + FitContents = true; + ItemSpacing = 4.0f; + + _headerLabel = new LabelTextNode + { + TextFlags = TextFlags.AutoAdjustNodeSize, + Size = new Vector2(280, 18), + TextColor = ColorHelper.GetColor(8), + }; + AddNode(_headerLabel); + + _itemsContainer = new VerticalListNode + { + Size = new Vector2(LabelWidth + 40f, 0), + ItemSpacing = 2.0f, + FitContents = true, + FirstItemSpacing = 2, + }; + AddNode(_itemsContainer); + + var addRow = new HorizontalListNode + { + Size = new Vector2(LabelWidth + 40f, RowHeight), + ItemSpacing = 4.0f, + }; + + var searchButton = new CircleButtonNode + { + Size = new Vector2(28), + Icon = ButtonIcon.MagnifyingGlass, + OnClick = () => OnSearchButtonClicked?.Invoke(), + TextTooltip = "Search the game database..." + }; + addRow.AddNode(searchButton); + + _addInput = new NumericInputNode + { + Size = new Vector2(120, RowHeight), + Min = 0, + Max = int.MaxValue, + Value = 0, + }; + addRow.AddNode(_addInput); + + var addButton = new TextButtonNode + { + Size = new Vector2(60, RowHeight), + String = "Add", + OnClick = AddCurrentValue, + }; + addRow.AddNode(addButton); + addRow.RecalculateLayout(); + AddNode(addRow); + RecalculateLayout(); + } + + public void SetList(List newList) + { + _list = newList; + RefreshItems(); + } + + public void AddValue(uint value) + { + if (!_list.Contains(value)) + { + _list.Add(value); + RefreshItems(); + OnChanged?.Invoke(); + } + } + + private void AddCurrentValue() + { + var value = (uint)_addInput.Value; + if (!_list.Contains(value)) + { + _list.Add(value); + RefreshItems(); + OnChanged?.Invoke(); + } + } + + private void RefreshItems() + { + _itemsContainer.Clear(); + + foreach (var value in _list) + { + _itemsContainer.AddNode(CreateItemNode(value)); + } + + if (_list.Count == 0) + { + _itemsContainer.Height = 0; + } + + _itemsContainer.RecalculateLayout(); + RecalculateLayout(); + OnChanged?.Invoke(); + } + + private UintListItemNode CreateItemNode(uint value) => new(value, LabelResolver) + { + Size = new Vector2(LabelWidth + 40f, RowHeight), + OnRemove = () => RemoveValue(value), + }; + + private void RemoveValue(uint value) + { + _list.Remove(value); + Services.Framework.RunOnTick(() => { + RefreshItems(); + OnChanged?.Invoke(); + }); + } +} + +public sealed class UintListItemNode : HorizontalListNode +{ + private const float LabelWidth = 300f; + + public uint Value { get; } + public Action? OnRemove { get; init; } + + public UintListItemNode(uint value, Func? labelResolver = null) + { + Value = value; + ItemSpacing = 4.0f; + + string idDisplay = value switch { + 0xFFFF_FFFE => "[Weekly]", + 0xFFFF_FFFD => "[Tome]", + _ => value.ToString() + }; + + var displayText = labelResolver is not null + ? $"{idDisplay} - {labelResolver(value)}" + : idDisplay; + + AddNode(new LabelTextNode + { + Size = new Vector2(LabelWidth, 24), + String = displayText, + TextColor = ColorHelper.GetColor(3), + }); + + AddNode(new CircleButtonNode + { + Size = new Vector2(28, 28), + Icon = ButtonIcon.Cross, + OnClick = () => OnRemove?.Invoke(), + }); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs b/AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs new file mode 100644 index 0000000..412eab4 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs @@ -0,0 +1,194 @@ +using System; +using System.Numerics; +using AetherBags.Addons; +using AetherBags.Configuration; +using AetherBags.Nodes.Color; +using AetherBags.Nodes.Configuration.Category; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace AetherBags.Nodes.Configuration.Currency; + +public sealed class CurrencyGeneralConfigurationNode : TabbedVerticalListNode +{ + private readonly UintListEditorNode? _currencyListEditor; + + public CurrencyGeneralConfigurationNode() + { + CurrencySettings config = System.Config.Currency; + + Width = 600; + ItemVerticalSpacing = 2; + + LabelTextNode titleNode = new LabelTextNode + { + Size = new Vector2(Width, 18), + String = "Currency Configuration", + TextColor = ColorHelper.GetColor(2), + TextOutlineColor = ColorHelper.GetColor(0), + }; + AddNode(titleNode); + + AddTab(1); + + CheckboxNode currencyEnabledCheckbox = new CheckboxNode + { + Size = new Vector2(Width, 18), + IsVisible = true, + String = "Show Currency", + IsChecked = config.Enabled, + OnClick = isChecked => + { + config.Enabled = isChecked; + RefreshCurrency(); + } + }; + AddNode(currencyEnabledCheckbox); + + AddTab(1); + + var defaultColorHandler = CreateColorHandler(color => config.DefaultColor = color); + ColorInputRow defaultCurrencyColorNode = new ColorInputRow + { + Label = "Default Currency Color", + Size = new Vector2(300, 24), + CurrentColor = config.DefaultColor, + DefaultColor = new CurrencySettings().DefaultColor, + OnColorConfirmed = defaultColorHandler, + OnColorChange = defaultColorHandler, + OnColorCanceled = defaultColorHandler, + OnColorPreviewed = defaultColorHandler, + }; + AddNode(defaultCurrencyColorNode); + + CheckboxNode cappedEnabledCheckbox = new CheckboxNode + { + Size = new Vector2(Width, 18), + IsVisible = true, + String = "Color Weekly Cap", + IsChecked = config.ColorWhenCapped, + TextTooltip = "Changes the color of the currency display when you have reached the maximum amount earnable for the current week (e.g., 450/450).", + OnClick = isChecked => + { + config.ColorWhenCapped = isChecked; + RefreshCurrency(); + } + }; + AddNode(cappedEnabledCheckbox); + + AddTab(1); + + var cappedColorHandler = CreateColorHandler(color => config.CappedColor = color); + ColorInputRow cappedCurrencyColorNode = new ColorInputRow + { + Label = "Weekly Cap Color", + Size = new Vector2(300, 24), + CurrentColor = config.CappedColor, + DefaultColor = new CurrencySettings().CappedColor, + OnColorConfirmed = cappedColorHandler, + OnColorChange = cappedColorHandler, + OnColorCanceled = cappedColorHandler, + OnColorPreviewed = cappedColorHandler, + }; + AddNode(cappedCurrencyColorNode); + + SubtractTab(1); + + CheckboxNode limitedEnabledCheckbox = new CheckboxNode + { + Size = new Vector2(Width, 18), + IsVisible = true, + String = "Color Max Capacity", + IsChecked = config.ColorWhenLimited, + TextTooltip = "Changes the color of the currency display when your total held amount has reached its maximum capacity (e.g., 2000/2000).", + OnClick = isChecked => + { + config.ColorWhenLimited = isChecked; + RefreshCurrency(); + } + }; + AddNode(limitedEnabledCheckbox); + + AddTab(1); + + var limitColorHandler = CreateColorHandler(color => config.LimitColor = color); + ColorInputRow limitCurrencyColorNode = new ColorInputRow + { + Label = "Max Capacity Color", + Size = new Vector2(300, 24), + CurrentColor = config.LimitColor, + DefaultColor = new CurrencySettings().LimitColor, + OnColorConfirmed = limitColorHandler, + OnColorChange = limitColorHandler, + OnColorCanceled = limitColorHandler, + OnColorPreviewed = limitColorHandler, + }; + AddNode(limitCurrencyColorNode); + + AddNode(new ResNode { Size = new Vector2(15) }); + + SubtractTab(2); + + AddNode(new ResNode { Size = new Vector2(15) }); + + _currencyListEditor = new UintListEditorNode + { + Label = "Displayed Currencies:", + LabelResolver = id => + { + return id switch + { + CurrencySettings.LimitedTomestoneId => "Current Limited Tomestone", + CurrencySettings.NonLimitedTomestoneId => "Current Non-Limited Tomestone", + _ => Services.DataManager.GetExcelSheet().GetRow(id).Name.ToString() + }; + }, + OnSearchButtonClicked = OpenCurrencyPicker, + OnChanged = () => { + System.Config.Currency.DisplayedCurrencies = _currencyListEditor!.GetList(); + RefreshCurrency(); + RecalculateLayout(); + } + }; + _currencyListEditor.SetList(System.Config.Currency.DisplayedCurrencies); + AddNode(_currencyListEditor); + + var quickAddRow = new HorizontalListNode { Size = new Vector2(600, 30), ItemSpacing = 8.0f }; + + quickAddRow.AddNode(new TextButtonNode { + String = "+ Gil", Size = new Vector2(70, 24), + OnClick = () => _currencyListEditor?.AddValue(1) + }); + + quickAddRow.AddNode(new TextButtonNode { + String = "+ Limited Tomestone", Size = new Vector2(150, 24), + OnClick = () => _currencyListEditor?.AddValue(CurrencySettings.LimitedTomestoneId) + }); + + quickAddRow.AddNode(new TextButtonNode { + String = "+ Non-Limited", Size = new Vector2(110, 24), + OnClick = () => _currencyListEditor?.AddValue(CurrencySettings.NonLimitedTomestoneId) + }); + AddNode(quickAddRow); + RecalculateLayout(); + } + + private Action CreateColorHandler(Action setter) => newColor => + { + setter(newColor); + RefreshCurrency(); + }; + + private void RefreshCurrency() => System.AddonInventoryWindow.ManualCurrencyRefresh(); + + private void OpenCurrencyPicker() { + var picker = new AddonCurrencyPicker + { + Title = "Select Currency to Add", + InternalName = "AetherBags_CurrencyPicker", + }; + picker.SelectionResult = item => _currencyListEditor?.AddValue(item.RowId); + picker.Open(); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs new file mode 100644 index 0000000..f95e1e9 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs @@ -0,0 +1,15 @@ +using System.Numerics; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Configuration.Currency; + +public sealed class CurrencyScrollingAreaNode : ScrollingListNode +{ + public CurrencyScrollingAreaNode() + { + AddNode(new CurrencyGeneralConfigurationNode + { + Width = 600 + }); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/General/FunctionalConfigurationNode.cs b/AetherBags/Nodes/Configuration/General/FunctionalConfigurationNode.cs new file mode 100644 index 0000000..877d183 --- /dev/null +++ b/AetherBags/Nodes/Configuration/General/FunctionalConfigurationNode.cs @@ -0,0 +1,173 @@ +using System; +using System.Linq; +using System.Numerics; +using AetherBags.Configuration; +using AetherBags.Inventory; +using AetherBags.Nodes.Input; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Configuration.General; + +internal sealed class FunctionalConfigurationNode : TabbedVerticalListNode +{ + private readonly CheckboxNode _hideDefaultBagsCheckboxNode; + private readonly CheckboxNode _hideSaddlebagsCheckboxNode; + private readonly CheckboxNode _hideRetainerbagsCheckboxNode; + private readonly LabeledEnumDropdownNode _stackDropDown; + + public FunctionalConfigurationNode() + { + GeneralSettings config = System.Config.General; + + ItemVerticalSpacing = 2; + + var titleNode = new CategoryTextNode + { + Height = 18, + String = "Functional Configuration", + }; + AddNode(titleNode); + + AddTab(1); + + var showWithGameCheckBox = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Auto-open with game inventory", + IsChecked = config.OpenWithGameInventory, + OnClick = isChecked => + { + config.OpenWithGameInventory = isChecked; + _hideDefaultBagsCheckboxNode?.IsEnabled = isChecked; + } + }; + AddNode(showWithGameCheckBox); + + AddTab(1); + _hideDefaultBagsCheckboxNode = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Hide default inventory bags", + IsEnabled = config.OpenWithGameInventory, + IsChecked = config.HideGameInventory, + OnClick = isChecked => + { + config.HideGameInventory = isChecked; + } + }; + AddNode(_hideDefaultBagsCheckboxNode); + SubtractTab(1); + + var showSaddleWithGameCheckBox = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Auto-open Saddlebags with game Saddlebags", + IsChecked = config.OpenSaddleBagsWithGameInventory, + OnClick = isChecked => + { + config.OpenSaddleBagsWithGameInventory = isChecked; + _hideSaddlebagsCheckboxNode?.IsEnabled = isChecked; + } + }; + AddNode(showSaddleWithGameCheckBox); + + AddTab(1); + _hideSaddlebagsCheckboxNode = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Hide default Saddlebags", + IsEnabled = config.OpenSaddleBagsWithGameInventory, + IsChecked = config.HideGameSaddleBags, + OnClick = isChecked => + { + config.HideGameSaddleBags = isChecked; + } + }; + AddNode(_hideSaddlebagsCheckboxNode); + SubtractTab(1); + + var showRetainerWithGameCheckBox = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Auto-open Retainer bags with game Retainer bags", + IsChecked = config.OpenRetainerWithGameInventory, + OnClick = isChecked => + { + config.OpenRetainerWithGameInventory = isChecked; + _hideRetainerbagsCheckboxNode?.IsEnabled = isChecked; + } + }; + AddNode(showRetainerWithGameCheckBox); + + AddTab(1); + _hideRetainerbagsCheckboxNode = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Hide default Retainer bags", + IsEnabled = config.OpenRetainerWithGameInventory, + IsChecked = config.HideGameRetainer, + OnClick = isChecked => + { + config.HideGameRetainer = isChecked; + } + }; + AddNode(_hideRetainerbagsCheckboxNode); + SubtractTab(1); + + var linkItemCheckBox = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Allow item linking with Shift+Click", + IsChecked = config.LinkItemEnabled, + OnClick = isChecked => + { + config.LinkItemEnabled = isChecked; + } + }; + AddNode(linkItemCheckBox); + + AddNode(new ResNode + { + Height = 6 + }); + + var searchModeDropDown = new LabeledEnumDropdownNode + { + Size = new Vector2(500, 20), + LabelText = "Search Mode", + LabelTextFlags = TextFlags.AutoAdjustNodeSize, + Options = Enum.GetValues().ToList(), + SelectedOption = config.SearchMode, + OnOptionSelected = selected => + { + config.SearchMode = selected; + InventoryOrchestrator.RefreshAll(updateMaps: false); + } + }; + AddNode(searchModeDropDown); + + _stackDropDown = new LabeledEnumDropdownNode + { + Size = new Vector2(500, 20), + IsEnabled = true, + LabelText = "Stack Mode", + LabelTextFlags = TextFlags.AutoAdjustNodeSize, + Options = Enum.GetValues().ToList(), + SelectedOption = config.StackMode, + OnOptionSelected = selected => + { + config.StackMode = selected; + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + AddNode(_stackDropDown); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs b/AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs new file mode 100644 index 0000000..f9d4e53 --- /dev/null +++ b/AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs @@ -0,0 +1,34 @@ +using System.Numerics; +using AetherBags.Configuration; +using AetherBags.Nodes.Configuration.Layout; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Configuration.General; + +public sealed class GeneralScrollingAreaNode : ScrollingListNode +{ + public GeneralScrollingAreaNode() + { + GeneralSettings config = System.Config.General; + + new ImportExportResetNode().AttachNode(this); + + ItemSpacing = 10; + + AddNode(new FunctionalConfigurationNode()); + + AddNode(new LayoutConfigurationNode()); + + AddNode(new CheckboxNode + { + Size = new Vector2(300, 20), + IsVisible = true, + String = "Debug Mode", + IsChecked = config.DebugEnabled, + OnClick = isChecked => + { + config.DebugEnabled = isChecked; + } + }); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs b/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs new file mode 100644 index 0000000..cac1e17 --- /dev/null +++ b/AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs @@ -0,0 +1,71 @@ +using System.IO; +using AetherBags.Helpers; +using AetherBags.Inventory; +using Dalamud.Game.ClientState.Keys; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Configuration.General; + +public sealed class ImportExportResetNode : HorizontalListNode +{ + public ImportExportResetNode() + { + Height = 0; + Width = 600; + Alignment = HorizontalListAnchor.Right; + FirstItemSpacing = 3; + ItemSpacing = 2; + IsVisible = true; + + AddNode(new ImGuiIconButtonNode { + Y = 3, + Height = 30, + Width = 30, + IsVisible = true, + TextTooltip = " Import Configuration\n(hold shift to confirm)", + TexturePath = Path.Combine(Services.PluginInterface.AssemblyLocation.Directory?.FullName!, @"Assets\Icons\download.png"), + OnClick = ImportConfig + }); + + AddNode(new ImGuiIconButtonNode { + Y = 3, + Height = 30, + Width = 30, + IsVisible = true, + TextTooltip = "Export Configuration", + TexturePath = Path.Combine(Services.PluginInterface.AssemblyLocation.Directory?.FullName!, @"Assets\Icons\upload.png"), + OnClick = ExportConfig + }); + + AddNode(new HoldButtonNode { + IsVisible = true, + Y = 0, + Height = 32, + Width = 100, + String = "Reset", + TextNode = { TextColor = ColorHelper.GetColor(50) }, + TextTooltip = " Reset configuration\n(hold button to confirm)", + OnClick = ResetConfig + }); + } + + private static void ResetConfig() + { + InventoryOrchestrator.CloseAll(); + ImportExportResetHelper.TryResetConfig(); + System.AddonConfigurationWindow.Close(); + } + + private static void ImportConfig() + { + if (!Services.KeyState[VirtualKey.SHIFT]) return; + + InventoryOrchestrator.CloseAll(); + ImportExportResetHelper.TryImportConfigFromClipboard(); + System.AddonConfigurationWindow.Close(); + } + + private static void ExportConfig() => ImportExportResetHelper.TryExportConfigToClipboard(System.Config); +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Layout/CompactLookaheadNode.cs b/AetherBags/Nodes/Configuration/Layout/CompactLookaheadNode.cs new file mode 100644 index 0000000..a98b783 --- /dev/null +++ b/AetherBags/Nodes/Configuration/Layout/CompactLookaheadNode.cs @@ -0,0 +1,61 @@ +using AetherBags.Configuration; +using KamiToolKit.Nodes; +using System.Numerics; +using AetherBags.Inventory; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; + +namespace AetherBags.Nodes.Configuration.Layout; + +internal sealed class CompactLookaheadNode : SimpleComponentNode +{ + public readonly LabelTextNode TitleNode; + public readonly NumericInputNode CompactLookahead = null!; + + public CompactLookaheadNode() + { + GeneralSettings config = System.Config.General; + + TitleNode = new LabelTextNode + { + TextFlags = TextFlags.AutoAdjustNodeSize, + Height = 24, + String = "Compact Lookahead", + }; + TitleNode.AttachNode(this); + + CompactLookahead = new NumericInputNode + { + Position = Position with { X = 240 }, + Size = Size with { X = 88 }, + IsVisible = true, + IsEnabled = config.CompactPackingEnabled, + Value = config.CompactLookahead, + OnValueUpdate = value => + { + config.CompactLookahead = value; + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + CompactLookahead.AttachNode(this); + + TitleNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(11, 20, 11, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(21, 30, 21, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(31, 40, 31, alpha: 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(41, 50, 41, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(51, 60, 51, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(61, 70, 61, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(71, 80, 71, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(81, 90, 81, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(91, 100, 91, alpha: 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(101, 110, 101, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(111, 115, 111, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(116, 135, 116, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(126, 135, 126, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(136, 145, 136, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(146, 155, 146, alpha: 255, multiplyColor: new Vector3(100.0f)) + .Build()); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Configuration/Layout/LayoutConfigurationNode.cs b/AetherBags/Nodes/Configuration/Layout/LayoutConfigurationNode.cs new file mode 100644 index 0000000..c68954c --- /dev/null +++ b/AetherBags/Nodes/Configuration/Layout/LayoutConfigurationNode.cs @@ -0,0 +1,95 @@ +using System.Numerics; +using AetherBags.Configuration; +using AetherBags.Inventory; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Configuration.Layout; + +internal class LayoutConfigurationNode : TabbedVerticalListNode +{ + private readonly CompactLookaheadNode _compactLookaheadNode = null!; + private readonly CheckboxNode _preferLargestFitCheckboxNode = null!; + private readonly CheckboxNode _useStableInsertCheckboxNode = null!; + + public LayoutConfigurationNode() + { + GeneralSettings config = System.Config.General; + + var titleNode = new CategoryTextNode + { + Height = 18, + String = "Layout Configuration", + }; + AddNode(titleNode); + + AddTab(1); + + var showCategoryItemAmountCheckboxNode = new CheckboxNode + { + Size = Size with { Y = 18 }, + IsVisible = true, + String = "Show Category Item Amount", + IsChecked = config.ShowCategoryItemCount, + OnClick = isChecked => + { + config.ShowCategoryItemCount = isChecked; + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + AddNode(showCategoryItemAmountCheckboxNode); + + var compactPackingCheckboxNode = new CheckboxNode + { + Height = 18, + IsVisible = true, + String = "Use Compact Packing", + IsChecked = config.CompactPackingEnabled, + OnClick = isChecked => + { + config.CompactPackingEnabled = isChecked; + _preferLargestFitCheckboxNode.IsEnabled = isChecked; + _useStableInsertCheckboxNode.IsEnabled = isChecked; + _compactLookaheadNode.CompactLookahead.IsEnabled = isChecked; + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + AddNode(compactPackingCheckboxNode); + + AddTab(1); + _preferLargestFitCheckboxNode = new CheckboxNode + { + Height = 18, + IsVisible = true, + String = "Prefer Largest Fit", + IsEnabled = config.CompactPackingEnabled, + IsChecked = config.CompactPreferLargestFit, + OnClick = isChecked => + { + config.CompactPreferLargestFit = isChecked; + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + AddNode(_preferLargestFitCheckboxNode); + + _useStableInsertCheckboxNode = new CheckboxNode + { + Height = 18, + IsVisible = true, + String = "Use Stable Insert", + IsEnabled = config.CompactPackingEnabled, + IsChecked = config.CompactStableInsert, + OnClick = isChecked => + { + config.CompactStableInsert = isChecked; + InventoryOrchestrator.RefreshAll(updateMaps: true); + } + }; + AddNode(_useStableInsertCheckboxNode); + + _compactLookaheadNode = new CompactLookaheadNode + { + Size = new Vector2(320, 20) + }; + AddNode(_compactLookaheadNode); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Currency/CurrencyListNode.cs b/AetherBags/Nodes/Currency/CurrencyListNode.cs new file mode 100644 index 0000000..f4fc8bd --- /dev/null +++ b/AetherBags/Nodes/Currency/CurrencyListNode.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using AetherBags.Currency; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Currency; + +public class CurrencyListNode : HorizontalListNode +{ + public List? CurrencyInfoList { get; set; } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Currency/CurrencyNode.cs b/AetherBags/Nodes/Currency/CurrencyNode.cs new file mode 100644 index 0000000..25b5c6e --- /dev/null +++ b/AetherBags/Nodes/Currency/CurrencyNode.cs @@ -0,0 +1,58 @@ +using System.Globalization; +using System.Numerics; +using AetherBags.Currency; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Currency; + +public class CurrencyNode : SimpleComponentNode +{ + private readonly IconImageNode _iconImageNode; + private readonly TextNode _countNode; + + public CurrencyNode() + { + _iconImageNode = new IconImageNode + { + FitTexture = true, + Size = new Vector2(24f) + }; + _iconImageNode.AttachNode(this); + + _countNode = new TextNode + { + TextFlags = TextFlags.Emboss, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + AlignmentType = AlignmentType.Left, + FontSize = 14, + Size = new Vector2(120.0f, 28.0f) + }; + _countNode.AttachNode(this); + } + + public required CurrencyInfo Currency { + get; + set { + field = value; + _iconImageNode.IconId = value.IconId; + _iconImageNode.Position = new Vector2(0f, 2f); + + _countNode.String = value.Amount.ToString("N0", CultureInfo.InvariantCulture); + _countNode.Position = new Vector2(_iconImageNode.Bounds.Right + 2f, 0f); + + // Limit > Capped > Normal + var config = System.Config.Currency; + + var isLimited = config.ColorWhenLimited && value.LimitReached; + var isCapped = config.ColorWhenCapped && value.IsCapped; + + _countNode.TextColor = + isLimited ? config.LimitColor : + isCapped ? config.CappedColor : + config.DefaultColor; + } + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Input/LabeledEnumDropdownNode.cs b/AetherBags/Nodes/Input/LabeledEnumDropdownNode.cs new file mode 100644 index 0000000..83fd449 --- /dev/null +++ b/AetherBags/Nodes/Input/LabeledEnumDropdownNode.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Input; + +public class LabeledEnumDropdownNode : SimpleComponentNode where T : Enum { + private readonly GridNode _gridNode; + private readonly TextNode _labelNode; + private readonly EnumDropDownNode _dropDownNode; + + public LabeledEnumDropdownNode() { + _gridNode = new GridNode { + GridSize = new GridSize(2, 1), + }; + _gridNode.AttachNode(this); + + _labelNode = new LabelTextNode { + String = string.Empty, + }; + _labelNode.AttachNode(_gridNode[0, 0]); + + _dropDownNode = new EnumDropDownNode { + Options = new List(), + }; + _dropDownNode.AttachNode(_gridNode[1, 0]); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + _gridNode.Size = Size; + + _labelNode.Size = _gridNode[0, 0].Size; + _dropDownNode.Size = _gridNode[1, 0].Size; + } + + public required ReadOnlySeString LabelText + { + get => _labelNode.String; + set => _labelNode.String = value; + } + + public Action? OnOptionSelected + { + get => _dropDownNode.OnOptionSelected; + set => _dropDownNode.OnOptionSelected = value; + } + + public T? SelectedOption + { + get => _dropDownNode.OptionListNode.SelectedOption; + set + { + _dropDownNode.OptionListNode.SelectedOption = value; + if (value != null) + { + _dropDownNode.LabelNode.String = value.Description; + } + } + } + + public int MaxListOptions + { + get => _dropDownNode.MaxListOptions; + set => _dropDownNode.MaxListOptions = value; + } + + public required List Options + { + get => _dropDownNode.Options!; + set => _dropDownNode.Options = value; + } + + public TextFlags LabelTextFlags + { + get => _labelNode.TextFlags; + set => _labelNode.TextFlags = value; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Input/TextInputWithButtonNode.cs b/AetherBags/Nodes/Input/TextInputWithButtonNode.cs new file mode 100644 index 0000000..8136840 --- /dev/null +++ b/AetherBags/Nodes/Input/TextInputWithButtonNode.cs @@ -0,0 +1,55 @@ +using System; +using System.Numerics; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Input; + +public class TextInputWithButtonNode : SimpleComponentNode { + private readonly TextInputNode _textInputNode; + private readonly CircleButtonNode _contextButton; + + public Action? OnButtonClicked { + get => _contextButton.OnClick; + set => _contextButton.OnClick = value; + } + + public TextInputWithButtonNode() { + _textInputNode = new TextInputNode { + PlaceholderString = "Search . . .", + }; + _textInputNode.AttachNode(this); + + _contextButton = new CircleButtonNode { + Icon = ButtonIcon.Filter, + Size = new Vector2(28f), + }; + _contextButton.AttachNode(this); + } + + public Vector3 HintAddColor { + get => _contextButton.AddColor; + set => _contextButton.AddColor = value; + } + + public required Action? OnInputReceived { + get => _textInputNode.OnInputReceived; + set => _textInputNode.OnInputReceived = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + _contextButton.Size = new Vector2(Height, Height); + _contextButton.Position = new Vector2(Width - _contextButton.Width, 0.0f); + + _textInputNode.Size = new Vector2(Width - _contextButton.Width - 5.0f, Height); + _textInputNode.Position = new Vector2(0.0f, 0.0f); + } + + public ReadOnlySeString SearchString { + get => _textInputNode.String; + set => _textInputNode.String = value; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs b/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs new file mode 100644 index 0000000..309ead0 --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs @@ -0,0 +1,99 @@ +using AetherBags.Nodes.Layout; + +namespace AetherBags.Nodes.Inventory; + +public sealed class InventoryCategoryHoverCoordinator +{ + private InventoryCategoryNode? _active; + private int _activeRowIndex = -1; + private bool _isProcessing; + + public void OnCategoryHoverChanged( + WrappingGridNode grid, + InventoryCategoryNode source, + bool hovering) + { + if (_isProcessing) + return; + + try + { + _isProcessing = true; + grid.RecalculateLayout(); + + if (hovering) + { + _active = source; + + if (!grid.TryGetRowIndex(source, out _activeRowIndex)) + { + SuppressAllExcept(grid, source); + source.SetHeaderSuppressed(false); + return; + } + + ClearAll(grid); + + var row = grid.Rows[_activeRowIndex]; + for (int i = 0; i < row.Count; i++) + { + if (row[i] is InventoryCategoryNode cat && !ReferenceEquals(cat, source)) + cat.SetHeaderSuppressed(true); + } + + source.SetHeaderSuppressed(false); + return; + } + + if (!ReferenceEquals(_active, source)) + return; + + _active = null; + + if (_activeRowIndex >= 0 && _activeRowIndex < grid.Rows.Count) + { + var row = grid.Rows[_activeRowIndex]; + for (int i = 0; i < row.Count; i++) + { + if (row[i] is InventoryCategoryNode cat) + cat.SetHeaderSuppressed(false); + } + } + else + { + ClearAll(grid); + } + + _activeRowIndex = -1; + } + finally + { + _isProcessing = false; + } + } + + public void ResetAll(WrappingGridNode grid) + { + _active = null; + _activeRowIndex = -1; + ClearAll(grid); + } + + private static void ClearAll(WrappingGridNode grid) + { + foreach (var node in grid.GetNodes()) + { + if (node is InventoryCategoryNode cat) + cat.SetHeaderSuppressed(false); + } + } + + private static void SuppressAllExcept(WrappingGridNode grid, InventoryCategoryNode source) + { + foreach (var node in grid.GetNodes()) + { + if (node is InventoryCategoryNode cat) + cat.SetHeaderSuppressed(!ReferenceEquals(cat, source)); + } + } +} diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs new file mode 100644 index 0000000..74c5ca5 --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs @@ -0,0 +1,551 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AetherBags.Helpers; +using AetherBags.Inventory; +using AetherBags.Inventory.Categories; +using AetherBags.Inventory.Items; +using AetherBags.Nodes.Layout; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +public class InventoryCategoryNode : InventoryCategoryNodeBase +{ + private const uint CategoryNodeKeyBase = 0x10000000; + + public override uint Key => CategoryNodeKeyBase | CategorizedInventory.Key; + private readonly TextNode _categoryNameTextNode; + private readonly HybridDirectionalFlexNode _itemGridNode; + + private const float ExpectedItemWidth = 42; + private const float ExpectedItemHeight = 46; + private const float HeaderHeight = 16; + private const float MinWidth = 40; + + private float? _fixedWidth; + private float? _maxWidth; + private int _hoverRefs; + private bool _headerSuppressed; + private bool _headerExpanded; + private float _baseHeaderWidth = 96f; + private string _fullHeaderText = string.Empty; + + private uint _lastCategoryKey; + private int _lastItemCount; + private ulong _lastItemsHash; + private int _lastItemsPerLine; + private bool _itemsNeedPopulation; + + public event Action? HeaderHoverChanged; + + public bool NeedsItemPopulation => _itemsNeedPopulation; + public Action? OnRefreshRequested { get; set; } + public Action? OnDragEnd { get; set; } + + public SharedNodePool? SharedItemPool { get; set; } + + public InventoryCategoryNode() + { + _categoryNameTextNode = new TextNode + { + Size = new Vector2(96, 16), + AlignmentType = AlignmentType.Left, + }; + + _categoryNameTextNode.AddEvent(AtkEventType.MouseOver, BeginHeaderHover); + _categoryNameTextNode.AddEvent(AtkEventType.MouseOut, EndHeaderHover); + + _categoryNameTextNode.TextFlags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; + _categoryNameTextNode.TextFlags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); + + _categoryNameTextNode.AddNodeFlags(NodeFlags.EmitsEvents | NodeFlags.HasCollision); + _categoryNameTextNode.AttachNode(this); + + _itemGridNode = new HybridDirectionalFlexNode + { + Position = new Vector2(0, HeaderHeight), + Size = new Vector2(240, 92), + FillRowsFirst = true, + ItemsPerLine = 10, + HorizontalPadding = 5, + VerticalPadding = 2, + }; + + _itemGridNode.NodeFlags |= NodeFlags.EmitsEvents; + _itemGridNode.AttachNode(this); + } + + private CategorizedInventory _categorizedInventory; + + public CategorizedInventory CategorizedInventory + { + get => _categorizedInventory; + set => SetCategoryData(value, _itemGridNode.ItemsPerLine); + } + + public void SetCategoryData(CategorizedInventory data, int itemsPerLine, bool deferItemCreation = false) + { + bool categoryChanged = data.Key != _lastCategoryKey; + bool itemsPerLineChanged = itemsPerLine != _lastItemsPerLine; + + ulong itemsHash = ComputeItemsHash(CollectionsMarshal.AsSpan(data.Items)); + bool itemsChanged = data.Items.Count != _lastItemCount || itemsHash != _lastItemsHash; + + _lastCategoryKey = data.Key; + _lastItemCount = data.Items.Count; + _lastItemsHash = itemsHash; + _lastItemsPerLine = itemsPerLine; + + _categorizedInventory = data; + + _fullHeaderText = System.Config.General.ShowCategoryItemCount + ? $"{data.Category.Name} ({data.Items.Count})" + : data.Category.Name; + + _categoryNameTextNode.String = _fullHeaderText; + _categoryNameTextNode.TextColor = data.Category.Color; + _categoryNameTextNode.TextTooltip = data.Category.Description; + + if (itemsChanged || categoryChanged) + { + _itemGridNode.ItemsPerLine = itemsPerLine; + + if (deferItemCreation) + { + _itemsNeedPopulation = true; + } + else + { + using (_itemGridNode.DeferRecalculateLayout()) + { + UpdateItemGrid(); + } + _itemsNeedPopulation = false; + } + } + else if (itemsPerLineChanged) + { + _itemGridNode.ItemsPerLine = itemsPerLine; + } + + if (categoryChanged || itemsChanged || itemsPerLineChanged) + { + RecalculateSize(); + } + } + + public void PopulateItems() + { + if (!_itemsNeedPopulation) + return; + + using (_itemGridNode.DeferRecalculateLayout()) + { + UpdateItemGrid(); + } + _itemsNeedPopulation = false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong ComputeItemsHash(ReadOnlySpan items) + { + ulong hash = 14695981039346656037UL; // FNV-1a offset basis + foreach (var item in items) + { + hash ^= item.Key; + hash *= 1099511628211UL; // FNV-1a prime + } + return hash; + } + + public int ItemsPerLine + { + get => _itemGridNode.ItemsPerLine; + set + { + if (_itemGridNode.ItemsPerLine == value) return; + _itemGridNode.ItemsPerLine = value; + RecalculateSize(); + } + } + + public float? FixedWidth + { + get => _fixedWidth; + set + { + if (_fixedWidth.Equals(value)) return; + _fixedWidth = value; + RecalculateSize(); + } + } + + public override float? MaxWidth + { + get => _maxWidth; + set => _maxWidth = value; + } + + public override bool IsPinnedInConfig => CategorizedInventory.Category?.IsPinned ?? false; + + public void BeginHeaderHover() + { + _hoverRefs++; + if (_hoverRefs != 1) return; + + _headerExpanded = true; + ApplyHeaderVisualStateAndSize(); + HeaderHoverChanged?.Invoke(this, true); + } + + public void EndHeaderHover() + { + if (_hoverRefs <= 0) return; + + _hoverRefs--; + if (_hoverRefs != 0) return; + + _headerExpanded = false; + ApplyHeaderVisualStateAndSize(); + HeaderHoverChanged?.Invoke(this, false); + } + + public void SetHeaderSuppressed(bool suppressed) + { + if (_headerSuppressed == suppressed) return; + _headerSuppressed = suppressed; + ApplyHeaderVisualStateAndSize(); + } + + private void ApplyHeaderVisualStateAndSize() + { + _categoryNameTextNode.IsVisible = ! _headerSuppressed; + if (_headerSuppressed) + return; + + var flags = _categoryNameTextNode.TextFlags; + flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); + + if (_headerExpanded) + { + flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis); + _categoryNameTextNode.TextFlags = flags; + + if (! string.IsNullOrEmpty(_fullHeaderText)) + _categoryNameTextNode.String = _fullHeaderText; + + Vector2 drawSize = _categoryNameTextNode.GetTextDrawSize(); + float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f); + _categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = expandedWidth }; + } + else + { + _categoryNameTextNode.Size = _categoryNameTextNode.Size with { X = _baseHeaderWidth }; + + if (!string.IsNullOrEmpty(_fullHeaderText)) + _categoryNameTextNode.String = _fullHeaderText; + + flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; + _categoryNameTextNode.TextFlags = flags; + } + } + + public override void RecalculateSize() + { + int itemCount = CategorizedInventory.Items.Count; + + float cellW = ExpectedItemWidth; + float cellH = ExpectedItemHeight; + float hPad = _itemGridNode.HorizontalPadding; + float vPad = _itemGridNode.VerticalPadding; + + if (itemCount == 0) + { + float width = _fixedWidth ?? MinWidth; + if (_maxWidth.HasValue) width = Math.Min(width, _maxWidth.Value); + Size = new Vector2(width, HeaderHeight); + _baseHeaderWidth = width; + _itemGridNode.Position = new Vector2(0, HeaderHeight); + _itemGridNode.Size = new Vector2(width, 0); + ApplyHeaderVisualStateAndSize(); + return; + } + + int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); + + float minUsableWidth = cellW; + if (_maxWidth.HasValue && _fixedWidth is null && _maxWidth.Value >= minUsableWidth) + { + int maxColumns = (int)MathF.Floor((_maxWidth.Value + hPad) / (cellW + hPad)); + maxColumns = Math.Max(1, maxColumns); + + float widthNeeded = maxColumns * cellW + (maxColumns - 1) * hPad; + if (widthNeeded > _maxWidth.Value && maxColumns > 1) + maxColumns--; + + itemsPerLine = Math.Min(itemsPerLine, maxColumns); + } + + int rows = (itemCount + itemsPerLine - 1) / itemsPerLine; + int actualColumns = Math.Min(itemCount, itemsPerLine); + + float calculatedWidth = _fixedWidth ?? Math.Max(MinWidth, actualColumns * cellW + (actualColumns - 1) * hPad); + + if (_maxWidth.HasValue && _fixedWidth is null && _maxWidth.Value >= minUsableWidth) + calculatedWidth = Math.Min(calculatedWidth, _maxWidth.Value); + + float height = HeaderHeight + rows * cellH + (rows - 1) * vPad; + + Size = new Vector2(calculatedWidth, height); + _itemGridNode.Position = new Vector2(0, HeaderHeight); + _itemGridNode.Size = new Vector2(calculatedWidth, height - HeaderHeight); + + if (_itemGridNode.ItemsPerLine != itemsPerLine) + _itemGridNode.ItemsPerLine = itemsPerLine; + _baseHeaderWidth = calculatedWidth; + + ApplyHeaderVisualStateAndSize(); + } + + private void UpdateItemGrid() + { + _itemGridNode.SyncWithListDataByKey( + dataList: CategorizedInventory.Items, + getKeyFromData: item => item.Key, + getKeyFromNode: node => node.ItemInfo?.Key ?? 0, + updateNode: UpdateInventoryDragDropNode, + createNodeMethod: CreateInventoryDragDropNode, + resetNodeForReuse: ResetDragDropNodeForReuse, + externalPool: SharedItemPool); + } + + private void UpdateInventoryDragDropNode(InventoryDragDropNode node, ItemInfo data) + { + node.ItemInfo = data; + ApplyItemDataToNode(node, data); + } + + private static void ResetDragDropNodeForReuse(InventoryDragDropNode node) + { + node.ResetForReuse(); + } + + private unsafe InventoryDragDropNode CreateInventoryDragDropNode(ItemInfo data) + { + var node = new InventoryDragDropNode + { + Size = new Vector2(42, 46), + IsVisible = true, + AcceptedType = DragDropType.Item, + IsClickable = true, + OnDiscard = OnNodeDiscard, + OnEnd = _ => OnDragEnd?.Invoke(), + OnPayloadAccepted = OnNodePayloadAccepted, + OnRollOver = OnNodeRollOver, + OnRollOut = OnNodeRollOut, + ItemInfo = data + }; + + ApplyItemDataToNode(node, data); + return node; + } + + private void ApplyItemDataToNode(InventoryDragDropNode node, ItemInfo data) + { + InventoryItem item = data.Item; + InventoryMappedLocation visualLocation = data.VisualLocation; + + var visualInvType = InventoryType.GetInventoryTypeFromContainerId(visualLocation.Container); + int absoluteIndex = visualInvType.GetInventoryStartIndex + visualLocation.Slot; + + node.IconId = item.IconId; + node.Alpha = data.VisualAlpha; + node.AddColor = data.HighlightOverlayColor; + node.IsDraggable = !data.IsSlotBlocked; + node.IconNode.IconExtras.AntsNode.IsVisible = data.IsRelationshipHighlighted; + node.Payload = new DragDropPayload + { + Type = DragDropType.Item, + Int1 = visualLocation.Container, + Int2 = visualLocation.Slot, + ReferenceIndex = (short)absoluteIndex + }; + } + + private void OnNodeDiscard(DragDropNode n) + { + if (n is not InventoryDragDropNode node) return; + OnDiscard(n, node.ItemInfo); + } + + private void OnNodePayloadAccepted(DragDropNode n, DragDropPayload acceptedPayload) + { + if (n is not InventoryDragDropNode node) return; + OnPayloadAccepted(n, acceptedPayload, node.ItemInfo); + } + + private unsafe void OnNodeRollOver(DragDropNode n) + { + if (n is not InventoryDragDropNode node) return; + BeginHeaderHover(); + var item = node.ItemInfo.Item; + n.ShowInventoryItemTooltip(item.Container, item.Slot); + } + + private unsafe void OnNodeRollOut(DragDropNode n) + { + EndHeaderHover(); + ushort addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(n)->Id; + AtkStage.Instance()->TooltipManager.HideTooltip(addonId); + } + + public void RefreshNodeVisuals() + { + var nodes = _itemGridNode.Nodes; + for (int i = 0; i < nodes.Count; i++) + { + if (nodes[i] is not InventoryDragDropNode itemNode || itemNode.ItemInfo == null) + continue; + + var info = itemNode.ItemInfo; + float newAlpha = info.VisualAlpha; + Vector3 newColor = info.HighlightOverlayColor; + bool newDraggable = !info.IsSlotBlocked; + bool newAntsVisible = info.IsRelationshipHighlighted; + + if (!NearlyEqual(itemNode.Alpha, newAlpha)) + itemNode.Alpha = newAlpha; + + if (itemNode.AddColor != newColor) + itemNode.AddColor = newColor; + + if (itemNode.IsDraggable != newDraggable) + itemNode.IsDraggable = newDraggable; + + if (itemNode.IconNode.IconExtras.AntsNode.IsVisible != newAntsVisible) + itemNode.IconNode.IconExtras.AntsNode.IsVisible = newAntsVisible; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool NearlyEqual(float a, float b) => MathF.Abs(a - b) < 0.001f; + + private unsafe void OnDiscard(DragDropNode node, ItemInfo item) + { + uint addonId = RaptureAtkUnitManager.Instance()->GetAddonByNode(node)->Id; + AgentInventoryContext.Instance()->DiscardItem(item.Item.GetLinkedItem(), item.Item.Container, item.Item.Slot, addonId); + } + + private void OnPayloadAccepted(DragDropNode node, DragDropPayload acceptedPayload, ItemInfo targetItemInfo) + { + try + { + // KTK clears node.Payload before invoking this, so setting it manually again + var nodePayload = new DragDropPayload + { + Type = DragDropType.Item, + Int1 = targetItemInfo.VisualLocation.Container, + Int2 = targetItemInfo.VisualLocation.Slot, + ReferenceIndex = (short)(targetItemInfo.Item.Container.GetInventoryStartIndex + targetItemInfo.VisualLocation.Slot) + }; + + Services.Logger.DebugOnly($"[OnPayload] ACCEPTED payload: Type={acceptedPayload.Type} Int1={acceptedPayload.Int1} Int2={acceptedPayload.Int2} Ref={acceptedPayload.ReferenceIndex}"); + Services.Logger.DebugOnly($"[OnPayload] NODE payload: Type={nodePayload.Type} Int1={nodePayload.Int1} Int2={nodePayload.Int2} Ref={nodePayload.ReferenceIndex}"); + + if (!acceptedPayload.IsValidInventoryPayload || !nodePayload.IsValidInventoryPayload) + { + Services.Logger.Warning($"[OnPayload] Invalid payload type: Accepted={acceptedPayload.Type} Node={nodePayload.Type}"); + return; + } + + if (acceptedPayload.IsSameBaseContainer(nodePayload)) + { + Services.Logger.DebugOnly("[OnPayload] Source and target are in the same base container, skipping move."); + node.IconId = targetItemInfo.IconId; + node.Payload = nodePayload; + return; + } + + var sourceCopy = acceptedPayload; + var targetCopy = nodePayload; + + InventoryMoveHelper.HandleItemMovePayload(sourceCopy, targetCopy); + OnRefreshRequested?.Invoke(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, "[OnPayload] Error handling payload acceptance"); + } + } + + public void ResetForReuse() + { + _lastCategoryKey = 0; + _lastItemCount = 0; + _lastItemsHash = 0; + _lastItemsPerLine = 0; + _itemsNeedPopulation = false; + + _hoverRefs = 0; + _headerSuppressed = false; + _headerExpanded = false; + _fullHeaderText = string.Empty; + + _fixedWidth = null; + _maxWidth = null; + + _categoryNameTextNode.String = string.Empty; + _categoryNameTextNode.TextTooltip = string.Empty; + _categoryNameTextNode.IsVisible = true; + + using (_itemGridNode.DeferRecalculateLayout()) + { + ReturnItemsToPool(); + _itemGridNode.ClearListOnly(); + } + } + + private void ReturnItemsToPool() + { + var nodes = _itemGridNode.Nodes; + for (int i = 0; i < nodes.Count; i++) + { + if (nodes[i] is not InventoryDragDropNode itemNode) + continue; + + if (SharedItemPool != null) + { + if (!SharedItemPool.TryReturn(itemNode)) + { + try + { + itemNode.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, "[InventoryCategoryNode] Error disposing overflow item node"); + } + } + } + else + { + try + { + itemNode.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, "[InventoryCategoryNode] Error disposing item node (no pool)"); + } + } + } + } +} diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs new file mode 100644 index 0000000..ad57edb --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs @@ -0,0 +1,24 @@ +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +/// +/// Base class for category-like nodes that can be displayed in the inventory grid. +/// Used to allow both regular categories and special categories (like looted items) to be hoisted/pinned. +/// +public abstract class InventoryCategoryNodeBase : SimpleComponentNode +{ + /// + /// Unique key for this category, used for sync operations. + /// + public abstract uint Key { get; } + + /// + /// Whether this category should be pinned in the layout. + /// + public virtual bool IsPinnedInConfig => false; + + public abstract float? MaxWidth { get; set; } + + public abstract void RecalculateSize(); +} diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs b/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs new file mode 100644 index 0000000..17f3ddd --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs @@ -0,0 +1,45 @@ +using AetherBags.Nodes.Layout; + +namespace AetherBags.Nodes.Inventory; + +public sealed class InventoryCategoryPinCoordinator +{ + public bool ApplyPinnedStates(WrappingGridNode grid) + { + bool changed = false; + + using (grid.DeferRecalculateLayout()) + { + foreach (var node in grid.GetNodes()) + { + bool shouldBePinned = node.IsPinnedInConfig; + + bool isPinned = grid.IsPinned(node); + + if (shouldBePinned) + { + if (!isPinned) + { + grid.PinNode(node); + changed = true; + } + } + else + { + if (isPinned) + { + grid.UnpinNode(node); + changed = true; + } + } + } + } + + return changed; + } + + public bool PrunePinnedNotInGrid(WrappingGridNode grid) + { + return false; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs b/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs new file mode 100644 index 0000000..0f23c28 --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryDragDropNode.cs @@ -0,0 +1,282 @@ +using System.Numerics; +using AetherBags.Addons; +using AetherBags.Inventory.Context; +using AetherBags.Inventory.Items; +using AetherBags.IPC.ExternalCategorySystem; +using Dalamud.Game.ClientState.Keys; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using KamiToolKit.Timelines; + +namespace AetherBags.Nodes.Inventory; + +public class InventoryDragDropNode : DragDropNode +{ + private readonly TextNode _quantityTextNode; + private IconNode? _badgeNode; + private ImageNode? _borderNode; + private ItemDecoration? _currentDecoration; + + public unsafe InventoryDragDropNode() + { + _quantityTextNode = new TextNode { + Size = new Vector2(40.0f, 12.0f), + Position = new Vector2(4.0f, 34.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(51), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Right, + }; + _quantityTextNode.AttachNode(this); + CollisionNode.AddEvent(AtkEventType.MouseDown, OnItemMouseDown); + CollisionNode.AddEvent(AtkEventType.MouseClick, OnItemClicked); + CollisionNode.AddEvent(AtkEventType.MouseOver, OnItemHover); + CollisionNode.AddEvent(AtkEventType.MouseOut, OnItemUnhover); + } + + public required ItemInfo ItemInfo + { + get; + set + { + field = value; + _quantityTextNode.String = value.ItemCount.ToString(); + ApplyDecoration(ExternalCategoryManager.GetDecoration(value.Item.ItemId)); + } + } + + public void ApplyDecoration(ItemDecoration? decoration) + { + if (_currentDecoration.Equals(decoration)) return; + _currentDecoration = decoration; + + if (decoration == null) + { + ClearDecoration(); + return; + } + + if (decoration.Value.Badge.HasValue) + { + ApplyBadge(decoration.Value.Badge.Value); + } + else + { + ClearBadge(); + } + + if (decoration.Value.Border != BorderStyle.None) + { + ApplyBorder(decoration.Value.Border); + } + else + { + ClearBorder(); + } + } + + private void ApplyBadge(BadgeInfo badge) + { + if (_badgeNode == null) + { + _badgeNode = new IconNode + { + Size = new Vector2(16, 16), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled, + }; + _badgeNode.AttachNode(this); + } + + _badgeNode.IconId = badge.IconId; + _badgeNode.IsVisible = true; + + if (badge.TintColor.HasValue) + { + _badgeNode.AddColor = new Vector3(badge.TintColor.Value.X, badge.TintColor.Value.Y, badge.TintColor.Value.Z); + } + + _badgeNode.Position = badge.Position switch + { + BadgePosition.TopLeft => new Vector2(0, 0), + BadgePosition.TopRight => new Vector2(26, 0), + BadgePosition.BottomLeft => new Vector2(0, 30), + BadgePosition.BottomRight => new Vector2(26, 30), + _ => new Vector2(26, 0) + }; + } + + private void ClearBadge() + { + if (_badgeNode != null) + { + _badgeNode.IsVisible = false; + } + } + + private BorderStyle _currentBorderStyle = BorderStyle.None; + + private void ApplyBorder(BorderStyle style) + { + if (_borderNode == null) + { + _borderNode = new SimpleImageNode + { + Size = new Vector2(42, 46), + Position = new Vector2(0, 0), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled, + TexturePath = "ui/uld/IconA_Frame.tex", + TextureCoordinates = new Vector2(0, 0), + TextureSize = new Vector2(48, 48), + }; + _borderNode.AttachNode(this); + } + + _borderNode.IsVisible = true; + + if (_currentBorderStyle != style) + { + _currentBorderStyle = style; + BuildBorderTimeline(style); + } + + if (style == BorderStyle.Pulse) + { + _borderNode.Timeline?.PlayAnimation(1); + } + else if (style == BorderStyle.Glow) + { + _borderNode.Timeline?.PlayAnimation(1); + } + } + + private void BuildBorderTimeline(BorderStyle style) + { + if (_borderNode == null) return; + + switch (style) + { + case BorderStyle.Solid: + _borderNode.AddColor = new Vector3(1.0f, 1.0f, 1.0f); + break; + + case BorderStyle.Glow: + _borderNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 50) + .AddLabel(10, 1, AtkTimelineJumpBehavior.LoopForever, 10) + .AddFrame(10, addColor: new Vector3(0.6f, 0.8f, 1.0f), alpha: 255) + .AddFrame(30, addColor: new Vector3(0.9f, 1.0f, 1.2f), alpha: 255) + .AddFrame(50, addColor: new Vector3(0.6f, 0.8f, 1.0f), alpha: 255) + .EndFrameSet() + .Build()); + break; + + case BorderStyle.Pulse: + _borderNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 40) + .AddLabel(1, 1, AtkTimelineJumpBehavior.LoopForever, 1) + .AddFrame(1, addColor: new Vector3(1.0f, 0.6f, 0.0f), alpha: 180) + .AddFrame(20, addColor: new Vector3(1.2f, 0.9f, 0.3f), alpha: 255) + .AddFrame(40, addColor: new Vector3(1.0f, 0.6f, 0.0f), alpha: 180) + .EndFrameSet() + .Build()); + break; + } + } + + private void ClearBorder() + { + if (_borderNode != null) + { + _borderNode.Timeline?.StopAnimation(); + _borderNode.IsVisible = false; + _currentBorderStyle = BorderStyle.None; + } + } + + private void ClearDecoration() + { + ClearBadge(); + ClearBorder(); + } + + private unsafe void OnItemMouseDown(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + InventoryItem item = ItemInfo.Item; + if (Services.KeyState[VirtualKey.SHIFT] && atkEventData->IsLeftClick && System.Config.General.LinkItemEnabled) + { + AgentChatLog.Instance()->LinkItem(item.ItemId); + return; + } + + if (!atkEventData->IsRightClick) return; + + if (Services.KeyState[VirtualKey.CONTROL] && ItemContextMenuHandler.TryShowExternalMenu(ItemInfo)) + { + return; + } + + AgentInventoryContext* context = AgentInventoryContext.Instance(); + context->OpenForItemSlot(item.Container, item.Slot, 0, context->AddonId); + } + + private unsafe void OnItemClicked(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + if (Services.KeyState[VirtualKey.SHIFT] && System.Config.General.LinkItemEnabled) return; + InventoryItem item = ItemInfo.Item; + if (!atkEventData->IsLeftClick) return; + + System.AetherBagsAPI?.API.RaiseItemClicked(item.ItemId); + item.UseItem(); + } + + private unsafe void OnItemHover(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + uint itemId = ItemInfo.Item.ItemId; + System.AetherBagsAPI?.API.RaiseItemHovered(itemId); + + if (System.Config.General.UseUnifiedExternalCategories) + { + var relatedItems = ExternalCategoryManager.GetRelatedItemIds(itemId, RelationshipType.SameSet); + if (relatedItems != null && relatedItems.Count > 0) + { + var relationships = ExternalCategoryManager.GetItemRelationships(itemId); + Vector3? highlightColor = null; + if (relationships != null) + { + foreach (var rel in relationships) + { + if (rel.Type == RelationshipType.SameSet && rel.HighlightColor.HasValue) + { + highlightColor = rel.HighlightColor; + break; + } + } + } + HighlightState.SetRelationshipHighlight(relatedItems, highlightColor); + } + } + } + + private unsafe void OnItemUnhover(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + System.AetherBagsAPI?.API.RaiseItemUnhovered(ItemInfo.Item.ItemId); + + if (System.Config.General.UseUnifiedExternalCategories) + { + HighlightState.SetRelationshipHighlight(null, null); + } + } + + public void ResetForReuse() + { + ClearDecoration(); + _quantityTextNode.String = string.Empty; + Alpha = 1.0f; + AddColor = Vector3.Zero; + IsDraggable = true; + IconNode.IconExtras.AntsNode.IsVisible = false; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryFooterNode.cs b/AetherBags/Nodes/Inventory/InventoryFooterNode.cs new file mode 100644 index 0000000..9ec16a7 --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryFooterNode.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Numerics; +using AetherBags.Currency; +using AetherBags.Nodes.Currency; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; +using static AetherBags.Inventory.State.InventoryStateBase; + +namespace AetherBags.Nodes.Inventory; + +public sealed class InventoryFooterNode : SimpleComponentNode +{ + private readonly TextNode _slotAmountTextNode; + private readonly CurrencyListNode _currencyListNode; + + public InventoryFooterNode() + { + _slotAmountTextNode = 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) + }; + _slotAmountTextNode.AttachNode(this); + + _currencyListNode = new CurrencyListNode + { + Position = new Vector2(0, 0), + Size = new Vector2(120, 28), + IsVisible = System.Config.Currency.Enabled, + ItemSpacing = 12f, + }; + _currencyListNode.AttachNode(this); + + RefreshCurrencies(); + } + + public void RefreshCurrencies() + { + var config = System.Config.Currency; + _currencyListNode.IsVisible = config.Enabled; + + if (!config.Enabled) return; + + IReadOnlyList currencyInfoList = GetCurrencyInfoList(config.DisplayedCurrencies); + _currencyListNode.SyncWithListDataByKey( + dataList: currencyInfoList, + getKeyFromData: currencyInfo => currencyInfo.ItemId, + getKeyFromNode: node => node.Currency.ItemId, + updateNode: (node, data) => + { + node.Currency = data; + }, + createNodeMethod: data => new CurrencyNode + { + Size = new Vector2(120, 28), + Currency = data + }); + } + + public ReadOnlySeString SlotAmountText + { + get => _slotAmountTextNode.String; + set => _slotAmountTextNode.String = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + _slotAmountTextNode.Position = new Vector2(Size.X - _slotAmountTextNode.Size.X - 10, 0); + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs b/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs new file mode 100644 index 0000000..786b4c0 --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using AetherBags.Inventory.Context; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using KamiToolKit.Timelines; + +namespace AetherBags.Nodes.Inventory; + +public sealed class InventoryNotificationNode : SimpleComponentNode +{ + private readonly SimpleNineGridNode glowNode; + private readonly TextNode titleTextNode; + private readonly TextNode messageTextNode; + + public InventoryNotificationNode() + { + AddTimeline(ParentLabels); + + glowNode = new SimpleNineGridNode { + TexturePath = "ui/uld/Inventory.tex", + TextureSize = new Vector2(56.0f, 56.0f), + TextureCoordinates = new Vector2(88.0f, 0.0f), + TopOffset = 10, + BottomOffset = 10, + LeftOffset = 26, + RightOffset = 26, + }; + glowNode.AttachNode(this); + glowNode.AddTimeline(GlowKeyFrames); + + titleTextNode = new TextNode + { + Position = new Vector2(0, 10f), + FontType = FontType.MiedingerMed, + FontSize = 18, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(37), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Center, + }; + titleTextNode.AttachNode(this); + titleTextNode.AddTimeline(TextKeyFrames); + + messageTextNode = new TextNode + { + Position = new Vector2(0, -10f), + FontType = FontType.Axis, + FontSize = 14, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(37), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Center, + }; + messageTextNode.AttachNode(this); + messageTextNode.AddTimeline(TextKeyFrames); + + Timeline?.PlayAnimation(17); + } + + protected override void OnSizeChanged() + { + base.OnSizeChanged(); + + glowNode.Size = Size with { Y = 40 }; + titleTextNode.Size = Size with { Y = 20 }; + messageTextNode.Size = Size with { Y = 16 }; + } + + public InventoryNotificationInfo NotificationInfo + { + get; + set + { + field = value; + + titleTextNode.String = value.Title; + messageTextNode.String = value.Message; + + if (value.Title.IsEmpty && value.Message.IsEmpty) + { + Timeline?.PlayAnimation(17); + return; + } + + Timeline?.PlayAnimation(101); + } + } = null!; + + // Future Zeff, this always goes on a parent + private Timeline ParentLabels => new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabel(1, 17, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 101, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(25, 102, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(59, 0, AtkTimelineJumpBehavior.LoopForever, 102) + .EndFrameSet() + .Build(); + + // Future Zeff, this always goes on a child + private Timeline GlowKeyFrames => new TimelineBuilder().BeginFrameSet(15, 59) + .AddFrame(10, scale: new Vector2(1.4f, 1.0f), alpha: 0, addColor: new Vector3(128, 128, 128)) + .AddFrame(15, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(128, 128, 128)) + .AddFrame(21, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(40, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(46, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(10, 10, 10)) + .AddFrame(59, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0)) + .EndFrameSet() + .Build(); + + // Future Zeff, this always goes on a child + private Timeline TextKeyFrames => new TimelineBuilder().BeginFrameSet(15, 59) + .AddFrame(15, alpha: 0, addColor: new Vector3(128, 128, 128)) + .AddFrame(18, alpha: 255, addColor: new Vector3(64, 64, 64)) + .AddFrame(25, alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(40, alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(46, alpha: 255, addColor: new Vector3(64, 64, 64)) + .AddFrame(59, alpha: 255, addColor: new Vector3(0, 0, 0)) + .EndFrameSet() + .Build(); +} \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs new file mode 100644 index 0000000..3144cee --- /dev/null +++ b/AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs @@ -0,0 +1,106 @@ +using System; +using AetherBags.Inventory.Items; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Common.Math; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +/// +/// A display-only item node for looted items. Not draggable, but shows tooltip and can be dismissed. +/// +public sealed unsafe class LootedItemDisplayNode : SimpleComponentNode +{ + private readonly IconNode _iconNode; + private readonly TextNode _quantityTextNode; + + public Action? OnDismiss { get; set; } + public Action? OnRollOver { get; set; } + public Action? OnRollOut { get; set; } + + public LootedItemDisplayNode() + { + Size = new Vector2(42, 46); + + _iconNode = new IconNode + { + Position = new Vector2(0, 0), + Size = new Vector2(42, 46), + }; + _iconNode.CollisionNode.NodeFlags = 0; + _iconNode.AttachNode(this); + + CollisionNode.AddEvent(AtkEventType.MouseClick, OnMouseClick); + CollisionNode.AddEvent(AtkEventType.MouseOver, OnMouseOver); + CollisionNode.AddEvent(AtkEventType.MouseOut, OnMouseOut); + + _quantityTextNode = new TextNode + { + Size = new Vector2(40.0f, 12.0f), + Position = new Vector2(4.0f, 34.0f), + Color = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(51), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Right, + }; + _quantityTextNode.AttachNode(this); + } + + public LootedItemInfo? LootedItem + { + get; + set + { + bool needsCollisionUpdate = field is null && value is not null; + field = value; + + if (value is not null) + { + InventoryItem item = value.Item; + _iconNode.IconId = item.IconId; + _iconNode.ItemTooltip = item.ItemId; + _quantityTextNode.String = value.Quantity > 1 ? value.Quantity.ToString() : string.Empty; + _iconNode.IsVisible = true; + _quantityTextNode.IsVisible = true; + } + else + { + _iconNode.IsVisible = false; + _quantityTextNode.String = string.Empty; + } + + if (needsCollisionUpdate) + { + var addon = RaptureAtkUnitManager.Instance()->GetAddonByNode(this); + if (addon is not null) + addon->UpdateCollisionNodeList(false); + } + } + } = null; + + private void OnMouseClick(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + if (!atkEventData->IsLeftClick) return; + OnDismiss?.Invoke(this); + } + + private void OnMouseOver(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + OnRollOver?.Invoke(this); + } + + private void OnMouseOut(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) + { + OnRollOut?.Invoke(this); + } + + public void ResetForReuse() + { + LootedItem = null; + _iconNode.IsVisible = false; + _quantityTextNode.String = string.Empty; + } +} diff --git a/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs new file mode 100644 index 0000000..e729007 --- /dev/null +++ b/AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.CompilerServices; +using AetherBags.Inventory.Items; +using AetherBags.Nodes.Layout; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Inventory; + +/// +/// A special category node for displaying recently looted items. +/// Items are not draggable but can be dismissed individually or cleared entirely. +/// +public class LootedItemsCategoryNode : InventoryCategoryNodeBase +{ + private const uint LootedCategoryKey = 0x20000001; + + public override uint Key => LootedCategoryKey; + private readonly TextNode _headerTextNode; + private readonly CircleButtonNode _clearButton; + private readonly HybridDirectionalFlexNode _itemGridNode; + + private const float HeaderHeight = 20; + private const float ClearButtonSize = 20; + private const float MinWidth = 100; + + private IReadOnlyList _lootedItems = Array.Empty(); + + private int _lastItemCount; + private long _lastItemsHash; + + private float? _maxWidth; + + private int _hoverRefs; + private bool _headerExpanded; + private float _baseHeaderWidth = 96f; + private string _fullHeaderText = "Recently Looted"; + + public event Action? HeaderHoverChanged; + public Action? OnDismissItem { get; set; } + public Action? OnClearAll { get; set; } + + public int ItemsPerLine + { + get => _itemGridNode.ItemsPerLine; + set + { + if (_itemGridNode.ItemsPerLine == value) return; + _itemGridNode.ItemsPerLine = value; + RecalculateSize(); + } + } + + public bool HasItems => _lootedItems.Count > 0; + + public override float? MaxWidth + { + get => _maxWidth; + set => _maxWidth = value; + } + + public LootedItemsCategoryNode() + { + _headerTextNode = new TextNode + { + Position = Vector2.Zero, + Size = new Vector2(96, HeaderHeight), + AlignmentType = AlignmentType.Left, + String = "Recently Looted", + TextFlags = TextFlags.OverflowHidden | TextFlags.Ellipsis, + TextColor = ColorHelper.GetColor(26), // Gold-ish color + }; + + _headerTextNode.AddEvent(AtkEventType.MouseOver, BeginHeaderHover); + _headerTextNode.AddEvent(AtkEventType.MouseOut, EndHeaderHover); + + _headerTextNode.TextFlags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; + _headerTextNode.TextFlags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); + + _headerTextNode.AddNodeFlags(NodeFlags.EmitsEvents | NodeFlags.HasCollision); + _headerTextNode.AttachNode(this); + + _clearButton = new CircleButtonNode + { + Size = new Vector2(ClearButtonSize), + Icon = ButtonIcon.CrossSmall, + OnClick = () => OnClearAll?.Invoke(), + }; + _clearButton.AttachNode(this); + + _itemGridNode = new HybridDirectionalFlexNode + { + Position = new Vector2(0, HeaderHeight), + Size = new Vector2(240, 92), + FillRowsFirst = true, + ItemsPerLine = 10, + HorizontalPadding = 5, + VerticalPadding = 2, + }; + _itemGridNode.NodeFlags |= NodeFlags.EmitsEvents; + _itemGridNode.AttachNode(this); + + RecalculateSize(); + } + + public void UpdateLootedItems(IReadOnlyList lootedItems) + { + long newHash = ComputeItemsHash(lootedItems); + bool itemsChanged = lootedItems.Count != _lastItemCount || newHash != _lastItemsHash; + + _lastItemCount = lootedItems.Count; + _lastItemsHash = newHash; + _lootedItems = lootedItems; + + UpdateHeaderText(); + + if (itemsChanged) + { + using (_itemGridNode.DeferRecalculateLayout()) + { + SyncItemGrid(); + } + RecalculateSize(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long ComputeItemsHash(IReadOnlyList items) + { + unchecked + { + long hash = unchecked((long)14695981039346656037UL); + for (int i = 0; i < items.Count; i++) + { + hash ^= items[i].Index; + hash *= 1099511628211L; + hash ^= items[i].Item.ItemId; + hash *= 1099511628211L; + } + return hash; + } + } + + private void UpdateHeaderText() + { + _fullHeaderText = _lootedItems.Count > 0 + ? $"Recently Looted ({_lootedItems.Count})" + : "Recently Looted"; + + _headerTextNode.String = _fullHeaderText; + } + + public void BeginHeaderHover() + { + _hoverRefs++; + if (_hoverRefs != 1) return; + + _headerExpanded = true; + ApplyHeaderVisualStateAndSize(); + HeaderHoverChanged?.Invoke(this, true); + } + + public void EndHeaderHover() + { + if (_hoverRefs <= 0) return; + + _hoverRefs--; + if (_hoverRefs != 0) return; + + _headerExpanded = false; + ApplyHeaderVisualStateAndSize(); + HeaderHoverChanged?.Invoke(this, false); + } + + private void ApplyHeaderVisualStateAndSize() + { + var flags = _headerTextNode.TextFlags; + flags &= ~(TextFlags.WordWrap | TextFlags.MultiLine); + + if (_headerExpanded) + { + flags &= ~(TextFlags.OverflowHidden | TextFlags.Ellipsis); + _headerTextNode.TextFlags = flags; + + if (!string.IsNullOrEmpty(_fullHeaderText)) + _headerTextNode.String = _fullHeaderText; + + Vector2 drawSize = _headerTextNode.GetTextDrawSize(); + float expandedWidth = MathF.Max(_baseHeaderWidth, drawSize.X + 4f); + _headerTextNode.Size = _headerTextNode.Size with { X = expandedWidth }; + + _clearButton.Position = new Vector2(expandedWidth + 4f, (HeaderHeight - ClearButtonSize) / 2); + } + else + { + _headerTextNode.Size = _headerTextNode.Size with { X = _baseHeaderWidth }; + + if (!string.IsNullOrEmpty(_fullHeaderText)) + _headerTextNode.String = _fullHeaderText; + + flags |= TextFlags.OverflowHidden | TextFlags.Ellipsis; + _headerTextNode.TextFlags = flags; + + float nodeWidth = Size.X; + _clearButton.Position = new Vector2(nodeWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); + } + } + + private void SyncItemGrid() + { + _itemGridNode.SyncWithListDataByKey( + dataList: _lootedItems, + getKeyFromData: item => item.Index, + getKeyFromNode: node => node.LootedItem?.Index ?? -1, + updateNode: UpdateLootedItemNode, + createNodeMethod: CreateLootedItemNode, + resetNodeForReuse: ResetLootedItemNodeForReuse); + } + + private static void UpdateLootedItemNode(LootedItemDisplayNode node, LootedItemInfo data) + { + node.LootedItem = data; + } + + private static void ResetLootedItemNodeForReuse(LootedItemDisplayNode node) + { + node.ResetForReuse(); + } + + private LootedItemDisplayNode CreateLootedItemNode(LootedItemInfo lootedItem) + { + return new LootedItemDisplayNode + { + OnDismiss = OnItemDismissed, + OnRollOver = _ => BeginHeaderHover(), + OnRollOut = _ => EndHeaderHover(), + LootedItem = lootedItem, + }; + } + + private void OnItemDismissed(LootedItemDisplayNode node) + { + if(node.LootedItem is null) return; + int index = node.LootedItem.Index; + OnDismissItem?.Invoke(index); + } + + public override void RecalculateSize() + { + int itemCount = _lootedItems.Count; + + const float cellW = 42f; + const float cellH = 46f; + + float hPad = _itemGridNode.HorizontalPadding; + float vPad = _itemGridNode.VerticalPadding; + + if (itemCount == 0) + { + float width = _maxWidth.HasValue ? Math.Min(MinWidth, _maxWidth.Value) : MinWidth; + Size = new Vector2(width, HeaderHeight); + _baseHeaderWidth = width - ClearButtonSize - 4; + _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); + _clearButton.Position = new Vector2(width - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); + _clearButton.IsVisible = false; + _itemGridNode.Position = new Vector2(0, HeaderHeight); + _itemGridNode.Size = new Vector2(width, 0); + ApplyHeaderVisualStateAndSize(); + return; + } + + int itemsPerLine = Math.Max(1, _itemGridNode.ItemsPerLine); + + float minUsableWidth = cellW; + if (_maxWidth.HasValue && _maxWidth.Value >= minUsableWidth) + { + int maxColumns = (int)MathF.Floor((_maxWidth.Value + hPad) / (cellW + hPad)); + maxColumns = Math.Max(1, maxColumns); + + float widthNeeded = maxColumns * cellW + (maxColumns - 1) * hPad; + if (widthNeeded > _maxWidth.Value && maxColumns > 1) + maxColumns--; + + itemsPerLine = Math.Min(itemsPerLine, maxColumns); + } + + int rows = (itemCount + itemsPerLine - 1) / itemsPerLine; + int actualColumns = Math.Min(itemCount, itemsPerLine); + + float calculatedWidth = Math.Max(MinWidth, actualColumns * cellW + (actualColumns - 1) * hPad); + + if (_maxWidth.HasValue && _maxWidth.Value >= minUsableWidth) + calculatedWidth = Math.Min(calculatedWidth, _maxWidth.Value); + + float gridHeight = rows * cellH + (rows - 1) * vPad; + float totalHeight = HeaderHeight + gridHeight; + + Size = new Vector2(calculatedWidth, totalHeight); + + if (_itemGridNode.ItemsPerLine != itemsPerLine) + _itemGridNode.ItemsPerLine = itemsPerLine; + + _baseHeaderWidth = calculatedWidth - ClearButtonSize - 4; + _headerTextNode.Size = new Vector2(_baseHeaderWidth, HeaderHeight); + _clearButton.Position = new Vector2(calculatedWidth - ClearButtonSize, (HeaderHeight - ClearButtonSize) / 2); + _clearButton.IsVisible = true; + _itemGridNode.Position = new Vector2(0, HeaderHeight); + _itemGridNode.Size = new Vector2(calculatedWidth, gridHeight); + ApplyHeaderVisualStateAndSize(); + } +} diff --git a/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs b/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs new file mode 100644 index 0000000..505bc05 --- /dev/null +++ b/AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs @@ -0,0 +1,32 @@ +using System. Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Inventory; + +public class SaddleBagFooterNode : SimpleComponentNode +{ + private readonly TextNode _slotCounterNode; + + private const float Padding = 8f; + + public SaddleBagFooterNode() + { + _slotCounterNode = new TextNode + { + Position = new Vector2(Padding, 4f), + Size = new Vector2(100, 20), + AlignmentType = AlignmentType.Left, + TextColor = new Vector4(1f, 1f, 1f, 1f), + FontSize = 14, + }; + _slotCounterNode.AttachNode(this); + } + + public ReadOnlySeString SlotAmountText + { + get => _slotCounterNode.String; + set => _slotCounterNode.String = $"Slots: {value}"; + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Layout/CollapsibleSectionNode.cs b/AetherBags/Nodes/Layout/CollapsibleSectionNode.cs new file mode 100644 index 0000000..2d73abe --- /dev/null +++ b/AetherBags/Nodes/Layout/CollapsibleSectionNode.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Layout; + +public class CollapsibleSectionNode : VerticalListNode +{ + protected readonly NineGridNode BackgroundNode; + protected readonly ImageNode ArrowNode; + protected readonly TextNode LabelNode; + protected new readonly CollisionNode CollisionNode; + protected readonly TabbedVerticalListNode ContentNode; + protected readonly SimpleComponentNode HeaderNode; + + private bool isCollapsed = true; + private float headerHeight = 28.0f; + + public Action? OnToggle; + + public TabbedVerticalListNode CollapsibleContent => ContentNode; + + public bool IsCollapsed + { + get => isCollapsed; + set { isCollapsed = value; UpdateState(); } + } + + public float HeaderHeight + { + get => headerHeight; + set + { + headerHeight = value; + HeaderNode.Height = value; + BackgroundNode.Height = value; + CollisionNode.Height = value; + ArrowNode.Y = (value - ArrowNode.Height) / 2.0f; + LabelNode.Height = value; + RecalculateLayout(); + } + } + + public uint FontSize { get => LabelNode.FontSize; set => LabelNode.FontSize = value; } + + public float TabSize + { + get => ContentNode.TabSize; + set => ContentNode.TabSize = value; + } + + public int TabStep + { + get => ContentNode.TabStep; + set => ContentNode.TabStep = value; + } + + public bool FitChildWidth + { + get => ContentNode.FitWidth; + set => ContentNode.FitWidth = value; + } + + public float NestingIndent + { + get; + set + { + field = value; + ArrowNode.X = value + 4.0f; + LabelNode.X = value + 23.0f; + ContentNode.X = value + 10.0f; + } + } + + public CollapsibleSectionNode() + { + FitContents = true; + ItemSpacing = 0.0f; + + HeaderNode = new SimpleComponentNode + { + Size = new Vector2(Width, headerHeight) + }; + + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListItemB.tex", + TextureSize = new Vector2(48.0f, 28.0f), + TextureCoordinates = new Vector2(0.0f, 24.0f), + Size = new Vector2(Width, headerHeight), + TopOffset = 10, LeftOffset = 12, RightOffset = 12, BottomOffset = 12, + Color = new Vector4(0.9f, 0.9f, 0.9f, 1.0f) + }; + BackgroundNode.AttachNode(HeaderNode); + + ArrowNode = new ImageNode { Position = new Vector2(4.0f, 2.0f), Size = new Vector2(24.0f, 24.0f) }; + ArrowNode.AddPart( + new Part { TexturePath = "ui/uld/ListItemB.tex", TextureCoordinates = new Vector2(0, 0), Size = new Vector2(24, 24), Id = 0 }, + new Part { TexturePath = "ui/uld/ListItemB.tex", TextureCoordinates = new Vector2(24, 0), Size = new Vector2(24, 24), Id = 1 } + ); + ArrowNode.AttachNode(HeaderNode); + + LabelNode = new TextNode { + Position = new Vector2(30.0f, 0.0f), + Size = new Vector2(Width - 23, headerHeight), + FontSize = 12, + FontType = FontType.Axis, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(50), + }; + LabelNode.AttachNode(HeaderNode); + + CollisionNode = new CollisionNode + { + Size = new Vector2(Width, headerHeight), + ShowClickableCursor = true + }; + CollisionNode.AddEvent(AtkEventType.MouseClick, () => { + IsCollapsed = !IsCollapsed; + OnToggle?.Invoke(); + }); + CollisionNode.AttachNode(HeaderNode); + + ContentNode = new TabbedVerticalListNode { + IsVisible = false, + X = 18.0f, + ItemVerticalSpacing = 4.0f, + TabSize = 18.0f, + FitWidth = true, + }; + + base.AddNode([HeaderNode, ContentNode]); + UpdateState(); + } + + public void RefreshLayout() + { + ContentNode.RecalculateLayout(); + RecalculateLayout(); + OnToggle?.Invoke(); + } + + private void UpdateState() + { + ContentNode.IsVisible = !isCollapsed; + ArrowNode.PartId = isCollapsed ? 0u : 1u; + + if (!isCollapsed) + { + ContentNode.Width = Math.Max(0, Width - ContentNode.X); + ContentNode.RecalculateLayout(); + } + + RecalculateLayout(); + OnToggle?.Invoke(); + } + + public void AddTab(int tabAmount = 1) => ContentNode.AddTab(tabAmount); + + public void SubtractTab(int tabAmount = 1) => ContentNode.SubtractTab(tabAmount); + + public new void AddNode(NodeBase node) => ContentNode.AddNode(node); + + public new void AddNode(IEnumerable nodes) => ContentNode.AddNode(nodes); + + public void AddNode(int tabIndex, NodeBase node) => ContentNode.AddNode(tabIndex, node); + + public void AddNode(int tabIndex, IEnumerable nodes) => ContentNode.AddNode(tabIndex, nodes); + + public new void RemoveNode(NodeBase node) => ContentNode.RemoveNode(node); + + public new void Clear() => ContentNode.Clear(); + + protected override void OnSizeChanged() + { + base.OnSizeChanged(); + if (BackgroundNode == null || LabelNode == null || CollisionNode == null) return; + + HeaderNode.Width = Width; + BackgroundNode.Width = Width; + LabelNode.Width = Math.Max(0, Width - LabelNode.X); + CollisionNode.Width = Width; + ContentNode.Width = Math.Max(0, Width - ContentNode.X); + } + + public ReadOnlySeString String { get => LabelNode.String; set => LabelNode.String = value; } +} diff --git a/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs b/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs new file mode 100644 index 0000000..beb5446 --- /dev/null +++ b/AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs @@ -0,0 +1,615 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.CompilerServices; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit; +using KamiToolKit.Nodes; + +namespace AetherBags.Nodes.Layout; + +public abstract class DeferrableLayoutListNode : SimpleComponentNode +{ + protected readonly List NodeList = []; + private bool _suppressRecalculateLayout; + private int _deferRecalcDepth; + private bool _pendingRecalc; + + private readonly Dictionary> _nodePoolByType = new(); + private const int MaxPoolSizePerType = 64; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private TU? TryRentFromPool(SharedNodePool? externalPool) where TU : NodeBase + { + if (externalPool != null) + { + return externalPool.TryRent(); + } + + if (_nodePoolByType.TryGetValue(typeof(TU), out var pool) && pool.Count > 0) + { + var node = (TU)pool.Pop(); + node.IsVisible = true; + return node; + } + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryReturnToPool(TU node, SharedNodePool? externalPool, Action? resetAction) where TU : NodeBase + { + if (externalPool != null) + { + resetAction?.Invoke(node); + return externalPool.TryReturn(node); + } + + var type = typeof(TU); + if (!_nodePoolByType.TryGetValue(type, out var pool)) + { + pool = new Stack(16); + _nodePoolByType[type] = pool; + } + + if (pool.Count >= MaxPoolSizePerType) + return false; + + resetAction?.Invoke(node); + node.IsVisible = false; + node.DetachNode(); + pool.Push(node); + return true; + } + + private void DisposePool() + { + foreach (var pool in _nodePoolByType.Values) + { + while (pool.Count > 0) + { + var node = pool.Pop(); + SafeDisposeNode(node); + } + } + _nodePoolByType.Clear(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected static void SafeDisposeNode(NodeBase node) + { + try + { + node.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, $"[SafeDisposeNode] Error disposing {node.GetType().Name}"); + } + } + + public IEnumerable GetNodes() where T : NodeBase + { + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is T t) + yield return t; + } + } + + public IReadOnlyList Nodes => NodeList; + + public bool ClipListContents + { + get => NodeFlags.HasFlag(NodeFlags.Clip); + set + { + if (value) + AddNodeFlags(NodeFlags.Clip); + else + RemoveNodeFlags(NodeFlags.Clip); + } + } + + public float ItemSpacing { get; set; } + + public float FirstItemSpacing { get; set; } + + public void RecalculateLayout() + { + if (_suppressRecalculateLayout) return; + + if (_deferRecalcDepth > 0) + { + _pendingRecalc = true; + return; + } + + InternalRecalculateLayout(); + + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is DeferrableLayoutListNode subNode) + subNode.RecalculateLayout(); + } + } + + protected virtual void AdjustNode(NodeBase node) { } + + protected abstract void InternalRecalculateLayout(); + + public ICollection InitialNodes + { + init => AddNode(value); + } + + public void AddNode(IEnumerable nodes) + { + _suppressRecalculateLayout = true; + try + { + foreach (var node in nodes) + { + AddNode(node); + } + } + finally + { + _suppressRecalculateLayout = false; + } + RecalculateLayout(); + } + + public virtual void AddNode(NodeBase? node) + { + if (node is null) return; + + NodeList.Add(node); + + node.AttachNode(this); + + RecalculateLayout(); + } + + public void RemoveNode(params NodeBase[] items) + { + _suppressRecalculateLayout = true; + try + { + foreach (var node in items) + { + RemoveNode(node); + } + } + finally + { + _suppressRecalculateLayout = false; + } + RecalculateLayout(); + } + + public virtual void RemoveNode(NodeBase node) + { + if (!NodeList.Contains(node)) return; + + NodeList.Remove(node); + SafeDisposeNode(node); + + RecalculateLayout(); + } + + public void AddDummy(float size = 0.0f) + { + var dummyNode = new ResNode + { + Size = new Vector2(size, size), + }; + + AddNode(dummyNode); + } + + public virtual void Clear() + { + _suppressRecalculateLayout = true; + try + { + for (int i = NodeList.Count - 1; i >= 0; i--) + { + var node = NodeList[i]; + NodeList.RemoveAt(i); + SafeDisposeNode(node); + } + } + finally + { + _suppressRecalculateLayout = false; + } + + DisposePool(); + + RecalculateLayout(); + } + + public void ClearListOnly() + { + _suppressRecalculateLayout = true; + try + { + NodeList.Clear(); + } + finally + { + _suppressRecalculateLayout = false; + } + + RecalculateLayout(); + } + + public delegate TU CreateNewNode(T data) where TU : NodeBase; + + public delegate T GetDataFromNode(TU node) where TU : NodeBase; + + private List? _existingScratch; + private List? _desiredScratch; + private List? _toRemoveScratch; + private HashSet? _dataKeysScratch; + private Dictionary? _byKeyScratch; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentExistingList(int capacity) + { + var list = _existingScratch ?? new List(capacity); + list.Clear(); + if (list.Capacity < capacity) list.Capacity = capacity; + _existingScratch = null; + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnExistingList(List list) + { + list.Clear(); + _existingScratch = list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentDesiredList(int capacity) + { + var list = _desiredScratch ?? new List(capacity); + list.Clear(); + if (list.Capacity < capacity) list.Capacity = capacity; + _desiredScratch = null; + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnDesiredList(List list) + { + list.Clear(); + _desiredScratch = list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentRemoveList(int capacity) + { + var list = _toRemoveScratch ?? new List(capacity); + list.Clear(); + if (list.Capacity < capacity) list.Capacity = capacity; + _toRemoveScratch = null; + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnRemoveList(List list) + { + list.Clear(); + _toRemoveScratch = list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private HashSet RentKeySet(int capacity) + { + var set = _dataKeysScratch ?? new HashSet(capacity); + set.Clear(); + _dataKeysScratch = null; + return set; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ReturnKeySet(HashSet set) + { + set.Clear(); + _dataKeysScratch = set; + } + + public bool SyncWithListDataByKey( + IReadOnlyList dataList, + Func getKeyFromData, + Func getKeyFromNode, + Action updateNode, + CreateNewNode createNodeMethod, + Action? resetNodeForReuse = null, + SharedNodePool? externalPool = null, + IEqualityComparer? keyComparer = null) where TU : NodeBase where TKey : notnull + { + keyComparer ??= EqualityComparer.Default; + + int dataCount = dataList.Count; + + var desiredKeys = RentKeySet(dataCount); + for (int i = 0; i < dataCount; i++) + { + desiredKeys.Add(getKeyFromData(dataList[i])!); + } + + var existing = RentExistingList(NodeList.Count); + var toRemove = RentRemoveList(16); + + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU tu) + { + var key = getKeyFromNode(tu); + if (desiredKeys.Contains(key)) + { + existing.Add(tu); + } + else + { + toRemove.Add(tu); + } + } + } + + bool structureChanged = toRemove.Count > 0; + + if (toRemove.Count > 0) + { + _suppressRecalculateLayout = true; + try + { + for (int i = 0; i < toRemove.Count; i++) + { + var node = (TU)toRemove[i]; + NodeList.Remove(node); + + if (!TryReturnToPool(node, externalPool, resetNodeForReuse)) + { + SafeDisposeNode(node); + } + } + } + finally + { + _suppressRecalculateLayout = false; + } + } + + Dictionary? byKey = null; + if (existing.Count > 0) + { + if (_byKeyScratch is Dictionary reusable) + { + byKey = reusable; + byKey.Clear(); + } + else + { + byKey = new Dictionary(existing.Count, keyComparer); + } + + for (int i = 0; i < existing.Count; i++) + { + var tu = (TU)existing[i]; + var key = getKeyFromNode(tu); + byKey.TryAdd(key, tu); + } + } + + var desired = RentDesiredList(dataCount); + + _suppressRecalculateLayout = true; + try + { + for (int i = 0; i < dataCount; i++) + { + var data = dataList[i]; + var key = getKeyFromData(data); + + if (byKey != null && byKey.TryGetValue(key, out var existingNode)) + { + updateNode(existingNode, data); + desired.Add(existingNode); + byKey.Remove(key); + } + else + { + TU newNode; + var pooledNode = TryRentFromPool(externalPool); + if (pooledNode != null) + { + newNode = pooledNode; + newNode.AttachNode(this); + } + else + { + newNode = createNodeMethod(data); + newNode.AttachNode(this); + } + + NodeList.Add(newNode); + updateNode(newNode, data); + desired.Add(newNode); + structureChanged = true; + } + } + } + finally + { + _suppressRecalculateLayout = false; + } + + bool orderChanged = false; + if (!structureChanged && desired.Count > 0) + { + int tuIndex = 0; + for (int i = 0; i < NodeList.Count && tuIndex < desired.Count; i++) + { + if (NodeList[i] is TU) + { + if (!ReferenceEquals(NodeList[i], desired[tuIndex])) + { + orderChanged = true; + break; + } + tuIndex++; + } + } + if (tuIndex != desired.Count) + orderChanged = true; + } + + if (structureChanged || orderChanged) + { + int insertIndex = -1; + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU) + { + insertIndex = i; + break; + } + } + + if (insertIndex < 0) + insertIndex = NodeList.Count; + + for (int i = NodeList.Count - 1; i >= 0; i--) + { + if (NodeList[i] is TU) + NodeList.RemoveAt(i); + } + + if (insertIndex > NodeList.Count) + insertIndex = NodeList.Count; + + NodeList.InsertRange(insertIndex, desired); + } + + ReturnKeySet(desiredKeys); + ReturnExistingList(existing); + ReturnRemoveList(toRemove); + ReturnDesiredList(desired); + + if (structureChanged || orderChanged) + { + RecalculateLayout(); + } + + if (byKey != null) + { + byKey.Clear(); + _byKeyScratch = byKey as Dictionary; + } + + return structureChanged || orderChanged; + } + + public bool SyncWithListDataByKey( + IReadOnlyList dataList, + Func getKeyFromData, + Func getKeyFromNode, + Action updateNode, + CreateNewNode createNodeMethod, + IEqualityComparer? keyComparer) where TU : NodeBase where TKey : notnull + => SyncWithListDataByKey(dataList, getKeyFromData, getKeyFromNode, updateNode, createNodeMethod, null, null, keyComparer); + + public bool SyncWithListData( + IEnumerable dataList, + GetDataFromNode getDataFromNode, + CreateNewNode createNodeMethod) where TU : NodeBase + { + _suppressRecalculateLayout = true; + var anythingChanged = false; + try + { + var existing = RentExistingList(NodeList.Count); + for (int i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU tu) + existing.Add(tu); + } + + var dataSet = new HashSet(EqualityComparer.Default); + foreach (var d in dataList) + dataSet.Add(d); + + var represented = new HashSet(EqualityComparer.Default); + + for (int i = 0; i < existing.Count; i++) + { + var tu = (TU)existing[i]; + var nodeData = getDataFromNode(tu); + + if (nodeData is null || !dataSet.Contains(nodeData)) + { + NodeList.Remove(tu); + SafeDisposeNode(tu); + anythingChanged = true; + continue; + } + + represented.Add(nodeData); + } + + foreach (var data in dataSet) + { + if (represented.Contains(data)) + continue; + + var newNode = createNodeMethod(data); + NodeList.Add(newNode); + newNode.AttachNode(this); + anythingChanged = true; + } + + ReturnExistingList(existing); + } + finally + { + _suppressRecalculateLayout = false; + } + + if (anythingChanged) + RecalculateLayout(); + + return anythingChanged; + } + + public void ReorderNodes(Comparison comparison) + { + NodeList.Sort(comparison); + RecalculateLayout(); + } + + public IDisposable DeferRecalculateLayout() + { + _deferRecalcDepth++; + return new RecalcDeferToken(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EndDefer() + { + _deferRecalcDepth--; + if (_deferRecalcDepth == 0 && _pendingRecalc) + { + _pendingRecalc = false; + RecalculateLayout(); + } + } + + private readonly struct RecalcDeferToken(DeferrableLayoutListNode owner) : IDisposable + { + public void Dispose() => owner.EndDefer(); + } +} diff --git a/AetherBags/Nodes/Layout/FlexGrowDirection.cs b/AetherBags/Nodes/Layout/FlexGrowDirection.cs new file mode 100644 index 0000000..e4bec85 --- /dev/null +++ b/AetherBags/Nodes/Layout/FlexGrowDirection.cs @@ -0,0 +1,9 @@ +namespace AetherBags.Nodes.Layout; + +public enum FlexGrowDirection +{ + DownRight, + DownLeft, + UpRight, + UpLeft +} diff --git a/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs b/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs new file mode 100644 index 0000000..cc9cfe4 --- /dev/null +++ b/AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs @@ -0,0 +1,130 @@ +using KamiToolKit; + +namespace AetherBags.Nodes.Layout; + +public class HybridDirectionalFlexNode : HybridDirectionalFlexNode { } + +public class HybridDirectionalFlexNode : DeferrableLayoutListNode where T : NodeBase +{ + public FlexGrowDirection GrowDirection + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = FlexGrowDirection.DownRight; + + public int ItemsPerLine + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = 1; + + public bool FillRowsFirst + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = true; + + public float HorizontalPadding + { + get => field; + set + { + if (field.Equals(value)) return; + field = value; + RecalculateLayout(); + } + } = 1; + + public float VerticalPadding + { + get => field; + set + { + if (field.Equals(value)) return; + field = value; + RecalculateLayout(); + } + } = 1; + + protected override void InternalRecalculateLayout() + { + int count = NodeList.Count; + if (count == 0) return; + + int itemsPerLine = ItemsPerLine; + if (itemsPerLine < 1) itemsPerLine = 1; + + NodeBase first = NodeList[0]; + float nodeWidth = first.Width; + float nodeHeight = first.Height; + + float hPad = HorizontalPadding; + float vPad = VerticalPadding; + + FlexGrowDirection dir = GrowDirection; + bool alignRight = dir == FlexGrowDirection.DownLeft || dir == FlexGrowDirection.UpLeft; + bool alignBottom = dir == FlexGrowDirection.UpRight || dir == FlexGrowDirection.UpLeft; + + float startX = alignRight ? Width : 0f; + float startY = alignBottom ? Height : 0f; + + float stepX = nodeWidth + hPad; + float stepY = nodeHeight + vPad; + + bool fillRowsFirst = FillRowsFirst; + + int major = 0; + int minor = 0; + + for (int i = 0; i < count; i++) + { + int row, col; + if (fillRowsFirst) + { + row = major; + col = minor; + } + else + { + col = major; + row = minor; + } + + float x = alignRight + ? startX - nodeWidth - col * stepX + : startX + col * stepX; + + float y = alignBottom + ? startY - nodeHeight - row * stepY + : startY + row * stepY; + + NodeBase node = NodeList[i]; + node.X = x; + node.Y = y; + + AdjustNode(node); + + minor++; + if (minor == itemsPerLine) + { + minor = 0; + major++; + } + } + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs b/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs new file mode 100644 index 0000000..1aa6b81 --- /dev/null +++ b/AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs @@ -0,0 +1,114 @@ +using KamiToolKit; + +namespace AetherBags.Nodes.Layout; + +public class HybridDirectionalStackNode : DeferrableLayoutListNode where T : NodeBase +{ + public FlexGrowDirection GrowDirection + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = FlexGrowDirection.DownRight; + + public bool Vertical + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = true; + + public float Spacing + { + get => field; + set + { + if (field.Equals(value)) return; + field = value; + RecalculateLayout(); + } + } = 1f; + + public bool StretchCrossAxis + { + get => field; + set + { + if (field == value) return; + field = value; + RecalculateLayout(); + } + } = true; + + protected override void InternalRecalculateLayout() + { + int count = NodeList.Count; + if (count == 0) return; + + FlexGrowDirection dir = GrowDirection; + bool alignRight = dir == FlexGrowDirection.DownLeft || dir == FlexGrowDirection.UpLeft; + bool alignBottom = dir == FlexGrowDirection.UpRight || dir == FlexGrowDirection.UpLeft; + + bool vertical = Vertical; + bool stretchCross = StretchCrossAxis; + + float containerW = Width; + float containerH = Height; + + float startX = alignRight ? containerW : 0f; + float startY = alignBottom ? containerH : 0f; + + float spacing = Spacing; + + float cursor = 0f; + + if (vertical) + { + for (int i = 0; i < count; i++) + { + NodeBase node = NodeList[i]; + + if (stretchCross) + node.Width = containerW; + + float w = node.Width; + float h = node.Height; + + node.X = alignRight ? startX - w : startX; + node.Y = alignBottom ? startY - h - cursor : startY + cursor; + + AdjustNode(node); + + cursor += node.Height + spacing; + } + } + else + { + for (int i = 0; i < count; i++) + { + NodeBase node = NodeList[i]; + + if (stretchCross) + node.Height = containerH; + + float w = node.Width; + float h = node.Height; + + node.X = alignRight ? startX - w - cursor : startX + cursor; + node.Y = alignBottom ? startY - h : startY; + + AdjustNode(node); + + cursor += node.Width + spacing; + } + } + } +} \ No newline at end of file diff --git a/AetherBags/Nodes/Layout/SharedNodePool.cs b/AetherBags/Nodes/Layout/SharedNodePool.cs new file mode 100644 index 0000000..75a9c1f --- /dev/null +++ b/AetherBags/Nodes/Layout/SharedNodePool.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using KamiToolKit; + +namespace AetherBags.Nodes.Layout; + +public sealed class SharedNodePool where T : NodeBase +{ + private readonly Stack _pool; + private readonly int _maxSize; + private readonly Func? _factory; + private readonly Action? _resetAction; + + public SharedNodePool(int maxSize = 128, Func? factory = null, Action? resetAction = null) + { + _maxSize = maxSize; + _factory = factory; + _resetAction = resetAction; + _pool = new Stack(Math.Min(maxSize, 64)); + } + + public int Count => _pool.Count; + public int MaxSize => _maxSize; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T? TryRent() + { + if (_pool.TryPop(out var node)) + { + node.IsVisible = true; + return node; + } + return null; + } + + public T RentOrCreate() + { + if (_pool.TryPop(out var node)) + { + node.IsVisible = true; + return node; + } + + if (_factory == null) + throw new InvalidOperationException("No factory provided and pool is empty"); + + return _factory(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryReturn(T node) + { + if (_pool.Count >= _maxSize) + return false; + + _resetAction?.Invoke(node); + node.IsVisible = false; + node.DetachNode(); + _pool.Push(node); + return true; + } + + public void Return(T node) + { + if (!TryReturn(node)) + { + try + { + node.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, $"[SharedNodePool] Error disposing overflow node {typeof(T).Name}"); + } + } + } + + public void Clear() + { + while (_pool.TryPop(out var node)) + { + try + { + node.Dispose(); + } + catch (Exception ex) + { + Services.Logger.Error(ex, $"[SharedNodePool] Error disposing pooled node {typeof(T).Name}"); + } + } + } + + public void Prewarm(int count) + { + if (_factory == null) + return; + + count = Math.Min(count, _maxSize - _pool.Count); + for (int i = 0; i < count; i++) + { + var node = _factory(); + node.IsVisible = false; + _pool.Push(node); + } + } +} diff --git a/AetherBags/Nodes/Layout/VirtualizationState.cs b/AetherBags/Nodes/Layout/VirtualizationState.cs new file mode 100644 index 0000000..cbeff46 --- /dev/null +++ b/AetherBags/Nodes/Layout/VirtualizationState.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace AetherBags.Nodes.Layout; + +public sealed class VirtualizationState +{ + private float _scrollPosition; + private float _viewportHeight; + private float _bufferSize = 100f; + + private readonly List _itemVisibility = new(capacity: 64); + + public float ScrollPosition + { + get => _scrollPosition; + set + { + if (MathF.Abs(_scrollPosition - value) < 0.5f) return; + _scrollPosition = value; + UpdateVisibility(); + } + } + + public float ViewportHeight + { + get => _viewportHeight; + set + { + if (MathF.Abs(_viewportHeight - value) < 0.5f) return; + _viewportHeight = value; + UpdateVisibility(); + } + } + + public float BufferSize + { + get => _bufferSize; + set => _bufferSize = value; + } + + public event Action? OnVisibilityChanged; + + public void SetItemLayout(int index, float y, float height) + { + while (_itemVisibility.Count <= index) + { + _itemVisibility.Add(new VisibilityInfo()); + } + + var info = _itemVisibility[index]; + info.Y = y; + info.Height = height; + _itemVisibility[index] = info; + } + + public void ClearLayout() + { + _itemVisibility.Clear(); + } + + public void SetItemCount(int count) + { + while (_itemVisibility.Count < count) + { + _itemVisibility.Add(new VisibilityInfo()); + } + if (_itemVisibility.Count > count) + { + _itemVisibility.RemoveRange(count, _itemVisibility.Count - count); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsVisible(int index) + { + if (index < 0 || index >= _itemVisibility.Count) + return false; + + return _itemVisibility[index].IsVisible; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsInVisibleRange(float y, float height) + { + float visibleTop = _scrollPosition - _bufferSize; + float visibleBottom = _scrollPosition + _viewportHeight + _bufferSize; + + float itemTop = y; + float itemBottom = y + height; + + return itemBottom >= visibleTop && itemTop <= visibleBottom; + } + + public void UpdateVisibility() + { + bool anyChanged = false; + float visibleTop = _scrollPosition - _bufferSize; + float visibleBottom = _scrollPosition + _viewportHeight + _bufferSize; + + for (int i = 0; i < _itemVisibility.Count; i++) + { + var info = _itemVisibility[i]; + float itemTop = info.Y; + float itemBottom = info.Y + info.Height; + + bool wasVisible = info.IsVisible; + bool isVisible = itemBottom >= visibleTop && itemTop <= visibleBottom; + + if (wasVisible != isVisible) + { + info.IsVisible = isVisible; + _itemVisibility[i] = info; + anyChanged = true; + } + } + + if (anyChanged) + { + OnVisibilityChanged?.Invoke(); + } + } + + public void GetVisibleRange(out int firstVisible, out int lastVisible) + { + firstVisible = -1; + lastVisible = -1; + + for (int i = 0; i < _itemVisibility.Count; i++) + { + if (_itemVisibility[i].IsVisible) + { + if (firstVisible < 0) firstVisible = i; + lastVisible = i; + } + } + } + + private struct VisibilityInfo + { + public float Y; + public float Height; + public bool IsVisible; + } +} diff --git a/AetherBags/Nodes/Layout/WrappingGridNode.cs b/AetherBags/Nodes/Layout/WrappingGridNode.cs new file mode 100644 index 0000000..1a123a4 --- /dev/null +++ b/AetherBags/Nodes/Layout/WrappingGridNode.cs @@ -0,0 +1,1052 @@ +using KamiToolKit; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace AetherBags.Nodes.Layout; + +public sealed class WrappingGridNode : DeferrableLayoutListNode where T : NodeBase +{ + public float HorizontalSpacing { get; set; } = 10f; + public float VerticalSpacing { get; set; } = 10f; + + public float TopPadding { get; set; } = 0f; + public float BottomPadding { get; set; } = 0f; + + private readonly List> _rows = new(); + private readonly Stack> _rowPool = new(); + + private readonly Dictionary _rowIndex = new(ReferenceEqualityComparer.Instance); + + private float _requiredHeight; + private bool _requiredHeightDirty = true; + + private readonly IReadOnlyList> _rowsView; + + private float _lastAvailableWidth = float.NaN; + private float _lastStartX = float.NaN; + private float _lastHSpace = float.NaN; + private float _lastVSpace = float.NaN; + private float _lastTopPadding = float.NaN; + private float _lastBottomPadding = float.NaN; + private bool _lastUseCompactPacking; + private bool _lastPreferLargestFit; + private bool _lastUseStableInsert; + private int _lastCompactLookahead; + + private int[] _orderScratch = Array.Empty(); + private bool _forceFullReflow; + + private T? _hoistedNode; + private readonly HashSet _pinned = new(ReferenceEqualityComparer.Instance); + + private readonly List _layoutOrder = new(capacity: 256); + private readonly List _pinnedScratch = new(capacity: 64); + private readonly List _normalScratch = new(capacity: 256); + private readonly HashSet _presentScratch = new(ReferenceEqualityComparer.Instance); + + public WrappingGridNode() + { + _rowsView = new RowsReadOnlyView(_rows); + } + + public IReadOnlyList> Rows => _rowsView; + + public T? HoistedNode => _hoistedNode; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGetRowIndex(NodeBase node, out int rowIndex) => _rowIndex.TryGetValue(node, out rowIndex); + + public void InvalidateLayout() + { + _forceFullReflow = true; + } + + public void SetHoistedNode(T? node) + { + if (ReferenceEquals(_hoistedNode, node)) + return; + + _hoistedNode = node; + + if (node is not null) + { + if (!NodeList.Contains(node)) + AddNode(node); + } + + RecalculateLayout(); + } + + public bool PinNode(T node) + { + if (_pinned.Add(node)) + { + RecalculateLayout(); + return true; + } + return false; + } + + public bool UnpinNode(T node) + { + if (_pinned.Remove(node)) + { + RecalculateLayout(); + return true; + } + return false; + } + + public void ClearPinned() + { + if (_pinned.Count == 0) return; + _pinned.Clear(); + RecalculateLayout(); + } + + public bool IsPinned(T node) => _pinned.Contains(node); + + protected override void InternalRecalculateLayout() + { + int layoutCount = BuildLayoutOrder(out int hoistedCount, out int pinnedCount); + + if (layoutCount == 0) + { + RecycleAllRows(); + _rowIndex.Clear(); + _requiredHeight = 0f; + _requiredHeightDirty = false; + _forceFullReflow = false; + RememberLayoutParams(); + return; + } + + bool forceReflow = _forceFullReflow; + _forceFullReflow = false; + + bool hasSpecials = hoistedCount != 0 || pinnedCount != 0; + bool compactEnabled = System.Config.General.CompactPackingEnabled; + + if (compactEnabled) + { + if (hasSpecials) + { + FullReflowCompactSections(layoutCount, hoistedCount, pinnedCount); + _requiredHeightDirty = true; + RememberLayoutParams(); + return; + } + + if (!forceReflow && _rows.Count != 0 && LayoutParamsMatchLast() && NodeSetMatchesExistingLayout(layoutCount)) + { + RepositionExistingRows(); + _requiredHeightDirty = true; + RememberLayoutParams(); + return; + } + + FullReflowCompact(layoutCount); + _requiredHeightDirty = true; + RememberLayoutParams(); + return; + } + + if (!forceReflow && + _rows.Count != 0 && + NodeSetMatchesExistingLayout(layoutCount) && + TryUpdateLayoutWithoutReflowOrTailReflow(layoutCount, hoistedCount, pinnedCount)) + { + _requiredHeightDirty = true; + RememberLayoutParams(); + return; + } + + FullReflowOrdered(layoutCount, hoistedCount, pinnedCount); + _requiredHeightDirty = true; + RememberLayoutParams(); + } + + private int BuildLayoutOrder(out int hoistedCount, out int pinnedCount) + { + _layoutOrder.Clear(); + _pinnedScratch.Clear(); + _normalScratch.Clear(); + + int nodeCount = NodeList.Count; + if (nodeCount == 0) + { + _hoistedNode = null; + if (_pinned.Count != 0) _pinned.Clear(); + + hoistedCount = 0; + pinnedCount = 0; + return 0; + } + + _presentScratch.Clear(); + var present = _presentScratch; + + bool hoistedPresent = false; + T? hoisted = _hoistedNode; + + for (int i = 0; i < nodeCount; i++) + { + if (NodeList[i] is not T node) + continue; + + present.Add(node); + + if (hoisted != null && ReferenceEquals(node, hoisted)) + { + hoistedPresent = true; + continue; + } + + if (_pinned.Contains(node)) + _pinnedScratch.Add(node); + else + _normalScratch.Add(node); + } + + if (_pinned.Count != 0) + _pinned.RemoveWhere(n => !present.Contains(n)); + + if (hoisted != null && !hoistedPresent) + _hoistedNode = null; + + if (hoistedPresent && hoisted != null) + _layoutOrder.Add(hoisted); + + for (int i = 0; i < _pinnedScratch.Count; i++) + _layoutOrder.Add(_pinnedScratch[i]); + + for (int i = 0; i < _normalScratch.Count; i++) + _layoutOrder.Add(_normalScratch[i]); + + hoistedCount = (hoistedPresent && hoisted != null) ? 1 : 0; + pinnedCount = _pinnedScratch.Count; + return _layoutOrder.Count; + } + + + private bool NodeSetMatchesExistingLayout(int layoutCount) + { + if (_rowIndex.Count != layoutCount) + return false; + + for (int i = 0; i < layoutCount; i++) + { + if (!_rowIndex.ContainsKey(_layoutOrder[i])) + return false; + } + + return true; + } + + private bool TryUpdateLayoutWithoutReflowOrTailReflow(int layoutCount, int hoistedCount, int pinnedCount) + { + if (!LayoutParamsMatchLast()) + return false; + + int mismatchRow = FindFirstMismatchRow(layoutCount, hoistedCount, pinnedCount, out int mismatchNodeIndex); + + if (mismatchRow < 0) + { + RepositionExistingRows(); + return true; + } + + TailReflowFrom(mismatchRow, mismatchNodeIndex, layoutCount, hoistedCount, pinnedCount); + return true; + } + + private int FindFirstMismatchRow(int layoutCount, int hoistedCount, int pinnedCount, out int mismatchNodeIndex) + { + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float startX = FirstItemSpacing; + + int normalStart = hoistedCount + pinnedCount; + + int rowIdx = 0; + int nodeIdx = 0; + + while (nodeIdx < layoutCount) + { + if (rowIdx >= _rows.Count) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + List existingRow = _rows[rowIdx]; + int existingRowCount = existingRow.Count; + + if (existingRowCount == 0) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + int predictedCount; + + if (hoistedCount != 0 && nodeIdx == 0) + { + predictedCount = 1; + } + else + { + int sectionEnd = nodeIdx < normalStart ? normalStart : layoutCount; + + predictedCount = 0; + float currentX = startX; + + while (nodeIdx + predictedCount < sectionEnd) + { + NodeBase node = _layoutOrder[nodeIdx + predictedCount]; + float w = node.Width; + + if (predictedCount != 0 && (currentX + w) > availableWidth) + break; + + predictedCount++; + currentX += w + hSpace; + } + + if (predictedCount == 0 && nodeIdx < sectionEnd) + predictedCount = 1; + } + + if (predictedCount != existingRowCount) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + for (int j = 0; j < existingRowCount; j++) + { + if (!ReferenceEquals(existingRow[j], _layoutOrder[nodeIdx + j])) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + } + + nodeIdx += existingRowCount; + rowIdx++; + } + + if (rowIdx < _rows.Count) + { + mismatchNodeIndex = nodeIdx; + return rowIdx; + } + + mismatchNodeIndex = -1; + return -1; + } + + private void RepositionExistingRows() + { + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(_layoutOrder.Count); + + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + float y = TopPadding; + + for (int rowIdx = 0; rowIdx < _rows.Count; rowIdx++) + { + List row = _rows[rowIdx]; + float x = startX; + float rowHeight = 0f; + + for (int j = 0; j < row.Count; j++) + { + NodeBase node = row[j]; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + _rowIndex[node] = rowIdx; + + x += node.Width + hSpace; + } + + y += rowHeight + vSpace; + } + } + + private void TailReflowFrom(int startRowIndex, int startNodeIndex, int layoutCount, int hoistedCount, int pinnedCount) + { + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(layoutCount); + + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + float y = TopPadding; + + if ((uint)startRowIndex > (uint)_rows.Count) + startRowIndex = _rows.Count; + + for (int rowIdx = 0; rowIdx < startRowIndex; rowIdx++) + { + List row = _rows[rowIdx]; + float x = startX; + float rowHeight = 0f; + + for (int j = 0; j < row.Count; j++) + { + NodeBase node = row[j]; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + _rowIndex[node] = rowIdx; + + x += node.Width + hSpace; + } + + y += rowHeight + vSpace; + } + + for (int i = _rows.Count - 1; i >= startRowIndex; i--) + { + List row = _rows[i]; + row.Clear(); + _rowPool.Push(row); + _rows.RemoveAt(i); + } + + int normalStart = hoistedCount + pinnedCount; + + int rowIndex = startRowIndex; + int idx = startNodeIndex; + + while (idx < layoutCount) + { + List row = RentRowList(capacityHint: 8); + + float x = startX; + float rowHeight = 0f; + + if (hoistedCount != 0 && idx == 0) + { + NodeBase node = _layoutOrder[0]; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + rowHeight = node.Height; + row.Add(node); + _rowIndex[node] = rowIndex; + + idx = 1; + } + else + { + int sectionEnd = idx < normalStart ? normalStart : layoutCount; + + while (idx < sectionEnd) + { + NodeBase node = _layoutOrder[idx]; + float w = node.Width; + + if (row.Count != 0 && (x + w) > availableWidth) + break; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + row.Add(node); + _rowIndex[node] = rowIndex; + + x += w + hSpace; + idx++; + } + + if (row.Count == 0 && idx < sectionEnd) + { + NodeBase node = _layoutOrder[idx]; + + node.X = startX; + node.Y = y; + + AdjustNode(node); + + rowHeight = node.Height; + + row.Add(node); + _rowIndex[node] = rowIndex; + + idx++; + } + } + + if (row.Count != 0) + { + _rows.Add(row); + rowIndex++; + y += rowHeight + vSpace; + } + else + { + RecycleRow(row); + break; + } + } + } + + private void FullReflowOrdered(int layoutCount, int hoistedCount, int pinnedCount) + { + RecycleAllRows(); + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(layoutCount); + + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + float y = TopPadding; + + int normalStart = hoistedCount + pinnedCount; + + int rowIdx = 0; + int idx = 0; + + while (idx < layoutCount) + { + List row = RentRowList(capacityHint: 8); + + float x = startX; + float rowHeight = 0f; + + if (hoistedCount != 0 && idx == 0) + { + NodeBase node = _layoutOrder[0]; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + rowHeight = node.Height; + + row.Add(node); + _rowIndex[node] = rowIdx; + + idx = 1; + } + else + { + int sectionEnd = idx < normalStart ? normalStart : layoutCount; + + while (idx < sectionEnd) + { + NodeBase node = _layoutOrder[idx]; + float w = node.Width; + + if (row.Count != 0 && (x + w) > availableWidth) + break; + + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + row.Add(node); + _rowIndex[node] = rowIdx; + + x += w + hSpace; + idx++; + } + + if (row.Count == 0 && idx < sectionEnd) + { + NodeBase node = _layoutOrder[idx]; + + node.X = startX; + node.Y = y; + + AdjustNode(node); + + rowHeight = node.Height; + + row.Add(node); + _rowIndex[node] = rowIdx; + + idx++; + } + } + + if (row.Count != 0) + { + _rows.Add(row); + rowIdx++; + y += rowHeight + vSpace; + } + else + { + RecycleRow(row); + break; + } + } + } + + private void FullReflowCompactSections(int layoutCount, int hoistedCount, int pinnedCount) + { + RecycleAllRows(); + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(layoutCount); + + float vSpace = VerticalSpacing; + float y = TopPadding; + + int rowIdx = 0; + int idx = 0; + + if (hoistedCount != 0) + { + NodeBase node = _layoutOrder[0]; + List row = RentRowList(capacityHint: 1); + + node.X = FirstItemSpacing; + node.Y = y; + + AdjustNode(node); + + row.Add(node); + _rowIndex[node] = rowIdx; + + _rows.Add(row); + + y += node.Height + vSpace; + rowIdx++; + idx = 1; + } + + int pinnedStart = idx; + int pinnedEnd = pinnedStart + pinnedCount; + if (pinnedCount > 0) + { + PackSectionCompact(pinnedStart, pinnedEnd, ref y, ref rowIdx); + idx = pinnedEnd; + } + + if (idx < layoutCount) + { + PackSectionCompact(idx, layoutCount, ref y, ref rowIdx); + } + } + + private void PackSectionCompact(int startIndex, int endIndex, ref float y, ref int rowIdx) + { + int sectionCount = endIndex - startIndex; + if (sectionCount <= 0) + return; + + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + EnsureOrderScratch(sectionCount); + for (int i = 0; i < sectionCount; i++) + _orderScratch[i] = i; + + int lookahead = System.Config.General.CompactLookahead; + if (lookahead < 0) lookahead = 0; + + int p = 0; + + while (p < sectionCount) + { + List row = RentRowList(capacityHint: 8); + + float x = startX; + float rowHeight = 0f; + + while (p < sectionCount) + { + int localIdx = _orderScratch[p]; + NodeBase node = _layoutOrder[startIndex + localIdx]; + float w = node.Width; + + if (row.Count == 0 || (x + w) <= availableWidth) + { + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + row.Add(node); + _rowIndex[node] = rowIdx; + + x += w + hSpace; + p++; + continue; + } + + int bestPos = -1; + float bestWidth = 0f; + + int end = p + lookahead; + if (end >= sectionCount) end = sectionCount - 1; + + for (int s = p + 1; s <= end; s++) + { + int candLocalIdx = _orderScratch[s]; + NodeBase cand = _layoutOrder[startIndex + candLocalIdx]; + float cw = cand.Width; + + if ((x + cw) <= availableWidth) + { + if (!System.Config.General.CompactPreferLargestFit) + { + bestPos = s; + break; + } + + if (cw > bestWidth) + { + bestWidth = cw; + bestPos = s; + } + } + } + + if (bestPos < 0) + break; + + if (bestPos != p) + { + int chosen = _orderScratch[bestPos]; + + if (System.Config.General.CompactStableInsert) + { + Array.Copy(_orderScratch, p, _orderScratch, p + 1, bestPos - p); + _orderScratch[p] = chosen; + } + else + { + _orderScratch[bestPos] = _orderScratch[p]; + _orderScratch[p] = chosen; + } + } + } + + if (row.Count == 0) + { + int localIdx = _orderScratch[p]; + NodeBase node = _layoutOrder[startIndex + localIdx]; + + node.X = startX; + node.Y = y; + + AdjustNode(node); + + rowHeight = node.Height; + + row.Add(node); + _rowIndex[node] = rowIdx; + + p++; + } + + _rows.Add(row); + rowIdx++; + + y += rowHeight + vSpace; + } + } + + private void FullReflowCompact(int count) + { + RecycleAllRows(); + _rowIndex.Clear(); + _rowIndex.EnsureCapacity(count); + + float availableWidth = Width; + float hSpace = HorizontalSpacing; + float vSpace = VerticalSpacing; + float startX = FirstItemSpacing; + + float y = TopPadding; + + EnsureOrderScratch(count); + for (int i = 0; i < count; i++) + _orderScratch[i] = i; + + int lookahead = System.Config.General.CompactLookahead; + if (lookahead < 0) lookahead = 0; + + int p = 0; + int rowIdx = 0; + + while (p < count) + { + List row = RentRowList(capacityHint: 8); + + float x = startX; + float rowHeight = 0f; + + while (p < count) + { + int idx = _orderScratch[p]; + NodeBase node = _layoutOrder[idx]; + float w = node.Width; + + if (row.Count == 0 || (x + w) <= availableWidth) + { + node.X = x; + node.Y = y; + + AdjustNode(node); + + float h = node.Height; + if (h > rowHeight) rowHeight = h; + + row.Add(node); + _rowIndex[node] = rowIdx; + + x += w + hSpace; + p++; + continue; + } + + int bestPos = -1; + float bestWidth = 0f; + + int end = p + lookahead; + if (end >= count) end = count - 1; + + for (int s = p + 1; s <= end; s++) + { + int candIdx = _orderScratch[s]; + NodeBase cand = _layoutOrder[candIdx]; + float cw = cand.Width; + + if ((x + cw) <= availableWidth) + { + if (!System.Config.General.CompactPreferLargestFit) + { + bestPos = s; + break; + } + + if (cw > bestWidth) + { + bestWidth = cw; + bestPos = s; + } + } + } + + if (bestPos < 0) + break; + + if (bestPos != p) + { + int chosen = _orderScratch[bestPos]; + + if (System.Config.General.CompactStableInsert) + { + Array.Copy(_orderScratch, p, _orderScratch, p + 1, bestPos - p); + _orderScratch[p] = chosen; + } + else + { + _orderScratch[bestPos] = _orderScratch[p]; + _orderScratch[p] = chosen; + } + } + } + + if (row.Count == 0) + { + int idx = _orderScratch[p]; + NodeBase node = _layoutOrder[idx]; + + node.X = startX; + node.Y = y; + + AdjustNode(node); + + rowHeight = node.Height; + + row.Add(node); + _rowIndex[node] = rowIdx; + + p++; + } + + _rows.Add(row); + rowIdx++; + + y += rowHeight + vSpace; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float GetRequiredHeight() + { + if (!_requiredHeightDirty) return _requiredHeight; + + float maxBottom = 0f; + int count = _layoutOrder.Count; + + for (int i = 0; i < count; i++) + { + NodeBase node = _layoutOrder[i]; + float bottom = node.Y + node.Height; + if (bottom > maxBottom) maxBottom = bottom; + } + + maxBottom += BottomPadding; + + _requiredHeight = maxBottom; + _requiredHeightDirty = false; + return maxBottom; + } + + private void RecycleAllRows() + { + for (int i = 0; i < _rows.Count; i++) + { + List row = _rows[i]; + row.Clear(); + _rowPool.Push(row); + } + _rows.Clear(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private List RentRowList(int capacityHint) + { + if (_rowPool.Count != 0) + { + List row = _rowPool.Pop(); + if (row.Capacity < capacityHint) row.Capacity = capacityHint; + return row; + } + + return new List(capacityHint); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void RecycleRow(List row) + { + row.Clear(); + _rowPool.Push(row); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool NearlyEqual(float a, float b) + { + float diff = MathF.Abs(a - b); + if (diff <= 0.05f) return true; + + float max = MathF.Max(MathF.Abs(a), MathF.Abs(b)); + return diff <= max * 0.0005f; + } + + private bool LayoutParamsMatchLast() + { + return + NearlyEqual(_lastAvailableWidth, Width) && + NearlyEqual(_lastStartX, FirstItemSpacing) && + NearlyEqual(_lastHSpace, HorizontalSpacing) && + NearlyEqual(_lastVSpace, VerticalSpacing) && + NearlyEqual(_lastTopPadding, TopPadding) && + NearlyEqual(_lastBottomPadding, BottomPadding) && + _lastUseCompactPacking == System.Config.General.CompactPackingEnabled && + _lastPreferLargestFit == System.Config.General.CompactPreferLargestFit && + _lastUseStableInsert == System.Config.General.CompactStableInsert && + _lastCompactLookahead == System.Config.General.CompactLookahead; + } + + private void RememberLayoutParams() + { + _lastAvailableWidth = Width; + _lastStartX = FirstItemSpacing; + _lastHSpace = HorizontalSpacing; + _lastVSpace = VerticalSpacing; + _lastTopPadding = TopPadding; + _lastBottomPadding = BottomPadding; + + _lastUseCompactPacking = System.Config.General.CompactPackingEnabled; + _lastPreferLargestFit = System.Config.General.CompactPreferLargestFit; + _lastUseStableInsert = System.Config.General.CompactStableInsert; + _lastCompactLookahead = System.Config.General.CompactLookahead; + } + + private void EnsureOrderScratch(int needed) + { + if (_orderScratch.Length >= needed) + return; + + int newSize = _orderScratch.Length == 0 ? 64 : _orderScratch.Length; + while (newSize < needed) newSize *= 2; + + _orderScratch = new int[newSize]; + } + + private sealed class RowsReadOnlyView : IReadOnlyList> + { + private readonly List> _rows; + public RowsReadOnlyView(List> rows) => _rows = rows; + + public int Count => _rows.Count; + public IReadOnlyList this[int index] => _rows[index]; + + public IEnumerator> GetEnumerator() + { + for (int i = 0; i < _rows.Count; i++) + yield return _rows[i]; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer where TRef : class + { + public static readonly ReferenceEqualityComparer Instance = new(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(TRef? x, TRef? y) => ReferenceEquals(x, y); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetHashCode(TRef obj) => RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/AetherBags/Plugin.cs b/AetherBags/Plugin.cs new file mode 100644 index 0000000..e44c922 --- /dev/null +++ b/AetherBags/Plugin.cs @@ -0,0 +1,123 @@ +using System.Numerics; +using AetherBags.Addons; +using AetherBags.Commands; +using AetherBags.Helpers; +using AetherBags.Hooks; +using AetherBags.Inventory; +using AetherBags.Inventory.Context; +using AetherBags.IPC; +using AetherBags.IPC.AetherBagsAPI; +using AetherBags.Monitoring; +using Dalamud.Plugin; +using KamiToolKit; + +namespace AetherBags; + +public class Plugin : IDalamudPlugin +{ + private readonly CommandHandler _commandHandler; + private readonly InventoryHooks _inventoryHooks; + private readonly InventoryMonitor inventoryMonitor; + + public Plugin(IDalamudPluginInterface pluginInterface) + { + pluginInterface.Create(); + + System.Config = Util.LoadConfigOrDefault(); + + BackupHelper.DoConfigBackup(pluginInterface); + + KamiToolKitLibrary.Initialize(pluginInterface); + + System.IPC = new IPCService(); + System.IPC.UpdateUnifiedCategorySupport(System.Config.General.UseUnifiedExternalCategories); + ItemContextMenuHandler.Initialize(); + System.LootedItemsTracker = new LootedItemsTracker(); + + System.AddonInventoryWindow = new AddonInventoryWindow + { + InternalName = "AetherBags_MainBags", + Title = "AetherBags", + Size = new Vector2(750, 750), + }; + + System.AddonSaddleBagWindow = new AddonSaddleBagWindow + { + InternalName = "AetherBags_SaddleBag", + Title = "AetherSaddlebag", + Size = new Vector2(750, 750), + }; + + System.AddonRetainerWindow = new AddonRetainerWindow + { + InternalName = "AetherBags_Retainer", + Title = "AetherRetainerbag", + Size = new Vector2(750, 750), + }; + + System.AddonConfigurationWindow = new AddonConfigurationWindow + { + InternalName = "AetherBags_Config", + Title = "AetherBags Config", + Size = new Vector2(640, 512), + }; + + Services.PluginInterface.UiBuilder.OpenMainUi += System.AddonInventoryWindow.Toggle; + Services.PluginInterface.UiBuilder.OpenConfigUi += System.AddonConfigurationWindow.Toggle; + + System.AetherBagsAPI = new AetherBagsIPCProvider(); + + _commandHandler = new CommandHandler(); + + Services.ClientState.Login += OnLogin; + Services.ClientState.Logout += OnLogout; + + if (Services.ClientState.IsLoggedIn) { + Services.Framework.RunOnFrameworkThread(OnLogin); + } + + _inventoryHooks = new InventoryHooks(); + inventoryMonitor = new InventoryMonitor(); + } + + public void Dispose() + { + InventoryAddonContextMenu.Close(); + ItemContextMenuHandler.Dispose(); + _inventoryHooks.Dispose(); + inventoryMonitor.Dispose(); + + System.LootedItemsTracker.Dispose(); + System.AetherBagsAPI?.Dispose(); + System.IPC.Dispose(); + HighlightState.ClearAll(); + + System.AddonInventoryWindow.Dispose(); + System.AddonSaddleBagWindow.Dispose(); + System.AddonRetainerWindow.Dispose(); + System.AddonConfigurationWindow.Dispose(); + + Util.SaveConfig(System.Config); + KamiToolKitLibrary.Dispose(); + } + + private void OnLogin() + { + System.Config = Util.LoadConfigOrDefault(); + System.IPC.UpdateUnifiedCategorySupport(System.Config.General.UseUnifiedExternalCategories); + System.LootedItemsTracker.Enable(); + + System.AddonInventoryWindow.DebugOpen(); + System.AddonConfigurationWindow.DebugOpen(); + } + + private void OnLogout(int type, int code) + { + Util.SaveConfig(System.Config); + System.LootedItemsTracker.Disable(); + System.AddonInventoryWindow.Close(); + System.AddonSaddleBagWindow.Close(); + System.AddonRetainerWindow.Close(); + System.AddonConfigurationWindow.Close(); + } +} \ No newline at end of file diff --git a/AetherBags/Services.cs b/AetherBags/Services.cs new file mode 100644 index 0000000..71fb89e --- /dev/null +++ b/AetherBags/Services.cs @@ -0,0 +1,26 @@ +using Dalamud.IoC; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; + +namespace AetherBags; + +public class Services +{ + [PluginService] public static IAddonLifecycle AddonLifecycle { get; set; } = null!; + [PluginService] public static IChatGui ChatGui { get; set; } = null!; + [PluginService] public static IClientState ClientState { get; private set; } = null!; + [PluginService] public static ICommandManager CommandManager { get; private set; } = null!; + [PluginService] public static ICondition Condition { get; private set; } = null!; + [PluginService] public static IDataManager DataManager { get; set; } = null!; + [PluginService] public static IDalamudPluginInterface PluginInterface { get; private set; } = null!; + [PluginService] public static IFramework Framework { get; private set; } = null!; + [PluginService] public static IGameGui GameGui { get; private set; } = null!; + [PluginService] public static IGameInventory GameInventory { get; set; } = null!; + [PluginService] public static IKeyState KeyState { get; private set; } = null!; + [PluginService] public static IPlayerState PlayerState { get; private set; } = null!; + [PluginService] public static IPluginLog Logger { get; private set; } = null!; + [PluginService] public static INotificationManager NotificationManager { get; private set; } = null!; + [PluginService] public static IObjectTable ObjectTable { get; private set; } = null!; + [PluginService] public static ISigScanner SigScanner { get; private set; } = null!; + [PluginService] public static IGameInteropProvider GameInteropProvider { get; private set; } = null!; +} \ No newline at end of file diff --git a/AetherBags/System.cs b/AetherBags/System.cs new file mode 100644 index 0000000..224772a --- /dev/null +++ b/AetherBags/System.cs @@ -0,0 +1,20 @@ +using AetherBags.Addons; +using AetherBags.Configuration; +using AetherBags.Inventory; +using AetherBags.IPC; +using AetherBags.IPC.AetherBagsAPI; +using AetherBags.Monitoring; + +namespace AetherBags; + +public static class System +{ + public static AddonInventoryWindow AddonInventoryWindow { get; set; } = null!; + public static AddonSaddleBagWindow AddonSaddleBagWindow { get; set; } = null!; + public static AddonRetainerWindow AddonRetainerWindow { get; set; } = null!; + public static AddonConfigurationWindow AddonConfigurationWindow { get; set; } = null!; + public static IPCService IPC { get; set; } = null!; + public static AetherBagsIPCProvider? AetherBagsAPI { get; set; } + public static SystemConfiguration Config { get; set; } = null!; + public static LootedItemsTracker LootedItemsTracker { get; set; } = null!; +} \ No newline at end of file diff --git a/AetherBags/changelog.md b/AetherBags/changelog.md new file mode 100644 index 0000000..b5730d4 --- /dev/null +++ b/AetherBags/changelog.md @@ -0,0 +1,2 @@ +# 1.0.0.0 +- Initial Release \ No newline at end of file diff --git a/AetherBags/packages.lock.json b/AetherBags/packages.lock.json new file mode 100644 index 0000000..7dce951 --- /dev/null +++ b/AetherBags/packages.lock.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[14.0.1, )", + "resolved": "14.0.1", + "contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA==" + }, + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + }, + "SixLabors.ImageSharp": { + "type": "Transitive", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, + "kamitoolkit": { + "type": "Project", + "dependencies": { + "SixLabors.ImageSharp": "[3.1.12, )" + } + } + } + } +} \ No newline at end of file diff --git a/Images/example.png b/Images/example.png new file mode 100644 index 0000000..690b3cf Binary files /dev/null and b/Images/example.png differ diff --git a/KamiToolKit/.editorconfig b/KamiToolKit/.editorconfig new file mode 100644 index 0000000..66f6ffe --- /dev/null +++ b/KamiToolKit/.editorconfig @@ -0,0 +1,108 @@ +root = true +# top-most EditorConfig file + +[*] +charset = utf-8 + +end_of_line = lf +insert_final_newline = true + +# 4 space indentation +indent_style = space +indent_size = 4 + +# Microsoft .NET properties +csharp_indent_braces = false +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = none +csharp_preferred_modifier_order = public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_code_quality_unused_parameters = non_public +dotnet_naming_rule.event_rule.severity = warning +dotnet_naming_rule.event_rule.style = on_upper_camel_case_style +dotnet_naming_rule.event_rule.symbols = event_symbols +dotnet_naming_rule.private_constants_rule.severity = warning +dotnet_naming_rule.private_constants_rule.style = upper_camel_case_style +dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols +dotnet_naming_rule.private_instance_fields_rule.severity = warning +dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style +dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_fields_symbols +dotnet_naming_rule.private_static_fields_rule.severity = warning +dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style +dotnet_naming_rule.private_static_fields_rule.symbols = private_static_fields_symbols +dotnet_naming_rule.private_static_readonly_rule.severity = warning +dotnet_naming_rule.private_static_readonly_rule.style = upper_camel_case_style +dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols +dotnet_naming_style.lower_camel_case_style.capitalization = camel_case +dotnet_naming_style.on_upper_camel_case_style.capitalization = pascal_case +dotnet_naming_style.on_upper_camel_case_style.required_prefix = On +dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case +dotnet_naming_symbols.event_symbols.applicable_accessibilities = * +dotnet_naming_symbols.event_symbols.applicable_kinds = event +dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field +dotnet_naming_symbols.private_constants_symbols.required_modifiers = const +dotnet_naming_symbols.private_instance_fields_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static +dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static, readonly +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_parentheses_in_other_operators = always_for_clarity:silent +dotnet_style_object_initializer = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_empty_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_before_open_square_brackets = false +csharp_space_before_comma = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_comma = true +csharp_space_after_cast = false +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = none +csharp_space_between_square_brackets = false + +# ReSharper properties +resharper_align_linq_query = true +resharper_align_multiline_argument = true +resharper_csharp_align_multiline_argument = false +resharper_csharp_align_multiline_calls_chain = false +resharper_align_multiline_expression = true +resharper_align_multiline_extends_list = true +resharper_align_multiline_for_stmt = true +resharper_align_multline_type_parameter_constrains = true +resharper_align_multline_type_parameter_list = true +resharper_braces_for_ifelse = required_for_multiline +resharper_can_use_global_alias = false +resharper_csharp_align_multiline_parameter = false +resharper_csharp_align_multiple_declaration = true +resharper_csharp_allow_comment_after_lbrace = true +resharper_csharp_empty_block_style = together +resharper_csharp_int_align_comments = true +resharper_csharp_new_line_before_while = true +resharper_csharp_stick_comment = false +resharper_csharp_wrap_after_declaration_lpar = true +resharper_indent_preprocessor_region = no_indent +resharper_new_line_before_finally = false +resharper_place_accessorholder_attribute_on_same_line = false +resharper_place_field_attribute_on_same_line = false + +# ReSharper inspection severities +csharp_style_deconstructed_variable_declaration = true:silent + diff --git a/KamiToolKit/.gitignore b/KamiToolKit/.gitignore new file mode 100644 index 0000000..4b17fe7 --- /dev/null +++ b/KamiToolKit/.gitignore @@ -0,0 +1,3 @@ +/obj/ +/bin/ +/.idea/ diff --git a/KamiToolKit/Assets/HorizontalGradient_WhiteToAlpha.png b/KamiToolKit/Assets/HorizontalGradient_WhiteToAlpha.png new file mode 100644 index 0000000..c37485d Binary files /dev/null and b/KamiToolKit/Assets/HorizontalGradient_WhiteToAlpha.png differ diff --git a/KamiToolKit/Assets/VerticalGradient_AlphaToBlack.png b/KamiToolKit/Assets/VerticalGradient_AlphaToBlack.png new file mode 100644 index 0000000..d007925 Binary files /dev/null and b/KamiToolKit/Assets/VerticalGradient_AlphaToBlack.png differ diff --git a/KamiToolKit/Assets/VerticalGradient_WhiteToAlpha.png b/KamiToolKit/Assets/VerticalGradient_WhiteToAlpha.png new file mode 100644 index 0000000..cc516f3 Binary files /dev/null and b/KamiToolKit/Assets/VerticalGradient_WhiteToAlpha.png differ diff --git a/KamiToolKit/Assets/alpha_background.png b/KamiToolKit/Assets/alpha_background.png new file mode 100644 index 0000000..c239751 Binary files /dev/null and b/KamiToolKit/Assets/alpha_background.png differ diff --git a/KamiToolKit/Assets/alpha_selector.png b/KamiToolKit/Assets/alpha_selector.png new file mode 100644 index 0000000..4d712ac Binary files /dev/null and b/KamiToolKit/Assets/alpha_selector.png differ diff --git a/KamiToolKit/Assets/color_ring.png b/KamiToolKit/Assets/color_ring.png new file mode 100644 index 0000000..7b98b03 Binary files /dev/null and b/KamiToolKit/Assets/color_ring.png differ diff --git a/KamiToolKit/Assets/color_ring_selector.png b/KamiToolKit/Assets/color_ring_selector.png new file mode 100644 index 0000000..dbdfb00 Binary files /dev/null and b/KamiToolKit/Assets/color_ring_selector.png differ diff --git a/KamiToolKit/Assets/color_select_dot.png b/KamiToolKit/Assets/color_select_dot.png new file mode 100644 index 0000000..00df911 Binary files /dev/null and b/KamiToolKit/Assets/color_select_dot.png differ diff --git a/KamiToolKit/Classes/AddonConfig.cs b/KamiToolKit/Classes/AddonConfig.cs new file mode 100644 index 0000000..22caaee --- /dev/null +++ b/KamiToolKit/Classes/AddonConfig.cs @@ -0,0 +1,8 @@ +using System.Numerics; + +namespace KamiToolKit.Classes; + +internal class AddonConfig { + public Vector2 Position = Vector2.Zero; + public float Scale = 1.0f; +} diff --git a/KamiToolKit/Classes/BatchToken.cs b/KamiToolKit/Classes/BatchToken.cs new file mode 100644 index 0000000..0b02495 --- /dev/null +++ b/KamiToolKit/Classes/BatchToken.cs @@ -0,0 +1,8 @@ +using System; +using KamiToolKit.Premade.Color; + +namespace KamiToolKit.Classes; + +internal readonly struct BatchToken(ColorPickerWidget owner) : IDisposable { + public void Dispose() => owner.EndBatchUpdate(); +} diff --git a/KamiToolKit/Classes/Bounds.cs b/KamiToolKit/Classes/Bounds.cs new file mode 100644 index 0000000..8af67c3 --- /dev/null +++ b/KamiToolKit/Classes/Bounds.cs @@ -0,0 +1,23 @@ +using System.Numerics; + +namespace KamiToolKit.Classes; + +public class Bounds { + public required Vector2 TopLeft { get; set; } + public required Vector2 BottomRight { get; set; } + + public float Top => TopLeft.Y; + public float Left => TopLeft.X; + public float Bottom => BottomRight.Y; + public float Right => BottomRight.X; + + public float Width => BottomRight.X - TopLeft.X; + public float Height => BottomRight.Y - TopLeft.Y; + public Vector2 Size => new(Width, Height); + + public float CenterX => (TopLeft.X + BottomRight.X) / 2.0f; + public float CenterY => (TopLeft.Y + BottomRight.Y) / 2.0f; + public Vector2 Center => new(CenterX, CenterY); + + public override string ToString() => $"{TopLeft}, {BottomRight}"; +} diff --git a/KamiToolKit/Classes/ColorHelper.cs b/KamiToolKit/Classes/ColorHelper.cs new file mode 100644 index 0000000..4ec3b14 --- /dev/null +++ b/KamiToolKit/Classes/ColorHelper.cs @@ -0,0 +1,18 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Classes; + +public static unsafe class ColorHelper { + public static Vector4 GetColor(uint colorId) + => ConvertToVector4(AtkStage.Instance()->AtkUIColorHolder->GetColor(true, colorId)); + + private static Vector4 ConvertToVector4(uint color) { + var a = (byte)(color >> 24); + var b = (byte)(color >> 16); + var g = (byte)(color >> 8); + var r = (byte)color; + + return new Vector4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f); + } +} diff --git a/KamiToolKit/Classes/CustomEventInterface.cs b/KamiToolKit/Classes/CustomEventInterface.cs new file mode 100644 index 0000000..ffbf8bb --- /dev/null +++ b/KamiToolKit/Classes/CustomEventInterface.cs @@ -0,0 +1,44 @@ +using System; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Component.GUI; +using static FFXIVClientStructs.FFXIV.Component.GUI.AtkModuleInterface; + +namespace KamiToolKit.Classes; + +public unsafe class CustomEventInterface : IDisposable { + + private readonly AtkEventInterface* eventInterface; + + private AtkEventInterface.Delegates.ReceiveEvent? receiveEventDelegate; + private AtkEventInterface.Delegates.ReceiveEventWithResult? receiveEventWithResultDelegate; + + public CustomEventInterface(AtkEventInterface.Delegates.ReceiveEvent eventHandler, AtkEventInterface.Delegates.ReceiveEventWithResult? receiveEventWithResult = null) { + receiveEventDelegate = eventHandler; + receiveEventWithResultDelegate = receiveEventWithResult; + + eventInterface = NativeMemoryHelper.UiAlloc(); + eventInterface->VirtualTable = (AtkEventInterface.AtkEventInterfaceVirtualTable*)NativeMemoryHelper.Malloc((ulong)sizeof(void*) * 2); + eventInterface->VirtualTable->ReceiveEvent = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(receiveEventDelegate); + + if (receiveEventWithResultDelegate is not null) { + eventInterface->VirtualTable->ReceiveEventWithResult = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(receiveEventWithResultDelegate); + } + else { + eventInterface->VirtualTable->ReceiveEventWithResult = (delegate* unmanaged)(delegate* unmanaged)&NullSub; + } + } + + public void Dispose() { + if (eventInterface is null) return; + + NativeMemoryHelper.Free(eventInterface->VirtualTable, (ulong)sizeof(void*) * 2); + NativeMemoryHelper.UiFree(eventInterface); + + receiveEventDelegate = null; + receiveEventWithResultDelegate = null; + } + + [UnmanagedCallersOnly] private static void NullSub() { } + + public static implicit operator AtkEventInterface*(CustomEventInterface listener) => listener.eventInterface; +} diff --git a/KamiToolKit/Classes/CustomEventListener.cs b/KamiToolKit/Classes/CustomEventListener.cs new file mode 100644 index 0000000..d3cfbec --- /dev/null +++ b/KamiToolKit/Classes/CustomEventListener.cs @@ -0,0 +1,35 @@ +using System; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Classes; + +public unsafe class CustomEventListener : IDisposable { + + private readonly AtkEventListener* eventListener; + + private AtkEventListener.Delegates.ReceiveEvent? receiveEventDelegate; + + public CustomEventListener(AtkEventListener.Delegates.ReceiveEvent eventHandler) { + receiveEventDelegate = eventHandler; + + eventListener = NativeMemoryHelper.UiAlloc(); + eventListener->VirtualTable = (AtkEventListener.AtkEventListenerVirtualTable*)NativeMemoryHelper.Malloc((ulong)sizeof(void*) * 3); + eventListener->VirtualTable->Dtor = (delegate* unmanaged)(delegate* unmanaged)&NullSub; + eventListener->VirtualTable->ReceiveGlobalEvent = (delegate* unmanaged)(delegate* unmanaged)&NullSub; + eventListener->VirtualTable->ReceiveEvent = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(receiveEventDelegate); + } + + public virtual void Dispose() { + if (eventListener is null) return; + + NativeMemoryHelper.Free(eventListener->VirtualTable, (ulong)sizeof(void*) * 3); + NativeMemoryHelper.UiFree(eventListener); + + receiveEventDelegate = null; + } + + [UnmanagedCallersOnly] private static void NullSub() { } + + public static implicit operator AtkEventListener*(CustomEventListener listener) => listener.eventListener; +} diff --git a/KamiToolKit/Classes/DalamudInterface.cs b/KamiToolKit/Classes/DalamudInterface.cs new file mode 100644 index 0000000..24a8add --- /dev/null +++ b/KamiToolKit/Classes/DalamudInterface.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.IoC; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; + +namespace KamiToolKit.Classes; + +internal class DalamudInterface { + + private static DalamudInterface? instance; + public static DalamudInterface Instance => instance ??= new DalamudInterface(); + + [PluginService] public IPluginLog Log { get; set; } = null!; + [PluginService] public IAddonLifecycle AddonLifecycle { get; set; } = null!; + [PluginService] public IDataManager DataManager { get; set; } = null!; + [PluginService] public ITextureProvider TextureProvider { get; set; } = null!; + [PluginService] public IFramework Framework { get; set; } = null!; + [PluginService] public IAddonEventManager AddonEventManager { get; set; } = null!; + [PluginService] public IDalamudPluginInterface PluginInterface { get; set; } = null!; + [PluginService] public IGameGui GameGui { get; set; } = null!; + [PluginService] public IGameInteropProvider GameInteropProvider { get; set; } = null!; + [PluginService] public ISeStringEvaluator SeStringEvaluator { get; set; } = null!; + + private DalamudInterface() { + if (!KamiToolKitLibrary.IsInitialized) + throw new Exception("KamiToolKit not initialized! You must call KamiToolKitLibrary.Initialize() before using KamiToolKit.\n" + + "Don't forget to call KamiToolKitLibrary.Dispose() in your plugins dispose to ensure all assets are freed and to trigger bad practice warnings."); + } + + public string GetAssetDirectoryPath() + => Path.Combine(PluginInterface.AssemblyLocation.DirectoryName ?? throw new Exception("Directory from Dalamud is Invalid Somehow"), "Assets"); + + public string GetAssetPath(string assetName) + => Path.Combine(GetAssetDirectoryPath(), assetName); + + public IDalamudTextureWrap? LoadAsset(string assetName) + => TextureProvider.GetFromFile(GetAssetPath(assetName)).GetWrapOrDefault(); +} + +internal static class Log { + + private static readonly bool ExcessiveLogging = false; + + internal static void Debug(string message) { + DalamudInterface.Instance.Log.Debug($"[KamiToolKit] {message}"); + } + + internal static void Fatal(string message) { + DalamudInterface.Instance.Log.Fatal($"[KamiToolKit] {message}"); + } + + internal static void Warning(string message) { + DalamudInterface.Instance.Log.Warning($"[KamiToolKit] {message}"); + } + + internal static void Verbose(string message) { + DalamudInterface.Instance.Log.Verbose($"[KamiToolKit] {message}"); + } + + internal static void Excessive(string message) { + if (ExcessiveLogging) { + Verbose($"[KamiToolKit] {message}"); + } + } + + internal static void Error(string message) { + DalamudInterface.Instance.Log.Error($"[KamiToolKit] {message}"); + } + + internal static void Exception(Exception exception, [CallerMemberName] string? callerName = null) { + DalamudInterface.Instance.Log.Error(exception, $"Exception in {callerName}"); + } +} diff --git a/KamiToolKit/Classes/DragDropPayload.cs b/KamiToolKit/Classes/DragDropPayload.cs new file mode 100644 index 0000000..18d9b37 --- /dev/null +++ b/KamiToolKit/Classes/DragDropPayload.cs @@ -0,0 +1,69 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Text; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Classes; + +public unsafe class DragDropPayload { + + public DragDropType Type { get; set; } = DragDropType.Nothing; + + public short ReferenceIndex { get; set; } + + /// Index (like AtkDragDropInterface.ReferenceIndex), InventoryType, etc. + public int Int1 { get; set; } + + /// ActionId, ItemId, EmoteId, InventorySlotIndex, ListIndex, MacroIndex etc. + public int Int2 { get; set; } = -1; + + // unknown usage + // public ulong Unk8 { get; set; } + + // unknown usage + // public AtkValue* AtkValue { get; set; } + + public ReadOnlySeString Text { get; set; } + + // unknown usage + // public uint Flags { get; set; } + + public static DragDropPayload FromDragDropInterface(AtkDragDropInterface* dragDropInterface) { + var payloadContainer = dragDropInterface->GetPayloadContainer(); + + return new DragDropPayload { + Type = dragDropInterface->DragDropType, + ReferenceIndex = dragDropInterface->DragDropReferenceIndex, + Int1 = payloadContainer->Int1, + Int2 = payloadContainer->Int2, + Text = new ReadOnlySeString(payloadContainer->Text), + }; + } + + public void ToDragDropInterface(AtkDragDropInterface* dragDropInterface, bool writeToPayloadContainer = true) { + dragDropInterface->DragDropType = Type; + dragDropInterface->DragDropReferenceIndex = ReferenceIndex; + + if (writeToPayloadContainer) { + var payloadContainer = dragDropInterface->GetPayloadContainer(); + payloadContainer->Clear(); + payloadContainer->Int1 = Int1; + payloadContainer->Int2 = Int2; + + if (Text.IsEmpty) { + payloadContainer->Text.Clear(); + } + else { + var stringBuilder = new SeStringBuilder().Append(Text); + payloadContainer->Text.SetString(stringBuilder.GetViewAsSpan()); + } + } + } + + public void Clear() { + Type = DragDropType.Nothing; + ReferenceIndex = 0; + Int1 = 0; + Int2 = -1; + Text = default; + } +} diff --git a/KamiToolKit/Classes/Experimental.cs b/KamiToolKit/Classes/Experimental.cs new file mode 100644 index 0000000..4878f71 --- /dev/null +++ b/KamiToolKit/Classes/Experimental.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace KamiToolKit.Classes; + +/// WARNING: These features are potentially extremely volatile, use at your own risk. +public unsafe class Experimental { + private static Experimental? instance; + public static Experimental Instance => instance ??= new Experimental(); + + public void EnableHooks() { } + + public void DisposeHooks() { + } + + // WARNING: May result in undefined state or accidental network requests + // Use at your own risk. + [Conditional("DEBUG")] + public static void ForceOpenAddon(AgentId agentId, int delayTicks = 0) { + if (delayTicks is not 0) { + DalamudInterface.Instance.Framework.RunOnTick(() => { + AgentModule.Instance()->GetAgentByInternalId(agentId)->Show(); + }, delayTicks: delayTicks); + } + else { + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + AgentModule.Instance()->GetAgentByInternalId(agentId)->Show(); + }); + } + } + + // WARNING: May result in undefined state or accidental network requests + // Use at your own risk. + [Conditional("DEBUG")] + public static void ForceCloseAddon(AgentId agentId) + => DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + AgentModule.Instance()->GetAgentByInternalId(agentId)->Hide(); + }); +} diff --git a/KamiToolKit/Classes/FlagHelper.cs b/KamiToolKit/Classes/FlagHelper.cs new file mode 100644 index 0000000..aec00ef --- /dev/null +++ b/KamiToolKit/Classes/FlagHelper.cs @@ -0,0 +1,23 @@ +using System.Numerics; + +namespace KamiToolKit.Classes; + +public static class FlagHelper { + public static bool ReadFlag(ref T flagsField, int flag) where T : struct, IBinaryInteger + => (flagsField & T.One << BitOperations.Log2((uint)flag)) != T.Zero; + + public static void SetFlag(ref T flagsField, int flag) where T : struct, IBinaryInteger + => flagsField |= T.One << BitOperations.Log2((uint)flag); + + public static void ClearFlag(ref T flagsField, int flag) where T : struct, IBinaryInteger + => flagsField &= ~(T.One << BitOperations.Log2((uint)flag)); + + public static void UpdateFlag(ref T flagsField, int flag, bool enable) where T : struct, IBinaryInteger { + if (enable) { + SetFlag(ref flagsField, flag); + } + else { + ClearFlag(ref flagsField, flag); + } + } +} diff --git a/KamiToolKit/Classes/GenericUtil.cs b/KamiToolKit/Classes/GenericUtil.cs new file mode 100644 index 0000000..a6451b2 --- /dev/null +++ b/KamiToolKit/Classes/GenericUtil.cs @@ -0,0 +1,18 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace KamiToolKit.Classes; + +internal static class GenericUtil { + public static bool AreEqual(T? left, T? right) { + if (default(T) == null) return ReferenceEquals(left, right); + + if (left == null || right == null) return left == null && right == null; + + var leftSpan = MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref left), Unsafe.SizeOf()); + var rightSpan = MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref right), Unsafe.SizeOf()); + + return leftSpan.SequenceEqual(rightSpan); + } +} diff --git a/KamiToolKit/Classes/ListPopulatorData.cs b/KamiToolKit/Classes/ListPopulatorData.cs new file mode 100644 index 0000000..07aee5e --- /dev/null +++ b/KamiToolKit/Classes/ListPopulatorData.cs @@ -0,0 +1,11 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using ListItemInfo = FFXIVClientStructs.FFXIV.Component.GUI.AtkComponentListItemPopulator.ListItemInfo; + +namespace KamiToolKit.Classes; + +public unsafe class ListPopulatorData { + public AtkUnitBase* Addon { get; init; } + public ListItemInfo* ItemInfo { get; init; } + public AtkResNode** NodeList { get; init; } + public uint Index { get; init; } +} diff --git a/KamiToolKit/Classes/NativeMemoryHelper.cs b/KamiToolKit/Classes/NativeMemoryHelper.cs new file mode 100644 index 0000000..0a6f7af --- /dev/null +++ b/KamiToolKit/Classes/NativeMemoryHelper.cs @@ -0,0 +1,75 @@ +using System; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.System.Memory; + +namespace KamiToolKit.Classes; + +internal static class NativeMemoryHelper { + public static unsafe T* UiAlloc(int elementCount, ulong alignment = 8) where T : unmanaged + => UiAlloc((uint)elementCount, alignment); + + public static unsafe T* UiAlloc(uint elementCount = 1, ulong alignment = 8) where T : unmanaged { + var allocSize = (ulong)sizeof(T) * elementCount; + var memory = (T*)IMemorySpace.GetUISpace()->Malloc(allocSize, alignment); + + IMemorySpace.Memset(memory, 0, allocSize); + + if (memory is null) { + throw new Exception($"Unable to allocate memory for {typeof(T)}"); + } + + return memory; + } + + public static unsafe void UiFree(T* memory) where T : unmanaged + => IMemorySpace.Free(memory); + + public static unsafe void UiFree(T* memory, uint elementCount) where T : unmanaged + => IMemorySpace.Free(memory, (ulong)sizeof(T) * elementCount); + + public static unsafe T* Create() where T : unmanaged, ICreatable { + var memory = IMemorySpace.GetUISpace()->Create(); + + if (memory is null) { + throw new Exception($"Unable to allocate memory for {typeof(T)}"); + } + + return memory; + } + + public static unsafe nint Malloc(ulong size, ulong alignment = 8) + => (nint)IMemorySpace.GetUISpace()->Malloc(size, alignment); + + public static unsafe void Free(void* memory, ulong size) + => IMemorySpace.Free(memory, size); + + public static unsafe void ResizeArray(ref T* array, int oldSize, uint newSize) where T : unmanaged + => ResizeArray(ref array, oldSize, (int)newSize); + + public static unsafe void ResizeArray(ref T* array, uint oldSize, uint newSize) where T : unmanaged + => ResizeArray(ref array, (int)oldSize, (int)newSize); + + public static unsafe void ResizeArray(ref T* array, uint oldSize, int newSize) where T : unmanaged + => ResizeArray(ref array, (int)oldSize, newSize); + + public static unsafe void ResizeArray(ref T* array, int oldSize, int newSize) where T : unmanaged { + var newBuffer = UiAlloc((uint)newSize); + + Copy(array, newBuffer, oldSize); + + if (array is not null) { + UiFree(array, (uint)oldSize); + } + + array = newBuffer; + } + + public static unsafe void Copy(T* oldBuffer, T* newBuffer, int count) where T : unmanaged + => Copy(oldBuffer, newBuffer, (uint)count); + + public static unsafe void Copy(T* oldBuffer, T* newBuffer, uint count) where T : unmanaged + => NativeMemory.Copy(oldBuffer, newBuffer, (nuint)(sizeof(T) * count)); + + public static unsafe void MemCopy(T* oldBuffer, T* newBuffer, uint byteCount) where T : unmanaged + => NativeMemory.Copy(oldBuffer, newBuffer, byteCount); +} diff --git a/KamiToolKit/Classes/NodeLinker.cs b/KamiToolKit/Classes/NodeLinker.cs new file mode 100644 index 0000000..8aff8fd --- /dev/null +++ b/KamiToolKit/Classes/NodeLinker.cs @@ -0,0 +1,199 @@ +using System; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Classes; + +public enum NodePosition { + BeforeTarget, + AfterTarget, + BeforeAllSiblings, + AfterAllSiblings, + AsLastChild, + AsFirstChild, +} + +internal static unsafe class NodeLinker { + internal static void AttachNode(AtkResNode* node, AtkResNode* attachTargetNode, NodePosition position) { + switch (position) { + case NodePosition.BeforeTarget: + EmplaceBefore(node, attachTargetNode); + break; + + case NodePosition.AfterTarget: + EmplaceAfter(node, attachTargetNode); + break; + + case NodePosition.BeforeAllSiblings: + EmplaceBeforeSiblings(node, attachTargetNode); + break; + + case NodePosition.AfterAllSiblings: + EmplaceAfterSiblings(node, attachTargetNode); + break; + + case NodePosition.AsLastChild: + EmplaceAsLastChild(node, attachTargetNode); + break; + + case NodePosition.AsFirstChild: + EmplaceAsFirstChild(node, attachTargetNode); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(position), position, null); + } + } + + private static void EmplaceBefore(AtkResNode* node, AtkResNode* attachTargetNode) { + node->ParentNode = attachTargetNode->ParentNode; + + // Target node is the head of the nodelist, we will be the new head. + if (attachTargetNode->NextSiblingNode is null) { + attachTargetNode->ParentNode->ChildNode = node; + } + + // We have a node that will be before us + if (attachTargetNode->NextSiblingNode is not null) { + attachTargetNode->NextSiblingNode->PrevSiblingNode = node; + node->NextSiblingNode = attachTargetNode->NextSiblingNode; + } + + attachTargetNode->NextSiblingNode = node; + node->PrevSiblingNode = attachTargetNode; + + if (attachTargetNode->ParentNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ParentNode->ChildCount++; + } + } + + private static void EmplaceAfter(AtkResNode* node, AtkResNode* attachTargetNode) { + node->ParentNode = attachTargetNode->ParentNode; + + // We have a node that will be after us + if (attachTargetNode->PrevSiblingNode is not null) { + attachTargetNode->PrevSiblingNode->NextSiblingNode = node; + node->PrevSiblingNode = attachTargetNode->PrevSiblingNode; + } + + attachTargetNode->PrevSiblingNode = node; + node->NextSiblingNode = attachTargetNode; + + if (attachTargetNode->ParentNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ParentNode->ChildCount++; + } + } + + private static void EmplaceBeforeSiblings(AtkResNode* node, AtkResNode* attachTargetNode) { + var current = attachTargetNode; + var previous = current; + + while (current is not null) { + previous = current; + current = current->NextSiblingNode; + } + + if (previous is not null) { + EmplaceBefore(node, previous); + } + + if (attachTargetNode->ParentNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ParentNode->ChildCount++; + } + } + + private static void EmplaceAfterSiblings(AtkResNode* node, AtkResNode* attachTargetNode) { + var current = attachTargetNode; + var previous = current; + + while (current is not null) { + previous = current; + current = current->PrevSiblingNode; + } + + if (previous is not null) { + EmplaceAfter(node, previous); + } + + if (attachTargetNode->ParentNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ParentNode->ChildCount++; + } + } + + private static void EmplaceAsLastChild(AtkResNode* node, AtkResNode* attachTargetNode) { + // If the child list is empty + if (attachTargetNode->ChildNode is null && attachTargetNode->GetNodeType() is not NodeType.Component) { + if (attachTargetNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ChildNode = node; + node->ParentNode = attachTargetNode; + attachTargetNode->ChildCount++; + } + else { + node->ParentNode = attachTargetNode; + } + } + // Else Add to the List + else { + var currentNode = attachTargetNode->ChildNode; + while (currentNode is not null && currentNode->PrevSiblingNode != null) { + currentNode = currentNode->PrevSiblingNode; + } + + node->ParentNode = attachTargetNode; + node->NextSiblingNode = currentNode; + + if (currentNode is not null) { + currentNode->PrevSiblingNode = node; + } + + if (attachTargetNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ChildCount++; + } + } + } + + private static void EmplaceAsFirstChild(AtkResNode* node, AtkResNode* attachTargetNode) { + // If the child list is empty + if (attachTargetNode->ChildNode is null && attachTargetNode->ChildCount is 0) { + if (attachTargetNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ChildNode = node; + node->ParentNode = attachTargetNode; + attachTargetNode->ChildCount++; + } + else { + node->ParentNode = attachTargetNode; + } + } + // Else Add to the List as the First Child + else { + if (attachTargetNode->GetNodeType() is not NodeType.Component) { + attachTargetNode->ChildNode->NextSiblingNode = node; + node->PrevSiblingNode = attachTargetNode->ChildNode; + attachTargetNode->ChildNode = node; + node->ParentNode = attachTargetNode; + attachTargetNode->ChildCount++; + } + else { + node->PrevSiblingNode = attachTargetNode->ChildNode; + node->ParentNode = attachTargetNode; + } + } + } + + public static void DetachNode(AtkResNode* node) { + if (node is null) return; + if (node->ParentNode is null) return; + + if (node->ParentNode->ChildNode == node) + node->ParentNode->ChildNode = node->PrevSiblingNode; + + if (node->PrevSiblingNode != null) + node->PrevSiblingNode->NextSiblingNode = node->NextSiblingNode; + + if (node->NextSiblingNode != null) + node->NextSiblingNode->PrevSiblingNode = node->PrevSiblingNode; + + if (node->ParentNode->GetNodeType() is not NodeType.Component) { + node->ParentNode->ChildCount--; + } + } +} diff --git a/KamiToolKit/Classes/Part.cs b/KamiToolKit/Classes/Part.cs new file mode 100644 index 0000000..7232175 --- /dev/null +++ b/KamiToolKit/Classes/Part.cs @@ -0,0 +1,34 @@ +using System.Numerics; + +namespace KamiToolKit.Classes; + +public class Part { + + public float Width { get; set; } + + public float Height { get; set; } + + public Vector2 Size { + get => new(Width, Height); + set { + Width = value.X; + Height = value.Y; + } + } + + public float U { get; set; } + + public float V { get; set; } + + public Vector2 TextureCoordinates { + get => new(U, V); + set { + U = value.X; + V = value.Y; + } + } + + public uint Id { get; set; } + + public string TexturePath { get; set; } = string.Empty; +} diff --git a/KamiToolKit/Classes/PartsList.cs b/KamiToolKit/Classes/PartsList.cs new file mode 100644 index 0000000..46611b5 --- /dev/null +++ b/KamiToolKit/Classes/PartsList.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Classes; + +/// +/// Wrapper around a AtkUldPartsList, manages adding multiple parts more easily. +/// +public unsafe class PartsList : IDisposable { + + internal AtkUldPartsList* InternalPartsList; + + private bool isDisposed; + + public PartsList() { + InternalPartsList = NativeMemoryHelper.UiAlloc(); + + InternalPartsList->Parts = null; + InternalPartsList->PartCount = 0; + InternalPartsList->Id = 0; + } + + public void Dispose() { + if (!isDisposed) { + foreach (var partIndex in Enumerable.Range(0, (int)PartCount)) { + ref var part = ref InternalPartsList->Parts[partIndex]; + + if (part.UldAsset is not null && part.UldAsset->AtkTexture.IsTextureReady()) { + part.UldAsset->AtkTexture.ReleaseTexture(); + part.UldAsset->AtkTexture.KernelTexture = null; + part.UldAsset->AtkTexture.TextureType = 0; + } + + NativeMemoryHelper.UiFree(part.UldAsset); + part.UldAsset = null; + } + + NativeMemoryHelper.UiFree(InternalPartsList); + InternalPartsList = null; + } + + isDisposed = true; + } + + private uint PartCount { + get => InternalPartsList->PartCount; + set => InternalPartsList->PartCount = value; + } + + public void Add(params Part[] items) { + foreach (var part in items) { + Add(part); + } + } + + public AtkUldPart* Add(Part item) { + NativeMemoryHelper.ResizeArray(ref InternalPartsList->Parts, PartCount, PartCount + 1); + + ref var newPart = ref InternalPartsList->Parts[PartCount]; + + newPart.Width = (ushort) item.Width; + newPart.Height = (ushort) item.Height; + newPart.U = (ushort) item.U; + newPart.V = (ushort) item.V; + + newPart.UldAsset = NativeMemoryHelper.UiAlloc(); + newPart.UldAsset->Id = item.Id; + newPart.UldAsset->AtkTexture.Ctor(); + newPart.LoadTexture(item.TexturePath); + + return &InternalPartsList->Parts[PartCount++]; + } + + public AtkUldPart* this[int index] { + get { + if (InternalPartsList is null) return null; + if (PartCount <= index) return null; + + return &InternalPartsList->Parts[index]; + } + } +} diff --git a/KamiToolKit/Classes/TabbedNodeEntry.cs b/KamiToolKit/Classes/TabbedNodeEntry.cs new file mode 100644 index 0000000..a199ee0 --- /dev/null +++ b/KamiToolKit/Classes/TabbedNodeEntry.cs @@ -0,0 +1,3 @@ +namespace KamiToolKit.Classes; + +public record TabbedNodeEntry(T Node, int Tab) where T : NodeBase; diff --git a/KamiToolKit/Classes/ViewportEventListener.cs b/KamiToolKit/Classes/ViewportEventListener.cs new file mode 100644 index 0000000..6b96b1c --- /dev/null +++ b/KamiToolKit/Classes/ViewportEventListener.cs @@ -0,0 +1,26 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Classes; + +public unsafe class ViewportEventListener(AtkEventListener.Delegates.ReceiveEvent eventHandler) : CustomEventListener(eventHandler) { + public void AddEvent(AtkEventType eventType, AtkResNode* node) { + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + Log.Verbose($"Registering ViewportEvent: {eventType}"); + AtkStage.Instance()->ViewportEventManager.RegisterEvent(eventType, 0, node, &node->AtkEventTarget, this, false); + }); + } + + public void RemoveEvent(AtkEventType eventType) { + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + Log.Verbose($"Unregistering ViewportEvent: {eventType}"); + AtkStage.Instance()->ViewportEventManager.UnregisterEvent(eventType, 0, this, false); + }); + } + + public override void Dispose() { + Log.Verbose("Disposing ViewportEventListener"); + + RemoveEvent(AtkEventType.UnregisterAll); + base.Dispose(); + } +} diff --git a/KamiToolKit/ContextMenu/ContextMenu.cs b/KamiToolKit/ContextMenu/ContextMenu.cs new file mode 100644 index 0000000..89fc4b1 --- /dev/null +++ b/KamiToolKit/ContextMenu/ContextMenu.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.ContextMenu; + +public unsafe class ContextMenu : IDisposable { + private readonly CustomEventInterface contextMenuEventInterface; + + private Dictionary? mainMenuEntries; + private Dictionary? mainMenuSubMenus; + private Dictionary? subMenuEntries; + + // Prevent the return entry from colliding with submenu items + private const int SubMenuIndexOffset = 1000; + + private List Items { get; set; } = []; + private IOrderedEnumerable OrderedItems => Items.OrderBy(item => item.DisplayPriority); + + public ContextMenu() { + contextMenuEventInterface = new CustomEventInterface(ContextMenuEventHandler); + } + + public void Dispose() { + contextMenuEventInterface.Dispose(); + } + + private AtkValue* ContextMenuEventHandler(AtkModuleInterface.AtkEventInterface* thisPtr, AtkValue* returnValue, AtkValue* values, uint valueCount, ulong eventKind) { + var handlerParam = (long)eventKind; + + if (handlerParam >= SubMenuIndexOffset) { + if (subMenuEntries?.TryGetValue(handlerParam, out var subItem) ?? false) { + subItem.OnClick(); + ClearAll(); + } + return returnValue; + } + + if (mainMenuSubMenus?.TryGetValue(handlerParam, out var subMenuItem) ?? false) { + OpenSubMenu(subMenuItem); + return returnValue; + } + + if (mainMenuEntries?.TryGetValue(handlerParam, out var item) ?? false) { + item.OnClick(); + ClearAll(); + return returnValue; + } + + subMenuEntries?.Clear(); + subMenuEntries = null; + + return returnValue; + } + + private void ClearAll() { + mainMenuEntries?.Clear(); + mainMenuEntries = null; + mainMenuSubMenus?.Clear(); + mainMenuSubMenus = null; + subMenuEntries?.Clear(); + subMenuEntries = null; + } + + public void AddItem(ReadOnlySeString name, Action callback) { + AddItem(new ContextMenuItem { + Name = name, + OnClick = callback, + }); + } + + public void RemoveItem(ReadOnlySeString name) { + var targetItem = Items.FirstOrDefault(item => item.Name == name); + if (targetItem is null) return; + + Items.Remove(targetItem); + } + + public void AddItem(ContextMenuItem item, params ContextMenuItem[] items) { + foreach (var entry in items.Prepend(item)) { + Items.Add(entry); + } + } + + public void RemoveItem(ContextMenuItem item, params ContextMenuItem[] items) { + foreach (var entry in items.Prepend(item)) { + Items.Remove(entry); + } + } + + public void Clear() => Items.Clear(); + + public void Open() { + var agentContextMenu = AgentContext.Instance(); + + agentContextMenu->ClearMenu(); + + mainMenuEntries = []; + mainMenuSubMenus = []; + subMenuEntries = null; + + foreach (var (index, item) in OrderedItems.Index()) { + if (item is ContextMenuSubItem subItem) { + mainMenuSubMenus.Add(index, subItem); + agentContextMenu->AddMenuItem(item.Name, contextMenuEventInterface, index, !item.IsEnabled, submenu: true); + } else { + mainMenuEntries.Add(index, item); + agentContextMenu->AddMenuItem(item.Name, contextMenuEventInterface, index, !item.IsEnabled, submenu: false); + } + } + + agentContextMenu->OpenContextMenu(); + } + + private void OpenSubMenu(ContextMenuSubItem subItem) { + var agentContextMenu = AgentContext.Instance(); + + // Set the state again to prevent the menu closing when going back and forth between the submenus + agentContextMenu->SubContextMenu.SelectedContextItemIndex = 0; + agentContextMenu->SubContextMenu.CurrentEventIndex = 8; + + agentContextMenu->OpenSubMenu(); + + var indexer = 0; + subMenuEntries = []; + + foreach (var item in subItem.SubItems.OrderBy(i => i.DisplayPriority)) { + if (item is ContextMenuSubItem) continue; + + var paramIndex = SubMenuIndexOffset + indexer; + subMenuEntries.Add(paramIndex, item); + agentContextMenu->AddMenuItem(item.Name, contextMenuEventInterface, paramIndex, !item.IsEnabled, submenu: false); + indexer++; + } + } + + public void Close() { + var agentContextMenu = AgentContext.Instance(); + + agentContextMenu->ClearMenu(); + ClearAll(); + } +} diff --git a/KamiToolKit/ContextMenu/ContextMenuItem.cs b/KamiToolKit/ContextMenu/ContextMenuItem.cs new file mode 100644 index 0000000..c06a204 --- /dev/null +++ b/KamiToolKit/ContextMenu/ContextMenuItem.cs @@ -0,0 +1,11 @@ +using System; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.ContextMenu; + +public class ContextMenuItem { + public required ReadOnlySeString Name { get; init; } + public bool IsEnabled { get; init; } = true; + public required Action OnClick { get; init; } + public int DisplayPriority { get; set; } +} diff --git a/KamiToolKit/ContextMenu/ContextMenuSubItem.cs b/KamiToolKit/ContextMenu/ContextMenuSubItem.cs new file mode 100644 index 0000000..8dffc27 --- /dev/null +++ b/KamiToolKit/ContextMenu/ContextMenuSubItem.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.ContextMenu; + +/// +/// One level of submenu only. Nested submenus not supported. +/// +public class ContextMenuSubItem : ContextMenuItem { + public List SubItems { get; set; } = []; + + public void AddItem(ReadOnlySeString name, Action callback) { + SubItems.Add(new ContextMenuItem { + Name = name, + OnClick = callback, + }); + } + + public void AddItem(ContextMenuItem item) => SubItems.Add(item); +} diff --git a/KamiToolKit/Controllers/AddonController.cs b/KamiToolKit/Controllers/AddonController.cs new file mode 100644 index 0000000..8ec2212 --- /dev/null +++ b/KamiToolKit/Controllers/AddonController.cs @@ -0,0 +1,125 @@ +using System; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Controllers; + +public class AddonController(string addonName) : AddonController(addonName); + +/// +/// This class provides functionality to add-and manage custom elements for any Addon +/// +public unsafe class AddonController : AddonEventController, IDisposable where T : unmanaged { + + internal readonly string AddonName; + + private AtkUnitBase* AddonPointer => (AtkUnitBase*)DalamudInterface.Instance.GameGui.GetAddonByName(AddonName).Address; + private bool IsEnabled { get; set; } + + private bool isSetupComplete; + + /// + /// This class provides functionality to add-and manage custom elements for any Addon + /// + public AddonController(string addonName) { + if (addonName is "NamePlate") { + throw new Exception("Attaching to NamePlate is not supported. Use OverlayController instead."); + } + + AddonName = addonName; + } + + public virtual void Dispose() => Disable(); + + public void Enable() { + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + if (IsEnabled) return; + + onInnerPreEnable?.Invoke((T*)AddonPointer); + + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostSetup, AddonName, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, AddonName, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostRefresh, AddonName, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, AddonName, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostUpdate, AddonName, OnAddonEvent); + + if (AddonPointer is not null) { + OnInnerAttach?.Invoke((T*)AddonPointer); + isSetupComplete = true; + } + + IsEnabled = true; + + onInnerPostEnable?.Invoke((T*)AddonPointer); + }); + } + + private void OnAddonEvent(AddonEvent type, AddonArgs args) { + var addon = (T*)args.Addon.Address; + + switch (type) { + case AddonEvent.PostSetup: + OnInnerAttach?.Invoke(addon); + isSetupComplete = true; + return; + + case AddonEvent.PreFinalize: + OnInnerDetach?.Invoke(addon); + isSetupComplete = false; + return; + + case AddonEvent.PostRefresh or AddonEvent.PostRequestedUpdate when isSetupComplete: + OnInnerRefresh?.Invoke(addon); + return; + + case AddonEvent.PostUpdate: + OnInnerUpdate?.Invoke(addon); + return; + } + } + + public void Disable() { + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + if (!IsEnabled) return; + + onInnerPreDisable?.Invoke((T*)AddonPointer); + + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(OnAddonEvent); + + if (AddonPointer is not null) { + OnInnerDetach?.Invoke((T*)AddonPointer); + } + + IsEnabled = false; + + onInnerPostDisable?.Invoke((T*)AddonPointer); + }); + } + + public event AddonControllerEvent? OnPreEnable { + add => onInnerPreEnable += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event AddonControllerEvent? OnPostEnable { + add => onInnerPostEnable += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event AddonControllerEvent? OnPreDisable { + add => onInnerPreDisable += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event AddonControllerEvent? OnPostDisable { + add => onInnerPostDisable += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + private AddonControllerEvent? onInnerPreEnable; + private AddonControllerEvent? onInnerPostEnable; + private AddonControllerEvent? onInnerPreDisable; + private AddonControllerEvent? onInnerPostDisable; +} diff --git a/KamiToolKit/Controllers/AddonEventController.cs b/KamiToolKit/Controllers/AddonEventController.cs new file mode 100644 index 0000000..0037d5c --- /dev/null +++ b/KamiToolKit/Controllers/AddonEventController.cs @@ -0,0 +1,40 @@ +using System; +using FFXIVClientStructs.FFXIV.Client.UI; + +namespace KamiToolKit.Controllers; + +public abstract unsafe class AddonEventController where T : unmanaged { + + protected AddonEventController() { + if (typeof(T) == typeof(AddonNamePlate)) { + throw new NotSupportedException("Attaching to NamePlate is not supported. Use OverlayController."); + } + } + + public delegate void AddonControllerEvent(T* addon); + + public event AddonControllerEvent? OnAttach { + add => OnInnerAttach += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event AddonControllerEvent? OnDetach { + add => OnInnerDetach += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event AddonControllerEvent? OnRefresh { + add => OnInnerRefresh += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event AddonControllerEvent? OnUpdate { + add => OnInnerUpdate += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + protected AddonControllerEvent? OnInnerAttach; + protected AddonControllerEvent? OnInnerDetach; + protected AddonControllerEvent? OnInnerRefresh; + protected AddonControllerEvent? OnInnerUpdate; +} diff --git a/KamiToolKit/Controllers/DynamicAddonController.cs b/KamiToolKit/Controllers/DynamicAddonController.cs new file mode 100644 index 0000000..a835133 --- /dev/null +++ b/KamiToolKit/Controllers/DynamicAddonController.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Controllers; + +/// +/// Addon controller for dynamically managing addons, typical use case is intended to +/// be for a single tasks, that can apply to one or many addons at once. +/// +public unsafe class DynamicAddonController : AddonEventController, IDisposable { + + private readonly HashSet trackedAddons = []; + private bool isEnabled; + + public DynamicAddonController(params string[] addonNames) { + foreach (var addonName in addonNames) { + AddAddon(addonName); + } + } + + public void AddAddon(string name) { + if (name is "NamePlate") { + Log.Error("Attaching to NamePlate is not supported. Use OverlayController instead."); + return; + } + + trackedAddons.Add(name); + + if (isEnabled) { + AddListeners(name); + } + } + + public void RemoveAddon(string name) { + trackedAddons.Remove(name); + + if (isEnabled) { + RemoveListeners(name); + } + } + + private void OnAddonEvent(AddonEvent type, AddonArgs args) { + var addon = (AtkUnitBase*)args.Addon.Address; + + switch (type) { + case AddonEvent.PostSetup: + OnInnerAttach?.Invoke(addon); + return; + + case AddonEvent.PreFinalize: + OnInnerDetach?.Invoke(addon); + return; + + case AddonEvent.PostRefresh or AddonEvent.PostRequestedUpdate: + OnInnerRefresh?.Invoke(addon); + return; + + case AddonEvent.PostUpdate: + OnInnerUpdate?.Invoke(addon); + return; + } + } + + public void Enable() { + foreach (var name in trackedAddons) { + AddListeners(name); + } + + isEnabled = true; + } + + public void Disable() { + isEnabled = false; + + foreach (var name in trackedAddons) { + RemoveListeners(name); + } + } + + private void AddListeners(string name) { + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostSetup, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostRefresh, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostUpdate, name, OnAddonEvent); + + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(name); + if (addon is not null) { + OnInnerAttach?.Invoke(addon); + } + }); + } + + private void RemoveListeners(string name) { + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(AddonEvent.PostSetup, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(AddonEvent.PreFinalize, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(AddonEvent.PostRefresh, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, name, OnAddonEvent); + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, name, OnAddonEvent); + + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(name); + if (addon is not null) { + OnInnerDetach?.Invoke(addon); + } + }); + } + + public void Dispose() { + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(OnAddonEvent); + Disable(); + } +} diff --git a/KamiToolKit/Controllers/MultiAddonController.cs b/KamiToolKit/Controllers/MultiAddonController.cs new file mode 100644 index 0000000..d298de6 --- /dev/null +++ b/KamiToolKit/Controllers/MultiAddonController.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Controllers; + +/// +/// For use with addons that have multiple persistent variants, but where only one is used at a time. +/// For example, Inventories or CastBars. +/// Using this with other addons will duplicate their associated events incorrectly. +/// +public unsafe class MultiAddonController : AddonEventController, IDisposable { + + private readonly List addonControllers = []; + + public MultiAddonController(params string[] addonNames) { + foreach (var addonName in addonNames) { + if (addonName is "NamePlate") { + Log.Error("Attaching to NamePlate is not supported. Use OverlayController instead."); + continue; + } + + // Don't allow duplicate addon controllers + if (addonControllers.Any(controller => controller.AddonName == addonName)) continue; + + var newController = new AddonController(addonName); + + addonControllers.Add(newController); + + newController.OnAttach += ControllerOnAttach; + newController.OnDetach += ControllerOnDetach; + newController.OnRefresh += ControllerOnRefresh; + newController.OnUpdate += ControllerOnUpdate; + } + } + + private void ControllerOnAttach(AtkUnitBase* addon) + => OnInnerAttach?.Invoke(addon); + + private void ControllerOnDetach(AtkUnitBase* addon) + => OnInnerDetach?.Invoke(addon); + + private void ControllerOnRefresh(AtkUnitBase* addon) + => OnInnerRefresh?.Invoke(addon); + + private void ControllerOnUpdate(AtkUnitBase* addon) + => OnInnerUpdate?.Invoke(addon); + + public void Dispose() { + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + addonControllers.ForEach(controller => controller.Dispose()); + addonControllers.Clear(); + }); + } + + public void Enable() { + addonControllers.ForEach(controller => controller.Enable()); + } + + public void Disable() + => addonControllers.ForEach(controller => controller.Disable()); +} diff --git a/KamiToolKit/Controllers/NativeListController.cs b/KamiToolKit/Controllers/NativeListController.cs new file mode 100644 index 0000000..db3ba2a --- /dev/null +++ b/KamiToolKit/Controllers/NativeListController.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Controllers; + +/// +/// Only one or the other field will be valid, be sure to check for null. +/// +public unsafe class ListItemData { + public AtkComponentListItemPopulator.ListItemInfo* ItemInfo { get; set; } + public AtkComponentListItemRenderer* ItemRenderer { get; set; } +} + +public unsafe class NativeListController(string addonName) : IDisposable { + + public required ShouldModifyElementHandler ShouldModifyElement { get; init; } + public required UpdateElementHandler UpdateElement { get; init; } + public required ResetElementHandler ResetElement { get; init; } + public required GetPopulatorNodeHandler GetPopulatorNode { get; init; } + + private Hook? onListPopulate; + private Hook? onRendererPopulate; + + public readonly List ModifiedIndexes = []; + + public event Action? OnClose { + add => OnInnerClose += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public event Action? OnOpen { + add => OnInnerOpen += value; + remove => throw new Exception("Do not remove events, on dispose addon state will be managed properly."); + } + + public void Enable() { + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PostSetup, addonName, OnAddonSetup); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, addonName, OnAddonFinalize); + + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(addonName); + if (addon is not null) { + Log.Warning("Caution: ListController was loaded after list was initialized, data may be stale."); + LoadPopulators(addon); + } + } + + public void Disable() => Dispose(); + + public void Dispose() { + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(OnAddonSetup, OnAddonFinalize); + + onListPopulate?.Dispose(); + onListPopulate = null; + + onRendererPopulate?.Dispose(); + onRendererPopulate = null; + } + + private void OnAddonSetup(AddonEvent type, AddonArgs args) + => LoadPopulators((AtkUnitBase*)args.Addon.Address); + + private void OnAddonFinalize(AddonEvent type, AddonArgs args) { + onListPopulate?.Dispose(); + onListPopulate = null; + + onRendererPopulate?.Dispose(); + onRendererPopulate = null; + + ModifiedIndexes.Clear(); + + OnInnerClose?.Invoke(); + } + + private void LoadPopulators(AtkUnitBase* addon) { + var populateMethod = GetPopulatorNode(addon)->Populator; + + if (populateMethod.Populate is not null) { + onListPopulate = DalamudInterface.Instance.GameInteropProvider.HookFromAddress(populateMethod.Populate, OnPopulateDetour); + onListPopulate?.Enable(); + } + + if (populateMethod.PopulateWithRenderer is not null) { + onRendererPopulate = DalamudInterface.Instance.GameInteropProvider.HookFromAddress(populateMethod.PopulateWithRenderer, OnRendererPopulateDetour); + onRendererPopulate?.Enable(); + } + + OnInnerOpen?.Invoke(); + } + + private void OnPopulateDetour(AtkUnitBase* unitBase, AtkComponentListItemPopulator.ListItemInfo* itemInfo, AtkResNode** nodeList) { + try { + var listItemData = new ListItemData { + ItemInfo = itemInfo, + }; + + var shouldModifyElement = ShouldModifyElement(unitBase, listItemData, nodeList); + + if (!shouldModifyElement) { + if (ModifiedIndexes.Contains(itemInfo->ListItem->Renderer->OwnerNode->NodeId)) { + ResetElement.Invoke(unitBase, listItemData, nodeList); + ModifiedIndexes.Remove(itemInfo->ListItem->Renderer->OwnerNode->NodeId); + } + } + + onListPopulate!.Original(unitBase, itemInfo, nodeList); + + if (shouldModifyElement) { + UpdateElement.Invoke(unitBase, listItemData, nodeList); + ModifiedIndexes.Add(itemInfo->ListItem->Renderer->OwnerNode->NodeId); + } + } + catch (Exception e) { + Log.Exception(e); + } + } + + private void OnRendererPopulateDetour(AtkUnitBase* unitBase, int listItemIndex, AtkResNode** nodeList, AtkComponentListItemRenderer* listItemRenderer) { + try { + var listItemData = new ListItemData { + ItemRenderer = listItemRenderer, + }; + + var shouldModifyElement = ShouldModifyElement(unitBase, listItemData, nodeList); + + if (!shouldModifyElement) { + if (ModifiedIndexes.Contains(listItemRenderer->OwnerNode->NodeId)) { + ResetElement.Invoke(unitBase, listItemData, nodeList); + ModifiedIndexes.Remove(listItemRenderer->OwnerNode->NodeId); + } + } + + onRendererPopulate!.Original(unitBase, listItemIndex, nodeList, listItemRenderer); + + if (shouldModifyElement) { + UpdateElement.Invoke(unitBase, listItemData, nodeList); + ModifiedIndexes.Add(listItemRenderer->OwnerNode->NodeId); + } + } + catch (Exception e) { + Log.Exception(e); + } + } + + public delegate bool ShouldModifyElementHandler(AtkUnitBase* unitBase, ListItemData listItemInfo, AtkResNode** nodeList); + public delegate AtkComponentListItemRenderer* GetPopulatorNodeHandler(AtkUnitBase* addon); + public delegate void UpdateElementHandler(AtkUnitBase* unitBase, ListItemData listItemInfo, AtkResNode** nodeList); + public delegate void ResetElementHandler(AtkUnitBase* unitBase, ListItemData listItemInfo, AtkResNode** nodeList); + + private Action? OnInnerClose { get; set; } + private Action? OnInnerOpen { get; set; } +} diff --git a/KamiToolKit/Enums/CounterFont.cs b/KamiToolKit/Enums/CounterFont.cs new file mode 100644 index 0000000..6c9c5dc --- /dev/null +++ b/KamiToolKit/Enums/CounterFont.cs @@ -0,0 +1,6 @@ +namespace KamiToolKit.Enums; + +public enum CounterFont { + MoneyFont, + ChocoboRace, +} diff --git a/KamiToolKit/Enums/DrawFlags.cs b/KamiToolKit/Enums/DrawFlags.cs new file mode 100644 index 0000000..defecee --- /dev/null +++ b/KamiToolKit/Enums/DrawFlags.cs @@ -0,0 +1,21 @@ +using System; + +namespace KamiToolKit.Enums; + +[Flags] +public enum DrawFlags : uint { + None = 0, + IsDirty = 0x1, + IsAnimating = 0x2, + CalculateTransformation = 0x4, + DisableRapidUp = 0x10, + DisableRapidDown = 0x20, + DisableRapidLeft = 0x40, + DisableRapidRight = 0x80, + DisableTimelineLabel = 0x100, + ClickableCursor = 0x100000, + RenderOnTop = 0x200000, + TextInputCursor = 0x400000, + UseEllipticalCollision = 0x800000, + UseTransformedCollision = 0x1000000, +} diff --git a/KamiToolKit/Enums/FlexFlags.cs b/KamiToolKit/Enums/FlexFlags.cs new file mode 100644 index 0000000..2686e9f --- /dev/null +++ b/KamiToolKit/Enums/FlexFlags.cs @@ -0,0 +1,31 @@ +using System; + +namespace KamiToolKit.Enums; + +[Flags] +public enum FlexFlags { + /// + /// Adjusts the height of the inserted node to be the same as the area generated + /// + FitHeight = 1 << 0, + + /// + /// Adjusts the width of the inserted node to be the same as the area generated + /// + FitWidth = 1 << 1, + + /// + /// Adjusts the FlexNode's height to fit the nodes that are inserted into it + /// + FitContentHeight = 1 << 3, + + /// + /// Center inserted nodes into the middle of the flex area horizontally + /// + CenterVertically = 1 << 4, + + /// + /// Center inserted nodes into the middle of the flex area vertically + /// + CenterHorizontally = 1 << 5, +} diff --git a/KamiToolKit/Enums/HorizontalListAnchor.cs b/KamiToolKit/Enums/HorizontalListAnchor.cs new file mode 100644 index 0000000..367b6e0 --- /dev/null +++ b/KamiToolKit/Enums/HorizontalListAnchor.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace KamiToolKit.Enums; + +public enum HorizontalListAnchor { + [Description("Left")] + Left, + + [Description("Right")] + Right, +} diff --git a/KamiToolKit/Enums/LayoutAnchor.cs b/KamiToolKit/Enums/LayoutAnchor.cs new file mode 100644 index 0000000..9ea0fd6 --- /dev/null +++ b/KamiToolKit/Enums/LayoutAnchor.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace KamiToolKit.Enums; + +public enum LayoutAnchor { + [Description("Top Left")] + TopLeft, + + [Description("Top Right")] + TopRight, + + [Description("Bottom Left")] + BottomLeft, + + [Description("Bottom Right")] + BottomRight, +} diff --git a/KamiToolKit/Enums/LayoutOrientation.cs b/KamiToolKit/Enums/LayoutOrientation.cs new file mode 100644 index 0000000..2bbd037 --- /dev/null +++ b/KamiToolKit/Enums/LayoutOrientation.cs @@ -0,0 +1,6 @@ +namespace KamiToolKit.Enums; + +public enum LayoutOrientation { + Vertical, + Horizontal, +} diff --git a/KamiToolKit/Enums/NodeEditMode.cs b/KamiToolKit/Enums/NodeEditMode.cs new file mode 100644 index 0000000..b5906cc --- /dev/null +++ b/KamiToolKit/Enums/NodeEditMode.cs @@ -0,0 +1,9 @@ +using System; + +namespace KamiToolKit.Enums; + +[Flags] +public enum NodeEditMode { + Resize = 1 << 1, + Move = 1 << 2, +} diff --git a/KamiToolKit/Enums/OverlayAddonState.cs b/KamiToolKit/Enums/OverlayAddonState.cs new file mode 100644 index 0000000..bcfe001 --- /dev/null +++ b/KamiToolKit/Enums/OverlayAddonState.cs @@ -0,0 +1,7 @@ +namespace KamiToolKit.Enums; + +internal enum OverlayAddonState { + None, + WaitForReady, + Ready, +} diff --git a/KamiToolKit/Enums/OverlayControllerState.cs b/KamiToolKit/Enums/OverlayControllerState.cs new file mode 100644 index 0000000..ca4a956 --- /dev/null +++ b/KamiToolKit/Enums/OverlayControllerState.cs @@ -0,0 +1,7 @@ +namespace KamiToolKit.Enums; + +internal enum ControllerState { + WaitForNameplate, + WaitForReady, + Ready, +} diff --git a/KamiToolKit/Enums/OverlayLayer.cs b/KamiToolKit/Enums/OverlayLayer.cs new file mode 100644 index 0000000..016264b --- /dev/null +++ b/KamiToolKit/Enums/OverlayLayer.cs @@ -0,0 +1,51 @@ +using System; +using System.ComponentModel; + +namespace KamiToolKit.Enums; + +public enum OverlayLayer { + /// + /// Layer that is the back most, this is below nameplates, but above the world itself. + /// + [Description("KTK_Overlay_Back")] + Background, + + /// + /// Above nameplate layer + /// + [Description("KTK_Overlay_Middle")] + BehindUserInterface, + + /// + /// Above most windows but below certain popup windows like battle text + /// + [Description("KTK_Overlay_Higher")] + AboveUserInterface, + + /// + /// Above everything, use with caution + /// + [Description("KTK_Overlay_Front")] + Foreground, +} + +public static class OverlayLayerExtensions { + extension(OverlayLayer layer) { + public int DepthLayer => layer switch { + OverlayLayer.Background => 1, + OverlayLayer.BehindUserInterface => 3, + OverlayLayer.AboveUserInterface => 7, + OverlayLayer.Foreground => 13, + _ => 1, + }; + } + + // Note: The game does not have a layer zero, but offsets the desired layer by one. + public static OverlayLayer GetOverlayLayer(this uint layer) => (layer + 1) switch { + 1 => OverlayLayer.Background, + 3 => OverlayLayer.BehindUserInterface, + 7 => OverlayLayer.AboveUserInterface, + 13 => OverlayLayer.Foreground, + _ => throw new Exception("Unknown depth layer: " + layer), + }; +} diff --git a/KamiToolKit/Enums/ResizeDirection.cs b/KamiToolKit/Enums/ResizeDirection.cs new file mode 100644 index 0000000..b3b0472 --- /dev/null +++ b/KamiToolKit/Enums/ResizeDirection.cs @@ -0,0 +1,6 @@ +namespace KamiToolKit.Enums; + +internal enum ResizeDirection { + BottomRight, + BottomLeft, +} diff --git a/KamiToolKit/Enums/TextInputFlags.cs b/KamiToolKit/Enums/TextInputFlags.cs new file mode 100644 index 0000000..d69f608 --- /dev/null +++ b/KamiToolKit/Enums/TextInputFlags.cs @@ -0,0 +1,20 @@ +using System; + +namespace KamiToolKit.Enums; + +[Flags] +public enum TextInputFlags : ushort { + Capitalize = 0x1, + Mask = 0x2, + EnableDictionary = 0x4, + EnableHistory = 0x8, + EnableIme = 0x10, + EscapeClears = 0x20, + AllowUpperCase = 0x40, + AllowLowerCase = 0x80, + AllowNumberInput = 0x100, + AllowSymbolInput = 0x200, + WordWrap = 0x400, + MultiLine = 0x800, + AutoMaxWidth = 0x1000, +} diff --git a/KamiToolKit/Enums/VerticalListAlignment.cs b/KamiToolKit/Enums/VerticalListAlignment.cs new file mode 100644 index 0000000..0bb8e23 --- /dev/null +++ b/KamiToolKit/Enums/VerticalListAlignment.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace KamiToolKit.Enums; + +public enum VerticalListAlignment { + [Description("Left")] + Left, + + [Description("Right")] + Right, +} diff --git a/KamiToolKit/Enums/VerticalListAnchor.cs b/KamiToolKit/Enums/VerticalListAnchor.cs new file mode 100644 index 0000000..bfffca1 --- /dev/null +++ b/KamiToolKit/Enums/VerticalListAnchor.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace KamiToolKit.Enums; + +public enum VerticalListAnchor { + [Description("Top")] + Top, + + [Description("Bottom")] + Bottom, +} diff --git a/KamiToolKit/Enums/WrapMode.cs b/KamiToolKit/Enums/WrapMode.cs new file mode 100644 index 0000000..6be1f4e --- /dev/null +++ b/KamiToolKit/Enums/WrapMode.cs @@ -0,0 +1,8 @@ +namespace KamiToolKit.Enums; + +public enum WrapMode { + None = 0, + Tile = 1, + Stretch = 2, + TileMirrored = 3, +} diff --git a/KamiToolKit/Extensions/AtkEventDataExtensions.cs b/KamiToolKit/Extensions/AtkEventDataExtensions.cs new file mode 100644 index 0000000..3c3d9c2 --- /dev/null +++ b/KamiToolKit/Extensions/AtkEventDataExtensions.cs @@ -0,0 +1,20 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using ModifierFlag = FFXIVClientStructs.FFXIV.Component.GUI.AtkEventData.AtkMouseData.ModifierFlag; + +namespace KamiToolKit.Extensions; + +public static class AtkEventDataExtensions { + extension(ref AtkEventData data) { + public Vector2 MousePosition => new(data.MouseData.PosX, data.MouseData.PosY); + public bool IsLeftClick => data.MouseData.ButtonId is 0; + public bool IsRightClick => data.MouseData.ButtonId is 1; + public bool IsNoModifiers => data.MouseData.Modifier is 0; + public bool IsAltHeld => data.MouseData.Modifier.HasFlag(ModifierFlag.Alt); + public bool IsControlHeld => data.MouseData.Modifier.HasFlag(ModifierFlag.Ctrl); + public bool IsShiftHeld => data.MouseData.Modifier.HasFlag(ModifierFlag.Shift); + public bool IsDragging => data.MouseData.Modifier.HasFlag(ModifierFlag.Dragging); + public bool IsScrollUp => data.MouseData.WheelDirection >= 1; + public bool IsScrollDown => data.MouseData.WheelDirection <= -1; + } +} diff --git a/KamiToolKit/Extensions/AtkImageNodeExtensions.cs b/KamiToolKit/Extensions/AtkImageNodeExtensions.cs new file mode 100644 index 0000000..8157e35 --- /dev/null +++ b/KamiToolKit/Extensions/AtkImageNodeExtensions.cs @@ -0,0 +1,19 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Extensions; + +public static unsafe class AtkImageNodeExtensions { + extension(ref AtkImageNode node) { + public uint IconId => node.GetIconId(); + + private uint GetIconId() { + if (node.PartsList is null) return 0; + if (node.PartsList->Parts is null) return 0; + if (node.PartsList->Parts->UldAsset is null) return 0; + if (node.PartsList->Parts->UldAsset->AtkTexture.TextureType is not TextureType.Resource) return 0; + if (node.PartsList->Parts->UldAsset->AtkTexture.Resource is null) return 0; + + return node.PartsList->Parts->UldAsset->AtkTexture.Resource->IconId; + } + } +} diff --git a/KamiToolKit/Extensions/AtkResNodeExtensions.cs b/KamiToolKit/Extensions/AtkResNodeExtensions.cs new file mode 100644 index 0000000..d2b12ef --- /dev/null +++ b/KamiToolKit/Extensions/AtkResNodeExtensions.cs @@ -0,0 +1,140 @@ +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit.Extensions; + +public static unsafe class AtkResNodeExtensions { + extension(ref AtkResNode node) { + public Vector2 Position { + get => new(node.X, node.Y); + set => node.SetPositionFloat(value.X, value.Y); + } + + public Vector2 ScreenPosition + => new(node.ScreenX, node.ScreenY); + + public Vector2 Size { + get => new(node.GetWidth(), node.GetHeight()); + set { + node.SetWidth((ushort) value.X); + node.SetHeight((ushort) value.Y); + } + } + + public Bounds Bounds => new() { + TopLeft = node.Position, + BottomRight = node.Position + node.Size, + }; + + public Vector2 Center + => node.Position + node.Size / 2.0f; + + public Vector2 Scale { + get => new (node.GetScaleX(), node.GetScaleY()); + set => node.SetScale(value.X, value.Y); + } + + public float RotationDegrees { + get => node.GetRotationDegrees(); + set => node.SetRotationDegrees(value - (int)(value / 360.0f) * 360.0f); + } + + public Vector2 Origin { + get => new(node.OriginX, node.OriginY); + set => node.SetOrigin(value.X, value.Y); + } + + public bool Visible { + get => node.IsVisible(); + set => node.ToggleVisibility(value); + } + + public Vector4 ColorVector { + get => node.Color.ToVector4(); + set => node.Color = value.ToByteColor(); + } + + public ColorHelpers.HsvaColor ColorHsva { + get => ColorHelpers.RgbaToHsv(node.ColorVector); + set => node.Color = ColorHelpers.HsvToRgb(value).ToByteColor(); + } + + public Vector3 AddColor { + get => new Vector3(node.AddRed, node.AddGreen, node.AddBlue) / 255.0f; + set { + node.AddRed = (short)(value.X * 255); + node.AddGreen = (short)(value.Y * 255); + node.AddBlue = (short)(value.Z * 255); + } + } + + public ColorHelpers.HsvaColor AddColorHsva { + get => ColorHelpers.RgbaToHsv(node.AddColor.AsVector4()); + set => node.AddColor = ColorHelpers.HsvToRgb(value).AsVector3(); + } + + public Vector3 MultiplyColor { + get => new Vector3(node.MultiplyRed, node.MultiplyGreen, node.MultiplyBlue) / 100.0f; + set { + node.MultiplyRed = (byte)(value.X * 100.0f); + node.MultiplyGreen = (byte)(value.Y * 100.0f); + node.MultiplyBlue = (byte)(value.Z * 100.0f); + } + } + + public ColorHelpers.HsvaColor MultiplyColorHsva { + get => ColorHelpers.RgbaToHsv(node.MultiplyColor.AsVector4()); + set => node.MultiplyColor = ColorHelpers.HsvToRgb(value).AsVector3(); + } + + public void AddNodeFlag(params NodeFlags[] flags) { + foreach (var flag in flags) { + node.NodeFlags |= flag; + } + } + + public void RemoveNodeFlag(params NodeFlags[] flags) { + foreach (var flag in flags) { + node.NodeFlags &= ~flag; + } + } + + public void AddDrawFlag(params DrawFlags[] flags) { + foreach (var flag in flags) { + node.DrawFlags |= (uint)flag; + } + } + + public void RemoveDrawFlag(params DrawFlags[] flags) { + foreach (var flag in flags) { + node.DrawFlags &= (uint)flag; + } + } + + public bool CheckCollision(short x, short y, bool inclusive = true) + => node.CheckCollisionAtCoords(x, y, inclusive); + + public bool CheckCollision(Vector2 position, bool inclusive = true) + => node.CheckCollisionAtCoords((short) position.X, (short) position.Y, inclusive); + + public bool CheckCollision(AtkEventData* eventData, bool inclusive = true) + => node.CheckCollisionAtCoords(eventData->MouseData.PosX, eventData->MouseData.PosY, inclusive); + + public bool IsActuallyVisible { + get { + if (!node.Visible) return false; + + var targetNode = node.ParentNode; + while (targetNode is not null) { + if (!targetNode->Visible) return false; + targetNode = targetNode->ParentNode; + } + + return true; + } + } + } +} diff --git a/KamiToolKit/Extensions/AtkStageExtensions.cs b/KamiToolKit/Extensions/AtkStageExtensions.cs new file mode 100644 index 0000000..b789277 --- /dev/null +++ b/KamiToolKit/Extensions/AtkStageExtensions.cs @@ -0,0 +1,39 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Extensions; + +public static unsafe class AtkStageExtensions { + extension(ref AtkStage atkStage) { + public void ClearNodeFocus(AtkResNode* targetNode) { + if (targetNode is null) return; + + foreach (ref var focusEntry in atkStage.AtkInputManager->FocusList) { + + // If this entry has no listener/addon, skip it + if (focusEntry.AtkEventListener is null) continue; + + // If this entry has our target node + if (focusEntry.AtkEventTarget == targetNode) { + + // Clear the entry + focusEntry.AtkEventTarget = null; + focusEntry.FocusParam = 0; + + // Clear the input managers focused node + atkStage.AtkInputManager->FocusedNode = null; + + // Clear collision managers collision node + atkStage.AtkCollisionManager->IntersectingCollisionNode = null; + + // Also remove this node from any additional focus nodes the addon might reference + var addon = (AtkUnitBase*) focusEntry.AtkEventListener; + foreach (ref var node in addon->AdditionalFocusableNodes) { + if (node.Value == targetNode) { + node = null; + } + } + } + } + } + } +} diff --git a/KamiToolKit/Extensions/AtkUldManagerExtensions.cs b/KamiToolKit/Extensions/AtkUldManagerExtensions.cs new file mode 100644 index 0000000..36f4436 --- /dev/null +++ b/KamiToolKit/Extensions/AtkUldManagerExtensions.cs @@ -0,0 +1,137 @@ +using System; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.Interop; +using KamiToolKit.Classes; + +namespace KamiToolKit.Extensions; + +public static unsafe class AtkUldManagerExtensions { + extension(ref AtkUldManager manager) { + private bool IsNodeInObjectList(AtkResNode* node) { + foreach (var objectNode in manager.ObjectNodeSpan) { + if (objectNode.Value == node) return true; + } + + return false; + } + + public bool IsNodeInDrawList(AtkResNode* node) { + foreach (var drawNode in manager.Nodes) { + if (drawNode.Value == node) return true; + } + + return false; + } + + /// + /// Adds node and all children nodes to this UldManager's Object List + /// + public void AddNodeToObjectList(NodeBase node) { + manager.AddNodeToObjectList(node.ResNode); + + foreach (var child in NodeBase.GetLocalChildren(node)) { + manager.AddNodeToObjectList(child.ResNode); + } + + manager.UpdateDrawNodeList(); + } + + public void AddNodeToObjectList(AtkResNode* newNode) { + if (newNode is null) return; + + // If the node is already in the object list, skip. + if (manager.IsNodeInObjectList(newNode)) return; + + var oldSize = manager.Objects->NodeCount; + var newSize = oldSize + 1; + var newBuffer = (AtkResNode**)NativeMemoryHelper.Malloc((ulong)(newSize * 8)); + + if (oldSize > 0) { + foreach (var index in Enumerable.Range(0, oldSize)) { + newBuffer[index] = manager.Objects->NodeList[index]; + } + + NativeMemoryHelper.Free(manager.Objects->NodeList, (ulong)(oldSize * 8)); + } + + newBuffer[newSize - 1] = newNode; + + manager.Objects->NodeList = newBuffer; + manager.Objects->NodeCount = newSize; + } + + /// + /// Removes node and all children nodes from this UldManager's Object List + /// + public void RemoveNodeFromObjectList(NodeBase node) { + manager.RemoveNodeFromObjectList(node.ResNode); + + foreach (var child in NodeBase.GetLocalChildren(node)) { + manager.RemoveNodeFromObjectList(child.ResNode); + } + + manager.UpdateDrawNodeList(); + } + + public void RemoveNodeFromObjectList(AtkResNode* node) { + if (node is null) return; + + // If the node isn't in the object list, skip. + if (!manager.IsNodeInObjectList(node)) return; + + var oldSize = manager.Objects->NodeCount; + var newSize = oldSize - 1; + var newBuffer = (AtkResNode**)NativeMemoryHelper.Malloc((ulong)(newSize * 8)); + + var newIndex = 0; + foreach (var index in Enumerable.Range(0, oldSize)) { + if (manager.Objects->NodeList[index] != node) { + newBuffer[newIndex] = manager.Objects->NodeList[index]; + newIndex++; + } + } + + NativeMemoryHelper.Free(manager.Objects->NodeList, (ulong)(oldSize * 8)); + manager.Objects->NodeList = newBuffer; + manager.Objects->NodeCount = newSize; + } + + public void PrintObjectList() { + Log.Debug("Beginning NodeList"); + + foreach (var index in Enumerable.Range(0, manager.Objects->NodeCount)) { + var nodePointer = manager.Objects->NodeList[index]; + Log.Debug($"[{index}]: {(nint)nodePointer:X}"); + } + } + + public uint GetMaxNodeId() { + uint max = 1; + foreach (var child in manager.Nodes) { + if (child.Value is null) continue; + + max = Math.Max(child.Value->NodeId, max); + } + + return max; + } + + public Span> ObjectNodeSpan + => new(manager.Objects->NodeList, manager.Objects->NodeCount); + + public T* SearchNodeById(uint nodeId) where T : unmanaged { + foreach (var node in manager.Nodes) { + if (node.Value is not null) { + if (node.Value->NodeId == nodeId) + return (T*) node.Value; + } + } + + return null; + } + + public AtkResNode* SearchNodeById(uint nodeId) + => manager.SearchNodeById(nodeId); + } +} diff --git a/KamiToolKit/Extensions/AtkUldPartExtensions.cs b/KamiToolKit/Extensions/AtkUldPartExtensions.cs new file mode 100644 index 0000000..760cddd --- /dev/null +++ b/KamiToolKit/Extensions/AtkUldPartExtensions.cs @@ -0,0 +1,110 @@ +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Dalamud.Interface.Textures.TextureWraps; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Extensions; + +public static unsafe class AtkUldPartExtensions { + extension(ref AtkUldPart part) { + public bool IsTextureReady => part.UldAsset is not null && part.UldAsset->AtkTexture.IsTextureReady(); + public Vector2 LoadedTextureSize => part.GetActualTextureSize(); + public string LoadedPath => part.GetLoadedPath(); + + public void LoadTexture(string path, bool resolveTheme = true) { + try { + if (part.UldAsset is null) return; + + part.TryUnloadTexture(); + + var texturePath = path.Replace("_hr1", string.Empty); + + var themedPath = texturePath.Replace("uld", GetThemePathModifier()); + if (DalamudInterface.Instance.DataManager.FileExists(themedPath) && resolveTheme) { + texturePath = themedPath; + } + + if (DalamudInterface.Instance.DataManager.FileExists(texturePath)) { + part.UldAsset->AtkTexture.LoadTextureWithDefaultVersion(texturePath); + } + } + catch (Exception e) { + Log.Exception(e); + } + } + + public void LoadIcon(uint iconId) + => part.UldAsset->AtkTexture.LoadIconTexture(iconId, GetIconSubFolder(iconId)); + + private Vector2 GetActualTextureSize() { + if (part.UldAsset is null) return Vector2.Zero; + if (!part.UldAsset->AtkTexture.IsTextureReady()) return Vector2.Zero; + if (part.UldAsset->AtkTexture.TextureType is 0) return Vector2.Zero; + if (part.UldAsset->AtkTexture.KernelTexture is null) return Vector2.Zero; + + var width = part.UldAsset->AtkTexture.GetTextureWidth(); + var height = part.UldAsset->AtkTexture.GetTextureHeight(); + return new Vector2(width, height); + } + + public void LoadTexture(Texture* texture) { + if (part.UldAsset is null) return; + + part.TryUnloadTexture(); + part.UldAsset->AtkTexture.KernelTexture = texture; + part.UldAsset->AtkTexture.TextureType = TextureType.KernelTexture; + } + + public void LoadTexture(IDalamudTextureWrap textureWrap) { + var texturePointer = (Texture*)DalamudInterface.Instance.TextureProvider.ConvertToKernelTexture(textureWrap, true); + if (texturePointer is null) return; + + part.LoadTexture(texturePointer); + } + + private string GetLoadedPath() { + if (part.UldAsset is null) return string.Empty; + if (part.UldAsset->AtkTexture.Resource is null) return string.Empty; + if (part.UldAsset->AtkTexture.Resource->TexFileResourceHandle is null) return string.Empty; + + return part.UldAsset->AtkTexture.Resource->TexFileResourceHandle->FileName.ToString(); + } + + private void TryUnloadTexture() { + if (part.UldAsset is null) return; + if (!part.UldAsset->AtkTexture.IsTextureReady()) return; + if (part.UldAsset->AtkTexture.TextureType is 0) return; + if (part.UldAsset->AtkTexture.KernelTexture is null) return; + + part.UldAsset->AtkTexture.ReleaseTexture(); + part.UldAsset->AtkTexture.KernelTexture = null; + part.UldAsset->AtkTexture.TextureType = 0; + } + } + + private static string GetThemePathModifier() => AtkStage.Instance()->AtkUIColorHolder->ActiveColorThemeType switch { + not 0 => $"uld/img{AtkStage.Instance()->AtkUIColorHolder->ActiveColorThemeType:00}", + _ => "uld", + }; + + public static IconSubFolder GetIconSubFolder(uint iconId) { + var textureManager = AtkStage.Instance()->AtkTextureResourceManager; + Span buffer = stackalloc byte[0x100]; + buffer.Clear(); + var bytePointer = (byte*) Unsafe.AsPointer(ref buffer[0]); + + var textureScale = textureManager->DefaultTextureScale; + var targetFolder = (IconSubFolder)textureManager->IconLanguage; + + // Try to resolve the path using the current language + AtkTexture.GetIconPath(bytePointer, iconId, textureScale, targetFolder); + var pathResult = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(bytePointer).String; + + // If the resolved path doesn't exist, re-process with default folder + return DalamudInterface.Instance.DataManager.FileExists(pathResult) ? targetFolder : IconSubFolder.None; + } +} diff --git a/KamiToolKit/Extensions/AtkUnitBaseExtensions.cs b/KamiToolKit/Extensions/AtkUnitBaseExtensions.cs new file mode 100644 index 0000000..f1f5309 --- /dev/null +++ b/KamiToolKit/Extensions/AtkUnitBaseExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using System.Numerics; +using System.Reflection; +using FFXIVClientStructs.Attributes; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Extensions; + +public static unsafe class AtkUnitBaseExtensions { + + public static string GetAddonTypeName() where T : unmanaged { + var type = typeof(T); + var attribute = type.GetCustomAttributes().OfType().FirstOrDefault(); + + if (attribute is null) throw new Exception("Unable to find AddonAttribute to resolve addon name."); + var addonName = attribute.AddonIdentifiers.FirstOrDefault(); + + if (addonName is null) throw new Exception("Addon attribute names are empty."); + return addonName; + } + + extension(ref AtkUnitBase addon) { + public Vector2 Size => addon.GetSize(); + public Vector2 RootSize => addon.GetRootSize(); + public Vector2 Position => new(addon.X, addon.Y); + + private Vector2 GetSize() { + var width = stackalloc short[1]; + var height = stackalloc short[1]; + + addon.GetSize(width, height, false); + return new Vector2(*width, *height); + } + + private Vector2 GetRootSize() { + if (addon.RootNode is null) return Vector2.Zero; + + return new Vector2(addon.RootNode->Width, addon.RootNode->Height); + } + } +} diff --git a/KamiToolKit/Extensions/ByteColorExtensions.cs b/KamiToolKit/Extensions/ByteColorExtensions.cs new file mode 100644 index 0000000..baaca04 --- /dev/null +++ b/KamiToolKit/Extensions/ByteColorExtensions.cs @@ -0,0 +1,12 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.Graphics; + +namespace KamiToolKit.Extensions; + +public static class ByteColorExtensions { + public static Vector4 ToVector4(this ByteColor color) + => new(color.R / 255.0f, color.G / 255.0f, color.B / 255.0f, color.A / 255.0f); + + public static ByteColor ToByteColor(this Vector4 v) + => new() { A = (byte)(v.W * 255), R = (byte)(v.X * 255), G = (byte)(v.Y * 255), B = (byte)(v.Z * 255) }; +} diff --git a/KamiToolKit/Extensions/EnumExtensions.cs b/KamiToolKit/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..8f5a0ee --- /dev/null +++ b/KamiToolKit/Extensions/EnumExtensions.cs @@ -0,0 +1,52 @@ +using System; +using System.ComponentModel; +using System.Numerics; +using System.Runtime.CompilerServices; +using Dalamud.Utility; + +namespace KamiToolKit.Extensions; + +internal static class EnumExtensions { + extension(Enum enumValue) { + public string Description => enumValue.GetDescription(); + + private string GetDescription() { + var attribute = enumValue.GetAttribute(); + return attribute?.Description ?? enumValue.ToString(); + } + } + + extension(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(flag, enable); break; + case 2: flagValue.SetFlag(flag, enable); break; + case 4: flagValue.SetFlag(flag, enable); break; + case 8: flagValue.SetFlag(flag, enable); break; + default: throw new NotSupportedException("Unsupported enum size"); + } + } + + private void SetFlag(T flag, bool enable) where TUnderlying : unmanaged, IBinaryInteger { + ref var value = ref Unsafe.As(ref flagValue); + var mask = Unsafe.As(ref flag); + + if (enable) + value |= mask; + else + value &= ~mask; + } + } +} diff --git a/KamiToolKit/Extensions/KnownColorExtensions.cs b/KamiToolKit/Extensions/KnownColorExtensions.cs new file mode 100644 index 0000000..7e9ef58 --- /dev/null +++ b/KamiToolKit/Extensions/KnownColorExtensions.cs @@ -0,0 +1,16 @@ +using System.Drawing; +using System.Numerics; +using Dalamud.Interface; +using Vector4 = System.Numerics.Vector4; + +namespace KamiToolKit.Extensions; + +public static class KnownColorExtensions { + public static Vector3 Vector3(this KnownColor color) { + var color4 = color.Vector(); + return new Vector3(color4.X, color4.Y, color4.Z); + } + + public static Vector3 AsVector3Color(this Vector4 vector4) + => new(vector4.X, vector4.Y, vector4.Z); +} diff --git a/KamiToolKit/Extensions/MainThreadSafety.cs b/KamiToolKit/Extensions/MainThreadSafety.cs new file mode 100644 index 0000000..eeb4827 --- /dev/null +++ b/KamiToolKit/Extensions/MainThreadSafety.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using KamiToolKit.Classes; + +namespace KamiToolKit.Extensions; + +public static unsafe class MainThreadSafety { + + /// + /// Returns true if not on the main thread. Use this to return early. + /// + public static bool TryAssertMainThread([CallerFilePath] string? callerFilePath = null, [CallerMemberName] string? callerName = null) { + if (Framework.Instance()->IsDestroying) return true; + + if (!ThreadSafety.IsMainThread) { + Log.Error($"{callerFilePath?.Split(@"\")[^1][..^2]}{callerName} must be invoked from the main thread."); + return true; + } + + return false; + } +} diff --git a/KamiToolKit/Extensions/ReadOnlySpanExtensions.cs b/KamiToolKit/Extensions/ReadOnlySpanExtensions.cs new file mode 100644 index 0000000..4403b19 --- /dev/null +++ b/KamiToolKit/Extensions/ReadOnlySpanExtensions.cs @@ -0,0 +1,10 @@ +using System; +using System.Text; + +namespace KamiToolKit.Extensions; + +public static class ReadOnlySpanExtensions { + extension(ReadOnlySpan span) { + public string String => Encoding.UTF8.GetString(span); + } +} diff --git a/KamiToolKit/Extensions/StopwatchExtensions.cs b/KamiToolKit/Extensions/StopwatchExtensions.cs new file mode 100644 index 0000000..19f2715 --- /dev/null +++ b/KamiToolKit/Extensions/StopwatchExtensions.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Extensions; + +public static class StopwatchExtensions { + extension(Stopwatch stopwatch) { + public void LogTime(string logMessage) { + DalamudInterface.Instance.Log.Debug($"{logMessage, -15}: {stopwatch, 15} :: {stopwatch.ElapsedMilliseconds} ms"); + stopwatch.Restart(); + } + } +} diff --git a/KamiToolKit/GlobalUsings.cs b/KamiToolKit/GlobalUsings.cs new file mode 100644 index 0000000..6c81c61 --- /dev/null +++ b/KamiToolKit/GlobalUsings.cs @@ -0,0 +1 @@ +global using KamiToolKit.Extensions; diff --git a/KamiToolKit/KamiToolKit.csproj b/KamiToolKit/KamiToolKit.csproj new file mode 100644 index 0000000..2a691eb --- /dev/null +++ b/KamiToolKit/KamiToolKit.csproj @@ -0,0 +1,32 @@ + + + + preview + false + + + + + false + + + + + + + + + + + + + + + false + + + + + + + diff --git a/KamiToolKit/KamiToolKit.csproj.DotSettings b/KamiToolKit/KamiToolKit.csproj.DotSettings new file mode 100644 index 0000000..82063e8 --- /dev/null +++ b/KamiToolKit/KamiToolKit.csproj.DotSettings @@ -0,0 +1,32 @@ + + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True \ No newline at end of file diff --git a/KamiToolKit/KamiToolKit.sln b/KamiToolKit/KamiToolKit.sln new file mode 100644 index 0000000..593d55d --- /dev/null +++ b/KamiToolKit/KamiToolKit.sln @@ -0,0 +1,14 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KamiToolKit", "KamiToolKit.csproj", "{52A9E8E2-ACC7-4696-8684-5C4994D0350C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {52A9E8E2-ACC7-4696-8684-5C4994D0350C}.Debug|Any CPU.ActiveCfg = Debug|x64 + {52A9E8E2-ACC7-4696-8684-5C4994D0350C}.Debug|Any CPU.Build.0 = Debug|x64 + EndGlobalSection +EndGlobal diff --git a/KamiToolKit/KamiToolKitLibrary.cs b/KamiToolKit/KamiToolKitLibrary.cs new file mode 100644 index 0000000..9179089 --- /dev/null +++ b/KamiToolKit/KamiToolKitLibrary.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Concurrent; +using Dalamud.Plugin; +using KamiToolKit.Classes; +using Serilog.Events; + +namespace KamiToolKit; + +public static class KamiToolKitLibrary { + internal static bool IsInitialized { get; private set; } + + internal static ConcurrentDictionary? AllocatedNodes; + + internal static string? DefaultWindowSubtitle; + + /// + /// Main initialization method for KamiToolKit. This method is required to be invoked before any KamiToolKit features are used. + /// Failure to do so will not result in any direct warnings, but will result in undefined behavior. + /// + public static void Initialize(IDalamudPluginInterface pluginInterface, string? defaultWindowSubtitle = null) { + IsInitialized = true; + DefaultWindowSubtitle = defaultWindowSubtitle; + + // Inject non-Experimental Properties + pluginInterface.Inject(DalamudInterface.Instance); + DalamudInterface.Instance.GameInteropProvider.InitializeFromAttributes(DalamudInterface.Instance); + + // Create node data share + AllocatedNodes = DalamudInterface.Instance.PluginInterface.GetOrCreateData("KamiToolKitAllocatedNodes", () => new ConcurrentDictionary()); + + // Inject Experimental Properties + pluginInterface.Inject(Experimental.Instance); + DalamudInterface.Instance.GameInteropProvider.InitializeFromAttributes(Experimental.Instance); + + Experimental.Instance.EnableHooks(); + + // Force enable Verbose so that users are able to get advanced logging information on request. + DalamudInterface.Instance.Log.MinimumLogLevel = LogEventLevel.Verbose; + + DalamudInterface.Instance.Log.Info($"KamiToolKit initialized for {pluginInterface.InternalName}"); + } + + /// + /// Alias for Cleanup + /// + public static void Dispose() => Cleanup(); + + /// + /// Alias for Cleanup + /// + public static void Shutdown() => Cleanup(); + + /// + /// Cleans up any potentially leaked resources that KamiToolKit has allocated. + /// + public static void Cleanup() { + if (MainThreadSafety.TryAssertMainThread()) return; + + NodeBase.DisposeNodes(); + NativeAddon.DisposeAddons(); + + DalamudInterface.Instance.PluginInterface.RelinquishData("KamiToolKitAllocatedNodes"); + + Experimental.Instance.DisposeHooks(); + } +} diff --git a/KamiToolKit/LICENSE b/KamiToolKit/LICENSE new file mode 100644 index 0000000..1f1870f --- /dev/null +++ b/KamiToolKit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 MidoriKami + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/KamiToolKit/NativeAddon/NativeAddon.AddonConfig.cs b/KamiToolKit/NativeAddon/NativeAddon.AddonConfig.cs new file mode 100644 index 0000000..7915b96 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.AddonConfig.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using System.Numerics; +using System.Text.Json; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public unsafe partial class NativeAddon { + private readonly JsonSerializerOptions serializerOptions = new() { + WriteIndented = true, + IncludeFields = true, + }; + + private AddonConfig LoadAddonConfig() { + var directory = DalamudInterface.Instance.PluginInterface.ConfigDirectory; + var file = new FileInfo(Path.Combine(directory.FullName, $"{InternalName}.addon.json")); + if (!file.Exists) { + file.Create().Close(); + + var newConfig = new AddonConfig(); + SaveAddonConfig(newConfig); + return newConfig; + } + + AddonConfig? addonConfig; + + try { + var data = File.ReadAllText(file.FullName); + addonConfig = JsonSerializer.Deserialize(data, serializerOptions); + addonConfig ??= new AddonConfig(); + } + catch (Exception e) { + DalamudInterface.Instance.Log.Error(e, "Exception while deserializing AddonConfig, creating new config."); + addonConfig = new AddonConfig(); + SaveAddonConfig(addonConfig); + } + + return addonConfig; + } + + private void SaveAddonConfig(AddonConfig addonConfig) { + var directory = DalamudInterface.Instance.PluginInterface.ConfigDirectory; + var file = new FileInfo(Path.Combine(directory.FullName, $"{InternalName}.addon.json")); + + var data = JsonSerializer.Serialize(addonConfig, serializerOptions); + + FilesystemUtil.WriteAllTextSafe(file.FullName, data); + } + + private void SaveAddonConfig() { + var configData = new AddonConfig { + Position = new Vector2(InternalAddon->X, InternalAddon->Y), + Scale = InternalAddon->Scale / AtkUnitBase.GetGlobalUIScale(), + }; + + SaveAddonConfig(configData); + } +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.CloseCallback.cs b/KamiToolKit/NativeAddon/NativeAddon.CloseCallback.cs new file mode 100644 index 0000000..f2b1cf6 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.CloseCallback.cs @@ -0,0 +1,37 @@ +using System.Linq; +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public abstract unsafe partial class NativeAddon { + + private static Hook? fireCallbackHook; + + private static void InitializeCloseCallback() { + fireCallbackHook ??= DalamudInterface.Instance.GameInteropProvider + .HookFromAddress(AtkUnitBase.Addresses.FireCallback.Value, OnFireCallback); + fireCallbackHook.Enable(); + } + + private static bool OnFireCallback(AtkUnitBase* thisPtr, uint valueCount, AtkValue* values, bool close) { + Log.Excessive($"[{thisPtr->NameString}] OnFireCallback"); + + foreach (var addon in CreatedAddons) { + if (addon == thisPtr && close && addon is { RespectCloseAll: true, IsOverlayAddon: false }) { + addon.Close(); + return true; + } + } + + return fireCallbackHook!.Original(thisPtr, valueCount, values, close); + } + + private static void DisposeCloseCallback() { + if (CreatedAddons.Count is 0 || CreatedAddons.All(addon => addon.IsOverlayAddon)) { + fireCallbackHook?.Dispose(); + fireCallbackHook = null; + } + } +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.Disposal.cs b/KamiToolKit/NativeAddon/NativeAddon.Disposal.cs new file mode 100644 index 0000000..c2ed593 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.Disposal.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public abstract partial class NativeAddon : IDisposable { + + private static readonly List CreatedAddons = []; + + private bool isDisposed; + + public virtual void Dispose() { + if (IsOverlayAddon) { + // Intentionally leak OverlayAddons, + // until Dalamud can implement OverlayAddons globally. + CreatedAddons.Remove(this); + GC.SuppressFinalize(this); + return; + } + + if (!isDisposed) { + Log.Debug($"Disposing addon {GetType()}"); + + Close(); + + // Close will remove this node automatically on AtkUnitBase.Finalize, + // However, this is after the plugin unloads, + // and will trigger a warning in auto-dispose if we don't remove this now. + CreatedAddons.Remove(this); + + GC.SuppressFinalize(this); + } + + isDisposed = true; + DisposeCloseCallback(); + } + + ~NativeAddon() => Dispose(); + + internal static void DisposeAddons() { + foreach (var addon in CreatedAddons.ToArray()) { + if (addon.IsOverlayAddon) continue; + + Log.Warning($"Addon {addon.GetType()} was not disposed properly please ensure you call dispose at an appropriate time."); + Log.Debug($"Automatically disposing addon {addon.GetType()} as a safety measure."); + + addon.Dispose(); + } + + CreatedAddons.Clear(); + DisposeCloseCallback(); + } +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.Flags.cs b/KamiToolKit/NativeAddon/NativeAddon.Flags.cs new file mode 100644 index 0000000..9a57523 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.Flags.cs @@ -0,0 +1,66 @@ +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public unsafe partial class NativeAddon { + + private void UpdateFlags() { + + // Disable Native AddonConfig + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A2, 0x40, true); + + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A1, 0x4, DisableClose); + + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A2, 0x8, DisableCloseTransition); + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A2, 0x40, DisableAddonConfig); + + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A3, 0x20, DisableClamping); + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A3, 0x1, EnableContextMenu); + + FlagHelper.UpdateFlag(ref InternalAddon->Flags1C8, 0x800, DisableScaleContextOption); + + if (IsOverlayAddon) { + SetOverlayFlags(); + } + } + + private void SetOverlayFlags() { + + OpenWindowSoundEffectId = 0; + InternalAddon->ShowSoundEffectId = 0; + + // Disable ability to focus window + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A0, 0x80, true); + + // Don't load into FocusedAddons list + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A1, 0x40, true); + + // Disable Controller Nav + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A2, 0x2, true); + + // Disable open/close transitions + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A2, 0x8, true); + + // Disable open/close sounds + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A2, 0x20, true); + + // Enable ClickThrough + FlagHelper.UpdateFlag(ref InternalAddon->Flags1A3, 0x40, true); + } + + public bool DisableClose { get; init; } + + public bool DisableCloseTransition { get; init; } + + internal bool DisableAddonConfig { get; init; } = true; + + public bool EnableContextMenu { get; init; } = true; + + public bool DisableClamping { get; init; } = true; + + public bool DisableScaleContextOption { get; init; } + + public bool RespectCloseAll { get; set; } = true; + + public bool IgnoreGlobalScale { get; set; } = false; +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.Functions.cs b/KamiToolKit/NativeAddon/NativeAddon.Functions.cs new file mode 100644 index 0000000..1027c26 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.Functions.cs @@ -0,0 +1,150 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public abstract unsafe partial class NativeAddon { + + protected virtual void OnSetup(AtkUnitBase* addon) { } + protected virtual void OnShow(AtkUnitBase* addon) { } + protected virtual void OnDraw(AtkUnitBase* addon) { } + protected virtual void OnUpdate(AtkUnitBase* addon) { } + protected virtual void OnHide(AtkUnitBase* addon) { } + protected virtual void OnFinalize(AtkUnitBase* addon) { } + protected virtual void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { } + protected virtual void OnRefresh(AtkUnitBase* addon, Span atkValues) { } + + private bool isSetup; + + private void Initialize(AtkUnitBase* thisPtr) { + Log.Verbose($"[{InternalName}] Initialize"); + + AtkUnitBase.StaticVirtualTablePointer->Initialize(thisPtr); + + thisPtr->UldManager.InitializeResourceRendererManager(); + + InitializeAddon(); + } + + private void Setup(AtkUnitBase* addon, uint valueCount, AtkValue* values) { + Log.Verbose($"[{InternalName}] Setup"); + + if (!IsOverlayAddon) { + SetInitialState(); + } + else { + ref var screenSize = ref AtkStage.Instance()->ScreenSize; + + addon->SetScale(1.0f / AtkUnitBase.GetGlobalUIScale(), true); + addon->SetSize((ushort)screenSize.Width, (ushort)screenSize.Height); + addon->SetPosition(0, 0); + } + + AtkUnitBase.StaticVirtualTablePointer->OnSetup(addon, valueCount, values); + + OnSetup(addon); + isSetup = true; + } + + private void Show(AtkUnitBase* addon, bool silenceOpenSoundEffect, uint unsetShowHideFlags) { + Log.Verbose($"[{InternalName}] Show"); + + OnShow(addon); + + AtkUnitBase.StaticVirtualTablePointer->Show(addon, silenceOpenSoundEffect, unsetShowHideFlags); + } + + private void Update(AtkUnitBase* addon, float delta) { + Log.Excessive($"[{InternalName}] Update"); + + OnUpdate(addon); + + AtkUnitBase.StaticVirtualTablePointer->Update(addon, delta); + } + + private void Draw(AtkUnitBase* addon) { + Log.Excessive($"[{InternalName}] Draw"); + + OnDraw(addon); + + AtkUnitBase.StaticVirtualTablePointer->Draw(addon); + } + + private void Hide(AtkUnitBase* addon, bool unkBool, bool callHideCallback, uint setShowHideFlags) { + Log.Verbose($"[{InternalName}] Hide"); + + OnHide(addon); + SaveAddonConfig(); + + AtkUnitBase.StaticVirtualTablePointer->Hide(addon, unkBool, callHideCallback, setShowHideFlags); + AtkUnitBase.StaticVirtualTablePointer->Close(addon, false); + } + + private void Hide2(AtkUnitBase* addon) { + Log.Verbose($"[{InternalName}] Hide2"); + + AtkUnitBase.StaticVirtualTablePointer->Hide2(addon); + } + + private void Finalizer(AtkUnitBase* addon) { + Log.Verbose($"[{InternalName}] Finalize"); + + OnFinalize(addon); + + if (RememberClosePosition) { + LastClosePosition = new Vector2(InternalAddon->X, InternalAddon->Y); + } + + AtkUnitBase.StaticVirtualTablePointer->Finalizer(InternalAddon); + isSetup = false; + } + + private AtkEventListener* Destructor(AtkUnitBase* addon, byte flags) { + Log.Verbose($"[{InternalName}] Destructor"); + + var result = AtkUnitBase.StaticVirtualTablePointer->Dtor(addon, flags); + + if ((flags & 1) == 1) { + InternalAddon = null; + disposeHandle?.Free(); + disposeHandle = null; + CreatedAddons.Remove(this); + + // Free our custom virtual table, the game doesn't know this exists and won't clear it on its own. + NativeMemoryHelper.Free(virtualTable, 0x8 * VirtualTableEntryCount); + } + + return result; + } + + private void RequestedUpdate(AtkUnitBase* thisPtr, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { + Log.Verbose($"[{InternalName}] RequestedUpdate"); + + // Prevent calls to OnRequestedUpdate before Setup is completed. The game will try to call this after Show but before Setup + if (isSetup) { + OnRequestedUpdate(thisPtr, numberArrayData, stringArrayData); + } + + AtkUnitBase.StaticVirtualTablePointer->OnRequestedUpdate(InternalAddon, numberArrayData, stringArrayData); + } + + private bool Refresh(AtkUnitBase* thisPtr, uint valueCount, AtkValue* values) { + Log.Verbose($"[{InternalName}] Refresh"); + + OnRefresh(thisPtr, new Span(values, (int)valueCount)); + + return AtkUnitBase.StaticVirtualTablePointer->OnRefresh(InternalAddon, valueCount, values); + } + + private void ScreenSizeChange(AtkUnitBase* thisPtr, int width, int height) { + Log.Verbose($"[{InternalName}] ScreenSizeChange"); + + AtkUnitBase.StaticVirtualTablePointer->OnScreenSizeChange(thisPtr, width, height); + + if (IsOverlayAddon || IgnoreGlobalScale) { + thisPtr->SetScale(1.0f / AtkUnitBase.GetGlobalUIScale(), true); + } + } +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.Properties.cs b/KamiToolKit/NativeAddon/NativeAddon.Properties.cs new file mode 100644 index 0000000..9aa9d00 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.Properties.cs @@ -0,0 +1,58 @@ +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit; + +public abstract unsafe partial class NativeAddon { + public void SetWindowPosition(Vector2 windowPosition) { + if (InternalAddon is null) return; + InternalAddon->SetPosition((short)windowPosition.X, (short)windowPosition.Y); + } + + public void SetWindowSize(Vector2 windowSize) { + if (InternalAddon is null) return; + + Size = windowSize; + InternalAddon->SetSize((ushort)Size.X, (ushort)Size.Y); + + WindowNode?.Size = Size; + } + + protected void SetWindowSize(float width, float height) + => SetWindowSize(new Vector2(width, height)); + + public required string InternalName { + get; + init => field = new string(value.Replace(" ", "").Take(31).ToArray()); + } + + public required ReadOnlySeString Title { get; set; } + + public ReadOnlySeString? Subtitle { get; set; } + + public int OpenWindowSoundEffectId { get; set; } = 23; + + public Vector2 Size { get; set; } = new(400.0f, 400.0f); + + public Vector2 ContentStartPosition => (WindowNode?.ContentStartPosition ?? Vector2.Zero) + ContentPadding; + + public Vector2 ContentSize => (WindowNode?.ContentSize ?? Vector2.Zero) - ContentPadding * 2.0f - new Vector2(0.0f, 4.0f); + + public Vector2 ContentPadding { get; set; } = new(8.0f, 0.0f); + + public int DepthLayer { get; init; } = 5; + + public bool IsOpen => InternalAddon is not null && InternalAddon->IsVisible; + + public int AddonId => InternalAddon is null ? 0 : InternalAddon->Id; + + public bool RememberClosePosition { get; set; } = true; + + internal Vector2 LastClosePosition = Vector2.Zero; + + public static implicit operator AtkUnitBase*(NativeAddon addon) => addon.InternalAddon; + + internal bool IsOverlayAddon { get; init; } +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.VirtualTable.cs b/KamiToolKit/NativeAddon/NativeAddon.VirtualTable.cs new file mode 100644 index 0000000..8186322 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.VirtualTable.cs @@ -0,0 +1,60 @@ +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public abstract unsafe partial class NativeAddon { + + private const int VirtualTableEntryCount = 200; + + private AtkUnitBase.Delegates.Dtor destructorFunction = null!; + private AtkUnitBase.Delegates.Draw drawFunction = null!; + private AtkUnitBase.Delegates.Finalizer finalizerFunction = null!; + private AtkUnitBase.Delegates.Hide hideFunction = null!; + private AtkUnitBase.Delegates.Initialize initializeFunction = null!; + private AtkUnitBase.Delegates.OnSetup onSetupFunction = null!; + private AtkUnitBase.Delegates.Show showFunction = null!; + private AtkUnitBase.Delegates.Hide2 softHideFunction = null!; + private AtkUnitBase.Delegates.Update updateFunction = null!; + private AtkUnitBase.Delegates.OnRequestedUpdate onRequestedUpdateFunction = null!; + private AtkUnitBase.Delegates.OnRefresh onRefreshFunction = null!; + private AtkUnitBase.Delegates.OnScreenSizeChange onScreenSizeChangedFunction = null!; + + private AtkUnitBase.AtkUnitBaseVirtualTable* virtualTable; + + private void RegisterVirtualTable() { + + // Overwrite virtual table with a custom copy, + // Note: currently there are 73 vfuncs, but there's no harm in copying more for when they add new vfuncs to the game + virtualTable = (AtkUnitBase.AtkUnitBaseVirtualTable*)NativeMemoryHelper.Malloc(0x8 * VirtualTableEntryCount); + NativeMemory.Copy(InternalAddon->VirtualTable, virtualTable, 0x8 * VirtualTableEntryCount); + InternalAddon->VirtualTable = virtualTable; + + initializeFunction = Initialize; + onSetupFunction = Setup; + showFunction = Show; + updateFunction = Update; + drawFunction = Draw; + hideFunction = Hide; + softHideFunction = Hide2; + finalizerFunction = Finalizer; + destructorFunction = Destructor; + onRequestedUpdateFunction = RequestedUpdate; + onRefreshFunction = Refresh; + onScreenSizeChangedFunction = ScreenSizeChange; + + virtualTable->Initialize = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(initializeFunction); + virtualTable->OnSetup = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(onSetupFunction); + virtualTable->Show = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(showFunction); + virtualTable->Update = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(updateFunction); + virtualTable->Draw = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(drawFunction); + virtualTable->Hide = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(hideFunction); + virtualTable->Hide2 = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(softHideFunction); + virtualTable->Finalizer = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(finalizerFunction); + virtualTable->Dtor = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(destructorFunction); + virtualTable->OnRequestedUpdate = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(onRequestedUpdateFunction); + virtualTable->OnRefresh = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(onRefreshFunction); + virtualTable->OnScreenSizeChange = (delegate* unmanaged)Marshal.GetFunctionPointerForDelegate(onScreenSizeChangedFunction); + } +} diff --git a/KamiToolKit/NativeAddon/NativeAddon.cs b/KamiToolKit/NativeAddon/NativeAddon.cs new file mode 100644 index 0000000..ed5e480 --- /dev/null +++ b/KamiToolKit/NativeAddon/NativeAddon.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Numerics; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using KamiToolKit.Timelines; + +namespace KamiToolKit; + +public abstract unsafe partial class NativeAddon { + + private GCHandle? disposeHandle; + + internal AtkUnitBase* InternalAddon; + + public ResNode RootNode = null!; + + protected WindowNodeBase? WindowNode { get; private set; } + + private void AllocateAddon() { + if (InternalAddon is not null) { + Log.Warning("Tried to allocate addon that was already allocated."); + return; + } + + var currentAddonCount = RaptureAtkUnitManager.Instance()->AllLoadedUnitsList.Count; + if (currentAddonCount >= 200) { + Log.Warning($"WARNING: Current Addon Count is approaching hard limits ({currentAddonCount}/250). Please ensure custom Addons are not being used as overlays."); + } + + if (currentAddonCount >= 225) { + Log.Error($"ERROR: Current Addon Count is too high. Aborting allocation ({currentAddonCount}/250)."); + return; + } + + if (InternalName.Length is 0) { + throw new NullReferenceException("InternalName is empty, this is not allowed."); + } + + Log.Verbose($"[{InternalName}] Allocating NativeAddon"); + + if (!IsOverlayAddon) { + InitializeCloseCallback(); + } + + InternalAddon = NativeMemoryHelper.Create(); + + RegisterVirtualTable(); + + RootNode = new ResNode { + NodeId = 1, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.Fill | NodeFlags.Focusable | NodeFlags.EmitsEvents, + IsAddonRootNode = true, + }; + + if (!IsOverlayAddon) { + WindowNode = CreateWindowNode?.Invoke() ?? new WindowNode(); + WindowNode.NodeId = 2; + } + + InternalAddon->NameString = InternalName; + + InternalAddon->ShowSoundEffectId = (short)OpenWindowSoundEffectId; + + UpdateFlags(); + } + + private void InitializeAddon() { + var widgetInfo = NativeMemoryHelper.UiAlloc(1, 16); + widgetInfo->Id = 1; + widgetInfo->NodeCount = 0; + widgetInfo->NodeList = null; + widgetInfo->WidgetAlignment = new AtkWidgetAlignment { + AlignmentType = AlignmentType.Center, + X = 50.0f, + Y = 50.0f, + }; + + InternalAddon->UldManager.Objects = (AtkUldObjectInfo*)widgetInfo; + InternalAddon->UldManager.ObjectCount = 1; + InternalAddon->UldManager.ResourceFlags |= AtkUldManagerResourceFlag.ArraysAllocated; + + InternalAddon->RootNode = RootNode; + InternalAddon->UldManager.AddNodeToObjectList(RootNode); + + LoadTimeline(); + + InternalAddon->UldManager.UpdateDrawNodeList(); + InternalAddon->UldManager.LoadedState = AtkLoadState.Loaded; + + if (!IsOverlayAddon && WindowNode is not null) { + WindowNode.AttachNode(this, NodePosition.AsFirstChild); + InternalAddon->WindowNode = WindowNode; + InternalAddon->UldManager.AddNodeToObjectList(WindowNode); + } + + // UldManager finished loading the uld + InternalAddon->Flags198 |= 2 << 0x1C; + + // LoadUldByName called + InternalAddon->Flags1A2 |= 4; + + InternalAddon->UpdateCollisionNodeList(false); + + // Set focus node to allow controller nav + WindowNode?.WindowHeaderFocusNode.AddNodeFlags(NodeFlags.Focusable); + InternalAddon->FocusNode = WindowNode is not null ? WindowNode.WindowHeaderFocusNode : RootNode; + + // Now that we have constructed this instance, track it for auto-dispose + CreatedAddons.Add(this); + } + + private void SetInitialState() { + WindowNode?.SetTitle(Title.ToString(), Subtitle?.ToString() ?? KamiToolKitLibrary.DefaultWindowSubtitle); + + InternalAddon->ShowSoundEffectId = (short)OpenWindowSoundEffectId; + + var addonConfig = LoadAddonConfig(); + if (addonConfig.Position != Vector2.Zero) { + InternalAddon->SetPosition((short)addonConfig.Position.X, (short)addonConfig.Position.Y); + } + else { + var screenSize = new Vector2(AtkStage.Instance()->ScreenSize.Width, AtkStage.Instance()->ScreenSize.Height); + var defaultPosition = screenSize / 2.0f - Size / 2.0f; + InternalAddon->SetPosition((short)defaultPosition.X, (short)defaultPosition.Y); + } + + if (addonConfig.Scale is not 1.0f) { + var newScale = Math.Clamp(addonConfig.Scale, 0.25f, 6.0f); + + InternalAddon->SetScale(newScale, true); + } + + SetWindowSize(Size); + + if (LastClosePosition != Vector2.Zero && RememberClosePosition) { + InternalAddon->SetPosition((short)LastClosePosition.X, (short)LastClosePosition.Y); + } + } + + public Func? CreateWindowNode { get; init; } + + /// + /// Initializes and Opens this instance of Addon + /// + public void Open() => DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + Log.Verbose($"[{InternalName}] Open Called"); + + if (InternalAddon is null) { + AllocateAddon(); + + if (InternalAddon is not null) { + AtkStage.Instance()->RaptureAtkUnitManager->InitializeAddon(InternalAddon, InternalName); + InternalAddon->Open((uint)DepthLayer - 1); + disposeHandle = GCHandle.Alloc(this); + } + } + else { + Log.Verbose($"[{InternalName}] Already open, skipping call."); + } + }); + + [Conditional("DEBUG")] + public void DebugOpen() => Open(); + + public void Close() { + if (InternalAddon is null) return; + + DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + Log.Verbose($"[{InternalName}] Close"); + + if (InternalAddon is not null) { + InternalAddon->Close(false); + } + }); + } + + public void Toggle() { + if (IsOpen) { + Close(); + } + else { + Open(); + } + } + + public void AddNode(ICollection nodes) { + foreach (var node in nodes) { + AddNode(node); + } + } + + public void AddNode(NodeBase? node) + => node?.AttachNode(this); + + private void LoadTimeline() { + RootNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 89) + .AddLabel(1, 101, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 102, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 103, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(30, 104, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(40, 105, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(50, 106, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(60, 107, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(70, 108, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(80, 109, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/NodeBase/NodeBase.Dispose.cs b/KamiToolKit/NodeBase/NodeBase.Dispose.cs new file mode 100644 index 0000000..49d9d68 --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.Dispose.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit; + +public abstract unsafe partial class NodeBase : IDisposable { + + internal const uint NodeIdBase = 100_000_000; + protected static readonly List CreatedNodes = []; + private static int logIndent = -1; + + internal static uint CurrentOffset; + + private bool isDisposed; + + internal abstract AtkResNode* ResNode { get; } + internal bool IsAddonRootNode; + + private delegate* unmanaged originalDestructorFunction; + private AtkResNode.Delegates.Destroy destructorFunction = null!; + private AtkResNode.AtkResNodeVirtualTable* virtualTable; + + public void Dispose() { + try { + logIndent++; + LogIndented($"Beginning Dispose for {GetType()}"); + logIndent++; + + if (MainThreadSafety.TryAssertMainThread()) { + if (Framework.Instance()->IsDestroying) { + LogIndented("Game is shutting down, aborting manual dispose."); + } + return; + } + + if (isDisposed) { + LogIndented("Node was already disposed, skipping."); + return; + } + + isDisposed = true; + + if (!IsNodeValid()) { + Log.Warning("Invalid node, dispose aborted."); + return; + } + + LogIndented("Disposing Children"); + foreach (var child in ChildNodes.ToList()) { + child.Dispose(); + } + LogIndented("Children Disposed"); + ChildNodes.Clear(); + + LogIndented("Disposing Tooltip Events"); + UnregisterTooltipEvents(); + + LogIndented("Clearing Native Focus"); + AtkStage.Instance()->ClearNodeFocus(ResNode); + + LogIndented("Detaching From UI"); + DetachNode(); + + LogIndented("Disposing Timeline"); + Timeline?.Dispose(); + ResNode->Timeline = null; + + LogIndented("Invoking Native Dispose"); + Dispose(true, false); + GC.SuppressFinalize(this); + CreatedNodes.Remove(this); + + logIndent--; + LogIndented("Dispose Complete"); + logIndent--; + } + catch (Exception e) { + Log.Exception(e); + logIndent = 0; + } + } + + private static void LogIndented(string message) + => Log.Verbose(new string(' ', logIndent * 2) + message); + + /// + /// Warning, this is only to ensure there are no memory leaks. + /// Ensure you have detached nodes safely from native ui before disposing. + /// + internal static void DisposeNodes() { + var leakedNodeCount = CreatedNodes.Count(node => !node.IsAddonRootNode && node.ResNode is not null && node.ResNode->ParentNode is null); + + if (leakedNodeCount is not 0) { + Log.Warning($"There were {leakedNodeCount} node(s) that were not disposed safely."); + } + + foreach (var node in CreatedNodes.ToArray()) { + if (node.ResNode is null) continue; + if (node.ResNode->ParentNode is not null) continue; + if (node.IsAddonRootNode) continue; + + Log.Warning($"Forcing disposal of: {node.GetType()}"); + node.Dispose(); + } + } + + ~NodeBase() => Dispose(false, false); + + /// + /// Dispose associated resources. If a resource modifies native state directly guard it with isNativeDestructor + /// + /// + /// Indicates if this specific call should dispose resources or not. This protects against double dispose, + /// or incorrectly manipulating native state too many times. + /// + /// + /// Indicates if the dispose call should try to completely clean up all resources, + /// or if it should only clean up managed resources. When false, be sure to only dispose + /// resources that exist in managed spaces, as the game has already cleaned up everything else. + /// + protected virtual void Dispose(bool disposing, bool isNativeDestructor) { + + // Dispose of managed resources that must be disposed regardless of how dispose is invoked + DisposeEvents(); + DisableEditMode(NodeEditMode.Move | NodeEditMode.Resize); + } + + private bool IsNodeValid() { + if (ResNode is null) return false; + if (ResNode->VirtualTable is null) return false; + if (ResNode->VirtualTable == AtkEventTarget.StaticVirtualTablePointer) return false; + + return true; + } + + public static implicit operator AtkResNode*(NodeBase node) => node.ResNode; + public static implicit operator AtkEventTarget*(NodeBase node) => &node.ResNode->AtkEventTarget; + + protected void BuildVirtualTable() { + // Back up original destructor pointer + originalDestructorFunction = ResNode->VirtualTable->Destroy; + + // Overwrite virtual table with a custom copy, + // Note: Currently there are only 2 vfuncs, but there's no harm in copying more for if they ever add more vfuncs to the game. + virtualTable = (AtkResNode.AtkResNodeVirtualTable*)NativeMemoryHelper.Malloc(0x8 * 4); + NativeMemory.Copy(ResNode->VirtualTable, virtualTable, 0x8 * 4); + ResNode->VirtualTable = virtualTable; + + // Pin managed function to virtual table entry + destructorFunction = DestructorDetour; + + // Replace native destructor with + virtualTable->Destroy = (delegate* unmanaged) Marshal.GetFunctionPointerForDelegate(destructorFunction); + } + + private void DestructorDetour(AtkResNode* thisPtr, bool free) { + Dispose(true, true); + InvokeOriginalDestructor(thisPtr, free); + + Log.Verbose($"Native has disposed node {GetType()}"); + GC.SuppressFinalize(this); + CreatedNodes.Remove(this); + + isDisposed = true; + } + + protected void InvokeOriginalDestructor(AtkResNode* thisPtr, bool free) { + if (virtualTable is null) return; // Shouldn't be possible, but just in case. + + originalDestructorFunction(thisPtr, free); + NativeMemoryHelper.Free(virtualTable, 0x8 * 4); + virtualTable = null; + } +} diff --git a/KamiToolKit/NodeBase/NodeBase.Edit.cs b/KamiToolKit/NodeBase/NodeBase.Edit.cs new file mode 100644 index 0000000..df8270c --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.Edit.cs @@ -0,0 +1,205 @@ +using System; +using System.Numerics; +using Dalamud.Game.Addon.Events; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Nodes; + +namespace KamiToolKit; + +public abstract unsafe partial class NodeBase { + + private Vector2 clickStartPosition = Vector2.Zero; + private NodeEditMode currentEditMode = 0; + + private ViewportEventListener? editEventListener; + + private bool isCursorSet; + + private bool isMoving; + private bool isResizing; + + private NodeEditOverlayNode? overlayNode; + + public Action? OnResizeComplete { get; set; } + public Action? OnMoveComplete { get; set; } + public Action? OnEditComplete { get; set; } + + public bool EnableMoving { + get; + set { + field = value; + if (value) { + EnableEditMode(NodeEditMode.Move); + } + else { + DisableEditMode(NodeEditMode.Move); + } + } + } + + public bool EnableResizing { + get; + set { + field = value; + if (value) { + EnableEditMode(NodeEditMode.Resize); + } + else { + DisableEditMode(NodeEditMode.Resize); + } + } + } + + public void EnableEditMode(NodeEditMode mode) { + + currentEditMode |= mode; + + if (overlayNode is null) { + overlayNode = new NodeEditOverlayNode { + Position = new Vector2(-16.0f, -16.0f), + Size = Size + new Vector2(32.0f, 32.0f), + }; + overlayNode.AttachNode(this); + ChildNodes.Add(overlayNode); + } + + overlayNode.ShowParts = currentEditMode.HasFlag(NodeEditMode.Resize); + + if (editEventListener is null) { + editEventListener = new ViewportEventListener(OnEditEvent); + editEventListener.AddEvent(AtkEventType.MouseMove, overlayNode); + editEventListener.AddEvent(AtkEventType.MouseDown, overlayNode); + } + } + + public void DisableEditMode(NodeEditMode mode) { + + currentEditMode &= ~mode; + + if (currentEditMode.HasFlag(NodeEditMode.Resize) || currentEditMode.HasFlag(NodeEditMode.Move)) return; + + if (editEventListener is not null) { + editEventListener.RemoveEvent(AtkEventType.MouseMove); + editEventListener.RemoveEvent(AtkEventType.MouseDown); + editEventListener.Dispose(); + editEventListener = null; + } + + if (overlayNode is not null) { + ChildNodes.Remove(overlayNode); + overlayNode.DetachNode(); + overlayNode.Dispose(); + overlayNode = null; + } + } + + private void OnEditEvent(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + if (overlayNode is null) return; + if (editEventListener is null) return; + + ref var mouseData = ref atkEventData->MouseData; + var mousePosition = new Vector2(mouseData.PosX, mouseData.PosY); + var mouseDelta = mousePosition - clickStartPosition; + + switch (eventType) { + // Move Logic + case AtkEventType.MouseMove when isMoving: { + Position += mouseDelta; + clickStartPosition = mousePosition; + + atkEvent->SetEventIsHandled(true); + } + break; + + // Update hover state when not resizing, as we latch that for the behavior + case AtkEventType.MouseMove when !isResizing: { + overlayNode.UpdateHover(atkEventData); + } + break; + + // Resize Logic + case AtkEventType.MouseMove when isResizing: { + Position += overlayNode.GetPositionDelta(mouseDelta); + Size += overlayNode.GetSizeDelta(mouseDelta); + + overlayNode.Size = Size + new Vector2(32.0f, 32.0f); + + clickStartPosition = mousePosition; + + atkEvent->SetEventIsHandled(true); + } + break; + + // Begin Resize Event + case AtkEventType.MouseDown when !isResizing && overlayNode.AnyHovered() && currentEditMode.HasFlag(NodeEditMode.Resize): { + editEventListener.AddEvent(AtkEventType.MouseUp, overlayNode); + + isResizing = true; + clickStartPosition = mousePosition; + + atkEvent->SetEventIsHandled(true); + } + break; + + // End Resize Event + case AtkEventType.MouseUp when isResizing: { + OnResizeComplete?.Invoke(this); + OnEditComplete?.Invoke(this); + + isResizing = false; + editEventListener.RemoveEvent(AtkEventType.MouseUp); + } + break; + + // Begin Move Event + case AtkEventType.MouseDown when !overlayNode.AnyHovered() && overlayNode.CheckCollision(atkEventData) && !isMoving && currentEditMode.HasFlag(NodeEditMode.Move): { + editEventListener.AddEvent(AtkEventType.MouseUp, overlayNode); + + isMoving = true; + clickStartPosition = mousePosition; + + atkEvent->SetEventIsHandled(true); + } + break; + + // End Move Event + case AtkEventType.MouseUp when isMoving: { + OnMoveComplete?.Invoke(this); + OnEditComplete?.Invoke(this); + + isMoving = false; + editEventListener.RemoveEvent(AtkEventType.MouseUp); + } + break; + } + + if (isCursorSet) { + ResetCursor(); + isCursorSet = false; + } + + if (currentEditMode.HasFlag(NodeEditMode.Move)) { + if (isMoving) { + SetCursor(AddonCursorType.Grab); + isCursorSet = true; + } + else if (CheckCollision(atkEventData)) { + SetCursor(AddonCursorType.Hand); + isCursorSet = true; + } + } + + if (overlayNode.AnyHovered() && currentEditMode.HasFlag(NodeEditMode.Resize)) { + overlayNode.SetCursor(); + isCursorSet = true; + } + } + + private static void SetCursor(AddonCursorType cursor) + => DalamudInterface.Instance.AddonEventManager.SetCursor(cursor); + + private static void ResetCursor() + => DalamudInterface.Instance.AddonEventManager.ResetCursor(); +} diff --git a/KamiToolKit/NodeBase/NodeBase.Events.cs b/KamiToolKit/NodeBase/NodeBase.Events.cs new file mode 100644 index 0000000..9996ea5 --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.Events.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit; + +internal class EventHandlerInfo { + public AtkEventListener.Delegates.ReceiveEvent? OnReceiveEventDelegate; + public Action? OnActionDelegate; +} + +public abstract unsafe partial class NodeBase { + + private CustomEventListener? nodeEventListener; + private readonly Dictionary eventHandlers = []; + + /// + /// When true, mousing over this node will show the finger cursor icon. + /// + public bool ShowClickableCursor { + get => DrawFlags.HasFlag(DrawFlags.ClickableCursor); + set { + if (value) { + DrawFlags |= DrawFlags.ClickableCursor; + } + else { + DrawFlags &= ~DrawFlags.ClickableCursor; + } + } + } + + /// + /// When true, mousing over this node will show the text input cursor icon. + /// + public bool ShowTextInputCursor { + get => DrawFlags.HasFlag(DrawFlags.TextInputCursor); + set { + if (value) { + DrawFlags |= DrawFlags.TextInputCursor; + } + else { + DrawFlags &= ~DrawFlags.TextInputCursor; + } + } + } + + public void AddEvent(AtkEventType eventType, Action callback) { + nodeEventListener ??= new CustomEventListener(HandleEvents); + + SetNodeEventFlags(eventType); + + if (eventHandlers.TryAdd(eventType, new EventHandlerInfo { OnActionDelegate = callback })) { + Log.Verbose($"[{eventType}] Registered for {GetType()} [{(nint)ResNode:X}]"); + ResNode->AtkEventManager.RegisterEvent(eventType, 0, this, this, nodeEventListener, false); + } + else { + eventHandlers[eventType].OnActionDelegate += callback; + } + } + + public void AddEvent(AtkEventType eventType, AtkEventListener.Delegates.ReceiveEvent callback) { + nodeEventListener ??= new CustomEventListener(HandleEvents); + + SetNodeEventFlags(eventType); + + if (eventHandlers.TryAdd(eventType, new EventHandlerInfo { OnReceiveEventDelegate = callback })) { + Log.Verbose($"[{eventType}] Registered for {GetType()} [{(nint)ResNode:X}]"); + ResNode->AtkEventManager.RegisterEvent(eventType, 0, this, this, nodeEventListener, false); + } + else { + eventHandlers[eventType].OnReceiveEventDelegate += callback; + } + } + + public void RemoveEvent(AtkEventType eventType) { + if (nodeEventListener is null) return; + + if (eventHandlers.Remove(eventType)) { + Log.Verbose($"[{eventType}] Unregistered from {GetType()} [{(nint)ResNode:X}]"); + ResNode->AtkEventManager.UnregisterEvent(eventType, 0, nodeEventListener, false); + } + + // If we have removed the last event, free the event listener + if (eventHandlers.Keys.Count is 0) { + nodeEventListener.Dispose(); + nodeEventListener = null; + } + } + + public void RemoveEvent(AtkEventType eventType, Action callback) { + if (nodeEventListener is null) return; + + if (eventHandlers.TryGetValue(eventType, out var handler)) { + handler.OnActionDelegate -= callback; + + if (handler.OnReceiveEventDelegate is null && handler.OnActionDelegate is null) { + RemoveEvent(eventType); + } + } + } + + public void RemoveEvent(AtkEventType eventType, AtkEventListener.Delegates.ReceiveEvent callback) { + if (nodeEventListener is null) return; + + if (eventHandlers.TryGetValue(eventType, out var handler)) { + handler.OnReceiveEventDelegate -= callback; + + if (handler.OnReceiveEventDelegate is null && handler.OnActionDelegate is null) { + RemoveEvent(eventType); + } + } + } + + private void DisposeEvents() { + if (nodeEventListener is not null) { + ResNode->AtkEventManager.UnregisterEvent(AtkEventType.UnregisterAll, 0, nodeEventListener, false); + } + + eventHandlers.Clear(); + + nodeEventListener?.Dispose(); + nodeEventListener = null; + } + + private void HandleEvents(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + try { + if (!IsVisible) return; + + if (eventHandlers.TryGetValue(eventType, out var handler)) { + + foreach (var noArgHandler in Delegate.EnumerateInvocationList(handler.OnActionDelegate)) { + try { + noArgHandler(); + } + catch (Exception e) { + Log.Exception(e); + } + } + + foreach (var argHandler in Delegate.EnumerateInvocationList(handler.OnReceiveEventDelegate)) { + try { + argHandler(thisPtr, eventType, eventParam, atkEvent, atkEventData); + } + catch (Exception e) { + Log.Exception(e); + } + } + } + } + catch (Exception e) { + Log.Exception(e); + } + } + + private void SetNodeEventFlags(AtkEventType eventType) { + switch (eventType) { + // Hover events need to propagate down to trigger various timelines + case AtkEventType.MouseOver: + case AtkEventType.MouseOut: + case AtkEventType.MouseWheel: + AddNodeFlags(NodeFlags.EmitsEvents, NodeFlags.RespondToMouse); + break; + + // Any kind of direct interaction should be a blocking event + // set HasCollision to prevent events from propagating + case AtkEventType.MouseDown: + case AtkEventType.MouseUp: + case AtkEventType.MouseMove: + case AtkEventType.MouseClick: + AddNodeFlags(NodeFlags.EmitsEvents, NodeFlags.RespondToMouse, NodeFlags.HasCollision); + break; + + // ButtonClick is mostly used as an event that native calls back to, when interacting with buttons + // We do not want to re-emit, or block events in this case + case AtkEventType.ButtonClick: + break; + } + } +} diff --git a/KamiToolKit/NodeBase/NodeBase.Linking.cs b/KamiToolKit/NodeBase/NodeBase.Linking.cs new file mode 100644 index 0000000..8cb55de --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.Linking.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit; + +public abstract unsafe partial class NodeBase { + + internal readonly List ChildNodes = []; + private NodeBase? parentNode; + + internal AtkUldManager* ParentUldManager { get; set; } + internal AtkUnitBase* ParentAddon { get; private set; } + + [OverloadResolutionPriority(1)] + public void AttachNode(NativeAddon? targetAddon, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformManagedAttach(targetAddon, targetPosition); + + public void AttachNode(AtkUnitBase* targetAddon, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach(targetAddon is not null ? targetAddon->RootNode : null, targetPosition); + + [OverloadResolutionPriority(1)] + public void AttachNode(NodeBase? targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformManagedAttach(targetNode, targetPosition); + + public void AttachNode(AtkResNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach(targetNode, targetPosition); + + public void AttachNode(AtkImageNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + public void AttachNode(AtkTextNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + public void AttachNode(AtkNineGridNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + public void AttachNode(AtkCounterNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + public void AttachNode(AtkCollisionNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + public void AttachNode(AtkClippingMaskNode* targetNode, NodePosition targetPosition = NodePosition.AsLastChild) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + public void AttachNode(AtkComponentNode* targetNode, NodePosition targetPosition = NodePosition.AfterAllSiblings) + => PerformNativeAttach((AtkResNode*)targetNode, targetPosition); + + private void PerformManagedAttach(NativeAddon? targetAddon, NodePosition targetPosition = NodePosition.AsLastChild) { + if (MainThreadSafety.TryAssertMainThread()) return; + if (targetAddon is null) return; + + // Check the Addon's node list to find out what NodeId we should be, and set that before attaching + if (NodeId > NodeIdBase) { + NodeId = targetAddon.InternalAddon->UldManager.GetMaxNodeId() + 1; + } + + PerformNativeAttach(targetAddon.RootNode, targetPosition); + + parentNode = targetAddon.RootNode; + parentNode.ChildNodes.Add(this); + } + + private void PerformManagedAttach(NodeBase? targetNode, NodePosition targetPosition) { + if (MainThreadSafety.TryAssertMainThread()) return; + if (targetNode is null) return; + + PerformNativeAttach(targetNode, targetPosition); + + parentNode = targetNode; + parentNode.ChildNodes.Add(this); + } + + private void PerformNativeAttach(AtkResNode* targetNode, NodePosition targetPosition) { + if (MainThreadSafety.TryAssertMainThread()) return; + if (targetNode is null) return; + + if (targetNode->GetNodeType() is NodeType.Component) { + + // If target is a ComponentNode, + // then we don't ever wanna be a child of the ComponentNode itself, + // we will want to be a sibling of the root node. + // Therefore, redirect the target position to be siblings. + targetPosition = targetPosition switch { + NodePosition.AsLastChild => NodePosition.AfterAllSiblings, + NodePosition.AsFirstChild => NodePosition.BeforeAllSiblings, + _ => targetPosition, + }; + + // If however, we are using BeforeTarget or AfterTarget, + // then we do want to attach to the ComponentNode + // else, attach to its root node. + var componentNode = targetNode->GetAsAtkComponentNode(); + if (componentNode is not null) { + targetNode = targetPosition switch { + NodePosition.AfterTarget => targetNode, + NodePosition.BeforeTarget => targetNode, + NodePosition.AfterAllSiblings => componentNode->Component->UldManager.RootNode, + NodePosition.BeforeAllSiblings => componentNode->Component->UldManager.RootNode, + _ => throw new ArgumentOutOfRangeException(nameof(targetPosition), targetPosition, null), + }; + + // We also need to check the components node list, to get a safely assigned nodeId + if (NodeId > NodeIdBase) { + NodeId = componentNode->Component->UldManager.GetMaxNodeId() + 1; + } + } + } + + NodeLinker.AttachNode(this, targetNode, targetPosition); + UpdateParentAddon(targetNode); + UpdateNative(); + } + + internal void ReattachNode(AtkResNode* newTarget) { + if (newTarget is null) return; + + DetachNode(); + AttachNode(newTarget); + } + + public void DetachNode() { + if (MainThreadSafety.TryAssertMainThread()) return; + if (ResNode is null) return; + + UnlinkFromNative(); + RemoveUldManagerObjectReferences(); + RemoveParentAddonReferences(); + RemoveParentNodeReferences(); + } + + private void UnlinkFromNative() { + NodeLinker.DetachNode(ResNode); + ResNode->ParentNode = null; + ResNode->NextSiblingNode = null; + ResNode->PrevSiblingNode = null; + } + + private void RemoveUldManagerObjectReferences() { + if (ParentUldManager is null) return; + + ParentUldManager->RemoveNodeFromObjectList(this); + ParentUldManager = null; + } + + private void RemoveParentAddonReferences() { + if (ParentAddon is null) return; + + ParentAddon->UldManager.UpdateDrawNodeList(); + ParentAddon->UpdateCollisionNodeList(false); + + ParentAddon = null; + + foreach (var child in GetAllChildren(this)) { + child.ParentAddon = null; + } + } + + private void RemoveParentNodeReferences() { + if (parentNode is null) return; + + parentNode.ChildNodes.Remove(this); + parentNode = null; + } + + private void UpdateNative() { + if (ResNode is null) return; + + MarkDirty(); + + if (ParentUldManager is null) { + ParentUldManager = GetUldManagerForNode(ResNode); + } + + if (ParentUldManager is not null) { + ParentUldManager->AddNodeToObjectList(this); + } + + if (ParentAddon is not null) { + if (ParentAddon->NameString is "NamePlate") { + Log.Warning("Warning, attaching to AddonNamePlate is not supported. Use OverlayController instead."); + } + + ParentAddon->UldManager.UpdateDrawNodeList(); + ParentAddon->UpdateCollisionNodeList(false); + } + } + + private void UpdateParentAddon(AtkResNode* node) { + if (parentNode is not null && parentNode.ParentAddon is not null) { + ParentAddon = parentNode.ParentAddon; + } + else if (ParentAddon is null) { + var targetParentAddon = RaptureAtkUnitManager.Instance()->GetAddonByNode(node); + if (targetParentAddon is not null) { + ParentAddon = targetParentAddon; + } + } + + if (ParentAddon is not null) { + foreach (var child in GetAllChildren(this)) { + child.ParentAddon = ParentAddon; + } + } + } + + private AtkUldManager* GetUldManagerForNode(AtkResNode* node) { + if (node is null) return null; + + var targetNode = node; + + if (targetNode->GetNodeType() is NodeType.Component) { + targetNode = targetNode->ParentNode; + } + + // Try to get UldManager via the first parent that is a component + while (targetNode is not null) { + if (targetNode->GetNodeType() is NodeType.Component) { + var componentNode = (AtkComponentNode*)targetNode; + return &componentNode->Component->UldManager; + } + + targetNode = targetNode->ParentNode; + } + + // We failed to find a parent component, try to get a parent addon instead + if (ParentAddon is not null) { + return &ParentAddon->UldManager; + } + + return null; + } + + private static IEnumerable GetAllChildren(NodeBase parent) { + foreach (var child in parent.ChildNodes) { + yield return child; + foreach (var childNode in GetAllChildren(child)) { + yield return childNode; + } + } + } + + internal static IEnumerable GetLocalChildren(NodeBase parent) { + if (parent is ComponentNode) yield break; + + foreach (var child in parent.ChildNodes) { + yield return child; + + if (child is ComponentNode) continue; + foreach (var childNode in GetLocalChildren(child)) { + yield return childNode; + } + } + } +} diff --git a/KamiToolKit/NodeBase/NodeBase.NativeProperties.cs b/KamiToolKit/NodeBase/NodeBase.NativeProperties.cs new file mode 100644 index 0000000..bc377b0 --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.NativeProperties.cs @@ -0,0 +1,244 @@ +using System; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Common.Math; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using Bounds = KamiToolKit.Classes.Bounds; +using Vector2 = System.Numerics.Vector2; +using Vector3 = System.Numerics.Vector3; +using Vector4 = System.Numerics.Vector4; + +namespace KamiToolKit; + +public abstract unsafe partial class NodeBase { + public virtual float X { + get => ResNode->GetXFloat(); + set => ResNode->SetXFloat(value); + } + + public virtual float Y { + get => ResNode->GetYFloat(); + set => ResNode->SetYFloat(value); + } + + public virtual Vector2 Position { + get => ResNode->Position; + set => ResNode->Position = value; + } + + public virtual float ScreenX { + get => ResNode->ScreenX; + set => ResNode->ScreenX = value; + } + + public virtual float ScreenY { + get => ResNode->ScreenY; + set => ResNode->ScreenY = value; + } + + public virtual Vector2 ScreenPosition + => ResNode->ScreenPosition; + + public virtual float Width { + get => ResNode->GetWidth(); + set { + ResNode->SetWidth((ushort)value); + OnSizeChanged(); + } + } + + public virtual float Height { + get => ResNode->GetHeight(); + set { + ResNode->SetHeight((ushort)value); + OnSizeChanged(); + } + } + + public virtual Vector2 Size { + get => ResNode->Size; + set { + ResNode->SetWidth((ushort)value.X); + ResNode->SetHeight((ushort)value.Y); + OnSizeChanged(); + } + } + + public Bounds Bounds + => ResNode->Bounds; + + public Vector2 Center + => ResNode->Center; + + public virtual float ScaleX { + get => ResNode->GetScaleX(); + set => ResNode->SetScaleX(value); + } + + public virtual float ScaleY { + get => ResNode->GetScaleY(); + set => ResNode->SetScaleY(value); + } + + public virtual Vector2 Scale { + get => ResNode->Scale; + set => ResNode->Scale = value; + } + + public virtual float Rotation { + get => ResNode->GetRotation(); + set => ResNode->SetRotation(value); + } + + public virtual float RotationDegrees { + get => ResNode->RotationDegrees; + set => ResNode->RotationDegrees = value; + } + + public virtual float OriginX { + get => ResNode->OriginX; + set => ResNode->OriginX = value; + } + + public virtual float OriginY { + get => ResNode->OriginY; + set => ResNode->OriginY = value; + } + + public virtual Vector2 Origin { + get => ResNode->Origin; + set => ResNode->Origin = value; + } + + private bool? lastIsVisible; + + public virtual bool IsVisible { + get => ResNode->Visible; + set { + ResNode->Visible = value; + if (lastIsVisible is null || lastIsVisible != value) { + OnVisibilityToggled?.Invoke(value); + lastIsVisible = value; + } + } + } + + private Action? OnVisibilityToggled { get; set; } + + public NodeFlags NodeFlags { + get => ResNode->NodeFlags; + set => ResNode->NodeFlags = value; + } + + public virtual Vector4 Color { + get => ResNode->ColorVector; + set => ResNode->ColorVector = value; + } + + public virtual ColorHelpers.HsvaColor ColorHsva { + get => ResNode->ColorHsva; + set => ResNode->ColorHsva = value; + } + + public virtual float Alpha { + get => ResNode->Color.A; + set => ResNode->SetAlpha((byte)(value * 255.0f)); + } + + public virtual Vector3 AddColor { + get => ResNode->AddColor; + set => ResNode->AddColor = value; + } + + public virtual ColorHelpers.HsvaColor AddColorHsva { + get => ResNode->AddColorHsva; + set => ResNode->AddColorHsva = value; + } + + public virtual Vector3 MultiplyColor { + get => ResNode->MultiplyColor; + set => ResNode->MultiplyColor = value; + } + + public virtual ColorHelpers.HsvaColor MultiplyColorHsva { + get => ResNode->MultiplyColorHsva; + set => ResNode->MultiplyColorHsva = value; + } + + public uint NodeId { + get => ResNode->NodeId; + set => ResNode->NodeId = value; + } + + public virtual DrawFlags DrawFlags { + get => (DrawFlags) ResNode->DrawFlags; + set => ResNode->DrawFlags = (uint) value & 0b1111_1111_1111_1100_0000_0011_1111_1111 | + ResNode->DrawFlags & 0b0000_0000_0000_0011_1111_1100_0000_0000; + } + + public virtual int ClipCount { + get => (int)((ResNode->DrawFlags & 0b0000_0000_0000_0011_1111_1100_0000_0000) >> 10); + set => ResNode->DrawFlags = (uint)(value << 10 & 0b0000_0000_0000_0011_1111_1100_0000_0000) + | ResNode->DrawFlags & 0b1111_1111_1111_1100_0000_0011_1111_1111; + } + + public void AddDrawFlags(params DrawFlags[] flags) { + foreach (var flag in flags) { + DrawFlags |= flag; + } + } + + public void RemoveDrawFlags(params DrawFlags[] flags) { + foreach (var flag in flags) { + DrawFlags &= ~flag; + } + } + + public int Priority { + get => ResNode->GetPriority(); + set => ResNode->SetPriority((ushort)value); + } + + protected virtual NodeType NodeType { + get => ResNode->GetNodeType(); + set => ResNode->Type = value; + } + + public virtual int ChildCount + => ResNode->ChildCount; + + protected virtual void OnSizeChanged() { } + + public void AddNodeFlags(params NodeFlags[] flags) { + foreach (var flag in flags) { + NodeFlags |= flag; + } + } + + public void RemoveNodeFlags(params NodeFlags[] flags) { + foreach (var flag in flags) { + NodeFlags &= ~flag; + } + } + + public void MarkDirty() { + foreach (var child in GetAllChildren(this)) { + child.ResNode->AddDrawFlag( [ DrawFlags.IsDirty ] ); + } + ResNode->AddDrawFlag([ DrawFlags.IsDirty ] ); + } + + public bool CheckCollision(short x, short y, bool inclusive = true) + => ResNode->CheckCollision(x, y, inclusive); + + public bool CheckCollision(Vector2 position, bool inclusive = true) + => ResNode->CheckCollision((short) position.X, (short) position.Y, inclusive); + + public bool CheckCollision(AtkEventData* eventData, bool inclusive = true) + => ResNode->CheckCollision(eventData, inclusive); + + public Matrix2x2 Transform { + get => ResNode->Transform; + set => ResNode->Transform = value; + } +} diff --git a/KamiToolKit/NodeBase/NodeBase.Timeline.cs b/KamiToolKit/NodeBase/NodeBase.Timeline.cs new file mode 100644 index 0000000..c5de9fc --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.Timeline.cs @@ -0,0 +1,19 @@ +using KamiToolKit.Timelines; + +namespace KamiToolKit; + +public abstract unsafe partial class NodeBase { + + public Timeline? Timeline { get; private set; } + + public void AddTimeline(Timeline timeline) { + Timeline?.Dispose(); + + Timeline = timeline; + ResNode->Timeline = timeline.InternalTimeline; + timeline.OwnerNode = ResNode; + } + + public void AddTimeline(TimelineBuilder builder) + => AddTimeline(builder.Build()); +} diff --git a/KamiToolKit/NodeBase/NodeBase.Tooltips.cs b/KamiToolKit/NodeBase/NodeBase.Tooltips.cs new file mode 100644 index 0000000..97866be --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.Tooltips.cs @@ -0,0 +1,151 @@ +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.Enums; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit; + +public record InventoryItemTooltip(InventoryType Inventory, short Slot); + +public unsafe partial class NodeBase { + + private AtkTooltipManager.AtkTooltipType tooltipType = AtkTooltipManager.AtkTooltipType.None; + private bool tooltipEventsRegistered; + + public virtual ReadOnlySeString TextTooltip { + get; + set { + field = value; + if (!value.IsEmpty) { + TryRegisterTooltipEvents(); + tooltipType |= AtkTooltipManager.AtkTooltipType.Text; + } + else { + tooltipType &= ~AtkTooltipManager.AtkTooltipType.Text; + } + } + } + + public virtual uint ActionTooltip { + get; + set { + field = value; + if (value is not 0) { + TryRegisterTooltipEvents(); + tooltipType |= AtkTooltipManager.AtkTooltipType.Action; + } + else { + tooltipType &= ~AtkTooltipManager.AtkTooltipType.Action; + } + } + } + + public virtual uint ItemTooltip { + get; + set { + field = value; + if (value is not 0) { + TryRegisterTooltipEvents(); + tooltipType |= AtkTooltipManager.AtkTooltipType.Item; + } + else { + tooltipType &= ~AtkTooltipManager.AtkTooltipType.Item; + } + } + } + + public virtual InventoryItemTooltip? InventoryItemTooltip { + get; + set { + field = value; + if (value is not null) { + TryRegisterTooltipEvents(); + tooltipType |= AtkTooltipManager.AtkTooltipType.Item; + } + else { + tooltipType &= ~AtkTooltipManager.AtkTooltipType.Item; + } + } + } + + private void TryRegisterTooltipEvents() { + if (tooltipEventsRegistered) return; + + AddEvent(AtkEventType.MouseOver, ShowTooltip); + AddEvent(AtkEventType.MouseOut, HideTooltip); + OnVisibilityToggled += ToggleCollisionFlag; + ToggleCollisionFlag(IsVisible); + + tooltipEventsRegistered = true; + } + + private void UnregisterTooltipEvents() { + if (tooltipEventsRegistered) { + RemoveEvent(AtkEventType.MouseOver, ShowTooltip); + RemoveEvent(AtkEventType.MouseOut, HideTooltip); + OnVisibilityToggled -= ToggleCollisionFlag; + tooltipEventsRegistered = false; + } + } + + private void ToggleCollisionFlag(bool isVisible) { + if (this is ComponentNode) return; + + if (isVisible) { + AddNodeFlags(NodeFlags.HasCollision); + } + else { + RemoveNodeFlags(NodeFlags.HasCollision); + } + } + + protected bool TooltipRegistered { get; set; } + + public void ShowTooltip() { + if (ParentAddon is null) return; // Shouldn't be possible + if (tooltipType is AtkTooltipManager.AtkTooltipType.None) return; + + using var stringBuilder = new RentedSeStringBuilder(); + using var stringBuffer = new AtkValue(); + if (!TextTooltip.IsEmpty) { + stringBuffer.SetManagedString(stringBuilder.Builder.Append(TextTooltip).GetViewAsSpan()); + } + + var tooltipArgs = new AtkTooltipManager.AtkTooltipArgs(); + + if (tooltipType.HasFlag(AtkTooltipManager.AtkTooltipType.Text)) { + tooltipArgs.TextArgs.AtkArrayType = 0; + tooltipArgs.TextArgs.Text = stringBuffer.String; + } + + if (tooltipType.HasFlag(AtkTooltipManager.AtkTooltipType.Action)) { + tooltipArgs.ActionArgs.Flags = 1; + tooltipArgs.ActionArgs.Kind = DetailKind.Action; + tooltipArgs.ActionArgs.Id = (int)ActionTooltip; + } + + if (tooltipType.HasFlag(AtkTooltipManager.AtkTooltipType.Item) && InventoryItemTooltip is {} inventoryTooltip) { + tooltipArgs.ItemArgs.Kind = DetailKind.InventoryItem; + tooltipArgs.ItemArgs.InventoryType = inventoryTooltip.Inventory; + tooltipArgs.ItemArgs.Slot = inventoryTooltip.Slot; + tooltipArgs.ItemArgs.BuyQuantity = -1; + tooltipArgs.ItemArgs.Flag1 = 0; + } + else if (tooltipType.HasFlag(AtkTooltipManager.AtkTooltipType.Item) && InventoryItemTooltip is null) { + tooltipArgs.ItemArgs.Kind = DetailKind.Item; + tooltipArgs.ItemArgs.ItemId = (int) ItemTooltip; + tooltipArgs.ItemArgs.BuyQuantity = -1; + tooltipArgs.ItemArgs.Flag1 = 0; + } + + AtkStage.Instance()->TooltipManager.ShowTooltip(tooltipType, ParentAddon->Id, this, &tooltipArgs); + } + + public void HideTooltip() { + if (ParentAddon is null) return; + + AtkStage.Instance()->TooltipManager.HideTooltip(ParentAddon->Id); + } +} diff --git a/KamiToolKit/NodeBase/NodeBase.cs b/KamiToolKit/NodeBase/NodeBase.cs new file mode 100644 index 0000000..3b5749b --- /dev/null +++ b/KamiToolKit/NodeBase/NodeBase.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit; + +public abstract unsafe class NodeBase : NodeBase where T : unmanaged, ICreatable { + protected NodeBase(NodeType nodeType) { + if (MainThreadSafety.TryAssertMainThread()) return; + + Log.Verbose($"Creating new node {GetType()}"); + Node = NativeMemoryHelper.Create(); + + if (ResNode is null) { + throw new Exception($"Unable to allocate memory for {typeof(T)}"); + } + + KamiToolKitLibrary.AllocatedNodes?.TryAdd((nint)Node, GetType()); + + BuildVirtualTable(); + + ResNode->Type = nodeType; + ResNode->NodeId = NodeIdBase + CurrentOffset++; + ResNode->ToggleVisibility(true); + + CreatedNodes.Add(this); + } + + public T* Node { get; private set; } + + internal sealed override AtkResNode* ResNode => (AtkResNode*)Node; + + public static implicit operator T*(NodeBase node) => (T*) node.ResNode; + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + try { + base.Dispose(disposing, isNativeDestructor); + } + catch (Exception e) { + Log.Exception(e); + } + finally { + if (!isNativeDestructor) { + InvokeOriginalDestructor(ResNode, true); + } + + KamiToolKitLibrary.AllocatedNodes?.Remove((nint)Node, out _); + + Node = null; + } + } + } +} diff --git a/KamiToolKit/Nodes/Basic/AlphaImageNode.cs b/KamiToolKit/Nodes/Basic/AlphaImageNode.cs new file mode 100644 index 0000000..73d9c0c --- /dev/null +++ b/KamiToolKit/Nodes/Basic/AlphaImageNode.cs @@ -0,0 +1,11 @@ +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public sealed class AlphaImageNode : ImGuiImageNode { + public AlphaImageNode() { + TexturePath = DalamudInterface.Instance.GetAssetPath("alpha_background.png"); + WrapMode = WrapMode.Tile; + } +} diff --git a/KamiToolKit/Nodes/Basic/AlternateCooldownNode.cs b/KamiToolKit/Nodes/Basic/AlternateCooldownNode.cs new file mode 100644 index 0000000..68121e1 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/AlternateCooldownNode.cs @@ -0,0 +1,47 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class AlternateCooldownNode : ResNode { + + public readonly ImageNode CooldownImage; + + public AlternateCooldownNode() { + CooldownImage = new ImageNode { + NodeId = 15, + Size = new Vector2(44.0f, 46.0f), + Position = new Vector2(0.0f, 2.0f), + Origin = new Vector2(22.0f, 23.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + PartId = 80, + }; + + IconNodeTextureHelper.LoadIconARecast2Texture(CooldownImage); + + CooldownImage.AttachNode(this); + + BuildTimeline(); + } + + private void BuildTimeline() { + CooldownImage.AddTimeline(new TimelineBuilder() + .BeginFrameSet(11, 92) + .AddFrame(11, alpha: 255, scale: new Vector2(1.0f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f), partId: 1) + .AddFrame(92, alpha: 255, scale: new Vector2(1.0f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f), partId: 79) + .EndFrameSet() + .BeginFrameSet(93, 174) + .AddFrame(93, alpha: 255, scale: new Vector2(1.0f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f), partId: 82) + .AddFrame(174, alpha: 255, scale: new Vector2(1.0f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f), partId: 160) + .EndFrameSet() + .BeginFrameSet(175, 205) + .AddFrame(175, alpha: 255, scale: new Vector2(1.0f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f), partId: 80) + .AddFrame(191, alpha: 255, scale: new Vector2(1.2f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(200.0f), partId: 80) + .AddFrame(205, alpha: 0, scale: new Vector2(1.25f), multiplyColor: new Vector3(100.0f), addColor: new Vector3(200.0f), partId: 80) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/AntsNode.cs b/KamiToolKit/Nodes/Basic/AntsNode.cs new file mode 100644 index 0000000..fc61773 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/AntsNode.cs @@ -0,0 +1,36 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class AntsNode : ResNode { + + public readonly ImageNode AntsImageNode; + + public AntsNode() { + AntsImageNode = new ImageNode { + NodeId = 13, + Size = new Vector2(48, 48), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + PartId = 13, + }; + + IconNodeTextureHelper.LoadIconAFrameTexture(AntsImageNode); + + AntsImageNode.AttachNode(this); + + BuildTimeline(); + } + + private void BuildTimeline() { + AntsImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(2, 9) + .AddFrame(2, partId: 6) + .AddFrame(9, partId: 13) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/BackgroundImageNode.cs b/KamiToolKit/Nodes/Basic/BackgroundImageNode.cs new file mode 100644 index 0000000..94c3c1f --- /dev/null +++ b/KamiToolKit/Nodes/Basic/BackgroundImageNode.cs @@ -0,0 +1,26 @@ +using System.Numerics; +using Dalamud.Interface; + +namespace KamiToolKit.Nodes; + +/// +/// A simple image node that makes it easy to display a single color. +/// +public unsafe class BackgroundImageNode : SimpleImageNode { + public BackgroundImageNode() { + FitTexture = true; + } + + public new Vector4 Color { + get => new(AddColor.X, AddColor.Y, AddColor.Z, ResNode->Color.A / 255.0f); + set { + ResNode->Color = new Vector4(0.0f, 0.0f, 0.0f, value.W).ToByteColor(); + AddColor = value.AsVector3Color(); + } + } + + public new ColorHelpers.HsvaColor ColorHsva { + get => ColorHelpers.RgbaToHsv(Color); + set => Color = ColorHelpers.HsvToRgb(value); + } +} diff --git a/KamiToolKit/Nodes/Basic/BorderNineGridNode.cs b/KamiToolKit/Nodes/Basic/BorderNineGridNode.cs new file mode 100644 index 0000000..29c76e0 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/BorderNineGridNode.cs @@ -0,0 +1,24 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +/// +/// A node that shows a border loaded from the party list textures +/// +public unsafe class BorderNineGridNode : NineGridNode { + public BorderNineGridNode() { + PartsList.Add(new Part { + TextureCoordinates = new Vector2(0.0f, 0.0f), + Size = new Vector2(64.0f, 64.0f), + Id = 0, + TexturePath = "ui/uld/PartyListTargetBase.tex", + }); + + TopOffset = 20; + LeftOffset = 20; + RightOffset = 20; + BottomOffset = 20; + PartsRenderType = 108; + } +} diff --git a/KamiToolKit/Nodes/Basic/CategoryTextNode.cs b/KamiToolKit/Nodes/Basic/CategoryTextNode.cs new file mode 100644 index 0000000..797620c --- /dev/null +++ b/KamiToolKit/Nodes/Basic/CategoryTextNode.cs @@ -0,0 +1,23 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +// Simple helper class for making basic text label, node will auto-resize to fit label +public sealed class CategoryTextNode : TextNode { + public CategoryTextNode() { + Height = 16.0f; + TextFlags = TextFlags.AutoAdjustNodeSize; + TextColor = ColorHelper.GetColor(2); + TextOutlineColor = ColorHelper.GetColor(7); + FontType = FontType.Axis; + FontSize = 14; + LineSpacing = 24; + AlignmentType = AlignmentType.Left; + } + + public override float Height { + get => base.Height; + set => base.Height = value + 8.0f; // Add extra height for padding + } +} diff --git a/KamiToolKit/Nodes/Basic/CheckboxNode.cs b/KamiToolKit/Nodes/Basic/CheckboxNode.cs new file mode 100644 index 0000000..89d019b --- /dev/null +++ b/KamiToolKit/Nodes/Basic/CheckboxNode.cs @@ -0,0 +1,230 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class CheckboxNode : ComponentNode { + + public readonly ImageNode BoxBackground; + public readonly ImageNode BoxForeground; + public readonly TextNode Label; + + public CheckboxNode() { + SetInternalComponentType(ComponentType.CheckBox); + + BoxBackground = new SimpleImageNode { + TexturePath = "ui/uld/CheckBoxA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(16.0f, 16.0f), + Size = new Vector2(16.0f, 16.0f), + Position = new Vector2(0.0f, 2.0f), + WrapMode = WrapMode.Stretch, + }; + BoxBackground.AttachNode(this); + + BoxForeground = new SimpleImageNode { + TexturePath = "ui/uld/CheckBoxA.tex", + TextureCoordinates = new Vector2(16.0f, 0.0f), + TextureSize = new Vector2(16.0f, 16.0f), + Size = new Vector2(16.0f, 16.0f), + Position = new Vector2(0.0f, 2.0f), + WrapMode = WrapMode.Stretch, + }; + BoxForeground.AttachNode(this); + + Label = new TextNode { + Size = new Vector2(0.0f, 20.0f), + Position = new Vector2(20.0f, 0.0f), + FontType = FontType.Axis, + AlignmentType = AlignmentType.Left, + FontSize = 14, + LineSpacing = 14, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + TextFlags = TextFlags.AutoAdjustNodeSize, + }; + Label.AttachNode(this); + + Component->Flags = 606464; + + Data->Nodes[0] = Label.NodeId; + Data->Nodes[1] = BoxBackground.NodeId; + Data->Nodes[2] = 0; + + LoadTimelines(); + + AddEvent(AtkEventType.ButtonClick, ClickHandler); + AddEvent(AtkEventType.InputReceived, ClickHandler); + + InitializeComponentEvents(); + Component->Left = 20; + Component->Right = 20; + Component->Top = 0; + Component->Bottom = 0; + + BoxForeground.IsVisible = Component->IsChecked; + BoxForeground.DrawFlags = 0; + } + + public Action? OnClick { get; set; } + + public ReadOnlySeString String { + get => Label.String; + set { + Label.String = value; + Width = Height + Label.Width + 4.0f; + } + } + + public bool IsChecked { + get => Component->IsChecked; + set => Component->SetChecked(value); + } + + private void ClickHandler() { + OnClick?.Invoke(Component->IsChecked); + } + + public bool DisableAutoResize { + get => Label.TextFlags.HasFlag(TextFlags.AutoAdjustNodeSize); + set { + if (value) { + Label.TextFlags &= ~TextFlags.AutoAdjustNodeSize; + Label.TextFlags |= TextFlags.Ellipsis; + } + else { + Label.TextFlags |= TextFlags.AutoAdjustNodeSize; + Label.TextFlags &= ~TextFlags.Ellipsis; + } + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BoxBackground.Size = new Vector2(Height, Height) - new Vector2(4.0f, 4.0f); + BoxForeground.Size = new Vector2(Height, Height) - new Vector2(4.0f, 4.0f); + + Label.Height = Height; + Label.X = Height; + + if (DisableAutoResize) { + Label.Width = Width - Height; + } + } + + private void LoadTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 155) + .AddLabelPair(1, 10, 1) + .AddLabelPair(11, 20, 2) + .AddLabelPair(21, 30, 3) + .AddLabelPair(31, 40, 7) + .AddLabelPair(41, 50, 6) + .AddLabelPair(51, 60, 4) + .AddLabelPair(61, 70, 8) + .AddLabelPair(71, 80, 9) + .AddLabelPair(81, 90, 10) + .AddLabelPair(91, 100, 14) + .AddLabelPair(101, 110, 13) + .AddLabelPair(111, 115, 11) + .AddLabelPair(116, 125, 12) + .AddLabelPair(126, 135, 5) + .AddLabelPair(136, 145, 15) + .AddLabelPair(146, 155, 16) + .EndFrameSet() + .Build()); + + CollisionNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 155) + .AddEmptyFrame(1) + .EndFrameSet() + .Build()); + + BoxBackground.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(11, 20) + .AddFrame(11, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(13, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(21, 30, 21, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .AddFrameSetWithFrame(31, 40, 31, new Vector2(0.0f, 2.0f), 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(41, 50, 41, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .BeginFrameSet(51, 60) + .AddFrame(51, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .AddFrame(60, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(61, 70, 61, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(71, 80) + .AddFrame(71, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(73, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(81, 90, 81, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .AddFrameSetWithFrame(91, 100, 91, new Vector2(0.0f, 2.0f), 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(101, 110, 101, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .BeginFrameSet(111, 115) + .AddFrame(111, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .AddFrame(115, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(116, 125, 116, new Vector2(0.0f, 2.0f), addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(126, 135, 126, new Vector2(0.0f, 2.0f), 255, new Vector3(16.0f), new Vector3(100.0f)) + .AddFrameSetWithFrame(136, 145, 126, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(146, 155, 146, new Vector2(0.0f, 2.0f), 255, multiplyColor: new Vector3(100.0f)) + .Build()); + + BoxForeground.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(61, 70, 61, alpha: 255, multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(71, 80) + .AddFrame(71, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(73, alpha: 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(81, 90, 81, alpha: 255, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(91, 100, 91, alpha: 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(101, 110, 101, alpha: 255, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(111, 115) + .AddFrame(111, alpha: 255, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .AddFrame(115, alpha: 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .BeginFrameSet(116, 125) + .AddFrame(116, alpha: 0, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .AddFrame(119, alpha: 255, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .BeginFrameSet(126, 135) + .AddFrame(126, alpha: 255, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .AddFrame(129, alpha: 0, addColor: new Vector3(16.0f), multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .BeginFrameSet(136, 145) + .AddFrame(136, alpha: 0, multiplyColor: new Vector3(100.0f)) + .AddFrame(140, alpha: 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .BeginFrameSet(146, 255) + .AddFrame(146, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(150, alpha: 0, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .Build()); + + Label.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(11, 20, 11, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(21, 30, 21, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(31, 40, 31, alpha: 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(41, 50, 41, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(51, 60, 51, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(61, 70, 61, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(71, 80, 71, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(81, 90, 81, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(91, 100, 91, alpha: 102, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(101, 110, 101, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(111, 115, 111, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(116, 135, 116, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(126, 135, 126, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(136, 145, 136, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(146, 155, 146, alpha: 255, multiplyColor: new Vector3(100.0f)) + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/ClippingMaskNode.cs b/KamiToolKit/Nodes/Basic/ClippingMaskNode.cs new file mode 100644 index 0000000..0bb246f --- /dev/null +++ b/KamiToolKit/Nodes/Basic/ClippingMaskNode.cs @@ -0,0 +1,36 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public unsafe class ClippingMaskNode : NodeBase { + public readonly PartsList PartsList; + + public ClippingMaskNode() : base(NodeType.ClippingMask) { + PartsList = new PartsList(); + + Node->PartsList = PartsList.InternalPartsList; + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + if (!isNativeDestructor) { + PartsList.Dispose(); + Node->PartsList = null; + } + + base.Dispose(disposing, isNativeDestructor); + } + } + + public ushort PartId { + get => Node->PartId; + set => Node->PartId = value; + } + + public AtkUldPart* AddPart(Part part) + => PartsList.Add(part); + + public void AddPart(params Part[] parts) + => PartsList.Add(parts); +} diff --git a/KamiToolKit/Nodes/Basic/CollisionNode.cs b/KamiToolKit/Nodes/Basic/CollisionNode.cs new file mode 100644 index 0000000..04ca453 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/CollisionNode.cs @@ -0,0 +1,20 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public unsafe class CollisionNode() : NodeBase(NodeType.Collision) { + public virtual CollisionType CollisionType { + get => Node->CollisionType; + set => Node->CollisionType = value; + } + + public virtual uint Uses { + get => Node->Uses; + set => Node->Uses = (ushort)value; + } + + public virtual AtkComponentBase* LinkedComponent { + get => Node->LinkedComponent; + set => Node->LinkedComponent = value; + } +} diff --git a/KamiToolKit/Nodes/Basic/CooldownNode.cs b/KamiToolKit/Nodes/Basic/CooldownNode.cs new file mode 100644 index 0000000..771648c --- /dev/null +++ b/KamiToolKit/Nodes/Basic/CooldownNode.cs @@ -0,0 +1,63 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class CooldownNode : ResNode { + + public readonly ImageNode CooldownImage; + public readonly ImageNode GlossyImageFrame; + + public CooldownNode() { + GlossyImageFrame = new ImageNode { + NodeId = 18, + Size = new Vector2(48.0f, 48.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + }; + + IconNodeTextureHelper.LoadIconAFrameTexture(GlossyImageFrame); + + GlossyImageFrame.AttachNode(this); + + CooldownImage = new ImageNode { + NodeId = 17, + Size = new Vector2(44.0f, 46.0f), + Position = new Vector2(2.0f, 2.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + PartId = 80, + }; + + IconNodeTextureHelper.LoadIconARecastTexture(CooldownImage); + + CooldownImage.AttachNode(this); + + BuildTimelines(); + } + + private void BuildTimelines() { + GlossyImageFrame.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, partId: 0) + .AddFrameSetWithFrame(11, 20, 11, partId: 1) + .AddFrameSetWithFrame(21, 30, 21, partId: 2) + .AddFrameSetWithFrame(31, 40, 31, partId: 3) + .AddFrameSetWithFrame(41, 50, 41, partId: 18) + .AddFrameSetWithFrame(51, 60, 51, partId: 19) + .AddFrameSetWithFrame(143, 165, 143, partId: 0) + .Build()); + + CooldownImage.AddTimeline(new TimelineBuilder() + .BeginFrameSet(61, 142) + .AddFrame(61, alpha: 255, partId: 1) + .AddFrame(142, alpha: 255, partId: 79) + .EndFrameSet() + .BeginFrameSet(143, 165) + .AddFrame(143, alpha: 255, partId: 80) + .AddFrame(165, alpha: 0, partId: 79) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/CounterNode.cs b/KamiToolKit/Nodes/Basic/CounterNode.cs new file mode 100644 index 0000000..6d4bce6 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/CounterNode.cs @@ -0,0 +1,155 @@ +using System.Numerics; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using Lumina.Text.Payloads; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +/// +/// A counter node for displaying numbers +/// +public unsafe class CounterNode : NodeBase { + + public readonly PartsList PartsList; + + public CounterNode() : base(NodeType.Counter) { + PartsList = new PartsList(); + PartsList.Add(new Part()); + + Node->PartsList = PartsList.InternalPartsList; + + NumberWidth = 10; + CommaWidth = 8; + SpaceWidth = 6; + TextAlignment = AlignmentType.Right; + CounterWidth = 32; + Font = CounterFont.MoneyFont; + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + if (!isNativeDestructor) { + PartsList.Dispose(); + Node->PartsList = null; + } + + base.Dispose(disposing, isNativeDestructor); + } + } + + protected string TexturePath { + get => PartsList[0]->LoadedPath; + set => PartsList[0]->LoadTexture(value); + } + + protected Vector2 TextureCoordinates { + get => new(PartsList[0]->U, PartsList[0]->V); + set { + PartsList[0]->U = (ushort) value.X; + PartsList[0]->V = (ushort) value.X; + } + } + + protected Vector2 TextureSize { + get => new(PartsList[0]->Width, PartsList[0]->Height); + set { + PartsList[0]->Width = (ushort) value.X; + PartsList[0]->Height = (ushort) value.X; + } + } + + public uint NumberWidth { + get => Node->NumberWidth; + set => Node->NumberWidth = (byte)value; + } + + public uint CommaWidth { + get => Node->CommaWidth; + set => Node->CommaWidth = (byte)value; + } + + public uint SpaceWidth { + get => Node->SpaceWidth; + set => Node->SpaceWidth = (byte)value; + } + + public AlignmentType TextAlignment { + get => (AlignmentType) Node->TextAlign; + set => Node->TextAlign = (ushort) value; + } + + public float CounterWidth { + get => Node->CounterWidth; + set => Node->CounterWidth = value; + } + + public int Number { + get => int.Parse(Node->NodeText.ToString()); + set => Node->SetText(ParseNumber(value)); + } + + public ReadOnlySeString String { + get => Node->NodeText.AsSpan(); + set => Node->SetText(ParseString(value)); + } + + public CounterFont Font { + get; + set { + field = value; + + var fontPath = string.Empty; + var partSize = Vector2.Zero; + + switch (value) { + case CounterFont.MoneyFont: + fontPath = "ui/uld/Money_Number.tex"; + partSize = new Vector2(22.0f, 22.0f); + break; + + case CounterFont.ChocoboRace: + fontPath = "ui/uld/RaceChocoboNum.tex"; + partSize = new Vector2(30.0f, 60.0f); + break; + } + + if (fontPath != string.Empty && partSize != Vector2.Zero) { + PartsList[0]->Width = (ushort)partSize.X; + PartsList[0]->Height = (ushort)partSize.Y; + PartsList[0]->LoadTexture(fontPath); + } + } + } + + private static ReadOnlySeString ParseString(ReadOnlySeString value) { + using var builder = new RentedSeStringBuilder(); + return builder.Builder.Append(value).GetViewAsSpan(); + } + + private static ReadOnlySeString ParseNumber(int value) { + using var rentedBuilder = new RentedSeStringBuilder(); + + // + var evaluatedString = DalamudInterface.Instance.SeStringEvaluator.EvaluateFromAddon(18, [ value ]); + + foreach (var payload in evaluatedString) { + switch (payload.Type) { + + // Fix for French thousands separators. + // The game calls FormatAddonText2 that does this. + case ReadOnlySePayloadType.Macro when payload.MacroCode is MacroCode.NonBreakingSpace: + rentedBuilder.Builder.Append(' '); + break; + + default: + rentedBuilder.Builder.Append(payload); + break; + } + } + + return rentedBuilder.Builder.GetViewAsSpan(); + } +} diff --git a/KamiToolKit/Nodes/Basic/CursorNode.cs b/KamiToolKit/Nodes/Basic/CursorNode.cs new file mode 100644 index 0000000..0eec326 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/CursorNode.cs @@ -0,0 +1,30 @@ +using System.Numerics; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class CursorNode : ResNode { + + public readonly SimpleImageNode CursorImageNode; + + public CursorNode() { + CursorImageNode = new SimpleImageNode { + NodeId = 3, + TexturePath = "ui/uld/TextInputA.tex", + Size = new Vector2(4.0f, 24.0f), + TextureCoordinates = new Vector2(68.0f, 0.0f), + TextureSize = new Vector2(4.0f, 24.0f), + WrapMode = WrapMode.Tile, + }; + CursorImageNode.AttachNode(this); + + CursorImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 8) + .AddEmptyFrame(1) + .EndFrameSet() + .Build()); + + Timeline?.PlayAnimation(101); + } +} diff --git a/KamiToolKit/Nodes/Basic/DragDropNode.cs b/KamiToolKit/Nodes/Basic/DragDropNode.cs new file mode 100644 index 0000000..1a57d2b --- /dev/null +++ b/KamiToolKit/Nodes/Basic/DragDropNode.cs @@ -0,0 +1,271 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.Enums; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class DragDropNode : ComponentNode { + + public readonly ImageNode DragDropBackgroundNode; + public readonly IconNode IconNode; + + public DragDropNode() { + SetInternalComponentType(ComponentType.DragDrop); + + DragDropBackgroundNode = new SimpleImageNode { + NodeId = 3, + Size = new Vector2(44.0f, 44.0f), + TexturePath = "ui/uld/DragTargetA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(44.0f, 44.0f), + WrapMode = WrapMode.Tile, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + }; + DragDropBackgroundNode.AttachNode(this); + + IconNode = new IconNode { + NodeId = 2, + Size = new Vector2(44.0f, 48.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + }; + IconNode.AttachNode(this); + + LoadTimelines(); + + Data->Nodes[0] = IconNode.NodeId; + + AcceptedType = DragDropType.Everything; + Payload = new DragDropPayload(); + + Component->AtkDragDropInterface.DragDropType = DragDropType.Everything; + Component->AtkDragDropInterface.DragDropReferenceIndex = 0; + + InitializeComponentEvents(); + + AddEvent(AtkEventType.DragDropBegin, DragDropBeginHandler); + AddEvent(AtkEventType.DragDropInsert, DragDropInsertHandler); + AddEvent(AtkEventType.DragDropDiscard, DragDropDiscardHandler); + AddEvent(AtkEventType.DragDropClick, DragDropClickHandler); + AddEvent(AtkEventType.DragDropRollOver, DragDropRollOverHandler); + AddEvent(AtkEventType.DragDropRollOut, DragDropRollOutHandler); + } + + private bool IsDragDropEndRegistered { get; set; } + + /// + /// Event that is triggered when a DragDrop is beginning + /// + public Action? OnBegin { get; set; } + + /// + /// Event that is triggered when a DragDrop has finished + /// + public Action? OnEnd { get; set; } + + /// + /// Event that is triggered when a compatible DragDrop is dropped onto this node + /// + public Action? OnPayloadAccepted { get; set; } + + /// + /// Event that is triggered when the item in this drag drop is being dropped onto the world + /// + public Action? OnDiscard { get; set; } + + /// + /// Event that is triggered when the item is clicked + /// + public Action? OnClicked { get; set; } + + /// + /// Event that is triggered when the item is being moused over + /// + public Action? OnRollOver { get; set; } + + /// + /// Event that is triggered when the item is no longer being moused over + /// + public Action? OnRollOut { get; set; } + + public DragDropPayload Payload { get; set; } + + public uint IconId { + get => IconNode.IconId; + set { + IconNode.IconId = value; + IconNode.IsVisible = value != 0; + } + } + + public bool IsIconDisabled { + get => IconNode.IsIconDisabled; + set => IconNode.IsIconDisabled = value; + } + + public int Quantity { + get => int.Parse(Component->GetQuantityText().ToString()); + set => Component->SetQuantity(value); + } + + public string QuantityString { + get => Component->GetQuantityText().ToString(); + set => Component->SetQuantityText(value); + } + + public DragDropType AcceptedType { + get => Component->AcceptedType; + set => Component->AcceptedType = value; + } + + public AtkDragDropInterface.SoundEffectSuppression SoundEffectSuppression { + get => Component->AtkDragDropInterface.DragDropSoundEffectSuppression; + set => Component->AtkDragDropInterface.DragDropSoundEffectSuppression = value; + } + + public bool IsDraggable { + get => !Component->Flags.HasFlag(DragDropFlag.Locked); + set { + if (value) { + Component->Flags &= ~DragDropFlag.Locked; + } + else { + Component->Flags |= DragDropFlag.Locked; + } + } + } + + /// + /// When true, allows left-clicking the item to trigger OnClicked + /// + public bool IsClickable { + get => Component->Flags.HasFlag(DragDropFlag.Clickable); + set { + if (value) { + Component->Flags |= DragDropFlag.Clickable; + } + else { + Component->Flags &= ~DragDropFlag.Clickable; + } + } + } + + private void DragDropBeginHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + atkEvent->SetEventIsHandled(); + Payload.ToDragDropInterface(atkEventData->DragDropData.DragDropInterface); + OnBegin?.Invoke(this); + + if (!IsDragDropEndRegistered) { + AddEvent(AtkEventType.DragDropEnd, DragDropEndHandler); + IsDragDropEndRegistered = true; + } + } + + public override ReadOnlySeString TextTooltip { + get; + set { + field = value; + switch (value) { + case { IsEmpty: false } when !TooltipRegistered: + AddEvent(AtkEventType.DragDropRollOver, ShowTooltip); + AddEvent(AtkEventType.DragDropRollOut, HideTooltip); + + TooltipRegistered = true; + break; + } + } + } + + private void DragDropInsertHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + atkEvent->SetEventIsHandled(); + + atkEvent->State.StateFlags |= AtkEventStateFlags.HasReturnFlags; + atkEvent->State.ReturnFlags = 1; + + var payload = DragDropPayload.FromDragDropInterface(atkEventData->DragDropData.DragDropInterface); + + Payload.Clear(); + IconId = 0; + + OnPayloadAccepted?.Invoke(this, payload); + } + + private void DragDropDiscardHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + atkEvent->SetEventIsHandled(); + + atkEvent->State.StateFlags |= AtkEventStateFlags.HasReturnFlags; + atkEvent->State.ReturnFlags = 1; + + OnDiscard?.Invoke(this); + } + + private void DragDropEndHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + atkEvent->SetEventIsHandled(); + atkEventData->DragDropData.DragDropInterface->GetPayloadContainer()->Clear(); + OnEnd?.Invoke(this); + + if (IsDragDropEndRegistered) { + RemoveEvent(AtkEventType.DragDropEnd, DragDropEndHandler); + IsDragDropEndRegistered = false; + } + } + + private void DragDropClickHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + atkEvent->SetEventIsHandled(); + + atkEvent->State.StateFlags |= AtkEventStateFlags.HasReturnFlags; + atkEvent->State.ReturnFlags = 1; + + OnClicked?.Invoke(this); + } + + private void DragDropRollOverHandler() + => OnRollOver?.Invoke(this); + + private void DragDropRollOutHandler() + => OnRollOut?.Invoke(this); + + /// Clear the payload data and set iconId to zero + public void Clear() { + Payload.Clear(); + IconId = 0; + } + + // Show fancy tooltip for the currently stored data + public void ShowTooltip(AtkTooltipManager.AtkTooltipType type, ActionKind actionKind) { + if (AtkStage.Instance()->DragDropManager.IsDragging) return; + + var addon = RaptureAtkUnitManager.Instance()->GetAddonByNode(ResNode); + if (addon is null) return; + + var tooltipArgs = new AtkTooltipManager.AtkTooltipArgs(); + tooltipArgs.Ctor(); + tooltipArgs.ActionArgs.Id = Payload.Int2; + tooltipArgs.ActionArgs.Kind = (DetailKind)actionKind; + + AtkStage.Instance()->TooltipManager.ShowTooltip( + AtkTooltipManager.AtkTooltipType.Action, + addon->Id, + ResNode, + &tooltipArgs); + } + + private void LoadTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabelPair(1, 10, 1) + .AddLabelPair(11, 19, 2) + .AddLabelPair(20, 29, 3) + .AddLabelPair(30, 39, 7) + .AddLabelPair(40, 49, 6) + .AddLabelPair(50, 59, 4) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/GifImageNode.cs b/KamiToolKit/Nodes/Basic/GifImageNode.cs new file mode 100644 index 0000000..6a72bc8 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/GifImageNode.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Numerics; +using System.Threading.Tasks; +using Dalamud.Interface.Textures; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Timelines; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace KamiToolKit.Nodes; + +public class GifImageNode : ResNode { + + public ImageNode ImageNode; + + public GifImageNode() { + ImageNode = new ImageNode(); + ImageNode.AttachNode(this); + } + + public required string FilePath { + set { + Task.Run(() => LoadFrames(value)); + } + } + + public override float Width { + get => base.Width; + set { + ImageNode.Width = value; + base.Width = value; + } + } + + public override float Height { + get => base.Height; + set { + ImageNode.Height = value; + base.Height = value; + } + } + + public Vector2 GifFrameSize { get; private set; } + + public bool FitNodeToGif { get; set; } + + public Action? OnGifLoaded { get; set; } + + private async void LoadFrames(string filepath) { + try { + var image = await LoadAsync(filepath); + if (image.Length <= 0) return; + + using var memoryStream = new MemoryStream(image); + using var processedImage = Image.Load(memoryStream); + if (processedImage.Frames.Count is 0) return; + + uint currentPartId = 0; + var frameDelay = processedImage.Frames.RootFrame.Metadata.GetGifMetadata().FrameDelay / 3.33333333f; + var frameCount = (int)(processedImage.Frames.Count * frameDelay); + GifFrameSize = new Vector2(processedImage.Width, processedImage.Height); + + if (FitNodeToGif) { + Size = GifFrameSize; + } + + foreach (var frame in processedImage.Frames) { + var buffer = new byte[8 * frame.Width * frame.Height]; + + frame.CopyPixelDataTo(buffer); + + var texture = await DalamudInterface.Instance.TextureProvider.CreateFromRawAsync(RawImageSpecification.Rgba32(frame.Width, frame.Height), buffer); + + unsafe { + var newPart = ImageNode.AddPart(new Part { + Size = texture.Size, + Id = currentPartId++, + }); + + newPart->LoadTexture(texture); + } + } + + ImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, frameCount) + .AddFrame(0, partId: 0) + .AddFrame(frameCount, partId: currentPartId) + .EndFrameSet() + .Build()); + + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, frameCount) + .AddLabel(1, 200, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(frameCount, 0, AtkTimelineJumpBehavior.LoopForever, 200) + .EndFrameSet() + .Build()); + + Timeline?.PlayAnimation( AtkTimelineJumpBehavior.LoopForever, 200); + + await DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + OnGifLoaded?.Invoke(); + }); + } + catch (Exception e) { + Log.Exception(e); + } + } + + private static async Task LoadAsync(string path) { + byte[] data = []; + + if (File.Exists(path)) { + data = await File.ReadAllBytesAsync(path); + } + + return data; + } +} diff --git a/KamiToolKit/Nodes/Basic/HoldButtonProgressNode.cs b/KamiToolKit/Nodes/Basic/HoldButtonProgressNode.cs new file mode 100644 index 0000000..3a5e5e1 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/HoldButtonProgressNode.cs @@ -0,0 +1,63 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class HoldButtonProgressNode : ResNode { + + public readonly ImageNode ImageNode; + + public HoldButtonProgressNode() { + ImageNode = new SimpleImageNode { + NodeId = 4, + TexturePath = "ui/uld/LongPressButtonA.tex", + TextureCoordinates = new Vector2(0.0f, 36.0f), + TextureSize = new Vector2(100.0f, 36.0f), + Size = new Vector2(0.0f, 36.0f), + WrapMode = WrapMode.Tile, + }; + ImageNode.AttachNode(this); + + BuildTimelines(); + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 83) + .AddLabel(1, 29, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(60, 30, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(61, 31, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(73, 32, AtkTimelineJumpBehavior.PlayOnce, 31) + .AddLabel(74, 33, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(83, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .BeginFrameSet(18, 26) + .AddEmptyFrame(18) + .EndFrameSet() + .BeginFrameSet(37, 53) + .AddEmptyFrame(37) + .EndFrameSet() + .BeginFrameSet(54, 71) + .AddEmptyFrame(54) + .EndFrameSet() + .Build() + ); + + ImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 60) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(61, 73) + .AddFrame(61, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(74, 83) + .AddFrame(74, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(76, addColor: new Vector3(150, 150, 100), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(83, addColor: new Vector3(20, 20, 20), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Basic/HorizontalLineNode.cs b/KamiToolKit/Nodes/Basic/HorizontalLineNode.cs new file mode 100644 index 0000000..c4274e4 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/HorizontalLineNode.cs @@ -0,0 +1,13 @@ +using System.Numerics; + +namespace KamiToolKit.Nodes; + +public class HorizontalLineNode : SimpleNineGridNode { + public HorizontalLineNode() { + TexturePath = "ui/uld/WindowA_Line.tex"; + TextureCoordinates = Vector2.Zero; + TextureSize = new Vector2(32.0f, 4.0f); + LeftOffset = 12.0f; + RightOffset = 12.0f; + } +} diff --git a/KamiToolKit/Nodes/Basic/IconExtras.cs b/KamiToolKit/Nodes/Basic/IconExtras.cs new file mode 100644 index 0000000..e213df6 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/IconExtras.cs @@ -0,0 +1,216 @@ +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class IconExtras : ResNode { + + public readonly AlternateCooldownNode AlternateCooldownNode; + public readonly AntsNode AntsNode; + public readonly ImageNode ChargeCountImageNode; + public readonly ImageNode ClickFlashImageNode; + public readonly CooldownNode CooldownNode; + public readonly ImageNode HoveredBorderImageNode; + public readonly TextNode QuantityTextNode; + public readonly TextNode ResourceCostTextNode; + + public readonly ImageNode TimelineImageNode; + + public IconExtras() { + TimelineImageNode = new SimpleImageNode { + NodeId = 19, + Size = new Vector2(40.0f, 40.0f), + Position = new Vector2(4.0f, 4.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + ImageNodeFlags = ImageNodeFlags.AutoFit, + }; + TimelineImageNode.AttachNode(this); + + CooldownNode = new CooldownNode { + NodeId = 16, + Size = new Vector2(48.0f, 48.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + }; + CooldownNode.AttachNode(this); + + AlternateCooldownNode = new AlternateCooldownNode { + NodeId = 14, + Size = new Vector2(44.0f, 48.0f), + Position = new Vector2(2.0f, 0.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + }; + AlternateCooldownNode.AttachNode(this); + + AntsNode = new AntsNode { + NodeId = 12, + Size = new Vector2(48.0f, 48.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + }; + AntsNode.AttachNode(this); + + HoveredBorderImageNode = new ImageNode { + NodeId = 11, + Size = new Vector2(72.0f, 72.0f), + Position = new Vector2(-12.0f, -12.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + PartId = 16, + WrapMode = WrapMode.Tile, + }; + + IconNodeTextureHelper.LoadIconAFrameTexture(HoveredBorderImageNode); + + HoveredBorderImageNode.AttachNode(this); + + ChargeCountImageNode = new ImageNode { + NodeId = 10, + Size = new Vector2(20.0f, 20.0f), + Position = new Vector2(28.0f, 28.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + }; + + foreach (var yIndex in Enumerable.Range(0, 2)) + foreach (var xIndex in Enumerable.Range(0, 5)) { + var coordinate = new Vector2(xIndex * 20.0f, yIndex * 20.0f); + ChargeCountImageNode.AddPart(new Part { + TexturePath = "ui/uld/IconA_ChargeIcon.tex", + TextureCoordinates = coordinate, + Size = new Vector2(20.0f, 20.0f), + Id = (uint)(xIndex + yIndex), + }); + } + ChargeCountImageNode.AttachNode(this); + + QuantityTextNode = new TextNode { + NodeId = 9, + Size = new Vector2(40.0f, 12.0f), + Position = new Vector2(4.0f, 34.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + Color = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(51), + AlignmentType = AlignmentType.Right, + }; + QuantityTextNode.AttachNode(this); + + // Also cooldown time text for non-globals + ResourceCostTextNode = new TextNode { + NodeId = 8, + Size = new Vector2(48.0f, 12.0f), + Position = new Vector2(3.0f, 37.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + Color = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(51), + AlignmentType = AlignmentType.Left, + }; + ResourceCostTextNode.AttachNode(this); + + ClickFlashImageNode = new ImageNode { + NodeId = 7, + Size = new Vector2(64, 64), + Position = new Vector2(-8.0f, -8.0f), + Origin = new Vector2(32.0f, 32.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + PartId = 17, + }; + + IconNodeTextureHelper.LoadIconAFrameTexture(ClickFlashImageNode); + + ClickFlashImageNode.AttachNode(this); + + BuildTimelines(); + } + + private void BuildTimelines() { + TimelineImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0, multiplyColor: new Vector3(100.0f), addColor: new Vector3(255.0f)) + .AddFrame(12, alpha: 63, multiplyColor: new Vector3(100.0f), addColor: new Vector3(255.0f)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 63, multiplyColor: new Vector3(100.0f), addColor: new Vector3(255.0f)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 63, multiplyColor: new Vector3(100.0f), addColor: new Vector3(255.0f)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 63, multiplyColor: new Vector3(100.0f), addColor: new Vector3(255.0f)) + .AddFrame(52, alpha: 0, multiplyColor: new Vector3(100.0f), addColor: new Vector3(255.0f)) + .EndFrameSet() + .Build()); + + CooldownNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 165) + .AddLabel(1, 19, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(11, 20, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(21, 21, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(31, 22, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(41, 101, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(51, 102, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabelPair(61, 142, 24) + .AddLabelPair(143, 165, 25) + .EndFrameSet() + .AddFrameSetWithFrame(1, 9, 1, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .BeginFrameSet(10, 19) + .AddFrame(10, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .AddFrame(12, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(20, 29, 20, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .AddFrameSetWithFrame(30, 39, 30, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .AddFrameSetWithFrame(40, 49, 40, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .BeginFrameSet(50, 59) + .AddFrame(50, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .AddFrame(52, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .EndFrameSet() + .Build()); + + AlternateCooldownNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 205) + .AddLabel(1, 17, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(11, 101, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(92, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(93, 102, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(174, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(175, 103, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(205, 0, AtkTimelineJumpBehavior.LoopForever, 103) + .EndFrameSet() + .Build()); + + AntsNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddLabel(1, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(2, 26, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.LoopForever, 26) + .EndFrameSet() + .Build()); + + HoveredBorderImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .AddFrame(12, alpha: 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .AddFrame(52, alpha: 0, multiplyColor: new Vector3(100.0f), addColor: new Vector3(0.0f)) + .EndFrameSet() + .Build()); + + ClickFlashImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255, scale: new Vector2(0.1f)) + .AddFrame(29, alpha: 0, scale: new Vector2(1.0f)) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/IconImageNode.cs b/KamiToolKit/Nodes/Basic/IconImageNode.cs new file mode 100644 index 0000000..9f7b3b8 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/IconImageNode.cs @@ -0,0 +1,28 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +/// +/// A simple image node for use with displaying game icons. +/// +/// This node is not intended to be used with multiple 's. +public unsafe class IconImageNode : SimpleImageNode { + + public IconImageNode() { + TextureSize = new Vector2(32.0f, 32.0f); + } + + public uint IconId { + get; + set { + if (value != field) { + field = value; + PartsList[0]->LoadIcon(value); + } + } + } + + public bool IsTextureReady => PartsList[0]->IsTextureReady; + public uint? LoadedIconId => Node->IconId; +} diff --git a/KamiToolKit/Nodes/Basic/IconIndicator.cs b/KamiToolKit/Nodes/Basic/IconIndicator.cs new file mode 100644 index 0000000..e74764c --- /dev/null +++ b/KamiToolKit/Nodes/Basic/IconIndicator.cs @@ -0,0 +1,44 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class IconIndicator : ResNode { + + public readonly ImageNode IconNode; + + public IconIndicator(uint innerNodeId) { + IconNode = new ImageNode { + NodeId = innerNodeId, + Size = new Vector2(18, 18), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Stretch, + PartId = (uint)(innerNodeId == 5 ? 25 : 30), + }; + + IconNodeTextureHelper.LoadIconAFrameTexture(IconNode); + + IconNode.AttachNode(this); + + BuildTimeline(); + } + + private void BuildTimeline() { + IconNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(11, 20, 11, partId: 14) + .AddFrameSetWithFrame(21, 30, 21, partId: 15) + .AddFrameSetWithFrame(31, 40, 31, partId: 21) + .AddFrameSetWithFrame(41, 50, 41, partId: 22) + .AddFrameSetWithFrame(51, 60, 51, partId: 23) + .AddFrameSetWithFrame(61, 70, 61, partId: 24) + .AddFrameSetWithFrame(71, 79, 71, partId: 29) + .AddFrameSetWithFrame(80, 89, 80, partId: 30) + .AddFrameSetWithFrame(90, 99, 90, partId: 25) + .AddFrameSetWithFrame(100, 109, 100, partId: 26) + .AddFrameSetWithFrame(110, 119, 110, partId: 27) + .AddFrameSetWithFrame(120, 129, 120, partId: 28) + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Basic/IconNodeTextureHelper.cs b/KamiToolKit/Nodes/Basic/IconNodeTextureHelper.cs new file mode 100644 index 0000000..0ab29f9 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/IconNodeTextureHelper.cs @@ -0,0 +1,78 @@ +using System.Linq; +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public static unsafe class IconNodeTextureHelper { + public static void LoadIconAFrameTexture(ImageNode image) { + image.AddPart(new Part { Id = 0, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f) }); + image.AddPart(new Part { Id = 1, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(48.0f, 0.0f) }); + image.AddPart(new Part { Id = 2, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(0.0f, 48.0f) }); + image.AddPart(new Part { Id = 3, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(48.0f, 48.0f) }); + image.AddPart(new Part { Id = 4, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(0.0f, 96.0f) }); + image.AddPart(new Part { Id = 5, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(48.0f, 96.0f) }); + image.AddPart(new Part { Id = 6, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(96.0f, 0.0f) }); + image.AddPart(new Part { Id = 7, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(144.0f, 0.0f) }); + image.AddPart(new Part { Id = 8, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(192.0f, 0.0f) }); + image.AddPart(new Part { Id = 9, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(96.0f, 48.0f) }); + image.AddPart(new Part { Id = 10, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(144.0f, 48.0f) }); + image.AddPart(new Part { Id = 11, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(192.0f, 48.0f) }); + image.AddPart(new Part { Id = 12, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(96.0f, 96.0f) }); + image.AddPart(new Part { Id = 13, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(144.0f, 96.0f) }); + image.AddPart(new Part { Id = 14, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(192.0f, 96.0f) }); + image.AddPart(new Part { Id = 15, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(192.0f, 114.0f) }); + image.AddPart(new Part { Id = 16, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(72.0f, 72.0f), TextureCoordinates = new Vector2(240.0f, 0.0f) }); + image.AddPart(new Part { Id = 17, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(64.0f, 64.0f), TextureCoordinates = new Vector2(240.0f, 72.0f) }); + image.AddPart(new Part { Id = 18, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(312.0f, 0.0f) }); + image.AddPart(new Part { Id = 19, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(312.0f, 48.0f) }); + image.AddPart(new Part { Id = 20, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(48.0f, 48.0f), TextureCoordinates = new Vector2(312.0f, 96.0f) }); + image.AddPart(new Part { Id = 21, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(210.0f, 114.0f) }); + image.AddPart(new Part { Id = 22, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(360.0f, 96.0f) }); + image.AddPart(new Part { Id = 23, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(378.0f, 96.0f) }); + image.AddPart(new Part { Id = 24, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(360.0f, 114.0f) }); + image.AddPart(new Part { Id = 25, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(210.0f, 96.0f) }); + image.AddPart(new Part { Id = 26, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(408.0f, 0.0f) }); + image.AddPart(new Part { Id = 27, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(408.0f, 18.0f) }); + image.AddPart(new Part { Id = 28, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(408.0f, 36.0f) }); + image.AddPart(new Part { Id = 29, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(396.0f, 96.0f) }); + image.AddPart(new Part { Id = 30, TexturePath = "ui/uld/IconA_Frame.tex", Size = new Vector2(18.0f, 18.0f), TextureCoordinates = new Vector2(396.0f, 114.0f) }); + } + + public static void LoadIconARecast2Texture(ImageNode imageNode) { + foreach (var yIndex in Enumerable.Range(0, 9)) + foreach (var xIndex in Enumerable.Range(0, 9)) { + var coordinate = new Vector2(xIndex * 44.0f, yIndex * 48.0f); + imageNode.AddPart(new Part { + TexturePath = "ui/uld/IconA_Recast2.tex", + TextureCoordinates = coordinate, + Size = new Vector2(44.0f, 46.0f), + Id = (uint)(xIndex + yIndex), + }); + } + + foreach (var yIndex in Enumerable.Range(9, 9)) + foreach (var xIndex in Enumerable.Range(9, 9)) { + var coordinate = new Vector2(xIndex * 44.0f, (yIndex - 9) * 48.0f); + imageNode.AddPart(new Part { + TexturePath = "ui/uld/IconA_Recast2.tex", + TextureCoordinates = coordinate, + Size = new Vector2(44.0f, 46.0f), + Id = (uint)(xIndex + yIndex), + }); + } + } + + public static void LoadIconARecastTexture(ImageNode imageNode) { + foreach (var yIndex in Enumerable.Range(0, 9)) + foreach (var xIndex in Enumerable.Range(0, 9)) { + var coordinate = new Vector2(xIndex * 44.0f, yIndex * 48.0f); + imageNode.AddPart(new Part { + TexturePath = "ui/uld/IconA_Recast.tex", + TextureCoordinates = coordinate, + Size = new Vector2(44.0f, 46.0f), + Id = (uint)(xIndex + yIndex), + }); + } + } +} diff --git a/KamiToolKit/Nodes/Basic/ImGuiImageNode.cs b/KamiToolKit/Nodes/Basic/ImGuiImageNode.cs new file mode 100644 index 0000000..7856e77 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/ImGuiImageNode.cs @@ -0,0 +1,64 @@ +using System.IO; +using Dalamud.Interface.Textures.TextureWraps; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +/// +/// A simple image node that allows you to load an IDalamudTextureWrap texture into a native image node. +/// This node creates a single +/// +/// This node is not intended to be used with multiple 's. +public class ImGuiImageNode : SimpleImageNode { + + public IDalamudTextureWrap? LoadedTexture; + + public override unsafe string TexturePath { + get => base.TexturePath; + set { + if (Path.IsPathRooted(value)) { + LoadTextureFromFile(value); + } + else if (DalamudInterface.Instance.DataManager.FileExists(value)) { + PartsList[0]->LoadTexture(value); + } + } + } + + /// + /// Takes ownership of passed in IDalamudTextureWrap, disposes texture when node is disposed. + /// + public unsafe void LoadTexture(IDalamudTextureWrap texture) { + var previouslyLoadedTexture = LoadedTexture; + + PartsList[0]->LoadTexture(texture); + + // Delay unloading texture until new texture is loaded. + previouslyLoadedTexture?.Dispose(); + LoadedTexture = texture; + } + + public void LoadTextureFromFile(string fileSystemPath) { + DalamudInterface.Instance.Framework.RunOnTick(async () => { + Alpha = 0.0f; + + var newTexture = await DalamudInterface.Instance.TextureProvider.GetFromFile(fileSystemPath).RentAsync(); + + LoadTexture(newTexture); + TextureSize = newTexture.Size; + + Alpha = 1.0f; + MarkDirty(); + }); + } + + // Note, disposes loaded IDalamudTextureWrap if either native or managed code frees this node. + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + base.Dispose(disposing, isNativeDestructor); + + LoadedTexture?.Dispose(); + LoadedTexture = null; + } + } +} diff --git a/KamiToolKit/Nodes/Basic/ImageNode.cs b/KamiToolKit/Nodes/Basic/ImageNode.cs new file mode 100644 index 0000000..bbcd6ae --- /dev/null +++ b/KamiToolKit/Nodes/Basic/ImageNode.cs @@ -0,0 +1,61 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public unsafe class ImageNode : NodeBase { + + public readonly PartsList PartsList; + + public ImageNode() : base(NodeType.Image) { + PartsList = new PartsList(); + + Node->PartsList = PartsList.InternalPartsList; + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + if (!isNativeDestructor) { + PartsList.Dispose(); + Node->PartsList = null; + } + + base.Dispose(disposing, isNativeDestructor); + } + } + + public uint PartId { + get => Node->PartId; + set => Node->PartId = (ushort) value; + } + + public WrapMode WrapMode { + get => (WrapMode) Node->WrapMode; + set => Node->WrapMode = (byte) value; + } + + public ImageNodeFlags ImageNodeFlags { + get => Node->Flags; + set => Node->Flags = value; + } + + /// + /// When set to true, will cause the loaded texture to + /// fit itself to the size of the node + /// + public bool FitTexture { + set { + if (value) { + ImageNodeFlags = ImageNodeFlags.AutoFit; + WrapMode = WrapMode.Stretch; + } + } + } + + public AtkUldPart* AddPart(Part part) + => PartsList.Add(part); + + public void AddPart(params Part[] parts) + => PartsList.Add(parts); +} diff --git a/KamiToolKit/Nodes/Basic/LabelTextNode.cs b/KamiToolKit/Nodes/Basic/LabelTextNode.cs new file mode 100644 index 0000000..df61c1f --- /dev/null +++ b/KamiToolKit/Nodes/Basic/LabelTextNode.cs @@ -0,0 +1,15 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public sealed class LabelTextNode : TextNode { + public LabelTextNode() { + TextColor = ColorHelper.GetColor(8); + TextOutlineColor = ColorHelper.GetColor(7); + FontType = FontType.Axis; + FontSize = 14; + LineSpacing = 24; + AlignmentType = AlignmentType.Left; + } +} diff --git a/KamiToolKit/Nodes/Basic/NineGridNode.cs b/KamiToolKit/Nodes/Basic/NineGridNode.cs new file mode 100644 index 0000000..cf640ae --- /dev/null +++ b/KamiToolKit/Nodes/Basic/NineGridNode.cs @@ -0,0 +1,78 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public unsafe class NineGridNode : NodeBase { + + public readonly PartsList PartsList; + + public NineGridNode() : base(NodeType.NineGrid) { + PartsList = new PartsList(); + + Node->PartsList = PartsList.InternalPartsList; + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + if (!isNativeDestructor) { + PartsList.Dispose(); + Node->PartsList = null; + } + + base.Dispose(disposing, isNativeDestructor); + } + } + + public uint PartId { + get => Node->PartId; + set => Node->PartId = value; + } + + public Vector4 Offsets { + get => new(Node->TopOffset, Node->BottomOffset, Node->LeftOffset, Node->RightOffset); + set { + Node->TopOffset = (short)value.X; + Node->BottomOffset = (short)value.Y; + Node->LeftOffset = (short)value.Z; + Node->RightOffset = (short)value.W; + } + } + + public float TopOffset { + get => Node->TopOffset; + set => Node->TopOffset = (short)value; + } + + public float BottomOffset { + get => Node->BottomOffset; + set => Node->BottomOffset = (short)value; + } + + public float LeftOffset { + get => Node->LeftOffset; + set => Node->LeftOffset = (short)value; + } + + public float RightOffset { + get => Node->RightOffset; + set => Node->RightOffset = (short)value; + } + + public uint BlendMode { + get => Node->BlendMode; + set => Node->BlendMode = value; + } + + public byte PartsRenderType { + get => Node->PartsTypeRenderType; + set => Node->PartsTypeRenderType = value; + } + + public AtkUldPart* AddPart(Part part) + => PartsList.Add(part); + + public void AddPart(params Part[] parts) + => PartsList.Add(parts); +} diff --git a/KamiToolKit/Nodes/Basic/NodeEditOverlayNode.cs b/KamiToolKit/Nodes/Basic/NodeEditOverlayNode.cs new file mode 100644 index 0000000..f450e34 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/NodeEditOverlayNode.cs @@ -0,0 +1,153 @@ +using System.Numerics; +using Dalamud.Game.Addon.Events; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +internal unsafe class NodeEditOverlayNode : SimpleComponentNode { + + private readonly NineGridNode backgroundNode; + private readonly ResizeNineGridNode bottomEditNode; + private readonly ResizeButtonNode leftCornerEditNode; + private readonly ResizeNineGridNode leftEditNode; + private readonly ResizeButtonNode rightCornerEditNode; + private readonly ResizeNineGridNode rightEditNode; + private readonly ResizeNineGridNode topEditNode; + + public NodeEditOverlayNode() { + backgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/HUDLayout.tex", + TextureSize = new Vector2(44.0f, 32.0f), + TextureCoordinates = new Vector2(0.0f, 0.0f), + TopOffset = 20, + BottomOffset = 8, + LeftOffset = 21, + RightOffset = 21, + Alpha = 0.75f, + }; + backgroundNode.AttachNode(this); + + rightEditNode = new ResizeNineGridNode(); + rightEditNode.AttachNode(this); + + bottomEditNode = new ResizeNineGridNode(); + bottomEditNode.AttachNode(this); + + leftEditNode = new ResizeNineGridNode(); + leftEditNode.AttachNode(this); + + topEditNode = new ResizeNineGridNode(); + topEditNode.AttachNode(this); + + rightCornerEditNode = new ResizeButtonNode(ResizeDirection.BottomRight); + rightCornerEditNode.AttachNode(this); + + leftCornerEditNode = new ResizeButtonNode(ResizeDirection.BottomLeft); + leftCornerEditNode.AttachNode(this); + } + + public bool ShowParts { + get; + set { + field = value; + rightEditNode.IsVisible = value; + bottomEditNode.IsVisible = value; + leftEditNode.IsVisible = value; + topEditNode.IsVisible = value; + rightCornerEditNode.IsVisible = value; + leftCornerEditNode.IsVisible = value; + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + backgroundNode.Size = Size - new Vector2(24.0f, 24.0f); + backgroundNode.Position = new Vector2(12.0f, 12.0f); + + const float lineThickness = 4.0f; + + leftEditNode.Size = new Vector2(Height - 32.0f, lineThickness); + leftEditNode.Position = new Vector2(16.0f + leftEditNode.Height / 2.0f, 16.0f); + leftEditNode.RotationDegrees = 90.0f; + + rightEditNode.Size = new Vector2(Height - 32.0f, lineThickness); + rightEditNode.Position = new Vector2(Width - 16.0f + rightEditNode.Height / 2.0f, 16.0f); + rightEditNode.RotationDegrees = 90.0f; + + topEditNode.Size = new Vector2(Width - 32.0f, lineThickness); + topEditNode.Position = new Vector2(16.0f, 16.0f - lineThickness / 2.0f); + + bottomEditNode.Size = new Vector2(Width - 32.0f, lineThickness); + bottomEditNode.Position = new Vector2(16.0f, Height - 16.0f - lineThickness / 2.0f); + + leftCornerEditNode.Size = new Vector2(24.0f, 24.0f); + leftCornerEditNode.Position = new Vector2(16.0f - lineThickness / 4.0f, Height - 16.0f - leftCornerEditNode.Height); + + rightCornerEditNode.Size = new Vector2(24.0f, 24.0f); + rightCornerEditNode.Position = new Vector2(Width - 16.0f - rightCornerEditNode.Width + lineThickness / 4.0f, Height - 16.0f - rightCornerEditNode.Height); + } + + public Vector2 GetSizeDelta(Vector2 mouseDelta) { + if (leftEditNode.IsHovered) return new Vector2(-mouseDelta.X, 0.0f); + if (rightEditNode.IsHovered) return new Vector2(mouseDelta.X, 0.0f); + if (topEditNode.IsHovered) return new Vector2(0.0f, -mouseDelta.Y); + if (bottomEditNode.IsHovered) return new Vector2(0.0f, mouseDelta.Y); + if (rightCornerEditNode.IsHovered) return mouseDelta; + if (leftCornerEditNode.IsHovered) return new Vector2(-mouseDelta.X, mouseDelta.Y); + + return Vector2.Zero; + } + + public Vector2 GetPositionDelta(Vector2 mouseDelta) { + if (leftEditNode.IsHovered) return new Vector2(mouseDelta.X, 0.0f); + if (topEditNode.IsHovered) return new Vector2(0.0f, mouseDelta.Y); + if (leftCornerEditNode.IsHovered) return new Vector2(mouseDelta.X, 0.0f); + + return Vector2.Zero; + } + + public void UpdateHover(AtkEventData* eventData) { + rightEditNode.IsHovered = rightEditNode.CheckCollision(eventData); + bottomEditNode.IsHovered = bottomEditNode.CheckCollision(eventData); + leftEditNode.IsHovered = leftEditNode.CheckCollision(eventData); + topEditNode.IsHovered = topEditNode.CheckCollision(eventData); + rightCornerEditNode.IsHovered = rightCornerEditNode.CheckCollision(eventData); + leftCornerEditNode.IsHovered = leftCornerEditNode.CheckCollision(eventData); + + if (rightCornerEditNode.IsHovered) { + bottomEditNode.IsHovered = false; + rightEditNode.IsHovered = false; + } + + if (leftCornerEditNode.IsHovered) { + leftEditNode.IsHovered = false; + bottomEditNode.IsHovered = false; + } + } + + public bool AnyHovered() { + if (rightEditNode.IsHovered) return true; + if (bottomEditNode.IsHovered) return true; + if (leftEditNode.IsHovered) return true; + if (topEditNode.IsHovered) return true; + if (rightCornerEditNode.IsHovered) return true; + if (leftCornerEditNode.IsHovered) return true; + + return false; + } + + public void SetCursor() { + if (rightEditNode.IsHovered) SetCursor(AddonCursorType.ResizeWE); + if (bottomEditNode.IsHovered) SetCursor(AddonCursorType.ResizeNS); + if (leftEditNode.IsHovered) SetCursor(AddonCursorType.ResizeWE); + if (topEditNode.IsHovered) SetCursor(AddonCursorType.ResizeNS); + if (rightCornerEditNode.IsHovered) SetCursor(AddonCursorType.ResizeNWSR); + if (leftCornerEditNode.IsHovered) SetCursor(AddonCursorType.ResizeNESW); + } + + private static void SetCursor(AddonCursorType cursor) + => DalamudInterface.Instance.AddonEventManager.SetCursor(cursor); +} diff --git a/KamiToolKit/Nodes/Basic/NumericInputNode.cs b/KamiToolKit/Nodes/Basic/NumericInputNode.cs new file mode 100644 index 0000000..9757bfd --- /dev/null +++ b/KamiToolKit/Nodes/Basic/NumericInputNode.cs @@ -0,0 +1,190 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class NumericInputNode : ComponentNode { + + public readonly ButtonBase AddButton; + public readonly NineGridNode BackgroundNode; + public readonly CursorNode CursorNode; + public readonly NineGridNode FocusBorderNode; + public readonly ButtonBase SubtractButton; + public readonly TextNode ValueTextNode; + + public NumericInputNode() { + SetInternalComponentType(ComponentType.NumericInput); + + BackgroundNode = new SimpleNineGridNode { + NodeId = 8, + Position = new Vector2(0.0f, 3.0f), + TexturePath = "ui/uld/NumericStepperB.tex", + TextureCoordinates = new Vector2(56.0f, 0.0f), + TextureSize = new Vector2(24.0f, 24.0f), + Height = 24.0f, + Offsets = new Vector4(10.0f), + }; + BackgroundNode.AttachNode(this); + + AddButton = new TextureButtonNode { + NodeId = 7, + TexturePath = "ui/uld/NumericStepperB.tex", + TextureCoordinates = new Vector2(28.0f, 0.0f), + TextureSize = new Vector2(28.0f, 28.0f), + Size = new Vector2(28.0f, 28.0f), + }; + AddButton.AttachNode(this); + + SubtractButton = new TextureButtonNode { + NodeId = 6, + TexturePath = "ui/uld/NumericStepperB.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(28.0f, 28.0f), + Size = new Vector2(28.0f, 28.0f), + }; + SubtractButton.AttachNode(this); + + ValueTextNode = new TextNode { + NodeId = 5, + Position = new Vector2(6.0f, 6.0f), + FontType = FontType.Axis, + TextColor = ColorHelper.GetColor(1), + FontSize = 12, + AlignmentType = AlignmentType.Top, + String = "999", + }; + ValueTextNode.AttachNode(this); + + FocusBorderNode = new SimpleNineGridNode { + NodeId = 4, + TexturePath = "ui/uld/TextInputA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(24.0f, 24.0f), + Position = new Vector2(-3.0f, -2.0f), + Offsets = new Vector4(10.0f), + IsVisible = false, + }; + FocusBorderNode.AttachNode(this); + + CursorNode = new CursorNode { + NodeId = 2, + Size = new Vector2(4.0f, 24.0f), + OriginY = 4.0f, + }; + + CursorNode.AttachNode(this); + + BuildTimelines(); + + Data->Nodes[0] = ValueTextNode.NodeId; + Data->Nodes[1] = 0; + Data->Nodes[2] = CursorNode.NodeId; + Data->Nodes[3] = AddButton.NodeId; + Data->Nodes[4] = SubtractButton.NodeId; + + Data->Max = int.MaxValue; + + InitializeComponentEvents(); + + AddEvent(AtkEventType.ValueUpdate, ValueUpdateHandler); + } + + public int Value { + get => Component->Value; + set => Component->InnerSetValue(value, true, false); + } + + public int Min { + get => Component->Data.Min; + set => Component->Data.Min = value; + } + + public int Max { + get => Component->Data.Max; + set => Component->Data.Max = value; + } + + public int Step { + get => Component->Data.Add; + set => Component->Data.Add = value; + } + + public Action? OnValueUpdate { get; set; } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ValueTextNode.Size = new Vector2(Width - 58.0f, Height / 2.0f); + FocusBorderNode.Size = new Vector2(Width - 40.0f, Height + 4.0f); + + BackgroundNode.Width = Width - 46.0f; + AddButton.X = Width - 50.0f; + SubtractButton.X = Width - 28.0f; + } + + private void ValueUpdateHandler() { + OnValueUpdate?.Invoke(Value); + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 29) + .AddLabel(1, 17, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 18, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(19, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 7, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(29, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(20, 20, 20), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + ValueTextNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 19) + .AddFrame(1, alpha: 255) + .AddFrame(1, textColor: new Vector3(255.0f, 255.0f, 255.0f) * 255.0f) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 127) + .AddFrame(20, textColor: new Vector3(255.0f, 255.0f, 255.0f) * 255.0f) + .EndFrameSet() + .Build() + ); + + FocusBorderNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0) + .AddFrame(12, alpha: 255) + .EndFrameSet() + .Build() + ); + + CursorNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 15) + .AddLabel(1, 101, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(15, 0, AtkTimelineJumpBehavior.LoopForever, 101) + .EndFrameSet() + .BeginFrameSet(1, 19) + .AddEmptyFrame(1) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Basic/ResNode.cs b/KamiToolKit/Nodes/Basic/ResNode.cs new file mode 100644 index 0000000..3e3ac37 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/ResNode.cs @@ -0,0 +1,8 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +/// +/// A generic basic resource node. +/// +public class ResNode() : NodeBase(NodeType.Res); diff --git a/KamiToolKit/Nodes/Basic/ResizeNineGridNode.cs b/KamiToolKit/Nodes/Basic/ResizeNineGridNode.cs new file mode 100644 index 0000000..3b2fe42 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/ResizeNineGridNode.cs @@ -0,0 +1,40 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public class ResizeNineGridNode : SimpleComponentNode { + + public readonly NineGridNode BorderNode; + + public ResizeNineGridNode() { + BorderNode = new SimpleNineGridNode { + TexturePath = "ui/uld/WindowA_line.tex", + TextureCoordinates = new Vector2(2.0f, 1.0f), + TextureSize = new Vector2(28.0f, 3.0f), + LeftOffset = 12, + RightOffset = 12, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + }; + BorderNode.AttachNode(this); + } + + public bool IsHovered { + get; + set { + field = value; + if (value) { + BorderNode.AddColor = new Vector3(100.0f, 100.0f, 100.0f) / 255.0f; + } + else { + BorderNode.AddColor = Vector3.Zero; + } + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BorderNode.Size = Size; + } +} diff --git a/KamiToolKit/Nodes/Basic/SimpleClippingMaskNode.cs b/KamiToolKit/Nodes/Basic/SimpleClippingMaskNode.cs new file mode 100644 index 0000000..830d972 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/SimpleClippingMaskNode.cs @@ -0,0 +1,59 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public unsafe class SimpleClippingMaskNode : ClippingMaskNode { + public SimpleClippingMaskNode() { + PartsList.Add(new Part()); + } + + public float U { + get => PartsList[0]->U; + set => PartsList[0]->U = (ushort)value; + } + + public float V { + get => PartsList[0]->V; + set => PartsList[0]->V = (ushort)value; + } + + public Vector2 TextureCoordinates { + get => new(U, V); + set { + U = value.X; + V = value.Y; + } + } + + public float TextureHeight { + get => PartsList[0]->Height; + set => PartsList[0]->Height = (ushort)value; + } + + public float TextureWidth { + get => PartsList[0]->Width; + set => PartsList[0]->Width = (ushort)value; + } + + public Vector2 TextureSize { + get => new(TextureWidth, TextureHeight); + set { + TextureWidth = value.X; + TextureHeight = value.Y; + } + } + + public virtual string TexturePath { + get => PartsList[0]->LoadedPath; + set => PartsList[0]->LoadTexture(value); + } + + public Vector2 ActualTextureSize => PartsList[0]->LoadedTextureSize; + + public void LoadTexture(string path) + => PartsList[0]->LoadTexture(path); + + public void LoadIcon(uint iconId) + => PartsList[0]->LoadIcon(iconId); +} diff --git a/KamiToolKit/Nodes/Basic/SimpleComponentNode.cs b/KamiToolKit/Nodes/Basic/SimpleComponentNode.cs new file mode 100644 index 0000000..118a24f --- /dev/null +++ b/KamiToolKit/Nodes/Basic/SimpleComponentNode.cs @@ -0,0 +1,22 @@ +using System; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public class SimpleComponentNode : ComponentNode { + public override ReadOnlySeString TextTooltip { + get => CollisionNode.TextTooltip; + set => CollisionNode.TextTooltip = value; + } + + public bool DisableCollisionNode { + set { + if (!value) { + throw new Exception("Clearing DisableCollisionNode is not supported."); + } + + CollisionNode.NodeFlags = 0; + } + } +} diff --git a/KamiToolKit/Nodes/Basic/SimpleCounterNode.cs b/KamiToolKit/Nodes/Basic/SimpleCounterNode.cs new file mode 100644 index 0000000..5f5fd44 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/SimpleCounterNode.cs @@ -0,0 +1,14 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public unsafe class SimpleCounterNode : CounterNode { + public SimpleCounterNode() { + PartsList.Add(new Part { + TexturePath = "ui/uld/Money_Number.tex", + TextureCoordinates = Vector2.Zero, + Size = new Vector2(22.0f, 22.0f), + }); + } +} diff --git a/KamiToolKit/Nodes/Basic/SimpleImageNode.cs b/KamiToolKit/Nodes/Basic/SimpleImageNode.cs new file mode 100644 index 0000000..fa27d2c --- /dev/null +++ b/KamiToolKit/Nodes/Basic/SimpleImageNode.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +/// +/// A simple image node that automatically creates a single , and exposes helpers to modify that +/// part. +/// +/// This node is not intended to be used with multiple 's. +public unsafe class SimpleImageNode : ImageNode { + public SimpleImageNode() { + PartsList.Add(new Part()); + } + + public float U { + get => PartsList[0]->U; + set => PartsList[0]->U = (ushort)value; + } + + public float V { + get => PartsList[0]->V; + set => PartsList[0]->V = (ushort)value; + } + + public Vector2 TextureCoordinates { + get => new(U, V); + set { + U = value.X; + V = value.Y; + } + } + + public float TextureHeight { + get => PartsList[0]->Height; + set => PartsList[0]->Height = (ushort)value; + } + + public float TextureWidth { + get => PartsList[0]->Width; + set => PartsList[0]->Width = (ushort)value; + } + + public Vector2 TextureSize { + get => new(TextureWidth, TextureHeight); + set { + TextureWidth = value.X; + TextureHeight = value.Y; + } + } + + public virtual string TexturePath { + get => PartsList[0]->LoadedPath; + set => PartsList[0]->LoadTexture(value); + } + + public Vector2 ActualTextureSize => PartsList[0]->LoadedTextureSize; + + public void LoadTexture(string path, bool resolveTheme = true) + => PartsList[0]->LoadTexture(path, resolveTheme); + + public void LoadIcon(uint iconId) + => PartsList[0]->LoadIcon(iconId); +} diff --git a/KamiToolKit/Nodes/Basic/SimpleNineGridNode.cs b/KamiToolKit/Nodes/Basic/SimpleNineGridNode.cs new file mode 100644 index 0000000..adda33a --- /dev/null +++ b/KamiToolKit/Nodes/Basic/SimpleNineGridNode.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public unsafe class SimpleNineGridNode : NineGridNode { + public SimpleNineGridNode() { + PartsList.Add(new Part()); + } + + public float U { + get => PartsList[0]->U; + set => PartsList[0]->U = (ushort)value; + } + + public float V { + get => PartsList[0]->V; + set => PartsList[0]->V = (ushort)value; + } + + public Vector2 TextureCoordinates { + get => new(U, V); + set { + U = value.X; + V = value.Y; + } + } + + public float TextureWidth { + get => PartsList[0]->Width; + set => PartsList[0]->Width = (ushort)value; + } + + public float TextureHeight { + get => PartsList[0]->Height; + set => PartsList[0]->Height = (ushort)value; + } + + public Vector2 TextureSize { + get => new(TextureWidth, TextureHeight); + set { + TextureWidth = value.X; + TextureHeight = value.Y; + } + } + + public string TexturePath { + get => PartsList[0]->LoadedPath; + set => PartsList[0]->LoadTexture(value); + } +} diff --git a/KamiToolKit/Nodes/Basic/SimpleOverlayNode.cs b/KamiToolKit/Nodes/Basic/SimpleOverlayNode.cs new file mode 100644 index 0000000..ed7c2fd --- /dev/null +++ b/KamiToolKit/Nodes/Basic/SimpleOverlayNode.cs @@ -0,0 +1,6 @@ +namespace KamiToolKit.Nodes; + +public class SimpleOverlayNode : SimpleComponentNode { + public SimpleOverlayNode() + => DisableCollisionNode = true; +} diff --git a/KamiToolKit/Nodes/Basic/TextInputSelectionListNode.cs b/KamiToolKit/Nodes/Basic/TextInputSelectionListNode.cs new file mode 100644 index 0000000..2f429ab --- /dev/null +++ b/KamiToolKit/Nodes/Basic/TextInputSelectionListNode.cs @@ -0,0 +1,49 @@ +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public class TextInputSelectionListNode : ResNode { + + public readonly NineGridNode BackgroundNode; + public readonly TextInputButtonNode[] Buttons = new TextInputButtonNode[9]; + public readonly TextNode LabelNode; + + public TextInputSelectionListNode() { + BackgroundNode = new SimpleNineGridNode { + NodeId = 15, + Size = new Vector2(186.0f, 208.0f), + TexturePath = "ui/uld/TextInputA.tex", + TextureCoordinates = new Vector2(48.0f, 0.0f), + TextureSize = new Vector2(20.0f, 20.0f), + TopOffset = 8.0f, + BottomOffset = 8.0f, + LeftOffset = 9.0f, + RightOffset = 9.0f, + PartsRenderType = 4, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.Fill | NodeFlags.EmitsEvents, + }; + BackgroundNode.AttachNode(this); + + LabelNode = new TextNode { + NodeId = 14, + Position = new Vector2(13.0f, 182.0f), + Size = new Vector2(160.0f, 21.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + AlignmentType = (AlignmentType)21, + FontType = FontType.MiedingerMed, + }; + LabelNode.AttachNode(this); + + foreach (var index in Enumerable.Range(0, 9)) { + Buttons[index] = new TextInputButtonNode { + NodeId = (uint)(13 - index), + Position = new Vector2(13.0f, 164.0f - 20.0f * index), + Size = new Vector2(160.0f, 24.0f), + }; + + Buttons[index].AttachNode(this); + } + } +} diff --git a/KamiToolKit/Nodes/Basic/TextNineGridNode.cs b/KamiToolKit/Nodes/Basic/TextNineGridNode.cs new file mode 100644 index 0000000..82a18b6 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/TextNineGridNode.cs @@ -0,0 +1,93 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TextNineGridNode : ComponentNode { + + public readonly NineGridNode BackgroundNineGrid; + public readonly TextNode TextNode; + + public TextNineGridNode() { + SetInternalComponentType(ComponentType.TextNineGrid); + + BackgroundNineGrid = new SimpleNineGridNode { + TexturePath = "ui/uld/ToolTipS.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(32.0f, 24.0f), + TopOffset = 10, + BottomOffset = 10, + LeftOffset = 15, + RightOffset = 15, + }; + BackgroundNineGrid.AttachNode(this); + + TextNode = new TextNode { + TextOutlineColor = ColorHelper.GetColor(55), + Position = new Vector2(4.0f, 1.0f), + FontSize = 23, + AlignmentType = AlignmentType.Right, + FontType = FontType.TrumpGothic, + TextFlags = TextFlags.Edge, + }; + TextNode.AttachNode(this); + + Data->Nodes[0] = TextNode.NodeId; + Data->Nodes[1] = 0; + + InitializeComponentEvents(); + + // Disable ParentNode else SetText + // causes this node to resize itself incorrectly. + Component->ParentNode = null; + } + + public ReadOnlySeString String { + get => TextNode.String; + set => Component->SetText(value); + } + + public int Number { + get => int.Parse(TextNode.String); + set => TextNode.String = value.ToString(); + } + + public int FontSize { + get => (int)TextNode.FontSize; + set => TextNode.FontSize = (uint)value; + } + + public FontType FontType { + get => TextNode.FontType; + set => TextNode.FontType = value; + } + + public Vector4 TextOutlineColor { + get => TextNode.TextOutlineColor; + set => TextNode.TextOutlineColor = value; + } + + public Vector4 TextColor { + get => TextNode.TextColor; + set => TextNode.TextColor = value; + } + + public TextFlags TextFlags { + get => TextNode.TextFlags; + set => TextNode.TextFlags = value; + } + + public AlignmentType AlignmentType { + get => TextNode.AlignmentType; + set => TextNode.AlignmentType = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundNineGrid.Size = Size; + TextNode.Size = Size - new Vector2(8.0f, 2.0f); + } +} diff --git a/KamiToolKit/Nodes/Basic/TextNode.cs b/KamiToolKit/Nodes/Basic/TextNode.cs new file mode 100644 index 0000000..f357cdc --- /dev/null +++ b/KamiToolKit/Nodes/Basic/TextNode.cs @@ -0,0 +1,154 @@ +using System.Numerics; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TextNode : NodeBase { + + public TextNode() : base(NodeType.Text) { + TextColor = ColorHelper.GetColor(8); + TextOutlineColor = ColorHelper.GetColor(7); + FontSize = 12; + FontType = FontType.Axis; + LineSpacing = 12; + AlignmentType = AlignmentType.Left; + } + + public Vector4 TextColor { + get => Node->TextColor.ToVector4(); + set => Node->TextColor = value.ToByteColor(); + } + + public Vector4 TextOutlineColor { + get => Node->EdgeColor.ToVector4(); + set => Node->EdgeColor = value.ToByteColor(); + } + + public Vector4 BackgroundColor { + get => Node->BackgroundColor.ToVector4(); + set => Node->BackgroundColor = value.ToByteColor(); + } + + public uint SelectStart { + get => Node->SelectStart; + set => Node->SelectStart = value; + } + + public uint SelectEnd { + get => Node->SelectEnd; + set => Node->SelectEnd = value; + } + + public AlignmentType AlignmentType { + get => Node->AlignmentType; + set { + Node->SetAlignment(value); + UpdateText(); + } + } + + public FontType FontType { + get => Node->FontType; + set { + Node->SetFont(value); + UpdateText(); + } + } + + public TextFlags TextFlags { + get => Node->TextFlags; + set { + Node->TextFlags = value; + UpdateText(); + } + } + + public void AddTextFlags(params TextFlags[] flags) { + foreach (var flag in flags) { + TextFlags |= flag; + } + } + + public void RemoveTextFlags(params TextFlags[] flags) { + foreach (var flag in flags) { + TextFlags &= ~flag; + } + } + + public uint FontSize { + get => Node->FontSize; + set { + Node->FontSize = (byte)value; + UpdateText(); + } + } + + public uint LineSpacing { + get => Node->LineSpacing; + set { + Node->LineSpacing = (byte)value; + UpdateText(); + } + } + + public uint CharSpacing { + get => Node->CharSpacing; + set { + Node->CharSpacing = (byte)value; + UpdateText(); + } + } + + public uint TextId { + get => Node->TextId; + set => Node->TextId = value; + } + + public ReadOnlySeString String { + get => new(Node->GetText().AsSpan()); + set { + using var builder = new RentedSeStringBuilder(); + Node->SetText(builder.Builder.Append(value).GetViewAsSpan()); + } + } + + public override Vector2 Size { + get => base.Size; + set { + base.Size = value; + UpdateText(); + } + } + + public void SetNumber(int number, bool showCommas = false, bool showPlusSign = false, int digits = 0, bool zeroPad = false) + => Node->SetNumber(number, showCommas, showPlusSign, (byte)digits, zeroPad); + + public Vector2 GetTextDrawSize(ReadOnlySeString text, bool considerScale = true) { + using var builder = new RentedSeStringBuilder(); + + ushort sizeX = 0; + ushort sizeY = 0; + + fixed (byte* ptr = builder.Builder.Append(text).GetViewAsSpan()) + Node->GetTextDrawSize(&sizeX, &sizeY, ptr, considerScale: considerScale); + + return new Vector2(sizeX, sizeY); + } + + public Vector2 GetTextDrawSize(bool considerScale = true) { + ushort sizeX = 0; + ushort sizeY = 0; + + Node->GetTextDrawSize(&sizeX, &sizeY, considerScale: considerScale); + + return new Vector2(sizeX, sizeY); + } + + private void UpdateText() { + using var builder = new RentedSeStringBuilder(); + Node->SetText(builder.Builder.Append(String).GetViewAsSpan()); + } +} diff --git a/KamiToolKit/Nodes/Basic/TextureImageNode.cs b/KamiToolKit/Nodes/Basic/TextureImageNode.cs new file mode 100644 index 0000000..04eb488 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/TextureImageNode.cs @@ -0,0 +1,26 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +/// +/// WARNING: This is a non-owning texture image node. +/// This node is meant to reference a texture that is owned elsewhere. +/// +public unsafe class TextureImageNode : SimpleImageNode { + public void SetTexture(Texture* texture) { + var asset = PartsList[0]->UldAsset; + asset->AtkTexture.KernelTexture = texture; + asset->AtkTexture.TextureType = TextureType.KernelTexture; + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + var asset = PartsList[0]->UldAsset; + asset->AtkTexture.KernelTexture = null; + asset->AtkTexture.TextureType = 0; + + base.Dispose(disposing, isNativeDestructor); + } + } +} diff --git a/KamiToolKit/Nodes/Basic/TreeListCategoryNode.cs b/KamiToolKit/Nodes/Basic/TreeListCategoryNode.cs new file mode 100644 index 0000000..c5c3e86 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/TreeListCategoryNode.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TreeListCategoryNode : ResNode { + + public readonly NineGridNode BackgroundNode; + public readonly SimpleComponentNode ChildContainer; + public readonly ImageNode CollapseArrowNode; + public readonly CollisionNode CollisionNode; + public readonly TextNode LabelNode; + + private readonly List children = []; + + public IReadOnlyCollection HeaderNodes => children.OfType().ToList(); + public IReadOnlyCollection Children => children.AsReadOnly(); + public IEnumerable GetNodes() where T : NodeBase => children.OfType(); + + public Action? OnToggle; + + public TreeListCategoryNode() { + CollisionNode = new CollisionNode { + Height = 28.0f, + }; + CollisionNode.AttachNode(this); + + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListItemB.tex", + TextureSize = new Vector2(48.0f, 28.0f), + TextureCoordinates = new Vector2(0.0f, 24.0f), + Height = 28.0f, + TopOffset = 10.0f, + LeftOffset = 12.0f, + RightOffset = 12.0f, + BottomOffset = 12.0f, + }; + BackgroundNode.AttachNode(this); + + CollapseArrowNode = new ImageNode { + Position = new Vector2(0.0f, 1.0f), + Size = new Vector2(24.0f, 24.0f), + PartId = 1, + }; + + CollapseArrowNode.AddPart(new Part { + TexturePath = "ui/uld/ListItemB.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + Size = new Vector2(24.0f, 24.0f), + Id = 0, + }); + + CollapseArrowNode.AddPart(new Part { + TexturePath = "ui/uld/ListItemB.tex", + TextureCoordinates = new Vector2(24.0f, 0.0f), + Size = new Vector2(24.0f, 24.0f), + Id = 1, + }); + CollapseArrowNode.AttachNode(this); + + LabelNode = new TextNode { + Position = new Vector2(23.0f, 0.0f), + FontType = FontType.Axis, + FontSize = 14, + Height = 28.0f, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(7), + }; + LabelNode.AttachNode(this); + + ChildContainer = new SimpleComponentNode { + Position = new Vector2(0.0f, 24.0f + VerticalPadding), + }; + ChildContainer.AttachNode(this); + + BuildTimelines(); + + CollisionNode.ShowClickableCursor = true; + CollisionNode.AddEvent(AtkEventType.MouseOver, () => Timeline?.PlayAnimation(IsCollapsed ? 2 : 9)); + CollisionNode.AddEvent(AtkEventType.MouseOut, () => Timeline?.PlayAnimation(IsCollapsed ? 1 : 8)); + CollisionNode.AddEvent(AtkEventType.MouseClick, () => { + IsCollapsed = !IsCollapsed; + UpdateCollapsed(); + OnToggle?.Invoke(!IsCollapsed); + }); + } + + public TreeListNode? ParentTreeListNode { get; set; } + + private bool InternalIsCollapsed { get; set; } + + public bool IsCollapsed { + get => InternalIsCollapsed; + set { + InternalIsCollapsed = value; + UpdateCollapsed(); + Timeline?.PlayAnimation(IsCollapsed ? 1 : 8); + } + } + + public float VerticalPadding { get; set; } = 4.0f; + + public ReadOnlySeString String { + get => LabelNode.String; + set => LabelNode.String = value; + } + + private void UpdateCollapsed() { + Timeline?.PlayAnimation(IsCollapsed ? 1 : 8); + ChildContainer.IsVisible = !IsCollapsed; + Height = IsCollapsed ? BackgroundNode.Height : ChildContainer.Height + BackgroundNode.Height; + ParentTreeListNode?.RefreshLayout(); + } + + public void RecalculateLayout() { + ChildContainer.Height = 0.0f; + + foreach (var child in children) { + if (!child.IsVisible) continue; + + child.Y = ChildContainer.Height; + child.Width = ChildContainer.Width; + + ChildContainer.Height += child.Height + VerticalPadding; + Height = ChildContainer.Height + BackgroundNode.Height; + } + + UpdateCollapsed(); + } + + public void AddHeader(ReadOnlySeString label) { + var newHeaderNode = new TreeListHeaderNode { + Size = new Vector2(Width, 24.0f), + String = label, + }; + + AddNode(newHeaderNode); + } + + public void AddNode(NodeBase node) { + node.Y = ChildContainer.Height; + node.Width = ChildContainer.Width; + node.NodeId = (uint)children.Count + 2; + + ChildContainer.Height += node.Height + VerticalPadding; + Height = ChildContainer.Height + BackgroundNode.Height; + + children.Add(node); + node.AttachNode(ChildContainer); + UpdateCollapsed(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundNode.Width = Width; + CollapseArrowNode.Width = 24.0f; + LabelNode.Width = Width - 23.0f; + ChildContainer.Width = Width; + CollisionNode.Width = Width; + + foreach (var node in children) { + node.Width = Width; + } + } + + public void UpdateChildrenNodeId() { + CollisionNode.NodeId = NodeId * 10000 + 1; + BackgroundNode.NodeId = NodeId * 10000 + 2; + CollapseArrowNode.NodeId = NodeId * 10000 + 3; + LabelNode.NodeId = NodeId * 10000 + 4; + ChildContainer.NodeId = NodeId * 10000 + 5; + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 119) + .AddLabel(1, 1, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 2, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(19, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 3, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(29, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(30, 7, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(39, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(40, 6, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(49, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(50, 4, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(59, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(60, 8, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(69, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(70, 9, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(79, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(80, 10, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(89, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(90, 14, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(99, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(100, 13, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(109, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(110, 11, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(119, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + CollapseArrowNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(1, partId: 0) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(10, partId: 0) + .AddFrame(12, partId: 0) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(20, partId: 0) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .AddFrame(30, partId: 0) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(40, partId: 0) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(50, partId: 0) + .AddFrame(52, partId: 0) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(60, partId: 1) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(70, partId: 1) + .AddFrame(72, partId: 1) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(80, partId: 0) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 178) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .AddFrame(90, partId: 1) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(100, partId: 1) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(110, partId: 1) + .AddFrame(112, partId: 1) + .EndFrameSet() + .Build() + ); + + LabelNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 229) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 229) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 229) + .AddFrame(20, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 153) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 229) + .AddFrame(40, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 229) + .AddFrame(50, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 229) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 229) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 229) + .AddFrame(80, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 153) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 229) + .AddFrame(100, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 229) + .AddFrame(110, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 178) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Basic/TreeListHeaderNode.cs b/KamiToolKit/Nodes/Basic/TreeListHeaderNode.cs new file mode 100644 index 0000000..7adbc07 --- /dev/null +++ b/KamiToolKit/Nodes/Basic/TreeListHeaderNode.cs @@ -0,0 +1,45 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public class TreeListHeaderNode : ResNode { + + public readonly NineGridNode DecorationNode; + public readonly TextNode LabelNode; + + public TreeListHeaderNode() { + DecorationNode = new SimpleNineGridNode { + TexturePath = "ui/uld/journal_Separator.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(424.0f, 24.0f), + Size = new Vector2(24.0f, 24.0f), + LeftOffset = 25.0f, + RightOffset = 20.0f, + }; + DecorationNode.AttachNode(this); + + LabelNode = new TextNode { + Position = new Vector2(22.0f, 1.0f), + TextColor = ColorHelper.GetColor(7), + AlignmentType = AlignmentType.Left, + FontSize = 12, + FontType = FontType.Axis, + }; + LabelNode.AttachNode(this); + } + + public ReadOnlySeString String { + get => LabelNode.String; + set => LabelNode.String = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + DecorationNode.Size = Size; + LabelNode.Size = new Vector2(Width - 22.0f, Height); + } +} diff --git a/KamiToolKit/Nodes/Basic/VerticalLineNode.cs b/KamiToolKit/Nodes/Basic/VerticalLineNode.cs new file mode 100644 index 0000000..e16e86c --- /dev/null +++ b/KamiToolKit/Nodes/Basic/VerticalLineNode.cs @@ -0,0 +1,17 @@ +namespace KamiToolKit.Nodes; + +public sealed unsafe class VerticalLineNode : HorizontalLineNode { + public VerticalLineNode() { + RotationDegrees = 90.0f; + } + + public override float Height { + get => ResNode->GetWidth(); + set => ResNode->SetWidth((ushort) value); + } + + public override float Width { + get => ResNode->GetHeight(); + set => ResNode->SetHeight((ushort) value); + } +} diff --git a/KamiToolKit/Nodes/Basic/WindowBackgroundNode.cs b/KamiToolKit/Nodes/Basic/WindowBackgroundNode.cs new file mode 100644 index 0000000..e4ab3ef --- /dev/null +++ b/KamiToolKit/Nodes/Basic/WindowBackgroundNode.cs @@ -0,0 +1,22 @@ +using System.Numerics; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public class WindowBackgroundNode : NineGridNode { + public WindowBackgroundNode(bool selectedPath, string path = "ui/uld/WindowA_Bg") { + var basePath = $"{path}{(selectedPath ? "Selected" : "Normal")}"; + + PartsList.Add( + new Part { TextureCoordinates = new Vector2(0.0f, 0.0f), Size = new Vector2(16.0f, 64.0f), Id = 0, TexturePath = $"{basePath}_Corner.tex" }, + new Part { TextureCoordinates = new Vector2(0.0f, 0.0f), Size = new Vector2(32.0f, 64.0f), Id = 1, TexturePath = $"{basePath}_H.tex" }, + new Part { TextureCoordinates = new Vector2(16.0f, 0.0f), Size = new Vector2(16.0f, 64.0f), Id = 2, TexturePath = $"{basePath}_Corner.tex" }, + new Part { TextureCoordinates = new Vector2(0.0f, 0.0f), Size = new Vector2(16.0f, 32.0f), Id = 3, TexturePath = $"{basePath}_V.tex" }, + new Part { TextureCoordinates = new Vector2(0.0f, 0.0f), Size = new Vector2(32.0f, 32.0f), Id = 4, TexturePath = $"{basePath}_HV.tex" }, + new Part { TextureCoordinates = new Vector2(16.0f, 0.0f), Size = new Vector2(16.0f, 32.0f), Id = 5, TexturePath = $"{basePath}_V.tex" }, + new Part { TextureCoordinates = new Vector2(0.0f, 64.0f), Size = new Vector2(16.0f, 32.0f), Id = 6, TexturePath = $"{basePath}_Corner.tex" }, + new Part { TextureCoordinates = new Vector2(0.0f, 64.0f), Size = new Vector2(32.0f, 32.0f), Id = 7, TexturePath = $"{basePath}_H.tex" }, + new Part { TextureCoordinates = new Vector2(16.0f, 64.0f), Size = new Vector2(16.0f, 32.0f), Id = 8, TexturePath = $"{basePath}_Corner.tex" } + ); + } +} diff --git a/KamiToolKit/Nodes/Component/ButtonBase.cs b/KamiToolKit/Nodes/Component/ButtonBase.cs new file mode 100644 index 0000000..6087480 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ButtonBase.cs @@ -0,0 +1,93 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public abstract unsafe class ButtonBase : ComponentNode { + + protected ButtonBase() { + SetInternalComponentType(ComponentType.Button); + AddEvent(AtkEventType.ButtonClick, ClickHandler); + } + + public Action? OnClick { get; set; } + + public bool IsChecked { + get => Component->IsChecked; + set => Component->SetChecked(value); + } + + private void ClickHandler() { + OnClick?.Invoke(); + } + + protected static void LoadTwoPartTimelines(NodeBase parent, NodeBase foreground) { + parent.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabelPair(1, 9, 1) + .AddLabelPair(10, 19, 2) + .AddLabelPair(20, 29, 3) + .AddLabelPair(30, 39, 7) + .AddLabelPair(40, 49, 6) + .AddLabelPair(50, 59, 4) + .EndFrameSet() + .Build()); + + foreground.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 9, 1, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(10, 19) + .AddFrame(10, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(12, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(20, 29, 20, new Vector2(0.0f, 1.0f), 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .AddFrameSetWithFrame(30, 39, 30, Vector2.Zero, 178, multiplyColor: new Vector3(50.0f)) + .AddFrameSetWithFrame(40, 49, 40, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .BeginFrameSet(50, 59) + .AddFrame(50, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .AddFrame(52, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(130, 139, 130, Vector2.Zero, 255, new Vector3(16.0f), new Vector3(100.0f)) + .AddFrameSetWithFrame(140, 149, 140, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(150, 159, 150, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .Build()); + } + + protected static void LoadThreePartTimelines(NodeBase parent, NodeBase background, NodeBase foreground, Vector2 foregroundPositionOffset) { + parent.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 53) + .AddLabelPair(1, 10, 1) + .AddLabelPair(11, 17, 2) + .AddLabelPair(18, 26, 3) + .AddLabelPair(27, 36, 7) + .AddLabelPair(37, 46, 6) + .AddLabelPair(47, 53, 4) + .EndFrameSet() + .Build()); + + background.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(11, 17) + .AddFrame(11, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(13, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(18, 26, 18, new Vector2(0.0f, 1.0f), 255, new Vector3(16.0f)) + .AddFrameSetWithFrame(27, 36, 27, Vector2.Zero, 178, multiplyColor: new Vector3(50.0f)) + .AddFrameSetWithFrame(37, 46, 37, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .BeginFrameSet(47, 53) + .AddFrame(47, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .AddFrame(53, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .Build()); + + foreground.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(11, 17, 11, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(18, 26, 18, foregroundPositionOffset + new Vector2(0.0f, 1.0f), 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(27, 36, 27, foregroundPositionOffset, 153, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(37, 46, 37, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(47, 53, 47, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Component/ButtonListNode.cs b/KamiToolKit/Nodes/Component/ButtonListNode.cs new file mode 100644 index 0000000..5a3c799 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ButtonListNode.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public abstract class ListNode : SimpleComponentNode; + +/// Note, automatically inserts buttons to fill the set height, please ensure option count is greater than button count. +public abstract unsafe class ButtonListNode : ListNode { + + public readonly NineGridNode BackgroundNode; + public readonly ResNode ContainerNode; + public readonly ScrollBarNode ScrollBarNode; + public List Nodes = []; + + protected ButtonListNode() { + SetInternalComponentType(ComponentType.Base); + + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListB.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(32.0f, 32.0f), + TopOffset = 10, + BottomOffset = 12, + LeftOffset = 10, + RightOffset = 10, + }; + BackgroundNode.AttachNode(this); + + ContainerNode = new ResNode { + NodeFlags = NodeFlags.Visible | NodeFlags.Clip, + }; + ContainerNode.AttachNode(this); + + ScrollBarNode = new ScrollBarNode { + Position = new Vector2(0.0f, 9.0f), + Size = new Vector2(8.0f, 0.0f), + OnValueChanged = OnScrollUpdate, + HideWhenDisabled = true, + }; + ScrollBarNode.AttachNode(this); + + BuildTimelines(); + + ContainerNode.AddEvent(AtkEventType.MouseWheel, OnMouseWheel); + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + if (isFocusSet && !isNativeDestructor) { + if (ParentAddon is not null) { + ClearFocusable(ParentAddon); + } + } + + base.Dispose(disposing, isNativeDestructor); + } + } + + public T? SelectedOption { + get; + set { + field = value; + UpdateSelected(); + } + } + + public List? Options { + get; + set { + field = value; + RebuildNodeList(); + } + } + + protected float NodeHeight { get; set; } = 22.0f; + + private int ButtonCount { get; set; } + + public int MaxButtons { + get; + set { + field = value; + RebuildNodeList(); + } + } = 5; + + public int CurrentStartIndex { get; set; } + + public Action? OnOptionSelected { get; set; } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundNode.Size = Size; + ContainerNode.Size = new Vector2(Width - 25.0f, Height); + + foreach (var buttonNode in Nodes) { + buttonNode.Width = Width - 25.0f; + } + + ScrollBarNode.X = Width - 17.0f; + } + + private void OnScrollUpdate(int scrollPosition) { + var index = scrollPosition / 22.0f; + + CurrentStartIndex = (int)index; + UpdateNodes(); + } + + private void OnMouseWheel(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + CurrentStartIndex -= atkEventData->MouseData.WheelDirection; + UpdateNodes(); + ScrollBarNode.ScrollPosition = (int)(CurrentStartIndex * NodeHeight + 9.0f); + + atkEvent->SetEventIsHandled(); + } + + private void RebuildNodeList() { + foreach (var button in Nodes) { + button.DetachNode(); + button.Dispose(); + } + Nodes.Clear(); + + ButtonCount = Math.Min(MaxButtons, Options?.Count ?? 0); + + var height = ButtonCount * NodeHeight + 24.0f; + Height = height; + BackgroundNode.Height = height; + ContainerNode.Height = height; + ScrollBarNode.Height = height - 23.0f; + + foreach (var index in Enumerable.Range(0, ButtonCount)) { + var newButton = new ListButtonNode { + NodeId = (uint)index, + Size = new Vector2(Width - 25.0f, NodeHeight), + Position = new Vector2(8.0f, NodeHeight * index + 9.0f), + + String = $"Button {index}", + OnClick = () => OnOptionClick(index), + }; + + Nodes.Add(newButton); + newButton.AttachNode(ContainerNode); + } + + RecalculateScrollParams(); + UpdateNodes(); + } + + public void RecalculateScrollParams() { + if (Options is not null) { + ScrollBarNode.UpdateScrollParams((int)ScrollBarNode.Height, (int)(Options.Count * NodeHeight)); + } + } + + protected virtual void OnOptionClick(int nodeId) { + if (Options is null) return; + + SelectedOption = Options[nodeId + CurrentStartIndex]; + OnOptionSelected?.Invoke(Options[nodeId + CurrentStartIndex]); + + UpdateSelected(); + } + + private void UpdateSelected() { + if (Options is null) return; + + foreach (var index in Enumerable.Range(0, ButtonCount)) { + var option = Options[index + CurrentStartIndex]; + + Nodes[index].Selected = SelectedOption?.Equals(option) ?? false; + Nodes[index].String = GetLabelForOption(option); + } + } + + protected abstract string GetLabelForOption(T option); + + protected void UpdateNodes() { + if (Options is null) return; + var maxStartIndex = Options.Count - Nodes.Count; + + var max = Math.Max(0, maxStartIndex); + CurrentStartIndex = Math.Clamp(CurrentStartIndex, 0, max); + UpdateSelected(); + } + + public void SelectDefaultOption() { + if (Options is not null && Options.Count > 0) { + SelectedOption = Options.First(); + } + } + + public void Show() { + IsVisible = true; + AddDrawFlags(DrawFlags.RenderOnTop); + + if (ParentAddon is not null) { + SetFocusable(ParentAddon); + } + } + + public void Hide() { + IsVisible = false; + RemoveDrawFlags(DrawFlags.RenderOnTop); + + if (ParentAddon is not null) { + ClearFocusable(ParentAddon); + } + } + + public void Toggle(bool newState) { + if (newState) { + Show(); + } + else { + Hide(); + } + } + + private bool isFocusSet; + + public void SetFocusable(AtkUnitBase* addon) { + foreach (ref var focusableNode in addon->AdditionalFocusableNodes) { + if (focusableNode.Value is null) { + focusableNode = ResNode; + isFocusSet = true; + } + } + } + + public void ClearFocusable(AtkUnitBase* addon) { + foreach (ref var focusableNode in addon->AdditionalFocusableNodes) { + if (focusableNode.Value == ResNode) { + focusableNode = null; + isFocusSet = false; + } + } + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 29) + .AddLabel(1, 17, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 18, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(19, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 7, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(29, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/CircleButtonNode.cs b/KamiToolKit/Nodes/Component/CircleButtonNode.cs new file mode 100644 index 0000000..f6d0146 --- /dev/null +++ b/KamiToolKit/Nodes/Component/CircleButtonNode.cs @@ -0,0 +1,150 @@ +using System.Numerics; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public class CircleButtonNode : ButtonBase { + + public readonly SimpleImageNode ImageNode; + + public CircleButtonNode() { + ImageNode = new SimpleImageNode { + TexturePath = "ui/uld/CircleButtons.tex", + TextureSize = new Vector2(24.0f, 24.0f), + TextureCoordinates = new Vector2(0.0f, 112.0f), + WrapMode = WrapMode.Stretch, + }; + ImageNode.AttachNode(this); + + LoadTimelines(); + + InitializeComponentEvents(); + } + + public ButtonIcon Icon { + get; + set { + field = value; + var uldInfo = GetTextureCoordinateForIcon(value); + ImageNode.TextureCoordinates = uldInfo.TextureCoordinates; + ImageNode.TextureSize = uldInfo.TextureSize; + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ImageNode.Size = Size; + } + + private static UldTextureInfo GetTextureCoordinateForIcon(ButtonIcon icon) => icon switch { + ButtonIcon.GearCog => new UldTextureInfo(0.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.Filter => new UldTextureInfo(28.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.Sort => new UldTextureInfo(56.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.QuestionMark => new UldTextureInfo(84.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.Refresh => new UldTextureInfo(112.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.ChatBubble => new UldTextureInfo(140.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.LeftArrow => new UldTextureInfo(168.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.UpArrow => new UldTextureInfo(196.0f, 0.0f, 28.0f, 28.0f), + ButtonIcon.Chest => new UldTextureInfo(224.0f, 0.0f, 28.0f, 28.0f), + + ButtonIcon.Document => new UldTextureInfo(0.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.Edit => new UldTextureInfo(28.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.Add => new UldTextureInfo(56.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.RightArrow => new UldTextureInfo(84.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.MusicNote => new UldTextureInfo(112.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.Sprout => new UldTextureInfo(140.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.Dice => new UldTextureInfo(168.0f, 28.0f, 28.0f, 28.0f), + ButtonIcon.ArrowDown => new UldTextureInfo(196.0f, 28.0f, 28.0f, 28.0f), + + ButtonIcon.Eye => new UldTextureInfo(0.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.Envelope => new UldTextureInfo(28.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.Volume => new UldTextureInfo(56.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.Mute => new UldTextureInfo(84.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.WavePulse => new UldTextureInfo(112.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.CheckedBox => new UldTextureInfo(140.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.Cross => new UldTextureInfo(168.0f, 56.0f, 28.0f, 28.0f), + ButtonIcon.Globe => new UldTextureInfo(196.0f, 56.0f, 28.0f, 28.0f), + + ButtonIcon.ActiveGearCog => new UldTextureInfo(0.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.ActiveFilter => new UldTextureInfo(28.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.Update => new UldTextureInfo(56.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.ActiveRing => new UldTextureInfo(84.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.Exclamation => new UldTextureInfo(112.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.InsetDocument => new UldTextureInfo(140.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.GearCogWithChatBubble => new UldTextureInfo(168.0f, 84.0f, 28.0f, 28.0f), + ButtonIcon.FlatbedCartBoxes => new UldTextureInfo(196.0f, 84.0f, 28.0f, 28.0f), + + ButtonIcon.MagnifyingGlass => new UldTextureInfo(0.0f, 112.0f, 24.0f, 24.0f), + ButtonIcon.EditSmall => new UldTextureInfo(24.0f, 112.0f, 24.0f, 24.0f), + ButtonIcon.WeaponDraw => new UldTextureInfo(48.0f, 112.0f, 24.0f, 24.0f), + ButtonIcon.Headgear => new UldTextureInfo(72.0f, 112.0f, 24.0f, 24.0f), + ButtonIcon.Sword => new UldTextureInfo(96.0f, 112.0f, 24.0f, 24.0f), + ButtonIcon.Emotes => new UldTextureInfo(120.0f, 112.0f, 24.0f, 24.0f), + ButtonIcon.PersonStanding => new UldTextureInfo(144.0f, 112.0f, 24.0f, 24.0f), + + ButtonIcon.PaintBucket => new UldTextureInfo(0.0f, 136.0f, 24.0f, 24.0f), + ButtonIcon.EyeSmall => new UldTextureInfo(24.0f, 136.0f, 24.0f, 24.0f), + ButtonIcon.Undo => new UldTextureInfo(48.0f, 136.0f, 24.0f, 24.0f), + ButtonIcon.PinPaper => new UldTextureInfo(72.0f, 136.0f, 24.0f, 24.0f), + ButtonIcon.CrossSmall => new UldTextureInfo(96.0f, 136.0f, 24.0f, 24.0f), + + _ => new UldTextureInfo(0.0f, 0.0f, 28.0f, 28.0f), + }; + + private void LoadTimelines() + => LoadTwoPartTimelines(this, ImageNode); +} + +public enum ButtonIcon { + GearCog, + Filter, + Sort, + QuestionMark, + Refresh, + ChatBubble, + LeftArrow, + UpArrow, + Chest, + Document, + Edit, + Add, + RightArrow, + MusicNote, + Sprout, + Dice, + ArrowDown, + Eye, + Envelope, + Volume, + Mute, + WavePulse, + CheckedBox, + Cross, + Globe, + ActiveGearCog, + ActiveFilter, + Update, + ActiveRing, + Exclamation, + InsetDocument, + GearCogWithChatBubble, + FlatbedCartBoxes, + MagnifyingGlass, + EditSmall, + WeaponDraw, + Headgear, + Sword, + Emotes, + PersonStanding, + PaintBucket, + EyeSmall, + Undo, + PinPaper, + CrossSmall, +} + +internal record UldTextureInfo(float PositionX = 0.0f, float PositionY = 0.0f, float Width = 0.0f, float Height = 0.0f) { + public Vector2 TextureCoordinates => new(PositionX, PositionY); + public Vector2 TextureSize => new(Width, Height); +} diff --git a/KamiToolKit/Nodes/Component/ColorOptionTextButtonNode.cs b/KamiToolKit/Nodes/Component/ColorOptionTextButtonNode.cs new file mode 100644 index 0000000..e0d9cc4 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ColorOptionTextButtonNode.cs @@ -0,0 +1,116 @@ +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Premade.Color; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class ColorOptionTextButtonNode : ButtonBase { + + public readonly NineGridNode BackgroundNode; + public readonly TextNode LabelNode; + public readonly ColorPreviewNode ColorNode; + + public ColorOptionTextButtonNode() { + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ButtonA.tex", + TextureSize = new Vector2(100.0f, 28.0f), + LeftOffset = 16.0f, + RightOffset = 16.0f, + }; + BackgroundNode.AttachNode(this); + + ColorNode = new ColorPreviewNode { + DisableCollisionNode = true, + }; + ColorNode.AttachNode(this); + + LabelNode = new TextNode { + AlignmentType = AlignmentType.Center, + Position = new Vector2(16.0f, 3.0f), + }; + + LabelNode.AttachNode(this); + + LoadTimelines(); + + Data->Nodes[0] = LabelNode.NodeId; + Data->Nodes[1] = BackgroundNode.NodeId; + + InitializeComponentEvents(); + } + + public ReadOnlySeString String { + get => LabelNode.String; + set => LabelNode.String = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + LabelNode.Size = new Vector2(Width - 32.0f, Height - 8.0f); + BackgroundNode.Size = Size; + ColorNode.Size = new Vector2(17.0f, 17.0f); + } + + public ColorHelpers.HsvaColor? DefaultHsvaColor { + get => ColorNode.ColorHsva; + set => ColorNode.ColorHsva = value ?? default; + } + + public Vector4? DefaultColor { + get => ColorNode.Color; + set => ColorNode.Color = value ?? default; + } + + private void LoadTimelines() { + var foregroundPositionOffset = new Vector2(24.0f, 3.0f); + var colorElementPositionOffset = new Vector2(16.0f, 2.0f); + + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 53) + .AddLabelPair(1, 10, 1) + .AddLabelPair(11, 17, 2) + .AddLabelPair(18, 26, 3) + .AddLabelPair(27, 36, 7) + .AddLabelPair(37, 46, 6) + .AddLabelPair(47, 53, 4) + .EndFrameSet() + .Build()); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .BeginFrameSet(11, 17) + .AddFrame(11, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .AddFrame(13, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .EndFrameSet() + .AddFrameSetWithFrame(18, 26, 18, new Vector2(0.0f, 1.0f), 255, new Vector3(16.0f)) + .AddFrameSetWithFrame(27, 36, 27, Vector2.Zero, 178, multiplyColor: new Vector3(50.0f)) + .AddFrameSetWithFrame(37, 46, 37, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .BeginFrameSet(47, 53) + .AddFrame(47, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f), addColor: new Vector3(16.0f)) + .AddFrame(53, Vector2.Zero, 255, multiplyColor: new Vector3(100.0f)) + .EndFrameSet() + .Build()); + + ColorNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, colorElementPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(11, 17, 11, colorElementPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(18, 26, 18, colorElementPositionOffset + new Vector2(0.0f, 1.0f), 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(27, 36, 27, colorElementPositionOffset, 153, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(37, 46, 37, colorElementPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(47, 53, 47, colorElementPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .Build()); + + LabelNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 10, 1, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(11, 17, 11, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(18, 26, 18, foregroundPositionOffset + new Vector2(0.0f, 1.0f), 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(27, 36, 27, foregroundPositionOffset, 153, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(37, 46, 37, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(47, 53, 47, foregroundPositionOffset, 255, multiplyColor: new Vector3(100.0f)) + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Component/ComponentNode.cs b/KamiToolKit/Nodes/Component/ComponentNode.cs new file mode 100644 index 0000000..ddb6a87 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ComponentNode.cs @@ -0,0 +1,112 @@ +using System; +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public abstract unsafe class ComponentNode(NodeType nodeType) : NodeBase(nodeType) { + public abstract CollisionNode CollisionNode { get; } + public abstract AtkComponentBase* ComponentBase { get; } + public abstract AtkUldComponentDataBase* DataBase { get; } +} + +public abstract unsafe class ComponentNode : ComponentNode where T : unmanaged, ICreatable where TU : unmanaged { + public sealed override CollisionNode CollisionNode { get; } + public sealed override AtkComponentBase* ComponentBase => Node->Component; + public sealed override AtkUldComponentDataBase* DataBase => Node->Component->UldManager.ComponentData; + + protected ComponentNode() : base(NodeType.Component) { + Node->Component = (AtkComponentBase*) NativeMemoryHelper.Create(); + Node->Component->UldManager.ComponentData = (AtkUldComponentDataBase*)NativeMemoryHelper.UiAlloc(); + + ComponentBase->Initialize(); + + CollisionNode = new CollisionNode { + NodeId = 1, + LinkedComponent = ComponentBase, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.HasCollision | + NodeFlags.RespondToMouse | NodeFlags.Focusable | NodeFlags.EmitsEvents | NodeFlags.Fill, + }; + + CollisionNode.ResNode->ParentNode = ResNode; + CollisionNode.ParentUldManager = &((AtkComponentBase*)Component)->UldManager; + + ChildNodes.Add(CollisionNode); + + ComponentBase->OwnerNode = Node; + ComponentBase->ComponentFlags = 1; + + ref var uldManager = ref ComponentBase->UldManager; + + uldManager.Objects = (AtkUldObjectInfo*)NativeMemoryHelper.UiAlloc(); + ref var objects = ref uldManager.Objects; + uldManager.ObjectCount = 1; + + SetInternalComponentType(ComponentType.Base); + + objects->NodeList = (AtkResNode**)NativeMemoryHelper.Malloc(8); + objects->NodeList[0] = CollisionNode; + objects->NodeCount = 1; + objects->Id = 1000; + + uldManager.InitializeResourceRendererManager(); + uldManager.RootNode = CollisionNode; + + uldManager.UpdateDrawNodeList(); + uldManager.ResourceFlags = AtkUldManagerResourceFlag.Initialized | AtkUldManagerResourceFlag.ArraysAllocated; + uldManager.LoadedState = AtkLoadState.Loaded; + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + try { + if (!isNativeDestructor) { + Node->Component->Deinitialize(); + Node->Component->Dtor(1); + Node->Component = null; + } + } + catch (Exception e) { + Log.Exception(e); + } finally { + base.Dispose(disposing, isNativeDestructor); + } + } + } + + public static implicit operator AtkEventListener*(ComponentNode node) => &node.ComponentBase->AtkEventListener; + public static implicit operator T*(ComponentNode node) => node.Component; + public static implicit operator TU*(ComponentNode node) => node.Data; + + protected void SetInternalComponentType(ComponentType type) { + var componentInfo = (AtkUldComponentInfo*)ComponentBase->UldManager.Objects; + + componentInfo->ComponentType = type; + } + + protected void InitializeComponentEvents() { + ComponentBase->InitializeFromComponentData(DataBase); + ComponentBase->Setup(); + ComponentBase->SetEnabledState(true); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + CollisionNode.Size = Size; + ComponentBase->UldManager.RootNodeHeight = (ushort)Height; + ComponentBase->UldManager.RootNodeWidth = (ushort)Width; + } + + public virtual bool IsEnabled { + get => NodeFlags.HasFlag(NodeFlags.Enabled); + set => ComponentBase->SetEnabledState(value); + } + + public override int ChildCount => ComponentBase->UldManager.NodeListCount; + + public T* Component => (T*)ComponentBase; + + public TU* Data => (TU*)DataBase; +} diff --git a/KamiToolKit/Nodes/Component/DropDownNode.cs b/KamiToolKit/Nodes/Component/DropDownNode.cs new file mode 100644 index 0000000..ea3487f --- /dev/null +++ b/KamiToolKit/Nodes/Component/DropDownNode.cs @@ -0,0 +1,461 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public abstract unsafe class DropDownNode : SimpleComponentNode where T : ButtonListNode, new() { + + public readonly NineGridNode BackgroundNode; + public readonly ImageNode CollapseArrowNode; + public readonly CollisionNode DropDownFocusCollisionNode; + public readonly TextNode LabelNode; + public readonly T OptionListNode; + + protected DropDownNode() { + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/DropDownA.tex", + TextureSize = new Vector2(44.0f, 23.0f), + TextureCoordinates = new Vector2(0.0f, 0.0f), + Size = new Vector2(250.0f, 24.0f), + Height = 23.0f, + LeftOffset = 16.0f, + RightOffset = 16.0f, + }; + BackgroundNode.AttachNode(this); + + CollapseArrowNode = new SimpleImageNode { + TexturePath = "ui/uld/DropDownA.tex", + TextureCoordinates = new Vector2(44.0f, 0.0f), + TextureSize = new Vector2(12.0f, 12.0f), + Position = new Vector2(6.0f, 17.0f), + Size = new Vector2(12.0f, 12.0f), + WrapMode = WrapMode.Stretch, + }; + CollapseArrowNode.AttachNode(this); + + LabelNode = new TextNode { + Position = new Vector2(20.0f, 0.0f), + Size = new Vector2(218.0f, 21.0f), + FontType = FontType.Axis, + FontSize = 12, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(7), + String = "Demo", + }; + LabelNode.AttachNode(this); + + OptionListNode = new T { + NodeId = NodeIdBase, + Position = new Vector2(4.0f, 21.0f), + Size = new Vector2(242.0f, 243.0f), + IsVisible = false, + }; + OptionListNode.AttachNode(this); + + DropDownFocusCollisionNode = new CollisionNode(); + DropDownFocusCollisionNode.AttachNode(OptionListNode.CollisionNode, NodePosition.AfterTarget); + + DropDownFocusCollisionNode.AddEvent(AtkEventType.MouseDown, Toggle); + DropDownFocusCollisionNode.AddEvent(AtkEventType.MouseWheel, Toggle); + + BuildTimelines(); + + Timeline?.PlayAnimation(4); + + CollisionNode.ShowClickableCursor = true; + CollisionNode.AddEvent(AtkEventType.MouseOver, () => Timeline?.PlayAnimation(IsCollapsed ? 2 : 9)); + CollisionNode.AddEvent(AtkEventType.MouseOut, () => Timeline?.PlayAnimation(IsCollapsed ? 4 : 11)); + CollisionNode.AddEvent(AtkEventType.MouseClick, Toggle); + + Component->SoundEffectId = 1; + Component->SetEnabledState(true); + } + + public bool IsCollapsed { get; set; } = true; + + public int MaxListOptions { + get => OptionListNode.MaxButtons; + set => OptionListNode.MaxButtons = value; + } + + public TU? SelectedOption { + get => OptionListNode.SelectedOption; + set { + OptionListNode.SelectedOption = value; + UpdateLabel(value); + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + CollisionNode.Size = Size; + BackgroundNode.Size = new Vector2(Width, Height - 1.0f); + LabelNode.Size = new Vector2(Width - 32.0f, Height - 3.0f); + + OptionListNode.Width = Width - 8.0f; + OptionListNode.Position = new Vector2(4.0f, Height - 3.0f); + } + + public Action? OnCollapseToggled { get; set; } + public Action? OnUncollapsed { get; set; } + public Action? OnCollapsed { get; set; } + + public void Collapse(bool playSoundEffect = true) { + if (!IsEnabled) return; + if (IsCollapsed) return; + + IsCollapsed = true; + Timeline?.PlayAnimation(4); + OptionListNode.Toggle(false); + + // TODO: replace this (and in Uncollapse) with just a check for playSoundEffect and a call to Component->PlaySoundEffect(); + // when https://github.com/aers/FFXIVClientStructs/commit/e5b6fc51 landed in Dalamud + if (playSoundEffect && Component->SoundEffectId is not -1) + UIGlobals.PlaySoundEffect((uint)Component->SoundEffectId); + + OptionListNode.ReattachNode(this); + + // Need to reset position after reattaching, so screen position is recalculated correctly + OptionListNode.Position = Size with { X = 0.0f } + new Vector2(4.0f, -4.0f); + + OnCollapsed?.Invoke(); + } + + public void Uncollapse(bool playSoundEffect = true) { + if (!IsEnabled) return; + if (!IsCollapsed) return; + + IsCollapsed = false; + Timeline?.PlayAnimation(11); + OptionListNode.Toggle(true); + + if (playSoundEffect && Component->SoundEffectId is not -1) + UIGlobals.PlaySoundEffect((uint)Component->SoundEffectId); + + if (ParentAddon is not null) { + OptionListNode.Position = (ScreenPosition - ParentAddon->Position) / ParentAddon->Scale + Size with { X = 0.0f } + new Vector2(4.0f, -4.0f); + MoveListOnScreen(); + + DropDownFocusCollisionNode.Position = -OptionListNode.Position; + DropDownFocusCollisionNode.Size = ParentAddon->RootSize; + + OptionListNode.ReattachNode(ParentAddon->RootNode); + } + + OnUncollapsed?.Invoke(); + } + + public void Toggle() { + Toggle(true); + } + + public void Toggle(bool playSoundEffect) { + if (!IsEnabled) return; + + if (IsCollapsed) { + Uncollapse(playSoundEffect); + } + else { + Collapse(playSoundEffect); + } + + OnCollapseToggled?.Invoke(IsCollapsed); + } + + public void RecalculateScrollParams() + => OptionListNode.RecalculateScrollParams(); + + private void MoveListOnScreen() { + var screenSize = AtkStage.Instance()->ScreenSize; + var parentAddon = RaptureAtkUnitManager.Instance()->GetAddonByNode(ResNode); + if (parentAddon == null) { + return; + } + + var scale = parentAddon->Scale; + var scaledListSize = OptionListNode.Size * scale; + if (ScreenPosition.X + scaledListSize.X > screenSize.Width) { + OptionListNode.X += (screenSize.Width - OptionListNode.ScreenPosition.X - scaledListSize.X - 4f) / scale; + } + else if (ScreenPosition.X < 0) { + OptionListNode.X -= OptionListNode.ScreenPosition.X / scale; + } + + if (OptionListNode.ScreenPosition.Y + scaledListSize.Y > screenSize.Height) { + OptionListNode.Y += (screenSize.Height - OptionListNode.ScreenPosition.Y - scaledListSize.Y) / scale; + } + } + + protected abstract void UpdateLabel(TU? option); + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 120) + .AddLabel(1, 1, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 2, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(19, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 3, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(29, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(30, 7, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(39, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(40, 6, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(49, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(50, 4, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(59, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(60, 8, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(69, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(70, 9, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(79, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(80, 10, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(89, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(90, 14, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(99, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(100, 13, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(109, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(110, 11, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(120, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + CollapseArrowNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, new Vector2(6, 17)) + .AddFrame(1, rotation: 4.712389f) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, new Vector2(6, 17)) + .AddFrame(12, new Vector2(6, 17)) + .AddFrame(10, rotation: 4.712389f) + .AddFrame(12, rotation: 4.712389f) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, new Vector2(6, 18)) + .AddFrame(20, rotation: 4.712389f) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, new Vector2(6, 17)) + .AddFrame(30, rotation: 4.712389f) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, new Vector2(6, 17)) + .AddFrame(40, rotation: 4.712389f) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, new Vector2(6, 17)) + .AddFrame(52, new Vector2(6, 17)) + .AddFrame(50, rotation: 4.712389f) + .AddFrame(52, rotation: 4.712389f) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, new Vector2(6, 6)) + .AddFrame(60, rotation: 0) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, new Vector2(6, 6)) + .AddFrame(72, new Vector2(6, 6)) + .AddFrame(70, rotation: 0) + .AddFrame(72, rotation: 0) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, new Vector2(6, 7)) + .AddFrame(80, rotation: 0) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, new Vector2(6, 6)) + .AddFrame(90, rotation: 0) + .AddFrame(90, alpha: 178) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, new Vector2(6, 6)) + .AddFrame(100, rotation: 0) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 120) + .AddFrame(110, new Vector2(6, 6)) + .AddFrame(112, new Vector2(6, 6)) + .AddFrame(110, rotation: 0) + .AddFrame(112, rotation: 0) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + LabelNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, new Vector2(20, 0)) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, new Vector2(20, 0)) + .AddFrame(10, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, new Vector2(20, 1)) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, new Vector2(20, 0)) + .AddFrame(30, alpha: 153) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, new Vector2(20, 0)) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, new Vector2(20, 0)) + .AddFrame(50, alpha: 255) + .AddFrame(50, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, new Vector2(20, 0)) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, new Vector2(20, 0)) + .AddFrame(70, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, new Vector2(20, 1)) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, new Vector2(20, 0)) + .AddFrame(90, alpha: 153) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, new Vector2(20, 0)) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 120) + .AddFrame(110, new Vector2(20, 0)) + .AddFrame(110, alpha: 255) + .AddFrame(110, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, new Vector2(0, 0)) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, new Vector2(0, 0)) + .AddFrame(12, new Vector2(0, 0)) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, new Vector2(0, 1)) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, new Vector2(0, 0)) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, new Vector2(0, 0)) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, new Vector2(0, 0)) + .AddFrame(52, new Vector2(0, 0)) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, new Vector2(0, 0)) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, new Vector2(0, 0)) + .AddFrame(72, new Vector2(0, 0)) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, new Vector2(0, 1)) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, new Vector2(0, 0)) + .AddFrame(90, alpha: 178) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, new Vector2(0, 0)) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 120) + .AddFrame(110, new Vector2(0, 0)) + .AddFrame(112, new Vector2(0, 0)) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/EnumButtonListNode.cs b/KamiToolKit/Nodes/Component/EnumButtonListNode.cs new file mode 100644 index 0000000..e97ea65 --- /dev/null +++ b/KamiToolKit/Nodes/Component/EnumButtonListNode.cs @@ -0,0 +1,9 @@ +using System; + +namespace KamiToolKit.Nodes; + +public class EnumButtonListNode : ButtonListNode where T : Enum { + + protected override string GetLabelForOption(T option) + => option.Description; +} diff --git a/KamiToolKit/Nodes/Component/EnumDropDownNode.cs b/KamiToolKit/Nodes/Component/EnumDropDownNode.cs new file mode 100644 index 0000000..ce42d82 --- /dev/null +++ b/KamiToolKit/Nodes/Component/EnumDropDownNode.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace KamiToolKit.Nodes; + +public class EnumDropDownNode : DropDownNode, T> where T : Enum{ + + public EnumDropDownNode() { + OptionListNode.OnOptionSelected += OptionSelectedHandler; + } + + public Action? OnOptionSelected { get; set; } + + public required List? Options { + get => OptionListNode.Options; + set { + OptionListNode.Options = value; + OptionListNode.SelectDefaultOption(); + UpdateLabel(OptionListNode.SelectedOption); + } + } + + private void OptionSelectedHandler(T option) { + OnOptionSelected?.Invoke(option); + UpdateLabel(option); + Toggle(false); + } + + protected override void UpdateLabel(T? option) { + LabelNode.String = option?.Description; + } +} diff --git a/KamiToolKit/Nodes/Component/HoldButtonNode.cs b/KamiToolKit/Nodes/Component/HoldButtonNode.cs new file mode 100644 index 0000000..a258af5 --- /dev/null +++ b/KamiToolKit/Nodes/Component/HoldButtonNode.cs @@ -0,0 +1,270 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class HoldButtonNode : ComponentNode { + + public readonly NineGridNode BackgroundNode; + public readonly NineGridNode FrameNode; + public readonly HoldButtonProgressNode ProgressNode; + public readonly TextNode TextNode; + + public HoldButtonNode() { + SetInternalComponentType(ComponentType.HoldButton); + + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/LongPressButtonA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(100.0f, 36.0f), + Size = new Vector2(100.0f, 36.0f), + LeftOffset = 16, + RightOffset = 16, + }; + BackgroundNode.AttachNode(this); + + ProgressNode = new HoldButtonProgressNode { + Size = new Vector2(100.0f, 36.0f), + }; + ProgressNode.AttachNode(this); + + FrameNode = new SimpleNineGridNode { + TexturePath = "ui/uld/LongPressButtonA.tex", + TextureCoordinates = new Vector2(0.0f, 72.0f), + TextureSize = new Vector2(100.0f, 36.0f), + Size = new Vector2(100.0f, 36.0f), + }; + FrameNode.AttachNode(this); + + TextNode = new TextNode { + Position = new Vector2(16.0f, 8.0f), + Size = new Vector2(68.0f, 20.0f), + AlignmentType = AlignmentType.Center, + String = "OK", + }; + TextNode.AttachNode(this); + + Data->Nodes[0] = TextNode.NodeId; + Data->Nodes[1] = BackgroundNode.NodeId; + Data->Nodes[2] = ProgressNode.NodeId; + Data->Nodes[3] = ProgressNode.ImageNode.NodeId; + + InitializeComponentEvents(); + + AddEvent(AtkEventType.ButtonClick, ClickHandler); + + BuildTimelines(); + } + + public bool UnlockAfterClick { get; set; } + + public Action? OnClick { get; set; } + + public ReadOnlySeString String { + get => TextNode.String; + set => TextNode.String = value; + } + + private void ClickHandler() { + OnClick?.Invoke(); + + if (UnlockAfterClick) { + Reset(); + } + } + + public void Reset() { + Component->IsTargetReached = false; + Component->IsEventFired = false; + Component->Progress.StartValue = 0; + Component->Progress.TargetValue = 0; + Component->Progress.CurrentValue = 0; + Component->Progress.EndValue = 0; + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 20) + .AddLabel(1, 17, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(10, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(11, 101, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(20, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 10) + .AddFrame(1, new Vector2(0, 0)) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(11, 17) + .AddFrame(11, new Vector2(0, 0)) + .AddFrame(13, new Vector2(0, 0)) + .AddFrame(11, alpha: 255) + .AddFrame(13, alpha: 255) + .AddFrame(11, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(13, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(18, 26) + .AddFrame(18, new Vector2(0, 1)) + .AddFrame(18, alpha: 255) + .AddFrame(18, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(27, 36) + .AddFrame(27, new Vector2(0, 0)) + .AddFrame(27, alpha: 178) + .AddFrame(27, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(37, 46) + .AddFrame(37, new Vector2(0, 0)) + .AddFrame(37, alpha: 255) + .AddFrame(37, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(47, 53) + .AddFrame(47, new Vector2(0, 0)) + .AddFrame(53, new Vector2(0, 0)) + .AddFrame(47, alpha: 255) + .AddFrame(53, alpha: 255) + .AddFrame(47, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(53, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(54, 64) + .AddFrame(54, new Vector2(0, 0)) + .AddFrame(54, alpha: 255) + .AddFrame(54, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(65, 71) + .AddFrame(65, new Vector2(0, 0)) + .AddFrame(71, new Vector2(0, 0)) + .AddFrame(65, alpha: 255) + .AddFrame(71, alpha: 255) + .AddFrame(65, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(71, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + ProgressNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 83) + .AddLabel(1, 29, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(60, 30, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(61, 31, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(73, 32, AtkTimelineJumpBehavior.PlayOnce, 31) + .AddLabel(74, 33, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(83, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .BeginFrameSet(18, 26) + .AddEmptyFrame(18) + .EndFrameSet() + .BeginFrameSet(37, 53) + .AddEmptyFrame(37) + .EndFrameSet() + .BeginFrameSet(54, 71) + .AddEmptyFrame(54) + .EndFrameSet() + .Build() + ); + + FrameNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 10) + .AddFrame(1, new Vector2(0, 0)) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(11, 17) + .AddFrame(11, new Vector2(0, 0)) + .AddFrame(13, new Vector2(0, 0)) + .AddFrame(11, alpha: 255) + .AddFrame(13, alpha: 255) + .AddFrame(11, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(13, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(95, 95, 95)) + .EndFrameSet() + .BeginFrameSet(18, 26) + .AddFrame(18, new Vector2(0, 0)) + .AddFrame(18, alpha: 255) + .AddFrame(18, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(95, 95, 95)) + .EndFrameSet() + .BeginFrameSet(27, 36) + .AddFrame(27, new Vector2(0, 0)) + .AddFrame(27, alpha: 178) + .AddFrame(27, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(37, 46) + .AddFrame(37, new Vector2(0, 0)) + .AddFrame(37, alpha: 255) + .AddFrame(37, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(95, 95, 95)) + .EndFrameSet() + .BeginFrameSet(47, 53) + .AddFrame(47, new Vector2(0, 0)) + .AddFrame(53, new Vector2(0, 0)) + .AddFrame(47, alpha: 255) + .AddFrame(53, alpha: 255) + .AddFrame(47, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(95, 95, 95)) + .AddFrame(53, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(54, 64) + .AddFrame(54, new Vector2(0, 0)) + .AddFrame(54, alpha: 255) + .AddFrame(54, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(95, 95, 95)) + .EndFrameSet() + .BeginFrameSet(65, 71) + .AddFrame(65, new Vector2(0, 0)) + .AddFrame(71, new Vector2(0, 0)) + .AddFrame(65, alpha: 255) + .AddFrame(71, alpha: 255) + .AddFrame(65, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(95, 95, 95)) + .AddFrame(71, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + TextNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 10) + .AddFrame(1, new Vector2(16, 8)) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(11, 17) + .AddFrame(11, new Vector2(16, 8)) + .AddFrame(11, alpha: 255) + .AddFrame(11, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(18, 26) + .AddFrame(18, new Vector2(16, 9)) + .AddFrame(18, alpha: 255) + .AddFrame(18, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(27, 36) + .AddFrame(27, new Vector2(16, 8)) + .AddFrame(27, alpha: 153) + .AddFrame(27, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(37, 46) + .AddFrame(37, new Vector2(16, 8)) + .AddFrame(37, alpha: 255) + .AddFrame(37, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(47, 53) + .AddFrame(47, new Vector2(16, 8)) + .AddFrame(47, alpha: 255) + .AddFrame(47, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(54, 64) + .AddFrame(54, new Vector2(16, 8)) + .AddFrame(54, alpha: 255) + .AddFrame(54, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(65, 71) + .AddFrame(65, new Vector2(16, 8)) + .AddFrame(65, alpha: 255) + .AddFrame(65, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/IconButtonNode.cs b/KamiToolKit/Nodes/Component/IconButtonNode.cs new file mode 100644 index 0000000..913a152 --- /dev/null +++ b/KamiToolKit/Nodes/Component/IconButtonNode.cs @@ -0,0 +1,51 @@ +using System.Numerics; + +namespace KamiToolKit.Nodes; + +/// +/// Uses a GameIconId to display that icon as the decorator for the button. +/// +public class IconButtonNode : ButtonBase { + + public readonly NineGridNode BackgroundNode; + public readonly IconImageNode ImageNode; + + public IconButtonNode() { + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/BgParts.tex", + TextureSize = new Vector2(32.0f, 32.0f), + TextureCoordinates = new Vector2(33.0f, 65.0f), + TopOffset = 8.0f, + LeftOffset = 8.0f, + RightOffset = 8.0f, + BottomOffset = 8.0f, + }; + BackgroundNode.AttachNode(this); + + ImageNode = new IconImageNode { + TextureSize = new Vector2(32.0f, 32.0f), + FitTexture = true, + }; + ImageNode.AttachNode(this); + + LoadTimelines(); + + InitializeComponentEvents(); + } + + public uint IconId { + get => ImageNode.IconId; + set => ImageNode.IconId = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ImageNode.Size = Size - new Vector2(16.0f, 16.0f); + ImageNode.Position = BackgroundNode.Position + new Vector2(BackgroundNode.LeftOffset, BackgroundNode.TopOffset); + BackgroundNode.Size = Size; + } + + private void LoadTimelines() + => LoadThreePartTimelines(this, BackgroundNode, ImageNode, new Vector2(8.0f, 8.0f)); +} diff --git a/KamiToolKit/Nodes/Component/IconNode.cs b/KamiToolKit/Nodes/Component/IconNode.cs new file mode 100644 index 0000000..9d4a32f --- /dev/null +++ b/KamiToolKit/Nodes/Component/IconNode.cs @@ -0,0 +1,133 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class IconNode : ComponentNode { + + public readonly IconExtras IconExtras; + public readonly IconImageNode IconImage; + public readonly IconIndicator IconIndicator1; + public readonly IconIndicator IconIndicator2; + + public IconNode() { + SetInternalComponentType(ComponentType.Icon); + + IconImage = new IconImageNode { + NodeId = 20, + Size = new Vector2(40.0f, 40.0f), + Position = new Vector2(2.0f, 3.0f), + WrapMode = WrapMode.Tile, + ImageNodeFlags = ImageNodeFlags.AutoFit, + }; + IconImage.AttachNode(this); + + IconExtras = new IconExtras { + NodeId = 6, + Size = new Vector2(60, 60), + Position = new Vector2(-2.0f, 0.0f), + }; + IconExtras.AttachNode(this); + + IconIndicator1 = new IconIndicator(5) { + NodeId = 4, + Size = new Vector2(18.0f, 18.0f), + Position = new Vector2(27.0f, 11.0f), + }; + IconIndicator1.AttachNode(this); + + IconIndicator2 = new IconIndicator(3) { + NodeId = 2, + Size = new Vector2(18.0f, 18.0f), + Position = new Vector2(27.0f, -2.0f), + }; + IconIndicator2.AttachNode(this); + + BuildTimeline(); + + Data->Nodes[0] = IconImage.NodeId; + Data->Nodes[1] = IconExtras.CooldownNode.NodeId; + Data->Nodes[2] = IconExtras.NodeId; + Data->Nodes[3] = IconExtras.ResourceCostTextNode.NodeId; + Data->Nodes[4] = IconExtras.QuantityTextNode.NodeId; + Data->Nodes[5] = IconExtras.AntsNode.NodeId; + Data->Nodes[6] = IconIndicator1.IconNode.NodeId; + Data->Nodes[7] = IconIndicator2.IconNode.NodeId; + + InitializeComponentEvents(); + } + + public uint IconId { + get => Component->IconId; + set => Component->LoadIcon(value); + } + + public bool IsIconLoading + => Component->Flags.HasFlag(IconComponentFlags.IsIconLoading); + + public bool IsIconDisabled { + get => Component->Flags.HasFlag(IconComponentFlags.IsDisabled); + set => Component->SetIconImageDisableState(value); + } + + public byte ComboLevel { + get { + if (Component->Flags.HasFlag(IconComponentFlags.ComboLevel3)) + return 3; + if (Component->Flags.HasFlag(IconComponentFlags.ComboLevel2)) + return 2; + if (Component->Flags.HasFlag(IconComponentFlags.ComboLevel1)) + return 1; + return 0; + } + set => Component->SetComboLevel(value is >= 1 and <= 3, (byte)(value - 1)); + } + + public bool IsMacro { + get => Component->Flags.HasFlag(IconComponentFlags.IsMacro); + set => Component->SetIsMacro(value); + } + + public bool IsRecipe { + get => Component->Flags.HasFlag(IconComponentFlags.IsRecipe); + set => Component->SetIsRecipe(value); + } + + public bool IsBeingDragged + => Component->Flags.HasFlag(IconComponentFlags.IsBeingDragged); + + private void BuildTimeline() { + IconExtras.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabelPair(1, 9, 1) + .AddLabelPair(10, 19, 2) + .AddLabelPair(20, 29, 3) + .AddLabelPair(30, 39, 7) + .AddLabelPair(40, 49, 6) + .AddLabelPair(50, 59, 4) + .EndFrameSet() + .Build()); + + var iconIndicatorTimeline = new TimelineBuilder() + .BeginFrameSet(1, 129) + .AddLabel(1, 17, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(11, 101, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(21, 102, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(31, 103, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(41, 104, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(51, 105, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(61, 106, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(71, 107, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(80, 108, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(90, 109, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(100, 110, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(110, 111, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(120, 112, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet(); + + IconIndicator1.AddTimeline(iconIndicatorTimeline.Build()); + IconIndicator2.AddTimeline(iconIndicatorTimeline.Build()); + } +} diff --git a/KamiToolKit/Nodes/Component/IconToggleNode.cs b/KamiToolKit/Nodes/Component/IconToggleNode.cs new file mode 100644 index 0000000..fd34891 --- /dev/null +++ b/KamiToolKit/Nodes/Component/IconToggleNode.cs @@ -0,0 +1,81 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public class IconToggleNode : SimpleComponentNode { + private readonly IconImageNode iconNode; + private readonly ClippingMaskNode clipNode; + private readonly SimpleImageNode highlightNode; // For selected + private readonly SimpleImageNode lowlightNode; // For unselected + + public IconToggleNode() { + iconNode = new IconImageNode { + TextureSize = new Vector2(36.0f, 36.0f), + FitTexture = true, + }; + iconNode.AttachNode(this); + + clipNode = new SimpleClippingMaskNode { + TextureCoordinates = Vector2.Zero, + TextureSize = new Vector2(32.0f, 32.0f), + TexturePath = "ui/uld/BgPartsMask.tex", + Size = new Vector2(32.0f, 32.0f), + }; + clipNode.AttachNode(this); + + highlightNode = new SimpleImageNode { + Size = new Vector2(36.0f, 36.0f), + IsVisible = false, + TextureCoordinates = new Vector2(69.0f, 1.0f), + TextureSize = new Vector2(36.0f, 36.0f), + TexturePath = "ui/uld/BgParts.tex", + }; + highlightNode.AttachNode(this); + + lowlightNode = new SimpleImageNode { + Size = new Vector2(36.0f, 36.0f), + IsVisible = false, + TextureCoordinates = new Vector2(141.0f, 1.0f), + TextureSize = new Vector2(36.0f, 36.0f), + TexturePath = "ui/uld/BgParts.tex", + }; + lowlightNode.AttachNode(this); + + CollisionNode.AddEvent(AtkEventType.MouseClick, () => UIGlobals.PlaySoundEffect(1)); + } + + public uint IconId { + get => iconNode.IconId; + set => iconNode.IconId = value; + } + + public bool IsToggled { + get; + set { + field = value; + highlightNode.IsVisible = value; + lowlightNode.IsVisible = !value; + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + // Icon is 32x32 centered within the 36x36 node + var iconSize = Size - new Vector2(4.0f, 4.0f); + var iconOffset = new Vector2(2.0f, 2.0f); + iconNode.Size = iconSize; + iconNode.Position = iconOffset; + + clipNode.Size = iconSize; + clipNode.Position = iconOffset; + + highlightNode.Size = Size; + highlightNode.Position = Vector2.Zero; + + lowlightNode.Size = Size; + lowlightNode.Position = Vector2.Zero; + } +} diff --git a/KamiToolKit/Nodes/Component/ImGuiIconButtonNode.cs b/KamiToolKit/Nodes/Component/ImGuiIconButtonNode.cs new file mode 100644 index 0000000..eeb279c --- /dev/null +++ b/KamiToolKit/Nodes/Component/ImGuiIconButtonNode.cs @@ -0,0 +1,59 @@ +using System.Numerics; +using Dalamud.Interface.Textures.TextureWraps; + +namespace KamiToolKit.Nodes; + +public class ImGuiIconButtonNode : ButtonBase { + + public readonly NineGridNode BackgroundNode; + public readonly ImGuiImageNode ImageNode; + + public ImGuiIconButtonNode() { + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/BgParts.tex", + TextureSize = new Vector2(32.0f, 32.0f), + TextureCoordinates = new Vector2(33.0f, 65.0f), + TopOffset = 8.0f, + LeftOffset = 8.0f, + RightOffset = 8.0f, + BottomOffset = 8.0f, + }; + BackgroundNode.AttachNode(this); + + ImageNode = new ImGuiImageNode { + FitTexture = true, + }; + ImageNode.AttachNode(this); + + LoadTimelines(); + + InitializeComponentEvents(); + } + + public bool ShowBackground { + get => BackgroundNode.IsVisible; + set => BackgroundNode.IsVisible = value; + } + + public string TexturePath { + get => ImageNode.TexturePath; + set => ImageNode.TexturePath = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ImageNode.Size = Size - new Vector2(16.0f, 16.0f); + ImageNode.Position = BackgroundNode.Position + new Vector2(BackgroundNode.LeftOffset, BackgroundNode.TopOffset); + BackgroundNode.Size = Size; + } + + public void LoadTexture(IDalamudTextureWrap texture) + => ImageNode.LoadTexture(texture); + + public void LoadTextureFromFile(string path) + => ImageNode.LoadTextureFromFile(path); + + private void LoadTimelines() + => LoadThreePartTimelines(this, BackgroundNode, ImageNode, new Vector2(8.0f, 8.0f)); +} diff --git a/KamiToolKit/Nodes/Component/ListButtonNode.cs b/KamiToolKit/Nodes/Component/ListButtonNode.cs new file mode 100644 index 0000000..745001d --- /dev/null +++ b/KamiToolKit/Nodes/Component/ListButtonNode.cs @@ -0,0 +1,192 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class ListButtonNode : ButtonBase { + + public readonly NineGridNode HoverBackgroundNode; + public readonly TextNode LabelNode; + public readonly NineGridNode SelectedBackgroundNode; + + public ListButtonNode() { + HoverBackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListItemA.tex", + TextureCoordinates = new Vector2(0.0f, 22.0f), + TextureSize = new Vector2(64.0f, 22.0f), + LeftOffset = 16, + RightOffset = 1, + }; + HoverBackgroundNode.AttachNode(this); + + SelectedBackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListItemA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(64.0f, 22.0f), + LeftOffset = 16, + RightOffset = 1, + }; + SelectedBackgroundNode.AttachNode(this); + + LabelNode = new TextNode { + Position = new Vector2(10.0f, 1.0f), + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + FontType = FontType.Axis, + FontSize = 14, + AlignmentType = AlignmentType.Left, + String = "Label Not Set", + }; + LabelNode.AttachNode(this); + + LoadTimelines(); + + InitializeComponentEvents(); + } + + public bool Selected { + get => Component->IsChecked; + set => Component->SetChecked(value); + } + + public ReadOnlySeString String { + get => LabelNode.String; + set => LabelNode.String = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + HoverBackgroundNode.Size = Size; + SelectedBackgroundNode.Size = Size; + LabelNode.Size = new Vector2(Width - 10.0f, Height - 1.0f); + } + + private void LoadTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 120) + .AddLabel(1, 1, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 2, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(19, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 3, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(29, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(30, 7, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(39, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(40, 6, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(49, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(50, 4, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(59, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(60, 8, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(69, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(70, 9, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(79, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(80, 10, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(89, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(90, 14, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(99, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(100, 13, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(109, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(110, 11, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(120, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + HoverBackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 0) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0) + .AddFrame(13, alpha: 255) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 0) + .EndFrameSet() + .Build() + ); + + SelectedBackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 214) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 214) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 178) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 120) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 214) + .AddFrame(110, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + LabelNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 127) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 127) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .EndFrameSet() + .BeginFrameSet(110, 120) + .AddFrame(110, alpha: 255) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/LuminaButtonListNode.cs b/KamiToolKit/Nodes/Component/LuminaButtonListNode.cs new file mode 100644 index 0000000..c8bedd2 --- /dev/null +++ b/KamiToolKit/Nodes/Component/LuminaButtonListNode.cs @@ -0,0 +1,40 @@ +using System.Linq; +using KamiToolKit.Classes; +using Lumina.Excel; + +namespace KamiToolKit.Nodes; + +public class LuminaButtonListNode : ButtonListNode where T : struct, IExcelRow { + + public delegate string GetLabel(T excelRow); + + public delegate bool ShouldShow(T excelRow); + + public GetLabel? LabelFunction { + get; + set { + field = value; + ResolveOptions(); + } + } + + public ShouldShow? FilterFunction { + get; + set { + field = value; + ResolveOptions(); + } + } + + private void ResolveOptions() { + if (LabelFunction is null) return; + if (FilterFunction is null) return; + + Options = DalamudInterface.Instance.DataManager.GetExcelSheet() + .Where(row => FilterFunction(row)) + .ToList(); + } + + protected override string GetLabelForOption(T option) + => LabelFunction?.Invoke(option) ?? "ERROR: Label Function Not Found"; +} diff --git a/KamiToolKit/Nodes/Component/LuminaDropDownNode.cs b/KamiToolKit/Nodes/Component/LuminaDropDownNode.cs new file mode 100644 index 0000000..0b4701f --- /dev/null +++ b/KamiToolKit/Nodes/Component/LuminaDropDownNode.cs @@ -0,0 +1,47 @@ +using System; +using Lumina.Excel; + +namespace KamiToolKit.Nodes; + +public class LuminaDropDownNode : DropDownNode, T> where T : struct, IExcelRow { + + public LuminaDropDownNode() { + OptionListNode.OnOptionSelected += OptionSelectedHandler; + } + + public Action? OnOptionSelected { get; set; } + + public LuminaButtonListNode.GetLabel? LabelFunction { + get => OptionListNode.LabelFunction; + set { + OptionListNode.LabelFunction = value; + ResolveOptions(); + } + } + + public LuminaButtonListNode.ShouldShow? FilterFunction { + get => OptionListNode.FilterFunction; + set { + OptionListNode.FilterFunction = value; + ResolveOptions(); + } + } + + private void OptionSelectedHandler(T option) { + OnOptionSelected?.Invoke(option); + UpdateLabel(option); + Toggle(false); + } + + private void ResolveOptions() { + if (LabelFunction is null) return; + if (FilterFunction is null) return; + + OptionListNode.SelectDefaultOption(); + LabelNode.String = LabelFunction.Invoke(OptionListNode.SelectedOption); + } + + protected override void UpdateLabel(T option) { + LabelNode.String = LabelFunction?.Invoke(option) ?? "ERROR: Label Function Not Set"; + } +} diff --git a/KamiToolKit/Nodes/Component/ProgressBarCastNode.cs b/KamiToolKit/Nodes/Component/ProgressBarCastNode.cs new file mode 100644 index 0000000..1eb8302 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ProgressBarCastNode.cs @@ -0,0 +1,95 @@ +using System.Drawing; +using System.Numerics; +using Dalamud.Interface; + +namespace KamiToolKit.Nodes; + +public unsafe class ProgressBarCastNode : ProgressNode { + + public readonly NineGridNode BackgroundImageNode; + public readonly NineGridNode ProgressNode; + public readonly NineGridNode BorderImageNode; + + public ProgressBarCastNode() { + BackgroundImageNode = new SimpleNineGridNode { + TexturePath = "ui/uld/Parameter_Gauge.tex", + TextureSize = new Vector2(160.0f, 20.0f), + TextureCoordinates = new Vector2(0.0f, 100.0f), + LeftOffset = 20, + RightOffset = 20, + }; + BackgroundImageNode.AttachNode(this); + + ProgressNode = new SimpleNineGridNode { + TexturePath = "ui/uld/Parameter_Gauge.tex", + TextureSize = new Vector2(160.0f, 20.0f), + TextureCoordinates = new Vector2(0.0f, 40.0f), + MultiplyColor = new Vector3(90.0f, 75.0f, 75.0f) / 255.0f, + AddColor = KnownColor.Yellow.Vector().AsVector3Color() / 255.0f, + LeftOffset = 10, + RightOffset = 10, + }; + ProgressNode.AttachNode(this); + + BorderImageNode = new SimpleNineGridNode { + TexturePath = "ui/uld/Parameter_Gauge.tex", + TextureSize = new Vector2(160.0f, 20.0f), + TextureCoordinates = new Vector2(0.0f, 0.0f), + LeftOffset = 20, + RightOffset = 20, + }; + BorderImageNode.AttachNode(this); + } + + public override float Progress { + get => ProgressNode.Width / Width; + set => ProgressNode.Width = Width * value; + } + + public override Vector4 BackgroundColor { + get => new(BackgroundImageNode.AddColor.X, BackgroundImageNode.AddColor.Y, BackgroundImageNode.AddColor.Z, BackgroundImageNode.ResNode->Color.A / 255.0f); + set { + BackgroundImageNode.ResNode->Color = new Vector4(1.0f, 1.0f, 1.0f, value.W).ToByteColor(); + BackgroundImageNode.AddColor = value.AsVector3Color(); + } + } + + public Vector4 BorderColor { + get => new(BorderImageNode.AddColor.X, BorderImageNode.AddColor.Y, BorderImageNode.AddColor.Z, BorderImageNode.ResNode->Color.A / 255.0f); + set { + BorderImageNode.ResNode->Color = new Vector4(1.0f, 1.0f, 1.0f, value.W).ToByteColor(); + BorderImageNode.AddColor = value.AsVector3Color(); + } + } + + public override Vector4 BarColor { + get => new(ProgressNode.AddColor.X, ProgressNode.AddColor.Y, ProgressNode.AddColor.Z, ProgressNode.ResNode->Color.A / 255.0f); + set { + ProgressNode.ResNode->Color = new Vector4(1.0f, 1.0f, 1.0f, value.W).ToByteColor(); + ProgressNode.AddColor = value.AsVector3Color(); + } + } + + public override Vector3 MultiplyColor { + get => base.MultiplyColor; + set { + base.MultiplyColor = value; + BackgroundImageNode.MultiplyColor = value; + ProgressNode.MultiplyColor = value; + BorderImageNode.MultiplyColor = value; + } + } + + public bool BorderVisible { + get => BorderImageNode.IsVisible; + set => BorderImageNode.IsVisible = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundImageNode.Size = Size; + ProgressNode.Size = Size; + BorderImageNode.Size = Size; + } +} diff --git a/KamiToolKit/Nodes/Component/ProgressBarEnemyCastNode.cs b/KamiToolKit/Nodes/Component/ProgressBarEnemyCastNode.cs new file mode 100644 index 0000000..85ddeb3 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ProgressBarEnemyCastNode.cs @@ -0,0 +1,57 @@ +using System.Numerics; + +namespace KamiToolKit.Nodes; + +public unsafe class ProgressBarEnemyCastNode : ProgressNode { + + public readonly NineGridNode BackgroundImageNode; + public readonly NineGridNode ProgressNode; + + public ProgressBarEnemyCastNode() { + BackgroundImageNode = new SimpleNineGridNode { + TexturePath = "ui/uld/PartyList_GaugeCast.tex", + TextureSize = new Vector2(204.0f, 20.0f), + TextureCoordinates = new Vector2(0.0f, 12.0f), + LeftOffset = 20, + RightOffset = 20, + }; + BackgroundImageNode.AttachNode(this); + + ProgressNode = new SimpleNineGridNode { + TexturePath = "ui/uld/PartyList_GaugeCast.tex", + TextureSize = new Vector2(188.0f, 7.0f), + TextureCoordinates = new Vector2(8.0f, 3.0f), + LeftOffset = 10, + RightOffset = 10, + }; + ProgressNode.AttachNode(this); + } + + public override float Progress { + get => ProgressNode.Width / Width; + set => ProgressNode.Width = Width * value; + } + + public override Vector4 BackgroundColor { + get => new(BackgroundImageNode.AddColor.X, BackgroundImageNode.AddColor.Y, BackgroundImageNode.AddColor.Z, BackgroundImageNode.ResNode->Color.A / 255.0f); + set { + BackgroundImageNode.ResNode->Color = new Vector4(1.0f, 1.0f, 1.0f, value.W).ToByteColor(); + BackgroundImageNode.AddColor = value.AsVector3Color(); + } + } + + public override Vector4 BarColor { + get => new(ProgressNode.AddColor.X, ProgressNode.AddColor.Y, ProgressNode.AddColor.Z, ProgressNode.ResNode->Color.A / 255.0f); + set { + ProgressNode.ResNode->Color = new Vector4(1.0f, 1.0f, 1.0f, value.W).ToByteColor(); + ProgressNode.AddColor = value.AsVector3Color(); + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundImageNode.Size = Size; + ProgressNode.Size = Size; + } +} diff --git a/KamiToolKit/Nodes/Component/ProgressBarNode.cs b/KamiToolKit/Nodes/Component/ProgressBarNode.cs new file mode 100644 index 0000000..8e90ad5 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ProgressBarNode.cs @@ -0,0 +1,51 @@ +using System.Numerics; + +namespace KamiToolKit.Nodes; + +public class ProgressBarNode : ProgressNode { + + public readonly NineGridNode BackgroundNode; + public readonly NineGridNode ForegroundNode; + + public ProgressBarNode() { + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ToDoList.tex", + TextureCoordinates = new Vector2(108.0f, 8.0f), + TextureSize = new Vector2(44.0f, 12.0f), + LeftOffset = 6, + RightOffset = 6, + }; + BackgroundNode.AttachNode(this); + + ForegroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ToDoList.tex", + TextureCoordinates = new Vector2(112.0f, 0.0f), + TextureSize = new Vector2(40.0f, 8.0f), + LeftOffset = 4, + RightOffset = 4, + }; + ForegroundNode.AttachNode(this); + } + + public override Vector4 BackgroundColor { + get => BackgroundNode.Color; + set => BackgroundNode.Color = value; + } + + public override Vector4 BarColor { + get => ForegroundNode.Color; + set => ForegroundNode.Color = value; + } + + public override float Progress { + get => ForegroundNode.Width / Width; + set => ForegroundNode.Width = Width * value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundNode.Size = Size; + ForegroundNode.Size = Size; + } +} diff --git a/KamiToolKit/Nodes/Component/ProgressNode.cs b/KamiToolKit/Nodes/Component/ProgressNode.cs new file mode 100644 index 0000000..1b72b95 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ProgressNode.cs @@ -0,0 +1,9 @@ +using System.Numerics; + +namespace KamiToolKit.Nodes; + +public abstract class ProgressNode : SimpleComponentNode { + public abstract float Progress { get; set; } + public abstract Vector4 BarColor { get; set; } + public abstract Vector4 BackgroundColor { get; set; } +} diff --git a/KamiToolKit/Nodes/Component/RadioButtonGroupNode.cs b/KamiToolKit/Nodes/Component/RadioButtonGroupNode.cs new file mode 100644 index 0000000..6e0d49f --- /dev/null +++ b/KamiToolKit/Nodes/Component/RadioButtonGroupNode.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public class RadioButtonGroupNode : SimpleComponentNode { + + private readonly List radioButtons = []; + + public RadioButtonGroupNode() { + BuildTimelines(); + } + + public ReadOnlySeString? SelectedOption { + get => radioButtons.FirstOrDefault(button => button.IsSelected)?.String; + set { + if (value == null) + return; + + foreach (var radioButton in radioButtons) { + radioButton.IsChecked = radioButton.String == value; + radioButton.IsSelected = radioButton.String == value; + } + + RecalculateLayout(); + } + } + + public float VerticalPadding { get; set; } = 2.0f; + + public void AddButton(ReadOnlySeString label, Action callback) { + var newRadioButton = new RadioButtonNode { + Height = 16.0f, + String = label, + Callback = callback, + }; + + newRadioButton.AddEvent(AtkEventType.ButtonClick, () => ClickHandler(newRadioButton)); + + radioButtons.Add(newRadioButton); + newRadioButton.AttachNode(this); + + if (radioButtons.Count is 1) { + newRadioButton.IsChecked = true; + newRadioButton.IsSelected = true; + } + + RecalculateLayout(); + } + + public void RemoveButton(ReadOnlySeString label) { + var button = radioButtons.FirstOrDefault(button => button.String == label); + if (button is null) return; + + button.Dispose(); + radioButtons.Remove(button); + RecalculateLayout(); + } + + public void Clear() { + foreach (var node in radioButtons) { + node.Dispose(); + } + + radioButtons.Clear(); + } + + private void RecalculateLayout() { + var yPosition = 0.0f; + + foreach (var index in Enumerable.Range(0, radioButtons.Count)) { + var button = radioButtons[index]; + + button.Y = yPosition; + yPosition += button.Height + VerticalPadding; + } + + Height = yPosition; + } + + private void ClickHandler(RadioButtonNode selectedButton) { + foreach (var radioButton in radioButtons) { + radioButton.IsChecked = false; + radioButton.IsSelected = false; + } + + selectedButton.IsChecked = true; + selectedButton.IsSelected = true; + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 19) + .AddLabel(1, 101, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 102, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/RadioButtonNode.cs b/KamiToolKit/Nodes/Component/RadioButtonNode.cs new file mode 100644 index 0000000..7c66b22 --- /dev/null +++ b/KamiToolKit/Nodes/Component/RadioButtonNode.cs @@ -0,0 +1,309 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +internal unsafe class RadioButtonNode : ComponentNode { + public readonly TextNode LabelNode; + public readonly ImageNode SelectedImageNode; + + public readonly ImageNode UnselectedImageNode; + + public RadioButtonNode() { + SetInternalComponentType(ComponentType.RadioButton); + + UnselectedImageNode = new SimpleImageNode { + NodeId = 4, + TexturePath = "ui/uld/RadioButtonA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(16.0f, 16.0f), + Size = new Vector2(16.0f, 16.0f), + WrapMode = WrapMode.Tile, + }; + UnselectedImageNode.AttachNode(this); + + SelectedImageNode = new SimpleImageNode { + NodeId = 3, + TexturePath = "ui/uld/RadioButtonA.tex", + TextureCoordinates = new Vector2(16.0f, 0.0f), + TextureSize = new Vector2(16.0f, 16.0f), + Size = new Vector2(16.0f, 16.0f), + IsVisible = false, + WrapMode = WrapMode.Tile, + }; + SelectedImageNode.AttachNode(this); + + LabelNode = new TextNode { + NodeId = 2, + Position = new Vector2(20.0f, 0.0f), + Size = new Vector2(98.0f, 16.0f), + FontSize = 14, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + AlignmentType = AlignmentType.Left, + }; + LabelNode.AttachNode(this); + + BuildTimelines(); + + Data->Nodes[0] = LabelNode.NodeId; + Data->Nodes[1] = UnselectedImageNode.NodeId; + Data->Nodes[2] = 0; + Data->Nodes[3] = 0; + + AddEvent(AtkEventType.ButtonClick, ClickHandler); + + InitializeComponentEvents(); + } + + public Action? Callback { get; set; } + + public ReadOnlySeString String { + get => LabelNode.String; + set { + LabelNode.String = value; + Width = LabelNode.Width + LabelNode.Position.X; + } + } + + public bool IsChecked { + get => Component->IsChecked; + set => Component->SetChecked(value); + } + + public bool IsSelected { + get => Component->IsSelected; + set { + Component->IsSelected = value; + SelectedImageNode.IsVisible = value; + } + } + + private void ClickHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + Callback?.Invoke(); + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, new Vector2(24, 62)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, new Vector2(24, 44)) + .EndFrameSet() + .Build() + ); + + CollisionNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 159) + .AddEmptyFrame(1) + .EndFrameSet() + .Build() + ); + + UnselectedImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 102) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 102) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(120, 129) + .AddFrame(120, alpha: 255) + .AddFrame(120, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(130, 139) + .AddFrame(130, alpha: 255) + .AddFrame(130, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(140, 149) + .AddFrame(140, alpha: 255) + .AddFrame(140, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(150, 159) + .AddFrame(150, alpha: 255) + .AddFrame(150, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + SelectedImageNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 102) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(120, 129) + .AddFrame(120, alpha: 0) + .AddFrame(122, alpha: 255) + .AddFrame(120, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(122, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(130, 139) + .AddFrame(130, alpha: 255) + .AddFrame(132, alpha: 0) + .AddFrame(130, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(132, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(140, 149) + .AddFrame(140, alpha: 0) + .AddFrame(142, alpha: 255) + .AddFrame(140, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(142, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(150, 159) + .AddFrame(150, alpha: 255) + .AddFrame(152, alpha: 0) + .AddFrame(150, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(152, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + LabelNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 102) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(50, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 102) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(110, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(120, 129) + .AddFrame(120, alpha: 255) + .AddFrame(120, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(130, 139) + .AddFrame(130, alpha: 255) + .AddFrame(130, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(140, 149) + .AddFrame(140, alpha: 255) + .AddFrame(140, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(150, 159) + .AddFrame(150, alpha: 255) + .AddFrame(150, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/ResizeButtonNode.cs b/KamiToolKit/Nodes/Component/ResizeButtonNode.cs new file mode 100644 index 0000000..746d741 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ResizeButtonNode.cs @@ -0,0 +1,58 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +// Not intended for public use, this is specialized for KamiToolKit.NodeBase.Resize +internal class ResizeButtonNode : SimpleComponentNode { + + public readonly ImageNode SelectedImageNode; + public readonly ImageNode UnselectedImageNode; + + public ResizeButtonNode(ResizeDirection direction) { + + UnselectedImageNode = new SimpleImageNode { + TexturePath = "ui/uld/ChatLog.tex", + TextureCoordinates = new Vector2(32.0f, 34.0f), + TextureSize = new Vector2(18.0f, 18.0f), + Size = new Vector2(16.0f, 16.0f), + Origin = new Vector2(8.0f, 8.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + ImageNodeFlags = direction is ResizeDirection.BottomRight ? ImageNodeFlags.FlipV : ImageNodeFlags.FlipH | ImageNodeFlags.FlipV, + }; + UnselectedImageNode.AttachNode(this); + + SelectedImageNode = new SimpleImageNode { + TexturePath = "ui/uld/ChatLog.tex", + TextureCoordinates = new Vector2(4.0f, 34.0f), + TextureSize = new Vector2(18.0f, 18.0f), + Size = new Vector2(16.0f, 16.0f), + Origin = new Vector2(8.0f, 8.0f), + NodeFlags = NodeFlags.Enabled | NodeFlags.EmitsEvents, + WrapMode = WrapMode.Tile, + ImageNodeFlags = direction is ResizeDirection.BottomRight ? ImageNodeFlags.FlipV : ImageNodeFlags.FlipH | ImageNodeFlags.FlipV, + }; + SelectedImageNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + UnselectedImageNode.Size = Size - new Vector2(4.0f, 4.0f); + UnselectedImageNode.Position = new Vector2(2.0f, 2.0f); + + SelectedImageNode.Size = Size - new Vector2(4.0f, 4.0f); + SelectedImageNode.Position = new Vector2(2.0f, 2.0f); + } + + public bool IsHovered { + get; + set { + field = value; + UnselectedImageNode.IsVisible = !value; + SelectedImageNode.IsVisible = value; + } + } +} diff --git a/KamiToolKit/Nodes/Component/ScrollBarBackgroundButtonNode.cs b/KamiToolKit/Nodes/Component/ScrollBarBackgroundButtonNode.cs new file mode 100644 index 0000000..f5ed33b --- /dev/null +++ b/KamiToolKit/Nodes/Component/ScrollBarBackgroundButtonNode.cs @@ -0,0 +1,16 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public unsafe class ScrollBarBackgroundButtonNode : ComponentNode { + public ScrollBarBackgroundButtonNode() { + SetInternalComponentType(ComponentType.Button); + + Component->ButtonBGNode = CollisionNode; + + Data->Nodes[0] = 0; + Data->Nodes[1] = CollisionNode.NodeId; + + InitializeComponentEvents(); + } +} diff --git a/KamiToolKit/Nodes/Component/ScrollBarForegroundButtonNode.cs b/KamiToolKit/Nodes/Component/ScrollBarForegroundButtonNode.cs new file mode 100644 index 0000000..35b9ebd --- /dev/null +++ b/KamiToolKit/Nodes/Component/ScrollBarForegroundButtonNode.cs @@ -0,0 +1,88 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class ScrollBarForegroundButtonNode : ComponentNode { + + public readonly NineGridNode ButtonTexture; + + public ScrollBarForegroundButtonNode() { + SetInternalComponentType(ComponentType.Button); + + ButtonTexture = new SimpleNineGridNode { + TexturePath = "ui/uld/ScrollBarA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(8.0f, 16.0f), + TopOffset = 4, + BottomOffset = 4, + }; + ButtonTexture.AttachNode(this); + + Data->Nodes[0] = 0; + Data->Nodes[1] = ButtonTexture.NodeId; + + BuildTimelines(); + + InitializeComponentEvents(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ButtonTexture.Size = Size; + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabel(1, 1, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(9, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 2, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(19, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(20, 3, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(29, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(30, 7, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(39, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(40, 6, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(49, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(50, 4, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(59, 0, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + ButtonTexture.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/ScrollBarNode.cs b/KamiToolKit/Nodes/Component/ScrollBarNode.cs new file mode 100644 index 0000000..4447777 --- /dev/null +++ b/KamiToolKit/Nodes/Component/ScrollBarNode.cs @@ -0,0 +1,128 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public unsafe class ScrollBarNode : ComponentNode { + + public readonly ScrollBarBackgroundButtonNode BackgroundButtonNode; + public readonly ScrollBarForegroundButtonNode ForegroundButtonNode; + + public ScrollBarNode() { + SetInternalComponentType(ComponentType.ScrollBar); + + BackgroundButtonNode = new ScrollBarBackgroundButtonNode { + Size = new Vector2(8.0f, 306.0f), + }; + BackgroundButtonNode.AttachNode(this); + + ForegroundButtonNode = new ScrollBarForegroundButtonNode { + Size = new Vector2(8.0f, 306.0f), + }; + ForegroundButtonNode.AttachNode(this); + + Data->Nodes[0] = ForegroundButtonNode.NodeId; + Data->Nodes[1] = 0; // Arrow Up Button + Data->Nodes[2] = 0; // Arrow Down Button + Data->Nodes[3] = BackgroundButtonNode.NodeId; + + Data->Vertical = 1; + Data->Margin = 0; + + InitializeComponentEvents(); + + Component->MouseDownScreenPos = 0; + Component->MouseWheelSpeed = 24; + + AddEvent(AtkEventType.ValueUpdate, UpdateHandler); + } + + public Action? OnValueChanged { get; set; } + + public NodeBase? ContentNode { + get; + set { + field = value; + + if (value is not null) { + Component->ContentNode = value; + UpdateScrollParams(); + } + } + } + + public CollisionNode? ContentCollisionNode { + get; + set { + field = value; + Component->ContentCollisionNode = value is null ? null : value.Node; + UpdateScrollParams(); + } + } + + public int ScrollPosition { + get => Component->ScrollPosition; + set => Component->SetScrollPosition(value); + } + + public int ScrollSpeed { + get => Component->MouseWheelSpeed; + set => Component->MouseWheelSpeed = (short)value; + } + + public bool HideWhenDisabled { get; set; } + + private void UpdateHandler() { + OnValueChanged?.Invoke(Component->PendingScrollPosition); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundButtonNode.Size = Size; + ForegroundButtonNode.Size = Size; + } + + /// + /// Updates from attached Content and Collision nodes + /// + public void UpdateScrollParams() { + if (Component->ContentNode is null) return; + if (Component->ContentCollisionNode is null) return; + + var content = Component->ContentNode; + var collision = Component->ContentCollisionNode; + + UpdateScrollParams(collision->Height, content->Height); + } + + public void UpdateScrollParams(int barHeight, int offScreenHeight) { + var distance = offScreenHeight - barHeight; + + Component->ScrollbarLength = (short)barHeight; + Component->ScrollMaxPosition = Math.Max(distance, 0); + Component->ContentNodeOffScreenLength = Math.Max((short)distance, (short)0); + Component->EmptyLength = Math.Max(barHeight - (int)((float)barHeight / offScreenHeight * barHeight), 0); + ForegroundButtonNode.Height = barHeight - Component->EmptyLength; + + if (Component->ScrollPosition > Component->ScrollMaxPosition) { + Component->SetScrollPosition(Component->ScrollMaxPosition); + } + + if (Component->EmptyLength is 0) { + ForegroundButtonNode.Y = 0.0f; + + ContentNode?.Y = 0; + } + + var enabledState = Component->EmptyLength is not 0; + + Component->SetEnabledState(enabledState); + + if (HideWhenDisabled) { + BackgroundButtonNode.IsVisible = enabledState; + ForegroundButtonNode.IsVisible = enabledState; + } + } +} diff --git a/KamiToolKit/Nodes/Component/ScrollingAreaNode.cs b/KamiToolKit/Nodes/Component/ScrollingAreaNode.cs new file mode 100644 index 0000000..b5f5aea --- /dev/null +++ b/KamiToolKit/Nodes/Component/ScrollingAreaNode.cs @@ -0,0 +1,100 @@ +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public unsafe class ScrollingAreaNode : SimpleComponentNode where T : NodeBase, new() { + + public readonly SimpleComponentNode ContentAreaClipNode; + public readonly T ContentAreaNode; + public readonly ScrollBarNode ScrollBarNode; + public readonly CollisionNode ScrollingCollisionNode; + + public ScrollingAreaNode() { + ScrollingCollisionNode = new CollisionNode(); + ScrollingCollisionNode.AttachNode(this); + + ContentAreaClipNode = new SimpleComponentNode { + NodeFlags = NodeFlags.Clip | NodeFlags.EmitsEvents | NodeFlags.Visible, + }; + ContentAreaClipNode.AttachNode(this); + + ContentAreaNode = new T(); + ContentAreaNode.AttachNode(ContentAreaClipNode); + + ScrollBarNode = new ScrollBarNode { + ContentNode = ContentAreaNode, + ContentCollisionNode = ScrollingCollisionNode, + HideWhenDisabled = true, + }; + ScrollBarNode.AttachNode(this); + + ContentAreaClipNode.ResNode->AtkEventManager.RegisterEvent( + AtkEventType.MouseWheel, + 5, + null, + ScrollingCollisionNode, + ScrollBarNode, + false); + + ScrollingCollisionNode.ResNode->AtkEventManager.RegisterEvent( + AtkEventType.MouseWheel, + 5, + null, + ScrollingCollisionNode, + ScrollBarNode, + false); + + ContentAreaNode.ResNode->AtkEventManager.RegisterEvent( + AtkEventType.MouseWheel, + 5, + null, + ScrollingCollisionNode, + ScrollBarNode, + false); + } + + public virtual T ContentNode => ContentAreaNode; + + public int ScrollPosition { + get => ScrollBarNode.ScrollPosition; + set => ScrollBarNode.ScrollPosition = value; + } + + public int ScrollSpeed { + get => ScrollBarNode.ScrollSpeed; + set => ScrollBarNode.ScrollSpeed = value; + } + + public required float ContentHeight { + get => ContentAreaNode.Height; + set { + ContentAreaNode.Height = value; + ScrollBarNode.UpdateScrollParams(); + } + } + + public bool AutoHideScrollBar { + get => ScrollBarNode.HideWhenDisabled; + set => ScrollBarNode.HideWhenDisabled = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ContentAreaNode.Width = Width - 16.0f; + ScrollingCollisionNode.Size = new Vector2(Width - 16.0f, Height); + ContentAreaClipNode.Size = new Vector2(Width - 16.0f, Height); + ScrollBarNode.Size = new Vector2(8.0f, Height); + ScrollBarNode.UpdateScrollParams(); + + ScrollBarNode.X = Width - 8.0f; + } + + public void FitToContentHeight() { + if (ContentNode is LayoutListNode layoutNode) { + ContentHeight = layoutNode.Nodes.Sum(node => node.IsVisible ? node.Height + layoutNode.ItemSpacing : 0.0f) + layoutNode.FirstItemSpacing; + } + } +} diff --git a/KamiToolKit/Nodes/Component/SelectableNode.cs b/KamiToolKit/Nodes/Component/SelectableNode.cs new file mode 100644 index 0000000..4e41cb2 --- /dev/null +++ b/KamiToolKit/Nodes/Component/SelectableNode.cs @@ -0,0 +1,95 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public class SelectableNode : SimpleComponentNode { + private readonly NineGridNode hoveredBackgroundNode; + private readonly NineGridNode selectedBackgroundNode; + + public SelectableNode() { + hoveredBackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListItemA.tex", + TextureCoordinates = new Vector2(0.0f, 22.0f), + TextureSize = new Vector2(64.0f, 22.0f), + TopOffset = 6, + BottomOffset = 6, + LeftOffset = 16, + RightOffset = 1, + IsVisible = false, + }; + hoveredBackgroundNode.AttachNode(this); + + selectedBackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ListItemA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(64.0f, 22.0f), + TopOffset = 6, + BottomOffset = 6, + LeftOffset = 16, + RightOffset = 1, + IsVisible = false, + }; + selectedBackgroundNode.AttachNode(this); + + CollisionNode.AddEvent(AtkEventType.MouseOver, () => { + if (!IsSelected && EnableHighlight) { + IsHovered = true; + } + }); + CollisionNode.AddEvent(AtkEventType.MouseDown, () => { + if (EnableSelection) { + IsSelected = true; + OnClick?.Invoke(this); + } + }); + CollisionNode.AddEvent(AtkEventType.MouseOut, () => { + IsHovered = false; + }); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + hoveredBackgroundNode.Size = Size + new Vector2(6.0f, 6.0f); + hoveredBackgroundNode.Position = new Vector2(-3.0f, -3.0f); + + selectedBackgroundNode.Size = Size + new Vector2(6.0f, 6.0f); + selectedBackgroundNode.Position = new Vector2(-3.0f, -3.0f); + } + + public Action? OnClick { + get; + set { + field = value; + CollisionNode.ShowClickableCursor = value is not null && EnableSelection; + } + } + + public bool EnableSelection { + get; + set { + field = value; + CollisionNode.ShowClickableCursor = value; + } + } = true; + + public bool EnableHighlight { get; set; } = true; + + public bool IsHovered { + get => hoveredBackgroundNode.IsVisible; + set => hoveredBackgroundNode.IsVisible = value; + } + + public bool IsSelected { + get => selectedBackgroundNode.IsVisible; + set { + selectedBackgroundNode.IsVisible = value; + + if (value) { + hoveredBackgroundNode.IsVisible = false; + } + } + } +} diff --git a/KamiToolKit/Nodes/Component/SliderBackgroundButtonNode.cs b/KamiToolKit/Nodes/Component/SliderBackgroundButtonNode.cs new file mode 100644 index 0000000..0899d62 --- /dev/null +++ b/KamiToolKit/Nodes/Component/SliderBackgroundButtonNode.cs @@ -0,0 +1,83 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class SliderBackgroundButtonNode : ComponentNode { + + public readonly NineGridNode BackgroundTexture; + + public SliderBackgroundButtonNode() { + SetInternalComponentType(ComponentType.Button); + + BackgroundTexture = new SimpleNineGridNode { + TexturePath = "ui/uld/SliderGaugeHorizontalA.tex", + TextureCoordinates = new Vector2(16.0f, 0.0f), + TextureSize = new Vector2(40.0f, 8.0f), + LeftOffset = 8, + RightOffset = 8, + }; + BackgroundTexture.AttachNode(this); + + Component->ButtonBGNode = BackgroundTexture; + + Data->Nodes[0] = 0; + Data->Nodes[1] = BackgroundTexture.NodeId; + + BuildTimelines(); + + InitializeComponentEvents(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundTexture.Size = new Vector2(Width, Height / 2.0f); + BackgroundTexture.Y = Height / 4.0f; + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 20) + .AddFrame(1, alpha: 255) + .EndFrameSet() + .BeginFrameSet(21, 30) + .AddFrame(21, alpha: 127) + .EndFrameSet() + .Build() + ); + + BackgroundTexture.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/SliderForegroundButtonNode.cs b/KamiToolKit/Nodes/Component/SliderForegroundButtonNode.cs new file mode 100644 index 0000000..93cac96 --- /dev/null +++ b/KamiToolKit/Nodes/Component/SliderForegroundButtonNode.cs @@ -0,0 +1,78 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public class SliderForegroundButtonNode : ComponentNode { + + public readonly ImageNode HandleNode; + + public SliderForegroundButtonNode() { + SetInternalComponentType(ComponentType.Button); + + HandleNode = new SimpleImageNode { + TexturePath = "ui/uld/SliderGaugeHorizontalA.tex", + TextureCoordinates = new Vector2(1.0f, 1.0f), + TextureSize = new Vector2(14.0f, 15.0f), + Size = new Vector2(14.0f, 15.0f), + WrapMode = WrapMode.Stretch, + }; + HandleNode.AttachNode(this); + + BuildTimelines(); + + InitializeComponentEvents(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + HandleNode.Size = Size; + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 20) + .AddFrame(1, alpha: 255) + .EndFrameSet() + .BeginFrameSet(21, 30) + .AddFrame(21, alpha: 178) + .EndFrameSet() + .Build() + ); + + HandleNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 255) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(70, 70, 70)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/SliderNode.cs b/KamiToolKit/Nodes/Component/SliderNode.cs new file mode 100644 index 0000000..4c03774 --- /dev/null +++ b/KamiToolKit/Nodes/Component/SliderNode.cs @@ -0,0 +1,181 @@ +using System; +using System.Globalization; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class SliderNode : ComponentNode { + + public readonly NineGridNode ProgressTextureNode; + public readonly SliderBackgroundButtonNode SliderBackgroundButtonNode; + public readonly SliderForegroundButtonNode SliderForegroundButtonNode; + public readonly TextNode ValueNode; + public readonly TextNode FloatValueNode; + + public SliderNode() { + SetInternalComponentType(ComponentType.Slider); + + SliderBackgroundButtonNode = new SliderBackgroundButtonNode(); + SliderBackgroundButtonNode.AttachNode(this); + + ProgressTextureNode = new SimpleNineGridNode { + TexturePath = "ui/uld/SliderGaugeHorizontalA.tex", + TextureCoordinates = new Vector2(16.0f, 8.0f), + TextureSize = new Vector2(40.0f, 7.0f), + Height = 7.0f, + Y = 4.0f, + LeftOffset = 8, + RightOffset = 8, + }; + ProgressTextureNode.AttachNode(this); + + SliderForegroundButtonNode = new SliderForegroundButtonNode { + Size = new Vector2(16.0f, 16.0f), + }; + SliderForegroundButtonNode.AttachNode(this); + + ValueNode = new TextNode { + Size = new Vector2(24.0f, 16.0f), + FontType = FontType.Axis, + FontSize = 12, + AlignmentType = AlignmentType.TopLeft, + TextFlags = TextFlags.AutoAdjustNodeSize, + }; + ValueNode.AttachNode(this); + + FloatValueNode = new TextNode { + Size = new Vector2(24.0f, 16.0f), + IsVisible = false, + FontType = FontType.Axis, + FontSize = 12, + AlignmentType = AlignmentType.TopLeft, + TextFlags = TextFlags.AutoAdjustNodeSize, + }; + FloatValueNode.AttachNode(this); + + Data->Step = 1; + Data->Min = 0; + Data->Max = 100; + Data->OfffsetL = 4; + Data->OffsetR = 50; + + Data->Nodes[0] = ProgressTextureNode.NodeId; + Data->Nodes[1] = SliderForegroundButtonNode.NodeId; + Data->Nodes[2] = ValueNode.NodeId; + Data->Nodes[3] = SliderBackgroundButtonNode.NodeId; + + BuildTimelines(); + + InitializeComponentEvents(); + + Component->SliderSize = 220; + Component->OffsetR = 50; + Component->OffsetL = 4; + + AddEvent(AtkEventType.SliderValueUpdate, ValueChangedHandler); + } + + public Action? OnValueChanged { get; set; } + + public required Range Range { + get => Data->Min .. Data->Max; + set { + Component->SetMaxValue(value.End.Value); + Component->SetMinValue(value.Start.Value); + + Value = Math.Clamp(Value, value.Start.Value, value.End.Value); + } + } + + public int Step { + get => Component->Steps; + set => Component->Steps = value; + } + + public int Value { + get => Component->Value; + set { + Component->SetValue(value); + UpdateFormattedText(); + } + } + + public int DecimalPlaces { + get; + set { + field = value; + UpdateFormattedText(); + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + SliderBackgroundButtonNode.Size = new Vector2(Width - 18.0f - 25.0f, Height / 2.0f); + SliderBackgroundButtonNode.Position = new Vector2(0.0f, 4.0f); + + ProgressTextureNode.Size = new Vector2(0.0f, Height / 2.0f - 1.0f); + ProgressTextureNode.Position = new Vector2(0.0f, 4.0f); + + SliderForegroundButtonNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + SliderForegroundButtonNode.Position = new Vector2(0.0f, 0.0f); + + ValueNode.Size = new Vector2(0.0f, Height); + ValueNode.Position = new Vector2(Width - 18.0f - 20.0f, 0.0f); + + FloatValueNode.Size = new Vector2(0.0f, Height); + FloatValueNode.Position = new Vector2(Width - 18.0f - 20.0f, 0.0f); + + Component->SliderSize = (short)Width; + } + + private void ValueChangedHandler() { + OnValueChanged?.Invoke(Value); + UpdateFormattedText(); + } + + private void UpdateFormattedText() { + if (DecimalPlaces is not 0) { + var formatInfo = new NumberFormatInfo { + NumberDecimalDigits = DecimalPlaces, + }; + + FloatValueNode.IsVisible = true; + FloatValueNode.String = string.Format(formatInfo, "{0:F}", Value / MathF.Pow(10, DecimalPlaces)); + ValueNode.FontSize = 0; + } + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 30) + .AddLabel(1, 17, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(11, 18, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(21, 7, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + + ProgressTextureNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 20) + .AddFrame(1, alpha: 255) + .EndFrameSet() + .BeginFrameSet(21, 30) + .AddFrame(21, alpha: 127) + .EndFrameSet() + .Build() + ); + + ValueNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 20) + .AddFrame(1, alpha: 255) + .EndFrameSet() + .BeginFrameSet(21, 30) + .AddFrame(21, alpha: 153) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/TabBarNode.cs b/KamiToolKit/Nodes/Component/TabBarNode.cs new file mode 100644 index 0000000..b860086 --- /dev/null +++ b/KamiToolKit/Nodes/Component/TabBarNode.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public class TabBarNode : SimpleComponentNode { + + private readonly List radioButtons = []; + + public TabBarNode() { + BuildTimelines(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + RecalculateLayout(); + } + + public void AddTab(ReadOnlySeString label, Action callback, bool isEnabled = true) { + var newButton = new TabBarRadioButtonNode { + Height = Height, + String = label, + OnClick = callback, + IsEnabled = isEnabled, + MultiplyColor = isEnabled ? Vector3.One : new Vector3(0.6f, 0.6f, 0.6f), + }; + + newButton.AddEvent(AtkEventType.ButtonClick, () => ClickHandler(newButton)); + + radioButtons.Add(newButton); + newButton.AttachNode(this); + + if (radioButtons.Count is 1) { + newButton.IsSelected = true; + } + + RecalculateLayout(); + } + + private void ClickHandler(TabBarRadioButtonNode button) { + foreach (var radioButton in radioButtons) { + radioButton.IsChecked = false; + radioButton.IsSelected = false; + } + + button.IsChecked = true; + button.IsSelected = true; + } + + public void SelectTab(ReadOnlySeString label) { + var button = radioButtons.FirstOrDefault(button => button.String == label); + if (button is null) return; + + ClickHandler(button); + } + + public void DisableTab(ReadOnlySeString label) { + var button = radioButtons.FirstOrDefault(button => button.String == label); + if (button is null) return; + + button.IsEnabled = false; + button.MultiplyColor = new Vector3(0.6f, 0.6f, 0.6f); + } + + public void EnableTab(ReadOnlySeString label) { + var button = radioButtons.FirstOrDefault(button => button.String == label); + if (button is null) return; + + button.IsEnabled = true; + button.MultiplyColor = Vector3.One; + } + + public void ToggleTab(ReadOnlySeString label) { + var button = radioButtons.FirstOrDefault(button => button.String == label); + if (button is null) return; + + button.IsEnabled = !button.IsEnabled; + + if (button.IsEnabled) { + button.MultiplyColor = Vector3.One; + } + else { + button.MultiplyColor = new Vector3(0.6f, 0.6f, 0.6f); + } + } + + public void RemoveTab(ReadOnlySeString label) { + var button = radioButtons.FirstOrDefault(button => button.String == label); + if (button is null) return; + + button.Dispose(); + radioButtons.Remove(button); + RecalculateLayout(); + } + + public void Clear() { + foreach (var node in radioButtons) { + node.Dispose(); + } + + radioButtons.Clear(); + } + + private void RecalculateLayout() { + var step = Width / radioButtons.Count; + + foreach (var index in Enumerable.Range(0, radioButtons.Count)) { + var button = radioButtons[index]; + + button.Width = step + 5.0f; + button.X = step * index - 5.0f; + button.Height = Height; + } + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 20) + .AddLabel(1, 101, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(11, 102, AtkTimelineJumpBehavior.PlayOnce, 0) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/TabBarRadioButtonNode.cs b/KamiToolKit/Nodes/Component/TabBarRadioButtonNode.cs new file mode 100644 index 0000000..5ccfdb4 --- /dev/null +++ b/KamiToolKit/Nodes/Component/TabBarRadioButtonNode.cs @@ -0,0 +1,287 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TabBarRadioButtonNode : ComponentNode { + + public readonly TextNode LabelNode; + public readonly NineGridNode SelectedNineGridNode; + public readonly NineGridNode UnselectedNineGridNode; + + public TabBarRadioButtonNode() { + SetInternalComponentType(ComponentType.RadioButton); + + UnselectedNineGridNode = new SimpleNineGridNode { + Position = new Vector2(-2.0f, -1.0f), + TexturePath = "ui/uld/TabButtonA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(88.0f, 26.0f), + LeftOffset = 16, + RightOffset = 16, + }; + UnselectedNineGridNode.AttachNode(this); + + SelectedNineGridNode = new SimpleNineGridNode { + Position = new Vector2(-2.0f, -1.0f), + TexturePath = "ui/uld/TabButtonA.tex", + TextureCoordinates = new Vector2(0.0f, 26.0f), + TextureSize = new Vector2(88.0f, 26.0f), + LeftOffset = 16, + RightOffset = 16, + IsVisible = false, + }; + SelectedNineGridNode.AttachNode(this); + + LabelNode = new TextNode { + Position = new Vector2(13.0f, 2.0f), + AlignmentType = AlignmentType.Center, + TextColor = ColorHelper.GetColor(50), + }; + LabelNode.AttachNode(this); + + BuildTimelines(); + + Data->Nodes[0] = LabelNode.NodeId; + Data->Nodes[1] = UnselectedNineGridNode.NodeId; + Data->Nodes[2] = 0; + Data->Nodes[3] = 0; + + AddEvent(AtkEventType.ButtonClick, ClickHandler); + + InitializeComponentEvents(); + } + + public Action? OnClick { get; set; } + + public ReadOnlySeString String { + get => LabelNode.String; + set => Component->SetText(value); + } + + public bool IsSelected { + get => Component->IsSelected; + set { + Component->IsSelected = value; + if (value) { + SelectedNineGridNode.IsVisible = true; + UnselectedNineGridNode.IsVisible = false; + } + else { + SelectedNineGridNode.IsVisible = false; + UnselectedNineGridNode.IsVisible = true; + } + } + } + + public bool IsChecked { + get => Component->IsChecked; + set => Component->SetChecked(value); + } + + private void ClickHandler() { + OnClick?.Invoke(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + CollisionNode.Size = Size; + UnselectedNineGridNode.Size = new Vector2(Width + 4.0f, Height + 2.0f); + SelectedNineGridNode.Size = new Vector2(Width + 4.0f, Height + 2.0f); + LabelNode.Size = new Vector2(Width - 25.0f, Height - 4.0f); + } + + private void BuildTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(11, 20) + .AddFrame(11, new Vector2(525, 0)) + .EndFrameSet() + .Build() + ); + + UnselectedNineGridNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(12, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 178) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 255) + .AddFrame(50, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(52, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(120, 129) + .AddFrame(120, alpha: 255) + .AddFrame(122, alpha: 0) + .AddFrame(120, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(122, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(130, 139) + .AddFrame(130, alpha: 0) + .AddFrame(132, alpha: 255) + .AddFrame(130, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(132, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(140, 149) + .AddFrame(140, alpha: 255) + .AddFrame(142, alpha: 0) + .AddFrame(140, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(142, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(150, 159) + .AddFrame(150, alpha: 0) + .AddFrame(152, alpha: 255) + .AddFrame(150, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(152, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + SelectedNineGridNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(72, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(72, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 178) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(50, 50, 50)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(112, alpha: 255) + .AddFrame(110, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(112, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(120, 129) + .AddFrame(120, alpha: 0) + .AddFrame(122, alpha: 255) + .AddFrame(120, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(122, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(130, 139) + .AddFrame(130, alpha: 255) + .AddFrame(132, alpha: 0) + .AddFrame(130, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(132, addColor: new Vector3(16, 16, 16), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(140, 149) + .AddFrame(140, alpha: 0) + .AddFrame(142, alpha: 255) + .AddFrame(140, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(142, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(150, 159) + .AddFrame(150, alpha: 255) + .AddFrame(152, alpha: 0) + .AddFrame(150, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .AddFrame(152, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + + LabelNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 9) + .AddFrame(1, alpha: 255) + .AddFrame(1, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(10, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(20, 29) + .AddFrame(20, alpha: 255) + .AddFrame(20, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(30, 39) + .AddFrame(30, alpha: 153) + .AddFrame(30, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(40, 49) + .AddFrame(40, alpha: 255) + .AddFrame(40, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(50, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(60, 69) + .AddFrame(60, alpha: 255) + .AddFrame(60, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(70, 79) + .AddFrame(70, alpha: 255) + .AddFrame(70, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(80, 89) + .AddFrame(80, alpha: 255) + .AddFrame(80, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(90, 99) + .AddFrame(90, alpha: 153) + .AddFrame(90, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(80, 80, 80)) + .EndFrameSet() + .BeginFrameSet(100, 109) + .AddFrame(100, alpha: 255) + .AddFrame(100, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(110, 119) + .AddFrame(110, alpha: 255) + .AddFrame(110, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(120, 129) + .AddFrame(120, alpha: 255) + .AddFrame(120, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(130, 139) + .AddFrame(130, alpha: 255) + .AddFrame(130, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(140, 149) + .AddFrame(140, alpha: 255) + .AddFrame(140, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .BeginFrameSet(150, 159) + .AddFrame(150, alpha: 255) + .AddFrame(150, addColor: new Vector3(0, 0, 0), multiplyColor: new Vector3(100, 100, 100)) + .EndFrameSet() + .Build() + ); + } +} diff --git a/KamiToolKit/Nodes/Component/TextButtonListNode.cs b/KamiToolKit/Nodes/Component/TextButtonListNode.cs new file mode 100644 index 0000000..89b4896 --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextButtonListNode.cs @@ -0,0 +1,5 @@ +namespace KamiToolKit.Nodes; + +public class TextButtonListNode : ButtonListNode { + protected override string GetLabelForOption(string option) => option; +} diff --git a/KamiToolKit/Nodes/Component/TextButtonNode.cs b/KamiToolKit/Nodes/Component/TextButtonNode.cs new file mode 100644 index 0000000..a0f8ff0 --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextButtonNode.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TextButtonNode : ButtonBase { + + public readonly NineGridNode BackgroundNode; + public readonly TextNode LabelNode; + + public TextButtonNode() { + BackgroundNode = new SimpleNineGridNode { + TexturePath = "ui/uld/ButtonA.tex", + TextureSize = new Vector2(100.0f, 28.0f), + LeftOffset = 16.0f, + RightOffset = 16.0f, + }; + BackgroundNode.AttachNode(this); + + LabelNode = new TextNode { + AlignmentType = AlignmentType.Center, + Position = new Vector2(16.0f, 3.0f), + TextColor = ColorHelper.GetColor(50), + }; + LabelNode.AttachNode(this); + + LoadTimelines(); + + Data->Nodes[0] = LabelNode.NodeId; + Data->Nodes[1] = BackgroundNode.NodeId; + + InitializeComponentEvents(); + } + + public ReadOnlySeString String { + get => LabelNode.String; + set => LabelNode.String = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + LabelNode.Size = new Vector2(Width - 32.0f, Height - 8.0f); + BackgroundNode.Size = Size; + } + + private void LoadTimelines() + => LoadThreePartTimelines(this, BackgroundNode, LabelNode, new Vector2(16.0f, 3.0f)); +} diff --git a/KamiToolKit/Nodes/Component/TextDropDownNode.cs b/KamiToolKit/Nodes/Component/TextDropDownNode.cs new file mode 100644 index 0000000..edb2d42 --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextDropDownNode.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace KamiToolKit.Nodes; + +public class TextDropDownNode : DropDownNode { + + public TextDropDownNode() { + OptionListNode.OnOptionSelected += OptionSelectedHandler; + } + + public Action? OnOptionSelected { get; set; } + + public required List? Options { + get => OptionListNode.Options; + set { + OptionListNode.Options = value; + OptionListNode.SelectDefaultOption(); + UpdateLabel(OptionListNode.SelectedOption); + } + } + + private void OptionSelectedHandler(string option) { + OnOptionSelected?.Invoke(option); + UpdateLabel(option); + Toggle(false); + } + + protected override void UpdateLabel(string? option) { + LabelNode.String = option ?? "ERROR: Invalid Default Option"; + } +} diff --git a/KamiToolKit/Nodes/Component/TextInputButton.cs b/KamiToolKit/Nodes/Component/TextInputButton.cs new file mode 100644 index 0000000..41ee6b4 --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextInputButton.cs @@ -0,0 +1,77 @@ +using System.Drawing; +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Timelines; + +namespace KamiToolKit.Nodes; + +public unsafe class TextInputButtonNode : ButtonBase { + + public readonly NineGridNode BackgroundNode; + public readonly TextNode LabelNode; + + public TextInputButtonNode() { + BackgroundNode = new SimpleNineGridNode { + Size = new Vector2(160.0f, 24.0f), + LeftOffset = 16.0f, + RightOffset = 1.0f, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.Fill | NodeFlags.EmitsEvents, + TexturePath = "ui/uld/ListItemA.tex", + TextureCoordinates = new Vector2(0.0f, 22.0f), + TextureSize = new Vector2(63.0f, 22.0f), + }; + BackgroundNode.AttachNode(this); + + LabelNode = new TextNode { + Position = new Vector2(12.0f, 2.0f), + Size = new Vector2(140.0f, 18.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + AlignmentType = AlignmentType.Left, + TextFlags = TextFlags.AutoAdjustNodeSize, + TextColor = KnownColor.White.Vector(), + TextOutlineColor = KnownColor.White.Vector(), + BackgroundColor = KnownColor.Black.Vector(), + }; + LabelNode.AttachNode(this); + + Data->Nodes[0] = LabelNode.NodeId; + Data->Nodes[1] = BackgroundNode.NodeId; + + LoadTimeline(); + + InitializeComponentEvents(); + } + + private void LoadTimeline() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabelPair(1, 9, 1) + .AddLabelPair(10, 19, 2) + .AddLabelPair(20, 29, 3) + .AddLabelPair(30, 39, 7) + .AddLabelPair(40, 49, 6) + .AddLabelPair(50, 59, 4) + .EndFrameSet() + .Build()); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0) + .AddFrame(13, alpha: 255) + .EndFrameSet() + .AddFrameSetWithFrame(20, 29, 20, alpha: 255) + .AddFrameSetWithFrame(40, 49, 40, alpha: 255) + .BeginFrameSet(50, 59) + .AddFrame(50, alpha: 255) + .AddFrame(52, alpha: 0) + .EndFrameSet() + .Build()); + + LabelNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 29, 1, alpha: 255, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(30, 39, 30, alpha: 153, multiplyColor: new Vector3(80.0f)) + .AddFrameSetWithFrame(40, 59, 40, alpha: 255, multiplyColor: new Vector3(100.0f)) + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Component/TextInputNode.cs b/KamiToolKit/Nodes/Component/TextInputNode.cs new file mode 100644 index 0000000..c51164e --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextInputNode.cs @@ -0,0 +1,318 @@ +using System; +using System.Drawing; +using System.Numerics; +using System.Runtime.InteropServices; +using Dalamud.Interface; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.Graphics; +using FFXIVClientStructs.FFXIV.Client.System.Input; +using FFXIVClientStructs.FFXIV.Component.GUI; +using InteropGenerator.Runtime; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TextInputNode : ComponentNode { + public readonly NineGridNode BackgroundNode; + public readonly TextNode CurrentTextNode; + public readonly CursorNode CursorNode; + public readonly NineGridNode FocusNode; + public readonly TextInputSelectionListNode SelectionListNode; + public readonly TextNode TextLimitsNode; + public readonly TextNode PlaceholderTextNode; + + private AtkComponentInputBase.CallbackDelegate? pinnedCallbackFunction; + + public TextInputNode() { + SetInternalComponentType(ComponentType.TextInput); + + BackgroundNode = new SimpleNineGridNode { + NodeId = 19, + TexturePath = "ui/uld/TextInputA.tex", + TextureCoordinates = new Vector2(24.0f, 0.0f), + TextureSize = new Vector2(24.0f, 24.0f), + Offsets = new Vector4(10.0f), + Size = new Vector2(152.0f, 28.0f), + }; + BackgroundNode.AttachNode(this); + + FocusNode = new SimpleNineGridNode { + NodeId = 18, + TexturePath = "ui/uld/TextInputA.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(24.0f, 24.0f), + Offsets = new Vector4(10.0f), + Size = new Vector2(152.0f, 28.0f), + }; + FocusNode.AttachNode(this); + + TextLimitsNode = new TextNode { + NodeId = 17, + Position = new Vector2(-24.0f, 6.0f), + Size = new Vector2(170.0f, 19.0f), + FontType = FontType.MiedingerMed, + FontSize = 14, + AlignmentType = (AlignmentType)21, + }; + TextLimitsNode.AttachNode(this); + + CurrentTextNode = new TextNode { + NodeId = 16, + Position = new Vector2(10.0f, 6.0f), + Size = new Vector2(132.0f, 18.0f), + AlignmentType = AlignmentType.TopLeft, + TextFlags = TextFlags.AutoAdjustNodeSize, + TextColor = ColorHelper.GetColor(1), + }; + CurrentTextNode.AttachNode(this); + + SelectionListNode = new TextInputSelectionListNode { + NodeId = 4, + Position = new Vector2(0.0f, 22.0f), + Size = new Vector2(186.0f, 208.0f), + }; + SelectionListNode.AttachNode(this); + + CursorNode = new CursorNode { + NodeId = 2, + Position = new Vector2(10.0f, 2.0f), + Size = new Vector2(4.0f, 24.0f), + OriginY = 4.0f, + }; + CursorNode.AttachNode(this); + + PlaceholderTextNode = new TextNode { + Position = new Vector2(8.0f, 0.0f), + TextColor = ColorHelper.GetColor(3), + }; + PlaceholderTextNode.AttachNode(this); + + Data->Nodes[0] = CurrentTextNode.NodeId; + Data->Nodes[1] = BackgroundNode.NodeId; + Data->Nodes[2] = CursorNode.NodeId; + Data->Nodes[3] = SelectionListNode.NodeId; + Data->Nodes[4] = SelectionListNode.Buttons[8].NodeId; + Data->Nodes[5] = SelectionListNode.Buttons[7].NodeId; + Data->Nodes[6] = SelectionListNode.Buttons[6].NodeId; + Data->Nodes[7] = SelectionListNode.Buttons[5].NodeId; + Data->Nodes[8] = SelectionListNode.Buttons[4].NodeId; + Data->Nodes[9] = SelectionListNode.Buttons[3].NodeId; + Data->Nodes[10] = SelectionListNode.Buttons[2].NodeId; + Data->Nodes[11] = SelectionListNode.Buttons[1].NodeId; + Data->Nodes[12] = SelectionListNode.Buttons[0].NodeId; + Data->Nodes[13] = SelectionListNode.LabelNode.NodeId; + Data->Nodes[14] = SelectionListNode.BackgroundNode.NodeId; + Data->Nodes[15] = TextLimitsNode.NodeId; + + Data->CandidateColor = new ByteColor { R = 66 }; + Data->IMEColor = new ByteColor { R = 67 }; + Data->FocusColor = KnownColor.Black.Vector().ToByteColor(); + + Flags = TextInputFlags.EnableIme | TextInputFlags.AllowUpperCase | TextInputFlags.AllowLowerCase | + TextInputFlags.EnableDictionary | TextInputFlags.AllowNumberInput | TextInputFlags.AllowSymbolInput; + + EnableCompletion = false; + Component->EnableTabCallback = true; + + LoadTimelines(); + + pinnedCallbackFunction = OnCallback; + Component->Callback = (delegate* unmanaged) Marshal.GetFunctionPointerForDelegate(pinnedCallbackFunction); + + InitializeComponentEvents(); + + CollisionNode.AddEvent(AtkEventType.FocusStart, () => { + PlaceholderTextNode.IsVisible = false; + OnFocused?.Invoke(); + + if (AutoSelectAll && Component->EvaluatedString.Length > 0) { + DalamudInterface.Instance.Framework.RunOnTick(() => { + var keyModifiers = new AtkTextInput.KeyModifiers { + IsControlDown = true, + }; + + AtkStage.Instance()->AtkInputManager->TextInput->ProcessKeyShortcut(SeVirtualKey.A, &keyModifiers); + }, delayTicks: 1); + } + }); + + CollisionNode.AddEvent(AtkEventType.FocusStop, () => { + OnUnfocused?.Invoke(); + if (!PlaceholderString.IsNullOrEmpty() && String.IsEmpty) { + PlaceholderTextNode.IsVisible = true; + PlaceholderTextNode.String = PlaceholderString; + } + }); + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + base.Dispose(disposing, isNativeDestructor); + + pinnedCallbackFunction = null; + } + } + + public bool IsFocused + => AtkStage.Instance()->AtkInputManager->FocusedNode == CollisionNode.Node; + + public int MaxCharacters { + get => (int)Component->ComponentTextData.MaxChar; + set => Component->ComponentTextData.MaxChar = (uint)value; + } + + public bool ShowLimitText { + get => TextLimitsNode.IsVisible; + set => TextLimitsNode.IsVisible = value; + } + + public TextInputFlags Flags { + get => (TextInputFlags) ((byte)Data->Flags1 | (byte)Data->Flags2 << 8); + set { + Data->Flags1 = (TextInputFlags1)((ushort)value & 0xFF); + Data->Flags2 = (TextInputFlags2)((ushort)value >> 8); + } + } + + public bool EnableCompletion { + get => Component->EnableCompletion; + set => Component->EnableCompletion = value; + } + + public bool EnableFocusSounds { + get => Component->EnableFocusSounds; + set => Component->EnableFocusSounds = value; + } + + public virtual ReadOnlySeString String { + get => Component->EvaluatedString.AsSpan(); + set { + Component->SetText(value); + UpdatePlaceholderVisibility(); + } + } + + public string? PlaceholderString { + get; + set { + field = value; + UpdatePlaceholderVisibility(); + } + } + + public bool IsError { + get => FocusNode.MultiplyColor == new Vector3(1.0f, 0.6f, 0.6f); + set => FocusNode.MultiplyColor = value ? new Vector3(1.0f, 0.6f, 0.6f) : Vector3.One; + } + + public bool AutoSelectAll { get; set; } = true; + + public void ClearFocus() { + if (IsFocused) { + AtkStage.Instance()->AtkInputManager->SetFocus(null, ParentAddon, 0); + } + } + + public virtual Action? OnInputReceived { get; set; } + public virtual Action? OnInputComplete { get; set; } + public Action? OnFocusLost { get; set; } + public Action? OnEscapeEntered { get; set; } + public Action? OnTabEntered { get; set; } + public Action? OnFocused { get; set; } + public Action? OnUnfocused { get; set; } + + private InputCallbackResult OnCallback(AtkUnitBase* addon, InputCallbackType type, CStringPointer rawString, CStringPointer evaluatedString, int eventKind) { + try { + switch (type) { + case InputCallbackType.Enter: + if (this is TextMultiLineInputNode) break; + + OnInputComplete?.Invoke(Component->EvaluatedString.AsSpan()); + ClearFocus(); + break; + + case InputCallbackType.TextChanged: + OnInputReceived?.Invoke(Component->EvaluatedString.AsSpan()); + break; + + case InputCallbackType.Escape: + OnEscapeEntered?.Invoke(); + break; + + case InputCallbackType.FocusLost: + OnFocusLost?.Invoke(); + break; + + case InputCallbackType.Tab: + OnTabEntered?.Invoke(); + break; + } + + return InputCallbackResult.None; + } + catch (Exception e) { + Log.Exception(e); + return InputCallbackResult.None; + } + } + + private void UpdatePlaceholderVisibility() { + PlaceholderTextNode.String = PlaceholderString ?? string.Empty; + PlaceholderTextNode.IsVisible = String.IsEmpty && !PlaceholderString.IsNullOrEmpty(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + BackgroundNode.Size = Size; + FocusNode.Size = Size; + PlaceholderTextNode.Size = Size; + TextLimitsNode.Size = new Vector2(Width + 18.0f, Height - 9.0f); + CurrentTextNode.Size = new Vector2(Width - 20.0f, Height - 10.0f); + } + + private void LoadTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 29) + .AddLabelPair(1, 9, 17) + .AddLabelPair(10, 19, 18) + .AddLabelPair(20, 29, 7) + .EndFrameSet() + .Build()); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 9, 1, alpha: 255) + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 255) + .AddFrame(12, alpha: 255) + .EndFrameSet() + .AddFrameSetWithFrame(20, 29, 20, alpha: 127) + .Build()); + + FocusNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0) + .AddFrame(12, alpha: 255) + .EndFrameSet() + .Build()); + + TextLimitsNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 9, 1, alpha: 102) + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 102) + .AddFrame(12, alpha: 127) + .EndFrameSet() + .AddFrameSetWithFrame(20, 29, 20, alpha: 76) + .Build()); + + CursorNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 15) + .AddLabel(1, 101, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(15, 0, AtkTimelineJumpBehavior.LoopForever, 101) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Component/TextMultiLineInputNode.cs b/KamiToolKit/Nodes/Component/TextMultiLineInputNode.cs new file mode 100644 index 0000000..85d6e4a --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextMultiLineInputNode.cs @@ -0,0 +1,97 @@ +using System; +using FFXIVClientStructs.FFXIV.Client.System.Input; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class TextMultiLineInputNode : TextInputNode { + + public TextMultiLineInputNode() { + TextLimitsNode.AlignmentType = AlignmentType.BottomRight; + + CurrentTextNode.TextFlags |= TextFlags.MultiLine; + CurrentTextNode.LineSpacing = 14; + + Flags |= TextInputFlags.MultiLine; + + CollisionNode.AddEvent(AtkEventType.InputReceived, InputComplete); + + Component->InputSanitizationFlags = AllowedEntities.UppercaseLetters | AllowedEntities.LowercaseLetters | AllowedEntities.Numbers | + AllowedEntities.SpecialCharacters | AllowedEntities.CharacterList | AllowedEntities.OtherCharacters | + AllowedEntities.Payloads | AllowedEntities.Unknown9; + + Component->ComponentTextData.Flags2 = TextInputFlags2.MultiLine | TextInputFlags2.AllowSymbolInput | TextInputFlags2.AllowNumberInput; + + Component->ComponentTextData.MaxLine = byte.MaxValue; + Component->ComponentTextData.MaxByte = ushort.MaxValue; + } + + public uint MaxLines { + get => Component->ComponentTextData.MaxLine; + set => Component->ComponentTextData.MaxLine = value; + } + + public uint MaxBytes { + get => Component->ComponentTextData.MaxByte; + set => Component->ComponentTextData.MaxByte = value; + } + + public override ReadOnlySeString String { + get => base.String; + set { + base.String = value; + PlaceholderTextNode.IsVisible = PlaceholderString is not null && value.IsEmpty; + UpdateHeightForContent(); + } + } + + public override Action? OnInputReceived { + get => base.OnInputReceived; + set { + base.OnInputReceived = _ => UpdateHeightForContent(); + base.OnInputReceived += value; + } + } + + public bool AutoUpdateHeight { get; set; } + + public Action? HeightChanged { get; set; } + + private void UpdateHeightForContent() { + if (!AutoUpdateHeight) return; + + var text = String; + var lineCount = Math.Max(1, text.ToString().Split('\r', '\n').Length); + var lineHeight = CurrentTextNode.LineSpacing; + var contentHeight = Math.Max(Height, lineCount * lineHeight + 20); + + var oldHeight = Height; + Height = contentHeight; + + if (Math.Abs(contentHeight - oldHeight) > 0.1f) { + HeightChanged?.Invoke(Height); + } + } + + private void InputComplete() { + if (UIInputData.Instance()->IsKeyPressed(SeVirtualKey.RETURN)) { + var textInputComponent = Node->GetAsAtkComponentTextInput(); + var cursorPos = textInputComponent->CursorPos; + + using (var utf8String = new Utf8String()) { + utf8String.SetString("\r"); + textInputComponent->WriteString(&utf8String); + } + + textInputComponent->CursorPos = cursorPos + 1; + textInputComponent->SelectionStart = cursorPos + 1; + textInputComponent->SelectionEnd = cursorPos + 1; + } + + OnInputComplete?.Invoke(Component->EvaluatedString.AsSpan()); + } +} diff --git a/KamiToolKit/Nodes/Component/TextMultiLineInputNodeScrollable.cs b/KamiToolKit/Nodes/Component/TextMultiLineInputNodeScrollable.cs new file mode 100644 index 0000000..bf8832e --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextMultiLineInputNodeScrollable.cs @@ -0,0 +1,184 @@ +using System; +using System.Linq; +using FFXIVClientStructs.FFXIV.Client.System.Input; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Enums; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + + +/// +/// Needs More Work. +/// +internal unsafe class TextMultiLineInputNodeScrollable : TextInputNode { + + private int startLineIndex; + + private bool isProgrammaticTextSet; + + private ReadOnlySeString fullText; + private ReadOnlySeString lastDisplayedText; + + public TextMultiLineInputNodeScrollable() { + TextLimitsNode.AlignmentType = AlignmentType.BottomRight; + + CurrentTextNode.TextFlags |= TextFlags.MultiLine; + CurrentTextNode.LineSpacing = 14; + + Flags |= TextInputFlags.MultiLine; + + CollisionNode.AddEvent(AtkEventType.InputReceived, InputComplete); + CollisionNode.AddEvent(AtkEventType.MouseWheel, OnMouseScrolled); + + Component->InputSanitizationFlags = AllowedEntities.UppercaseLetters | AllowedEntities.LowercaseLetters | AllowedEntities.Numbers | + AllowedEntities.SpecialCharacters | AllowedEntities.CharacterList | AllowedEntities.OtherCharacters | + AllowedEntities.Payloads | AllowedEntities.Unknown9; + + Component->ComponentTextData.Flags2 = TextInputFlags2.MultiLine | TextInputFlags2.AllowSymbolInput | TextInputFlags2.AllowNumberInput; + + Component->ComponentTextData.MaxLine = byte.MaxValue; + Component->ComponentTextData.MaxByte = ushort.MaxValue; + } + + public uint MaxLines { + get => Component->ComponentTextData.MaxLine; + set => Component->ComponentTextData.MaxLine = value; + } + + public uint MaxBytes { + get => Component->ComponentTextData.MaxByte; + set => Component->ComponentTextData.MaxByte = value; + } + + public override ReadOnlySeString String { + get => fullText; + set { + isProgrammaticTextSet = true; + fullText = value; + UpdateCurrentTextDisplay(); + isProgrammaticTextSet = false; + } + } + + public override Action? OnInputReceived { + get => base.OnInputReceived; + set { + base.OnInputReceived = currentComponentText => { + if (isProgrammaticTextSet) return; + + ApplyDisplayChangesToFullText(currentComponentText.ToString()); + lastDisplayedText = currentComponentText; + UpdateLineCountDisplay(); + }; + + base.OnInputReceived += value; + } + } + + private void OnMouseScrolled(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None); + var lineHeight = CurrentTextNode.LineSpacing; + var maxVisibleLines = (int)(Height / lineHeight); + + var oldStartLineIndex = startLineIndex; + + if (atkEventData->IsScrollUp) + startLineIndex = Math.Max(0, startLineIndex - 1); + + else if (atkEventData->IsScrollDown) + startLineIndex = Math.Min(Math.Max(0, lines.Length - maxVisibleLines), startLineIndex + 1); + + if (oldStartLineIndex != startLineIndex) { + UpdateCurrentTextDisplay(); + } + + atkEvent->SetEventIsHandled(); + } + + private void ApplyDisplayChangesToFullText(string newDisplayedText) { + var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None).ToList(); + var oldDisplayLines = lastDisplayedText.ToString().Split(['\r', '\n'], StringSplitOptions.None); + var newDisplayLines = newDisplayedText.Split(['\r', '\n'], StringSplitOptions.None); + + if (startLineIndex < lines.Count) { + var removeCount = Math.Min(oldDisplayLines.Length, lines.Count - startLineIndex); + lines.RemoveRange(startLineIndex, removeCount); + + lines.InsertRange(startLineIndex, newDisplayLines); + } + else { + lines.AddRange(newDisplayLines); + } + + for (var i = lines.Count - 1; i >= 0; i--) { + if (string.IsNullOrEmpty(lines[i])) + lines.RemoveAt(i); + else + break; + } + + if (lines.Count == 0) + lines.Add(string.Empty); + + fullText = string.Join("\r", lines); + lastDisplayedText = newDisplayedText; + } + + private void UpdateLineCountDisplay() { + var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None); + var lineHeight = CurrentTextNode.LineSpacing; + var totalLines = lines.Length; + var maxVisibleLines = (int)(Height / lineHeight); + + if (maxVisibleLines <= 0) return; + + startLineIndex = Math.Clamp(startLineIndex, 0, Math.Max(0, totalLines - maxVisibleLines)); + + var currentEndLine = Math.Min(startLineIndex + maxVisibleLines, totalLines); + var limitText = $"{startLineIndex + 1}-{currentEndLine}/{totalLines}"; + + TextLimitsNode.String = limitText; + } + + private void UpdateCurrentTextDisplay() { + var lines = fullText.ToString().Split(['\r', '\n'], StringSplitOptions.None); + var lineHeight = CurrentTextNode.LineSpacing; + var maxVisibleLines = (int)(Height / lineHeight); + + if (maxVisibleLines <= 0) return; + + startLineIndex = Math.Clamp(startLineIndex, 0, Math.Max(0, lines.Length - maxVisibleLines)); + + var displayText = startLineIndex > 0 && startLineIndex < lines.Length + ? string.Join("\r", lines.Skip(startLineIndex).Take(maxVisibleLines)) + : fullText.ToString(); + + lastDisplayedText = displayText; + var capturedProgrammaticFlag = isProgrammaticTextSet; + + isProgrammaticTextSet = capturedProgrammaticFlag; + Component->SetText(displayText); + UpdateLineCountDisplay(); + } + + private void InputComplete() { + if (UIInputData.Instance()->IsKeyPressed(SeVirtualKey.RETURN)) { + var textInputComponent = Node->GetAsAtkComponentTextInput(); + var cursorPos = textInputComponent->CursorPos; + + using (var utf8String = new Utf8String()) { + utf8String.SetString("\r"); + textInputComponent->WriteString(&utf8String); + } + + textInputComponent->CursorPos = cursorPos + 1; + textInputComponent->SelectionStart = cursorPos + 1; + textInputComponent->SelectionEnd = cursorPos + 1; + } + + OnInputComplete?.Invoke(Component->EvaluatedString.AsSpan()); + } +} diff --git a/KamiToolKit/Nodes/Component/TextureButtonNode.cs b/KamiToolKit/Nodes/Component/TextureButtonNode.cs new file mode 100644 index 0000000..b51f3ac --- /dev/null +++ b/KamiToolKit/Nodes/Component/TextureButtonNode.cs @@ -0,0 +1,44 @@ +using System.Numerics; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public class TextureButtonNode : ButtonBase { + + public readonly SimpleImageNode ImageNode; + + public TextureButtonNode() { + ImageNode = new ImGuiImageNode { + WrapMode = WrapMode.Stretch, + }; + ImageNode.AttachNode(this); + + LoadTimelines(); + + InitializeComponentEvents(); + } + + public string TexturePath { + get => ImageNode.TexturePath; + set => ImageNode.TexturePath = value; + } + + public Vector2 TextureCoordinates { + get => ImageNode.TextureCoordinates; + set => ImageNode.TextureCoordinates = value; + } + + public Vector2 TextureSize { + get => ImageNode.TextureSize; + set => ImageNode.TextureSize = value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ImageNode.Size = Size; + } + + private void LoadTimelines() + => LoadTwoPartTimelines(this, ImageNode); +} diff --git a/KamiToolKit/Nodes/Component/TreeListNode.cs b/KamiToolKit/Nodes/Component/TreeListNode.cs new file mode 100644 index 0000000..ef9ff2f --- /dev/null +++ b/KamiToolKit/Nodes/Component/TreeListNode.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace KamiToolKit.Nodes; + +public class TreeListNode : SimpleComponentNode { + + private readonly SimpleComponentNode childContainer; + + private readonly List children = []; + + public ReadOnlyCollection CategoryNodes => children.AsReadOnly(); + + public TreeListNode() { + childContainer = new SimpleComponentNode(); + childContainer.AttachNode(this); + } + + public float CategoryVerticalSpacing { get; set; } = 4.0f; + + public Action? OnLayoutUpdate { get; set; } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + childContainer.Width = Width; + } + + public void AddCategoryNode(TreeListCategoryNode node) { + RefreshLayout(); + + children.Add(node); + + node.NodeId = (uint)children.Count + 1; + node.Width = childContainer.Width; + node.Y = childContainer.Height; + node.AttachNode(childContainer); + node.ParentTreeListNode = this; + + childContainer.Height += node.Height + CategoryVerticalSpacing; + } + + public void RefreshLayout() { + childContainer.Height = 0.0f; + + foreach (var child in children) { + if (!child.IsVisible) continue; + + child.Y = childContainer.Height; + childContainer.Height += child.Height + CategoryVerticalSpacing; + child.UpdateChildrenNodeId(); + } + + OnLayoutUpdate?.Invoke(childContainer.Height); + } +} diff --git a/KamiToolKit/Nodes/Component/WindowNode.cs b/KamiToolKit/Nodes/Component/WindowNode.cs new file mode 100644 index 0000000..71f58c6 --- /dev/null +++ b/KamiToolKit/Nodes/Component/WindowNode.cs @@ -0,0 +1,265 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Timelines; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Nodes; + +public unsafe class WindowNode : WindowNodeBase { + + public readonly ImageNode BackgroundImageNode; + public readonly WindowBackgroundNode BackgroundNode; + public readonly WindowBackgroundNode BorderNode; + public readonly TextureButtonNode CloseButtonNode; + public readonly TextureButtonNode ConfigurationButtonNode; + public readonly SimpleNineGridNode DividingLineNode; + public readonly CollisionNode HeaderCollisionNode; + public readonly ResNode HeaderContainerNode; + public readonly TextureButtonNode InformationButtonNode; + public readonly TextNode SubtitleNode; + public readonly TextNode TitleNode; + + public WindowNode() { + CollisionNode.NodeId = 13; + Component->ShowFlags = 1; + + HeaderCollisionNode = new CollisionNode { + Uses = 2, + NodeId = 12, + Height = 28.0f, + Position = new Vector2(8.0f, 8.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.HasCollision | NodeFlags.RespondToMouse | NodeFlags.EmitsEvents | NodeFlags.Focusable, + }; + HeaderCollisionNode.AttachNode(this); + + BackgroundNode = new WindowBackgroundNode(false) { + NodeId = 11, + Position = Vector2.Zero, + Offsets = new Vector4(64.0f, 32.0f, 32.0f, 32.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.Fill | NodeFlags.EmitsEvents, + PartsRenderType = 19, + }; + BackgroundNode.AttachNode(this); + + BorderNode = new WindowBackgroundNode(true) { + NodeId = 10, + Position = Vector2.Zero, + Offsets = new Vector4(64.0f, 32.0f, 32.0f, 32.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.Fill | NodeFlags.EmitsEvents, + PartsRenderType = 7, + }; + BorderNode.AttachNode(this); + + BackgroundImageNode = new SimpleImageNode { + NodeId = 9, + WrapMode = WrapMode.Stretch, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + TexturePath = "ui/uld/WindowA_Gradation.tex", + TextureCoordinates = new Vector2(6.0f, 2.0f), + TextureSize = new Vector2(24.0f, 24.0f), + }; + BackgroundImageNode.AttachNode(this); + + HeaderContainerNode = new ResNode { + NodeId = 2, + Size = new Vector2(477.0f, 38.0f), + }; + HeaderContainerNode.AttachNode(this); + + DividingLineNode = new SimpleNineGridNode { + NodeId = 8, + TexturePath = "ui/uld/WindowA_Line.tex", + TextureCoordinates = Vector2.Zero, + TextureSize = new Vector2(32.0f, 4.0f), + Size = new Vector2(650.0f, 4.0f), + LeftOffset = 12.0f, + RightOffset = 12.0f, + Position = new Vector2(10.0f, 33.0f), + }; + DividingLineNode.AttachNode(HeaderContainerNode); + + CloseButtonNode = new TextureButtonNode { + NodeId = 7, + Size = new Vector2(28.0f, 28.0f), + Position = new Vector2(449.0f, 6.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + TexturePath = "ui/uld/WindowA_Button.tex", + TextureCoordinates = new Vector2(0.0f, 0.0f), + TextureSize = new Vector2(28.0f, 28.0f), + }; + CloseButtonNode.AttachNode(HeaderContainerNode); + + ConfigurationButtonNode = new TextureButtonNode { + NodeId = 6, + Size = new Vector2(16.0f, 16.0f), + Position = new Vector2(435.0f, 8.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + TexturePath = "ui/uld/WindowA_Button.tex", + TextureCoordinates = new Vector2(44.0f, 0.0f), + TextureSize = new Vector2(16.0f, 16.0f), + }; + ConfigurationButtonNode.AttachNode(HeaderContainerNode); + + InformationButtonNode = new TextureButtonNode { + NodeId = 5, + Size = new Vector2(16.0f, 16.0f), + Position = new Vector2(421.0f, 8.0f), + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + TexturePath = "ui/uld/WindowA_Button.tex", + TextureCoordinates = new Vector2(28.0f, 0.0f), + TextureSize = new Vector2(16.0f, 16.0f), + }; + InformationButtonNode.AttachNode(HeaderContainerNode); + + SubtitleNode = new TextNode { + NodeId = 4, + LineSpacing = 12, + AlignmentType = AlignmentType.Left, + FontSize = 12, + FontType = FontType.Axis, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + TextColor = ColorHelper.GetColor(3), + TextOutlineColor = ColorHelper.GetColor(6), + BackgroundColor = Vector4.Zero, + Size = new Vector2(46.0f, 20.0f), + Position = new Vector2(83.0f, 17.0f), + }; + SubtitleNode.AttachNode(HeaderContainerNode); + + TitleNode = new TextNode { + NodeId = 3, + LineSpacing = 23, + AlignmentType = AlignmentType.Left, + FontSize = 23, + FontType = FontType.TrumpGothic, + TextFlags = TextFlags.AutoAdjustNodeSize, + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents, + TextColor = ColorHelper.GetColor(2), + TextOutlineColor = ColorHelper.GetColor(7), + BackgroundColor = Vector4.Zero, + Size = new Vector2(86.0f, 31.0f), + Position = new Vector2(12.0f, 7.0f), + }; + TitleNode.AttachNode(HeaderContainerNode); + + Data->ShowCloseButton = 1; + Data->ShowConfigButton = 0; + Data->ShowHelpButton = 0; + Data->ShowHeader = 1; + Data->Nodes[0] = TitleNode.NodeId; + Data->Nodes[1] = SubtitleNode.NodeId; + Data->Nodes[2] = CloseButtonNode.NodeId; + Data->Nodes[3] = ConfigurationButtonNode.NodeId; + Data->Nodes[4] = InformationButtonNode.NodeId; + Data->Nodes[5] = 0; + Data->Nodes[6] = HeaderContainerNode.NodeId; + Data->Nodes[7] = 0; + + NodeFlags = NodeFlags.Visible | NodeFlags.Enabled | NodeFlags.EmitsEvents; + + LoadTimelines(); + + InitializeComponentEvents(); + } + + public AtkUnitBase* OwnerAddon { + get => Component->OwnerUnitBase; + set => Component->OwnerUnitBase = value; + } + + public ReadOnlySeString Title { + get => TitleNode.String; + set { + TitleNode.String = value; + TitleNode.IsVisible = true; + } + } + + public ReadOnlySeString Subtitle { + get => SubtitleNode.String; + set { + SubtitleNode.String = value; + SubtitleNode.IsVisible = true; + SubtitleNode.X = TitleNode.X + TitleNode.Width + 2.0f; + } + } + + public override void SetTitle(string title, string? subtitle = null) { + base.SetTitle(title, subtitle); + SubtitleNode.Position = new Vector2(TitleNode.Bounds.Right + 4.0f, SubtitleNode.Y); + } + + public bool ShowCloseButton { + get => CloseButtonNode.IsVisible; + set => CloseButtonNode.IsVisible = value; + } + + public bool ShowConfigButton { + get => ConfigurationButtonNode.IsVisible; + set => ConfigurationButtonNode.IsVisible = value; + } + + public bool ShowHelpButton { + get => InformationButtonNode.IsVisible; + set => InformationButtonNode.IsVisible = value; + } + + public bool ShowHeader { + get => InformationButtonNode.IsVisible; + set => InformationButtonNode.IsVisible = value; + } + + public bool Focused { + get => BorderNode.IsVisible; + set => BorderNode.IsVisible = value; + } + + public override float HeaderHeight => HeaderContainerNode.Height; + + public override Vector2 ContentSize => new(BackgroundImageNode.Width, BackgroundImageNode.Height - HeaderHeight); + + public override Vector2 ContentStartPosition => new(BackgroundImageNode.X, BackgroundImageNode.Y + HeaderHeight); + + public override ResNode WindowHeaderFocusNode => HeaderContainerNode; + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + HeaderContainerNode.Width = Width; + HeaderCollisionNode.Width = Width - 14.0f; + BackgroundNode.Size = Size; + BorderNode.Size = Size; + BackgroundImageNode.Size = new Vector2(Width - 8.0f, Height - 16.0f); + BackgroundImageNode.Position = new Vector2(4.0f, 4.0f); + + CloseButtonNode.X = Width - 33.0f; + ConfigurationButtonNode.X = Width - 47.0f; + InformationButtonNode.X = Width - 61.0f; + DividingLineNode.Width = Width - 20.0f; + } + + private void LoadTimelines() { + AddTimeline(new TimelineBuilder() + .BeginFrameSet(1, 29) + .AddLabelPair(1, 9, 17) + .AddLabelPair(10, 19, 18) + .AddLabelPair(20, 29, 7) + .EndFrameSet() + .Build()); + + BackgroundNode.AddTimeline(new TimelineBuilder() + .AddFrameSetWithFrame(1, 9, 1, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(10, 19, 10, multiplyColor: new Vector3(100.0f)) + .AddFrameSetWithFrame(20, 29, 20, multiplyColor: new Vector3(50.0f)) + .Build()); + + BorderNode.AddTimeline(new TimelineBuilder() + .BeginFrameSet(10, 19) + .AddFrame(10, alpha: 0) + .AddFrame(12, alpha: 255) + .EndFrameSet() + .Build()); + } +} diff --git a/KamiToolKit/Nodes/Component/WindowNodeBase.cs b/KamiToolKit/Nodes/Component/WindowNodeBase.cs new file mode 100644 index 0000000..c6563c8 --- /dev/null +++ b/KamiToolKit/Nodes/Component/WindowNodeBase.cs @@ -0,0 +1,19 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public abstract unsafe class WindowNodeBase : ComponentNode { + + protected WindowNodeBase() { + SetInternalComponentType(ComponentType.Window); + } + + public abstract Vector2 ContentSize { get; } + public abstract Vector2 ContentStartPosition { get; } + public abstract float HeaderHeight { get; } + public abstract ResNode WindowHeaderFocusNode { get; } + + public virtual void SetTitle(string title, string? subtitle = null) + => Component->SetTitle(title, subtitle ?? string.Empty); +} diff --git a/KamiToolKit/Nodes/Layout/AlignedHorizontalListNode.cs b/KamiToolKit/Nodes/Layout/AlignedHorizontalListNode.cs new file mode 100644 index 0000000..5273592 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/AlignedHorizontalListNode.cs @@ -0,0 +1,7 @@ +namespace KamiToolKit.Nodes; + +public class AlignedHorizontalListNode : HorizontalListNode { + protected override void AdjustNode(NodeBase node) { + node.Y = 0.0f; + } +} diff --git a/KamiToolKit/Nodes/Layout/AlignedVerticalListNode.cs b/KamiToolKit/Nodes/Layout/AlignedVerticalListNode.cs new file mode 100644 index 0000000..a5efa2b --- /dev/null +++ b/KamiToolKit/Nodes/Layout/AlignedVerticalListNode.cs @@ -0,0 +1,7 @@ +namespace KamiToolKit.Nodes; + +public abstract class AlignedVerticalListNode : VerticalListNode { + protected override void AdjustNode(NodeBase node) { + node.X = 0.0f; + } +} diff --git a/KamiToolKit/Nodes/Layout/GridNode.cs b/KamiToolKit/Nodes/Layout/GridNode.cs new file mode 100644 index 0000000..a19bcd1 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/GridNode.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace KamiToolKit.Nodes; + +public record GridSize(int Columns, int Rows); + +public class GridNode : SimpleComponentNode { + + private readonly List gridNodes = []; + + public SimpleComponentNode this[int x, int y] { + get => gridNodes[x + y * GridSize.Columns]; + set => gridNodes[x + y * GridSize.Columns] = value; + } + + public SimpleComponentNode this[int index] { + get => gridNodes[index]; + set => gridNodes[index] = value; + } + + /// + /// Warning: Changing this value will dispose any existing layout nodes. + /// + public required GridSize GridSize { + get; + set { + field = value; + ReallocateArray(); + } + } = new(0, 0); + + private void ReallocateArray() { + foreach (var node in gridNodes) { + node.Dispose(); + } + gridNodes.Clear(); + + foreach (var _ in Enumerable.Range(0, GridSize.Rows * GridSize.Columns)) { + gridNodes.Add(new SimpleComponentNode()); + } + + foreach (var row in Enumerable.Range(0, GridSize.Rows)) { + foreach (var column in Enumerable.Range(0, GridSize.Columns)) { + this[column, row].AttachNode(this); + this[column, row].IsVisible = true; + } + } + + RecalculateLayout(); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + RecalculateLayout(); + } + + public void RecalculateLayout() { + var gridWidth = Width / GridSize.Columns; + var gridHeight = Height / GridSize.Rows; + + foreach (var row in Enumerable.Range(0, GridSize.Rows)) { + foreach (var column in Enumerable.Range(0, GridSize.Columns)) { + this[column, row].Size = new Vector2(gridWidth, gridHeight); + this[column, row].Position = new Vector2(column * gridWidth, row * gridHeight); + } + } + } +} diff --git a/KamiToolKit/Nodes/Layout/HorizontalFlexNode.cs b/KamiToolKit/Nodes/Layout/HorizontalFlexNode.cs new file mode 100644 index 0000000..dcd7a82 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/HorizontalFlexNode.cs @@ -0,0 +1,49 @@ +using System.Linq; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public class HorizontalFlexNode : LayoutListNode { + + public FlexFlags AlignmentFlags { get; set; } = FlexFlags.FitContentHeight; + + public float FitPadding { get; set; } = 4.0f; + + public override float Width { + get => base.Width; + set { + base.Width = value; + RecalculateLayout(); + } + } + + protected override void OnRecalculateLayout() { + var step = Width / NodeList.Count; + + if (NodeList.Count != 0 && AlignmentFlags.HasFlag(FlexFlags.FitContentHeight)) { + Height = NodeList.Max(node => node.Height); + } + + foreach (var index in Enumerable.Range(0, NodeList.Count)) { + + if (AlignmentFlags.HasFlag(FlexFlags.CenterHorizontally)) { + NodeList[index].X = step * index + step / 2.0f - NodeList[index].Width / 2.0f; + } + else { + NodeList[index].X = step * index; + } + + if (AlignmentFlags.HasFlag(FlexFlags.FitHeight)) { + NodeList[index].Height = Height; + } + + if (AlignmentFlags.HasFlag(FlexFlags.CenterVertically)) { + NodeList[index].Y = Height / 2 - NodeList[index].Height / 2; + } + + if (AlignmentFlags.HasFlag(FlexFlags.FitWidth)) { + NodeList[index].Width = step - FitPadding; + } + } + } +} diff --git a/KamiToolKit/Nodes/Layout/HorizontalListNode.cs b/KamiToolKit/Nodes/Layout/HorizontalListNode.cs new file mode 100644 index 0000000..104b677 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/HorizontalListNode.cs @@ -0,0 +1,66 @@ +using System.Linq; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public class HorizontalListNode : LayoutListNode { + + public HorizontalListAnchor Alignment { + get; + set { + field = value; + RecalculateLayout(); + } + } + + public override float Width { + get => base.Width; + set { + base.Width = value; + RecalculateLayout(); + } + } + + /// + /// Adjusts contained nodes heights to match this nodes height + /// + public bool FitHeight { get; set; } + + /// + /// Resizes the horizontal list node to fit all contents + /// + public bool FitToContentHeight { get; set; } + + protected override void OnRecalculateLayout() { + var startX = Alignment switch { + HorizontalListAnchor.Left => 0.0f + FirstItemSpacing, + HorizontalListAnchor.Right => Width - FirstItemSpacing, + _ => 0.0f, + }; + + foreach (var node in NodeList) { + if (!node.IsVisible) continue; + + if (Alignment is HorizontalListAnchor.Right) { + startX -= node.Width + ItemSpacing; + } + + node.X = startX; + AdjustNode(node); + + if (Alignment is HorizontalListAnchor.Left) { + startX += node.Width + ItemSpacing; + } + + if (FitHeight) { + node.Height = Height; + } + } + + if (FitToContentHeight) { + Height = NodeList.Max(node => node.Height); + } + } + + public float AreaRemaining => Width - NodeList.Sum(node => node.Width + ItemSpacing) - ItemSpacing; +} diff --git a/KamiToolKit/Nodes/Layout/LabelLayoutNode.cs b/KamiToolKit/Nodes/Layout/LabelLayoutNode.cs new file mode 100644 index 0000000..40df28a --- /dev/null +++ b/KamiToolKit/Nodes/Layout/LabelLayoutNode.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Numerics; + +namespace KamiToolKit.Nodes; + +public class LabelLayoutNode : LayoutListNode { + + public bool FillWidth { get; set; } + + protected override void OnRecalculateLayout() { + if (Nodes.Count is 0) return; + + var labelNode = Nodes[0]; + + var labelNodeWidth = labelNode.Width; + labelNode.Position = new Vector2(0.0f, 0.0f); + + var position = labelNodeWidth + FirstItemSpacing; + foreach (var node in Nodes.Skip(1)) { + node.X = position; + + if (FillWidth) { + node.Width = (Width - labelNodeWidth - FirstItemSpacing) / (Nodes.Count - 1); + } + + position += node.Width + ItemSpacing; + } + } +} diff --git a/KamiToolKit/Nodes/Layout/LayoutListNode.cs b/KamiToolKit/Nodes/Layout/LayoutListNode.cs new file mode 100644 index 0000000..805ad94 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/LayoutListNode.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Nodes; + +public abstract class LayoutListNode : SimpleComponentNode { + + protected readonly List NodeList = []; + private bool suppressRecalculateLayout; + + public IEnumerable GetNodes() where T : NodeBase => NodeList.OfType(); + + public IReadOnlyList Nodes => NodeList; + + public bool ClipListContents { + get => NodeFlags.HasFlag(NodeFlags.Clip); + set { + if (value) { + AddNodeFlags(NodeFlags.Clip); + } + else { + RemoveNodeFlags(NodeFlags.Clip); + } + } + } + + public float ItemSpacing { get; set; } + + public float FirstItemSpacing { get; set; } + + public void RecalculateLayout() { + if (suppressRecalculateLayout) return; + + OnRecalculateLayout(); + + foreach (var node in NodeList) { + if (node is LayoutListNode subNode) { + subNode.RecalculateLayout(); + } + } + } + + protected abstract void OnRecalculateLayout(); + + protected virtual void AdjustNode(NodeBase node) { } + + public ICollection InitialNodes { + init => AddNode(value); + } + + public void AddNode(IEnumerable nodes) + { + suppressRecalculateLayout = true; + try + { + foreach (var node in nodes) + { + AddNode(node); + } + } + finally + { + suppressRecalculateLayout = false; + } + RecalculateLayout(); + } + + public virtual void AddNode(NodeBase? node) { + if (node is null) return; + + NodeList.Add(node); + + node.AttachNode(this); + + RecalculateLayout(); + } + + public void RemoveNode(params NodeBase[] items) + { + suppressRecalculateLayout = true; + try + { + foreach (var node in items) + { + RemoveNode(node); + } + } + finally + { + suppressRecalculateLayout = false; + } + RecalculateLayout(); + } + + public virtual void RemoveNode(NodeBase node) { + if (!NodeList.Contains(node)) return; + + NodeList.Remove(node); + node.Dispose(); + + RecalculateLayout(); + } + + public void AddDummy(float size = 0.0f) { + var dummyNode = new ResNode { + Size = new Vector2(size, size), + }; + + AddNode(dummyNode); + } + + public virtual void Clear() + { + suppressRecalculateLayout = true; + try + { + foreach (var node in NodeList.ToList()) + { + RemoveNode(node); + } + } + finally + { + suppressRecalculateLayout = false; + } + RecalculateLayout(); + } + + public delegate TU CreateNewNode(T data) where TU : NodeBase; + + public delegate T GetDataFromNode(TU node) where TU : NodeBase; + + public bool SyncWithListData(IEnumerable dataList, GetDataFromNode getDataFromNode, CreateNewNode createNodeMethod) where TU : NodeBase + { + suppressRecalculateLayout = true; + var anythingChanged = false; + try + { + var nodesOfType = GetNodes().ToList(); + var dataSet = dataList.ToHashSet(EqualityComparer.Default); + var represented = new HashSet(EqualityComparer.Default); + + foreach (var node in nodesOfType) + { + var nodeData = getDataFromNode(node); + + if (nodeData is null || !dataSet.Contains(nodeData)) + { + RemoveNode(node); + anythingChanged = true; + continue; + } + + represented.Add(nodeData); + } + + foreach (var data in dataSet) + { + if (represented.Contains(data)) + continue; + + var newNode = createNodeMethod(data); + AddNode(newNode); + anythingChanged = true; + } + } + finally + { + suppressRecalculateLayout = false; + } + RecalculateLayout(); + + return anythingChanged; + } + + public bool SyncWithListDataByKey( + IReadOnlyList dataList, + Func getKeyFromData, + Func getKeyFromNode, + Action updateNode, + CreateNewNode createNodeMethod, + IEqualityComparer? keyComparer = null) where TU : NodeBase where TKey : notnull + { + suppressRecalculateLayout = true; + var anythingChanged = false; + try + { + keyComparer ??= EqualityComparer.Default; + + var existing = new List(capacity: NodeList.Count); + foreach (var t in NodeList) + { + if (t is TU tu) + existing.Add(tu); + } + + var byKey = new Dictionary(existing.Count, keyComparer); + List? duplicates = null; + + foreach (var node in existing) + { + var key = getKeyFromNode(node); + + if (!byKey.TryAdd(key, node)) + (duplicates ??= new List(4)).Add(node); + } + + var desired = new List(dataList.Count); + + foreach (var data in dataList) + { + var key = getKeyFromData(data); + + if (byKey.TryGetValue(key, out var existingNode)) + { + updateNode(existingNode, data); + desired.Add(existingNode); + byKey.Remove(key); + } + else + { + var newNode = createNodeMethod(data); + AddNode(newNode); + updateNode(newNode, data); + + desired.Add(newNode); + anythingChanged = true; + } + } + + if (byKey.Count != 0) + { + foreach (var kv in byKey) + { + RemoveNode(kv.Value); + anythingChanged = true; + } + } + + if (duplicates is not null) + { + for (var i = 0; i < duplicates.Count; i++) + { + RemoveNode(duplicates[i]); + anythingChanged = true; + } + } + + var desiredCount = desired.Count; + var j = 0; + var mismatch = false; + + for (var i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU) + { + if (j >= desiredCount) + { + mismatch = true; + break; + } + + NodeBase desiredNode = desired[j++]; + if (!ReferenceEquals(NodeList[i], desiredNode)) + { + NodeList[i] = desiredNode; + anythingChanged = true; + } + } + } + + if (!mismatch && j != desiredCount) + mismatch = true; + + if (mismatch) + { + var firstTuIndex = -1; + + for (var i = 0; i < NodeList.Count; i++) + { + if (NodeList[i] is TU) + { + firstTuIndex = i; + break; + } + } + + if (firstTuIndex < 0) + firstTuIndex = NodeList.Count; + + for (var i = NodeList.Count - 1; i >= 0; i--) + { + if (NodeList[i] is TU) + NodeList.RemoveAt(i); + } + + NodeList.InsertRange(firstTuIndex, desired); + anythingChanged = true; + } + } + finally + { + suppressRecalculateLayout = false; + } + RecalculateLayout(); + + return anythingChanged; + } + + public void ReorderNodes(Comparison comparison) { + NodeList.Sort(comparison); + RecalculateLayout(); + } +} diff --git a/KamiToolKit/Nodes/Layout/ListBoxNode.cs b/KamiToolKit/Nodes/Layout/ListBoxNode.cs new file mode 100644 index 0000000..2250bdf --- /dev/null +++ b/KamiToolKit/Nodes/Layout/ListBoxNode.cs @@ -0,0 +1,200 @@ +using System; +using System.Linq; +using System.Numerics; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +/// Node that manages the layout of other nodes +public class ListBoxNode : LayoutListNode { + + public readonly BackgroundImageNode Background; + public readonly BorderNineGridNode Border; + + public ListBoxNode() { + Background = new BackgroundImageNode { + IsVisible = false, + }; + Background.AttachNode(this); + + Border = new BorderNineGridNode { + IsVisible = false, + }; + Border.AttachNode(this); + } + + public LayoutAnchor LayoutAnchor { + get; + set { + field = value; + RecalculateLayout(); + } + } + + public bool FitContents { + get; + set { + field = value; + RecalculateLayout(); + Size = GetMinimumSize(); + } + } + + public LayoutOrientation LayoutOrientation { + get; + set { + field = value; + RecalculateLayout(); + } + } + + public Vector4 BackgroundColor { + get => Background.Color; + set => Background.Color = value; + } + + public bool ShowBackground { + get => Background.IsVisible; + set => Background.IsVisible = value; + } + + public bool ShowBorder { + get => Border.IsVisible; + set => Border.IsVisible = value; + } + + public override float Height { + get => base.Height; + set => base.Height = FitContents ? GetMinimumSize().Y : value; + } + + public override float Width { + get => base.Width; + set => base.Width = FitContents ? GetMinimumSize().X : value; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + Background.Size = Size; + + Border.Size = Size + new Vector2(30.0f, 30.0f); + Border.Position = -new Vector2(15.0f, 15.0f); + + RecalculateLayout(); + } + + protected override void OnRecalculateLayout() { + var runningPosition = LayoutOrientation switch { + LayoutOrientation.Vertical when LayoutAnchor is LayoutAnchor.TopLeft or LayoutAnchor.TopRight + => GetLayoutStartPosition() + new Vector2(0.0f, FirstItemSpacing), + + LayoutOrientation.Vertical when LayoutAnchor is LayoutAnchor.BottomLeft or LayoutAnchor.BottomRight + => GetLayoutStartPosition() - new Vector2(0.0f, FirstItemSpacing), + + LayoutOrientation.Horizontal when LayoutAnchor is LayoutAnchor.BottomLeft or LayoutAnchor.TopLeft + => GetLayoutStartPosition() + new Vector2(FirstItemSpacing, 0.0f), + + LayoutOrientation.Horizontal when LayoutAnchor is LayoutAnchor.BottomRight or LayoutAnchor.TopRight + => GetLayoutStartPosition() - new Vector2(FirstItemSpacing, 0.0f), + + _ => Vector2.Zero, + }; + + foreach (var node in NodeList.Where(node => node.IsVisible)) { + if (LayoutOrientation is LayoutOrientation.Vertical) { + switch (LayoutAnchor) { + case LayoutAnchor.TopLeft: + node.Position = runningPosition; + runningPosition.Y += node.Height * node.Scale.Y + ItemSpacing; + break; + + case LayoutAnchor.TopRight: + node.Position = runningPosition - new Vector2(node.Width * node.Scale.X, 0.0f); + runningPosition.Y += node.Height * node.Scale.Y + ItemSpacing; + break; + + case LayoutAnchor.BottomLeft: + node.Position = runningPosition - new Vector2(0.0f, node.Height * node.Scale.Y); + runningPosition.Y -= node.Height * node.Scale.Y + ItemSpacing; + break; + + case LayoutAnchor.BottomRight: + node.Position = runningPosition - new Vector2(node.Width * node.Scale.X, node.Height * node.Scale.Y); + runningPosition.Y -= node.Height * node.Scale.Y + ItemSpacing; + break; + } + } + else if (LayoutOrientation is LayoutOrientation.Horizontal) { + switch (LayoutAnchor) { + case LayoutAnchor.TopLeft: + node.Position = runningPosition; + runningPosition.X += node.Width * node.Scale.X + ItemSpacing; + break; + + case LayoutAnchor.TopRight: + node.Position = runningPosition - new Vector2(node.Width * node.Scale.X, 0.0f); + runningPosition.X -= node.Width * node.Scale.X + ItemSpacing; + break; + + case LayoutAnchor.BottomLeft: + node.Position = runningPosition - new Vector2(0.0f, node.Height * node.Scale.Y); + runningPosition.X += node.Width * node.Scale.X + ItemSpacing; + break; + + case LayoutAnchor.BottomRight: + node.Position = runningPosition - new Vector2(node.Width * node.Scale.X, node.Height * node.Scale.Y); + runningPosition.X -= node.Width * node.Scale.X + ItemSpacing; + break; + } + } + } + } + + public override void AddNode(NodeBase? node) { + base.AddNode(node); + Size = GetMinimumSize(); + } + + public override void RemoveNode(NodeBase node) { + base.RemoveNode(node); + Size = GetMinimumSize(); + } + + /// + /// Get the current minimum size that would contain all the nodes including their margins. + /// + public Vector2 GetMinimumSize() { + var size = LayoutOrientation switch { + LayoutOrientation.Vertical => new Vector2(0.0f, FirstItemSpacing), + LayoutOrientation.Horizontal => new Vector2(FirstItemSpacing, 0.0f), + _ => Vector2.Zero, + }; + + foreach (var node in NodeList.Where(node => node.IsVisible)) { + switch (LayoutOrientation) { + // Horizontal we take max height, and add widths + case LayoutOrientation.Horizontal: + size.Y = MathF.Max(size.Y, node.Height); + size.X += node.Width + ItemSpacing; + break; + + // Vertical we take max width, and add heights + case LayoutOrientation.Vertical: + size.X = MathF.Max(size.X, node.Width); + size.Y += node.Height + ItemSpacing; + break; + } + } + + return size; + } + + private Vector2 GetLayoutStartPosition() => LayoutAnchor switch { + LayoutAnchor.TopLeft => Vector2.Zero, + LayoutAnchor.TopRight => new Vector2(Width, 0.0f), + LayoutAnchor.BottomLeft => new Vector2(0.0f, Height), + LayoutAnchor.BottomRight => new Vector2(Width, Height), + _ => throw new ArgumentOutOfRangeException(), + }; +} diff --git a/KamiToolKit/Nodes/Layout/ListItemNode.cs b/KamiToolKit/Nodes/Layout/ListItemNode.cs new file mode 100644 index 0000000..8a29320 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/ListItemNode.cs @@ -0,0 +1,40 @@ +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public abstract class ListItemNode : SelectableNode { + public abstract float ItemHeight { get; } + + public T? ItemData { + get; + set { + if (value is not null) { + if (!GenericUtil.AreEqual(field, value)) { + IsSettingNodeData = true; + SetNodeData(value); + IsSettingNodeData = false; + } + } + + field = value; + + IsVisible = value is not null; + } + } + + /// + /// Bool that indicates if SetNodeDate when different is being called. + /// Used to prevent things like checkboxes from trigger a file save due to the value being changed. + /// + protected bool IsSettingNodeData { get; private set; } + + protected abstract void SetNodeData(T itemData); + + public virtual void Update() { } + + protected void DisableInteractions() { + EnableSelection = false; + EnableHighlight = false; + DisableCollisionNode = true; + } +} diff --git a/KamiToolKit/Nodes/Layout/ListNode.cs b/KamiToolKit/Nodes/Layout/ListNode.cs new file mode 100644 index 0000000..9a0b474 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/ListNode.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public unsafe class ListNode : SimpleComponentNode where TU : ListItemNode, new() { + public readonly ScrollBarNode ScrollBarNode; + + public ListNode() { + using (var displayNode = new TU()) { + itemHeight = displayNode.ItemHeight; + } + + ScrollBarNode = new ScrollBarNode { + OnValueChanged = OnScrollUpdate, + ScrollSpeed = (int) itemHeight, + HideWhenDisabled = true, + }; + ScrollBarNode.AttachNode(this); + + AddEvent(AtkEventType.MouseWheel, OnMouseWheel); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ScrollBarNode.Size = new Vector2(8.0f, Height); + ScrollBarNode.Position = new Vector2(Width - 8.0f, 0.0f); + + var newNodeCount = (int)(Height / (itemHeight + ItemSpacing)); + if (newNodeCount != nodeCount) { + FullRebuild(); + } + + foreach (var node in nodeList) { + node.Width = ScrollBarNode.Bounds.Left - 8.0f; + } + + RecalculateScroll(); + } + + public Action? OnItemSelected { get; set; } + + public float ItemSpacing { + get; + set { + field = value; + FullRebuild(); + } + } + + public required List OptionsList { + get; + set { + field = value; + + var newNodeCount = (int)(Height / (itemHeight + ItemSpacing)); + if (newNodeCount != nodeCount) { + FullRebuild(); + } + else { + PopulateNodes(); + RecalculateScroll(); + } + } + } = []; + + private readonly List nodeList = []; + private readonly float itemHeight; + private T? selectedItem; + private int scrollPosition; + private int nodeCount; + + /// + /// Resets and rebuilds list + /// + public void FullRebuild() { + foreach (var node in nodeList) { + node.Dispose(); + } + nodeList.Clear(); + + scrollPosition = Math.Clamp(scrollPosition, 0, Math.Max(OptionsList.Count - nodeCount, 0)); + selectedItem = default; + + RebuildNodeList(); + PopulateNodes(); + RecalculateScroll(); + } + + public void Update() { + PopulateNodes(); + + foreach (var node in nodeList) { + if (node.IsVisible) { + node.Update(); + } + } + } + + private void RebuildNodeList() { + nodeCount = (int)(Height / (itemHeight + ItemSpacing)); + if (nodeCount < 1) return; + + foreach (var index in Enumerable.Range(0, nodeCount)) { + var node = new TU { + Size = new Vector2(ScrollBarNode.Bounds.Left - 8.0f, itemHeight), + Position = new Vector2(0.0f, index * (itemHeight + ItemSpacing)), + NodeId = (uint)index + 2, + OnClick = clickedNode => { + SelectItem(((TU)clickedNode).ItemData); + OnItemSelected?.Invoke(selectedItem); + }, + IsVisible = false, + }; + node.AttachNode(this); + nodeList.Add(node); + } + } + + private void PopulateNodes() { + foreach (var (nodeIndex, node) in nodeList.Index()) { + var dataIndex = scrollPosition + nodeIndex; + + if (dataIndex < OptionsList.Count) { + var item = OptionsList[dataIndex]; + node.ItemData = item; + node.IsVisible = true; + node.IsSelected = GenericUtil.AreEqual(item, selectedItem); + } + else { + node.IsVisible = false; + } + } + } + + private void SelectItem(T? item) { + if (item is null) return; + + selectedItem = item; + + foreach (var node in nodeList) { + if (node.ItemData is null) { + node.IsSelected = false; + } + else { + node.IsSelected = GenericUtil.AreEqual(node.ItemData, selectedItem); + } + } + } + + private void RecalculateScroll() { + if (OptionsList.Count < nodeCount) { + ScrollBarNode.ScrollPosition = 0; + ScrollBarNode.IsEnabled = false; + } + + var totalHeight = (int)( OptionsList.Count * (itemHeight + ItemSpacing) + ItemSpacing); + ScrollBarNode.UpdateScrollParams((int) (nodeList.Count * (itemHeight + ItemSpacing)), totalHeight); + ScrollBarNode.ScrollPosition = (int)( scrollPosition * (itemHeight + ItemSpacing) ); + } + + private void OnScrollUpdate(int newPosition) { + scrollPosition = (int)( newPosition / ( itemHeight + ItemSpacing ) ); + PopulateNodes(); + } + + private void OnMouseWheel(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + scrollPosition += atkEventData->IsScrollUp ? -1 : 1; + scrollPosition = Math.Clamp(scrollPosition, 0, Math.Max(0, OptionsList.Count - nodeCount)); + + ScrollBarNode.ScrollPosition = (int)( scrollPosition * (itemHeight + ItemSpacing) ); + PopulateNodes(); + + atkEvent->SetEventIsHandled(); + } +} diff --git a/KamiToolKit/Nodes/Layout/OrderedVerticalListNode.cs b/KamiToolKit/Nodes/Layout/OrderedVerticalListNode.cs new file mode 100644 index 0000000..85ea603 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/OrderedVerticalListNode.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public class OrderedVerticalListNode : VerticalListNode where T : NodeBase { + + public Func? OrderSelector { get; set; } + + protected override void OnRecalculateLayout() { + var typedList = NodeList.OfType(); + + if (OrderSelector is null) { + RecalculateLayout(); + return; + } + + var orderedList = typedList.OrderBy(OrderSelector).ToList(); + + var startY = Anchor switch { + VerticalListAnchor.Top => 0.0f + FirstItemSpacing, + VerticalListAnchor.Bottom => Height, + _ => 0.0f, + }; + + foreach (var node in orderedList) { + if (!node.IsVisible) continue; + + if (Anchor is VerticalListAnchor.Bottom) { + startY -= node.Height + ItemSpacing; + } + + node.Y = startY; + AdjustNode(node); + + if (Anchor is VerticalListAnchor.Top) { + startY += node.Height + ItemSpacing; + } + } + + if (FitContents) { + Height = orderedList.Sum(node => node.IsVisible ? node.Height + ItemSpacing : 0.0f) + FirstItemSpacing; + } + } +} diff --git a/KamiToolKit/Nodes/Layout/ScrollingListNode.cs b/KamiToolKit/Nodes/Layout/ScrollingListNode.cs new file mode 100644 index 0000000..968f682 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/ScrollingListNode.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +/// +/// This is a combination of a ScrollingAreaNode and a VerticalListNode for easy layout +/// +public class ScrollingListNode : SimpleComponentNode { + + private readonly ScrollingAreaNode listNode; + + public ScrollingListNode() { + listNode = new ScrollingAreaNode { + ContentHeight = 100.0f, + }; + listNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + listNode.Size = Size; + listNode.ContentNode.RecalculateLayout(); + listNode.FitToContentHeight(); + } + + public bool FitContents { + get => listNode.ContentNode.FitContents; + set => listNode.ContentNode.FitContents = value; + } + + public bool FitWidth { + get => listNode.ContentNode.FitWidth; + set => listNode.ContentNode.FitWidth = value; + } + + public VerticalListAnchor Anchor { + get => listNode.ContentNode.Anchor; + set => listNode.ContentNode.Anchor = value; + } + + public VerticalListAlignment Alignment { + get => listNode.ContentNode.Alignment; + set => listNode.ContentNode.Alignment = value; + } + + public bool ClipListContents { + get => listNode.ContentNode.ClipListContents; + set => listNode.ContentNode.ClipListContents = value; + } + + public float ItemSpacing { + get => listNode.ContentNode.ItemSpacing; + set => listNode.ContentNode.ItemSpacing = value; + } + + public float FirstItemSpacing { + get => listNode.ContentNode.FirstItemSpacing; + set => listNode.ContentNode.FirstItemSpacing = value; + } + + public ICollection InitialNodes { + init => listNode.ContentNode.AddNode(value); + } + + public bool AutoHideScrollBar { + get => listNode.AutoHideScrollBar; + set => listNode.AutoHideScrollBar = value; + } + + public int ScrollSpeed { + get => listNode.ScrollSpeed; + set => listNode.ScrollSpeed = value; + } + + public int ScrollPosition { + get => listNode.ScrollPosition; + set => listNode.ScrollPosition = value; + } + + public float ContentWidth => listNode.ContentNode.Width; + + public IReadOnlyList Nodes => listNode.ContentNode.Nodes; + + public IEnumerable GetNodes() where T : NodeBase => listNode.ContentNode.GetNodes(); + + public void RecalculateLayout() { + listNode.ContentNode.RecalculateLayout(); + listNode.FitToContentHeight(); + } + + public void FitToContentHeight() => listNode.FitToContentHeight(); + + public void AddNode(IEnumerable nodes) => listNode.ContentNode.AddNode(nodes); + + public void AddNode(NodeBase? node) => listNode.ContentNode.AddNode(node); + + public void RemoveNode(params NodeBase[] nodes) => listNode.ContentNode.RemoveNode(nodes); + + public void RemoveNode(NodeBase node) => listNode.ContentNode.RemoveNode(node); + + public void AddDummy(float size = 0.0f) => listNode.ContentNode.AddDummy(size); + + public void Clear() => listNode.ContentNode.Clear(); + + public void ReorderNodes(Comparison comparison) => listNode.ContentNode.ReorderNodes(comparison); + + public VerticalListNode VerticalListNode => listNode.ContentNode; +} diff --git a/KamiToolKit/Nodes/Layout/ScrollingTreeNode.cs b/KamiToolKit/Nodes/Layout/ScrollingTreeNode.cs new file mode 100644 index 0000000..7eea5ea --- /dev/null +++ b/KamiToolKit/Nodes/Layout/ScrollingTreeNode.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; + +namespace KamiToolKit.Nodes; + +/// +/// This is a combination of a ScrollingAreaNode and a TreeListNode for easy layout +/// +public class ScrollingTreeNode : SimpleComponentNode { + + private readonly ScrollingAreaNode listNode; + + public ScrollingTreeNode() { + listNode = new ScrollingAreaNode { + ContentHeight = 100.0f, + }; + listNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + listNode.Size = Size; + RecalculateLayout(); + } + + public float CategoryVerticalSpacing { + get => listNode.ContentNode.CategoryVerticalSpacing; + set => listNode.ContentNode.CategoryVerticalSpacing = value; + } + + public bool AutoHideScrollBar { + get => listNode.AutoHideScrollBar; + set => listNode.AutoHideScrollBar = value; + } + + public int ScrollSpeed { + get => listNode.ScrollSpeed; + set => listNode.ScrollSpeed = value; + } + + public IReadOnlyList CategoryNodes => listNode.ContentNode.CategoryNodes; + + public void RecalculateLayout() { + listNode.ContentNode.RefreshLayout(); + listNode.ContentHeight = CategoryNodes.Sum(node => node.IsVisible ? node.Height + CategoryVerticalSpacing : 0.0f); + } + + public void AddCategoryNode(TreeListCategoryNode node) => listNode.ContentNode.AddCategoryNode(node); + + public TreeListNode TreeListNode => listNode.ContentNode; +} diff --git a/KamiToolKit/Nodes/Layout/TabbedVerticalListNode.cs b/KamiToolKit/Nodes/Layout/TabbedVerticalListNode.cs new file mode 100644 index 0000000..c0ed8c0 --- /dev/null +++ b/KamiToolKit/Nodes/Layout/TabbedVerticalListNode.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Linq; +using KamiToolKit.Classes; + +namespace KamiToolKit.Nodes; + +public class TabbedVerticalListNode : SimpleComponentNode { + + private readonly List> nodeList = []; + + public float TabSize { get; set; } = 18.0f; + + public float ItemVerticalSpacing { get; set; } + + public bool FitWidth { get; set; } + + public int TabStep { get; set; } + + // Adds tab amount to any following nodes being added + public void AddTab(int tabAmount) { + TabStep += tabAmount; + } + + // Removes tab amount from any following nodes being added + public void SubtractTab(int tabAmount) { + TabStep -= tabAmount; + } + + public void AddNode(NodeBase node) { + AddNode(0, node); + } + + public void AddNode(IEnumerable nodes) { + AddNode(0, nodes); + } + + public void AddNode(int tabIndex, IEnumerable nodes) { + foreach (var node in nodes) { + AddNode(tabIndex, node); + } + } + + public void AddNode(int tabIndex, NodeBase node) { + nodeList.Add(new TabbedNodeEntry(node, tabIndex + TabStep)); + + node.AttachNode(this); + node.NodeId = (uint)nodeList.Count + 1; + + RecalculateLayout(); + } + + public void RemoveNode(params NodeBase[] nodes) { + foreach (var node in nodes) { + RemoveNode(node); + } + } + + public void RemoveNode(NodeBase node) { + var target = nodeList.FirstOrDefault(item => item.Node == node); + if (target is null) return; + + target.Node.DetachNode(); + nodeList.Remove(target); + RecalculateLayout(); + } + + public void Clear() { + foreach (var nodeEntry in nodeList) { + nodeEntry.Node.DetachNode(); + } + + nodeList.Clear(); + RecalculateLayout(); + } + + public void RecalculateLayout() { + var startY = 0.0f; + + foreach (var (node, tab) in nodeList) { + if (!node.IsVisible) continue; + + node.Y = startY; + node.X = tab * TabSize; + + if (FitWidth) { + node.Width = Width - node.X - ItemVerticalSpacing; + + // Also update layout of any contained nodes + if (node is LayoutListNode layoutNode) { + layoutNode.RecalculateLayout(); + } + } + + startY += node.Height + ItemVerticalSpacing; + } + + Height = startY + ItemVerticalSpacing; + } +} diff --git a/KamiToolKit/Nodes/Layout/VerticalListNode.cs b/KamiToolKit/Nodes/Layout/VerticalListNode.cs new file mode 100644 index 0000000..7f5dd6a --- /dev/null +++ b/KamiToolKit/Nodes/Layout/VerticalListNode.cs @@ -0,0 +1,82 @@ +using System.Linq; +using KamiToolKit.Enums; + +namespace KamiToolKit.Nodes; + +public class VerticalListNode : LayoutListNode { + + /// + /// Displays items starting from either the bottom or the top of the list + /// + public VerticalListAnchor Anchor { + get; + set { + field = value; + RecalculateLayout(); + } + } + + /// + /// Displays items either left aligned or right aligned + /// + public VerticalListAlignment Alignment { + get; + set { + field = value; + RecalculateLayout(); + } + } + + /// + /// Resizes this layout node to fit the height of the contained nodes. + /// + public bool FitContents { get; set; } + + /// + /// Resizes nodes that are inserted to be the same width as the content area + /// + public bool FitWidth { get; set; } + + protected override void OnRecalculateLayout() { + var startY = Anchor switch { + VerticalListAnchor.Top => 0.0f + FirstItemSpacing, + VerticalListAnchor.Bottom => Height, + _ => 0.0f, + }; + + foreach (var node in NodeList) { + if (!node.IsVisible) continue; + + if (Anchor is VerticalListAnchor.Bottom) { + startY -= node.Height + ItemSpacing; + } + + node.Y = startY; + + if (FitWidth) { + node.Width = Width; + } + else { + switch (Alignment) { + case VerticalListAlignment.Right: + node.X = Width - node.Width; + break; + + case VerticalListAlignment.Left: + node.X = 0.0f; + break; + } + } + + AdjustNode(node); + + if (Anchor is VerticalListAnchor.Top) { + startY += node.Height + ItemSpacing; + } + } + + if (FitContents) { + Height = NodeList.Sum(node => node.IsVisible ? node.Height + ItemSpacing : 0.0f) + FirstItemSpacing - ItemSpacing; + } + } +} diff --git a/KamiToolKit/Overlay/OverlayController.Addon.cs b/KamiToolKit/Overlay/OverlayController.Addon.cs new file mode 100644 index 0000000..db1200f --- /dev/null +++ b/KamiToolKit/Overlay/OverlayController.Addon.cs @@ -0,0 +1,3 @@ +namespace KamiToolKit.Overlay; + +internal class OverlayAddon : NativeAddon; diff --git a/KamiToolKit/Overlay/OverlayController.Node.cs b/KamiToolKit/Overlay/OverlayController.Node.cs new file mode 100644 index 0000000..a2ab25e --- /dev/null +++ b/KamiToolKit/Overlay/OverlayController.Node.cs @@ -0,0 +1,32 @@ +using FFXIVClientStructs.FFXIV.Client.UI; +using KamiToolKit.Enums; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Overlay; + +public abstract unsafe class OverlayNode : SimpleOverlayNode { + + public abstract OverlayLayer OverlayLayer { get; } + + /// + /// When true, this node will automatically hide when the game hides things like nameplates + /// + public virtual bool HideWithNativeUi => true; + + public override bool IsVisible { get; set; } = true; + + public void Update() { + OnUpdate(); + + base.IsVisible = IsVisible && !(HideWithNativeUi && !IsNameplateVisible()); + } + + protected abstract void OnUpdate(); + + private static bool IsNameplateVisible() { + var nameplateAddon = RaptureAtkUnitManager.Instance()->GetAddonByName("NamePlate"); + if (nameplateAddon is null) return false; + + return nameplateAddon->IsVisible; + } +} diff --git a/KamiToolKit/Overlay/OverlayController.cs b/KamiToolKit/Overlay/OverlayController.cs new file mode 100644 index 0000000..cd7b669 --- /dev/null +++ b/KamiToolKit/Overlay/OverlayController.cs @@ -0,0 +1,228 @@ +using System; +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.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; + +namespace KamiToolKit.Overlay; + +public unsafe class OverlayController : IDisposable { + private readonly Dictionary> overlayNodes = []; + private readonly Dictionary addonState = []; + + private ControllerState controllerState = ControllerState.WaitForNameplate; + + public OverlayController() { + ClearState(); + + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, "NamePlate", OnNamePlatePreFinalize); + + foreach (var overlayLayer in Enum.GetValues()) { + var addonName = overlayLayer.Description; + + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PreUpdate, addonName, OnOverlayAddonUpdate); + DalamudInterface.Instance.AddonLifecycle.RegisterListener(AddonEvent.PreFinalize, addonName, OnOverlayAddonFinalize); + } + + BeginStateCheck(); + } + + public void Dispose() { + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(AddonEvent.PreFinalize, "NamePlate"); + DalamudInterface.Instance.AddonLifecycle.UnregisterListener(OnOverlayAddonFinalize, OnOverlayAddonUpdate); + + foreach (var node in overlayNodes.SelectMany(nodeList => nodeList.Value)) { + node.Dispose(); + } + + overlayNodes.Clear(); + } + + // + // State management (framework thread) + // + + private void ClearState() { + controllerState = ControllerState.WaitForNameplate; + + foreach (var overlayLayer in Enum.GetValues()) { + addonState[overlayLayer] = OverlayAddonState.None; + } + } + + private void BeginStateCheck() { + DalamudInterface.Instance.Framework.Update -= CheckOverlayState; + DalamudInterface.Instance.Framework.Update += CheckOverlayState; + } + + private void CheckOverlayState(IFramework framework) { + switch (controllerState) { + case ControllerState.WaitForNameplate: + CheckNameplateReady(); + break; + + case ControllerState.WaitForReady: + CheckOverlayAddonsReady(); + break; + + case ControllerState.Ready: + DalamudInterface.Instance.Framework.Update -= CheckOverlayState; + break; + } + } + + private void CheckNameplateReady() { + var nameplate = RaptureAtkUnitManager.Instance()->GetAddonByName("NamePlate"); + if (nameplate is null) return; + if (!nameplate->IsReady) return; + + foreach (var overlayLayer in Enum.GetValues()) { + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(overlayLayer.Description); + + if (addon is null) { + if (addonState[overlayLayer] == OverlayAddonState.None) { + addonState[overlayLayer] = OverlayAddonState.WaitForReady; + CreateOverlayAddon(overlayLayer).Open(); + } + } + else { + addonState[overlayLayer] = OverlayAddonState.WaitForReady; + } + } + + controllerState = ControllerState.WaitForReady; + } + + private void CheckOverlayAddonsReady() { + var totalAddons = Enum.GetValues().Length; + var totalAddonsReady = 0; + + foreach (var overlayLayer in Enum.GetValues()) { + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(overlayLayer.Description); + if (addon is null) continue; + if (!addon->IsReady) continue; + + if (addonState[overlayLayer] is OverlayAddonState.WaitForReady) { + AttachAllNodes(overlayLayer); + addonState[overlayLayer] = OverlayAddonState.Ready; + } + totalAddonsReady++; + } + + if (totalAddonsReady == totalAddons) { + controllerState = ControllerState.Ready; + } + } + + private void AttachAllNodes(OverlayLayer layer) { + if (!overlayNodes.TryGetValue(layer, out var list)) return; + + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(layer.Description); + if (addon is null) return; + + foreach (var node in list) { + AttachNode(addon, node); + } + } + + // + // Public node access + // + + public void CreateNode(Func creationFunction) => DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + AddNode(creationFunction()); + }); + + public void AddNode(OverlayNode node) => DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + overlayNodes.TryAdd(node.OverlayLayer, []); + + if (overlayNodes[node.OverlayLayer].Contains(node)) return; + + overlayNodes[node.OverlayLayer].Add(node); + + if (addonState[node.OverlayLayer] is not OverlayAddonState.Ready) return; + + var addon = RaptureAtkUnitManager.Instance()->GetAddonByName(node.OverlayLayer.Description); + if (addon is null) return; + + AttachNode(addon, node); + }); + + public void RemoveNode(OverlayNode node) => DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + if (!overlayNodes.TryGetValue(node.OverlayLayer, out var list)) return; + + if (list.Remove(node)) { + node.Dispose(); + } + }); + + public void RemoveAllNodes() => DalamudInterface.Instance.Framework.RunOnFrameworkThread(() => { + foreach (var node in overlayNodes.SelectMany(set => set.Value).ToList()) { + RemoveNode(node); + } + }); + + // + // Events + // + + private void OnNamePlatePreFinalize(AddonEvent type, AddonArgs args) { + ClearState(); + + foreach (var overlayLayer in Enum.GetValues()) { + if (!overlayNodes.TryGetValue(overlayLayer, out var list)) continue; + + foreach (var node in list) { + node.DetachNode(); + } + } + + BeginStateCheck(); + } + + private void OnOverlayAddonFinalize(AddonEvent type, AddonArgs args) { + var addon = (AtkUnitBase*)args.Addon.Address; + var overlayLayer = addon->DepthLayer.GetOverlayLayer(); + + if (overlayNodes.TryGetValue(overlayLayer, out var list)) { + foreach (var node in list) { + node.DetachNode(); + } + } + + addonState[overlayLayer] = OverlayAddonState.None; + } + + private void OnOverlayAddonUpdate(AddonEvent type, AddonArgs args) { + var addon = (AtkUnitBase*)args.Addon.Address; + var overlayLayer = addon->DepthLayer.GetOverlayLayer(); + + if (addonState[overlayLayer] is not OverlayAddonState.Ready) return; + if (!overlayNodes.TryGetValue(overlayLayer, out var list)) return; + + foreach (var node in list) { + node.Update(); + } + } + + // + // Helpers + // + + private static OverlayAddon CreateOverlayAddon(OverlayLayer layer) => new() { + Title = layer.Description, + InternalName = layer.Description, + DepthLayer = layer.DepthLayer, + IsOverlayAddon = true, + }; + + private static void AttachNode(AtkUnitBase* addon, OverlayNode node) { + node.NodeId = (uint)addon->UldManager.NodeListCount + 1; + node.AttachNode(addon); + } +} diff --git a/KamiToolKit/Premade/Addons/ListConfigAddon.cs b/KamiToolKit/Premade/Addons/ListConfigAddon.cs new file mode 100644 index 0000000..9fa9b75 --- /dev/null +++ b/KamiToolKit/Premade/Addons/ListConfigAddon.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using KamiToolKit.Premade.Nodes; + +namespace KamiToolKit.Premade.Addons; + +public class ListConfigAddon : NativeAddon where T: class where TV : ConfigNode, new() where TU : ListItemNode, new() { + + private ModifyListNode? selectionListNode; + private VerticalLineNode? separatorLine; + private TV? configNode; + private TextNode? nothingSelectedTextNode; + + protected override unsafe void OnSetup(AtkUnitBase* addon) { + selectionListNode = new ModifyListNode { + Position = ContentStartPosition, + Size = new Vector2(250.0f, ContentSize.Y), + SortOptions = SortOptions, + Options = Options, + SelectionChanged = SelectionChanged, + AddNewEntry = OnAddClicked, + RemoveEntry = OnRemoveClicked, + ItemComparer = ItemComparer, + IsSearchMatch = OnSearchUpdated, + ItemSpacing = ItemSpacing, + }; + selectionListNode.AttachNode(this); + + separatorLine = new VerticalLineNode { + Position = ContentStartPosition + new Vector2(250.0f + 8.0f, 0.0f), + Size = new Vector2(4.0f, ContentSize.Y), + }; + separatorLine.AttachNode(this); + + nothingSelectedTextNode = new TextNode { + Position = ContentStartPosition + new Vector2(250.0f + 16.0f, 0.0f), + Size = ContentSize - new Vector2(250.0f + 16.0f, 0.0f), + AlignmentType = AlignmentType.Center, + TextFlags = TextFlags.WordWrap | TextFlags.MultiLine, + FontSize = 14, + LineSpacing = 22, + FontType = FontType.Axis, + String = "Please select an option on the left", + TextColor = ColorHelper.GetColor(1), + }; + nothingSelectedTextNode.AttachNode(this); + + configNode = new TV { + Position = ContentStartPosition + new Vector2(250.0f + 16.0f, 0.0f), + Size = ContentSize - new Vector2(250.0f + 16.0f, 0.0f), + OnConfigChanged = option => EditCompleted?.Invoke(option), + IsVisible = false, + }; + configNode.AttachNode(this); + } + + public required ModifyListNode.ItemCompareDelegate? ItemComparer { + get; + init { + field = value; + selectionListNode?.ItemComparer = value; + } + } + + public required ModifyListNode.IsSearchMatchDelegate? IsSearchMatch { + get; + init { + field = value; + selectionListNode?.IsSearchMatch = value; + } + } + + private void OnAddClicked() { + AddClicked?.Invoke(this); + selectionListNode?.RefreshList(); + } + + private void OnRemoveClicked(T listItem) { + RemoveClicked?.Invoke(this, listItem); + SelectionChanged(null); + selectionListNode?.RefreshList(); + } + + private void SelectionChanged(T? listItem) { + SetConfigNodeItem(listItem); + } + + private bool OnSearchUpdated(T obj, string searchString) { + SelectItem(null); + return IsSearchMatch?.Invoke(obj, searchString) ?? false; + } + + private void SetConfigNodeItem(T? configItem) { + if (configNode is null) return; + if (nothingSelectedTextNode is null) return; + + configNode.ConfigurationOption = configItem; + + configNode.IsVisible = configNode.ConfigurationOption is not null; + nothingSelectedTextNode.IsVisible = configNode.ConfigurationOption is null; + } + + public void RefreshList() + => selectionListNode?.RefreshList(); + + public void SelectItem(T? listItem) + => SelectionChanged(listItem); + + public List? SortOptions { + get; + set { + field = value; + selectionListNode?.SortOptions = value; + } + } = ["Alphabetical", "Id"]; + + public required List Options { get; + set { + field = value; + selectionListNode?.Options = value; + } + } = []; + + public float ItemSpacing { + get; + set { + field = value; + selectionListNode?.ItemSpacing = value; + } + } + + public Action>? AddClicked { + get; + set { + field = value; + selectionListNode?.AddNewEntry = () => { + value?.Invoke(this); + }; + } + } + + public Action, T>? RemoveClicked { + get; + set { + field = value; + selectionListNode?.RemoveEntry = entry => { + value?.Invoke(this, entry); + }; + } + } + + public Action? EditCompleted { get; set; } +} diff --git a/KamiToolKit/Premade/Color/ColorEditNode.cs b/KamiToolKit/Premade/Color/ColorEditNode.cs new file mode 100644 index 0000000..6bbeab3 --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorEditNode.cs @@ -0,0 +1,93 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Premade.Color; + +public class ColorEditNode : SimpleOverlayNode { + + private readonly ColorPreviewNode previewNode; + private readonly TextNode labelNode; + + private ColorPickerAddon? colorPicker = new() { + InternalName = "ColorPicker", + Title = "Color Picker", + }; + + public ColorEditNode() { + DisableCollisionNode = true; + + previewNode = new ColorPreviewNode(); + previewNode.AttachNode(this); + + labelNode = new TextNode { + AlignmentType = AlignmentType.Left, + }; + labelNode.AttachNode(this); + + previewNode.CollisionNode.ShowClickableCursor = true; + previewNode.CollisionNode.AddEvent(AtkEventType.MouseClick, OnClicked); + + labelNode.ShowClickableCursor = true; + labelNode.AddEvent(AtkEventType.MouseClick, OnClicked); + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + base.Dispose(disposing, isNativeDestructor); + + colorPicker?.Dispose(); + colorPicker = null; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + previewNode.Size = new Vector2(Height - 6.0f, Height - 6.0f); + previewNode.Position = new Vector2(3.0f, 3.0f); + + labelNode.Size = new Vector2(Width - Height - 12.0f, Height); + labelNode.Position = new Vector2(previewNode.Bounds.Right + 12.0f, 0.0f); + } + + private void OnClicked() { + var originalColor = CurrentColor; + colorPicker?.DefaultColor = DefaultColor; + colorPicker?.InitialColor = CurrentColor; + + colorPicker?.OnColorPreviewed = color => { + previewNode.Color = color; + CurrentColor = color; + OnColorPreviewed?.Invoke(color); + }; + + colorPicker?.OnColorCancelled = () => { + CurrentColor = originalColor; + OnColorCancelled?.Invoke(); + }; + + colorPicker?.OnColorConfirmed = color => { + CurrentColor = color; + OnColorConfirmed?.Invoke(color); + }; + + colorPicker?.Toggle(); + } + + public Vector4 CurrentColor { + get => previewNode.Color; + set => previewNode.Color = value; + } + + public ReadOnlySeString String { + get => labelNode.String; + set => labelNode.String = value; + } + + public Vector4? DefaultColor { get; set; } + + public Action? OnColorCancelled { get; set; } + public Action? OnColorPreviewed { get; set; } + public Action? OnColorConfirmed { get; set; } +} diff --git a/KamiToolKit/Premade/Color/ColorPickerAddon.cs b/KamiToolKit/Premade/Color/ColorPickerAddon.cs new file mode 100644 index 0000000..4044717 --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorPickerAddon.cs @@ -0,0 +1,135 @@ +using System; +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Color; + +public class ColorPickerAddon : NativeAddon { + private ColorPickerWidget? colorPicker; + private HorizontalLineNode? horizontalLine; + private TextButtonNode? confirmButton; + private ColorOptionTextButtonNode? defaultColorPreview; + private TextButtonNode? cancelButton; + + private bool isCancelClicked; + + private Vector4 initialRgba; + private ColorHelpers.HsvaColor initialHsva; + + protected override unsafe void OnSetup(AtkUnitBase* addon) { + SetWindowSize(new Vector2(400.0f, 425.0f)); + + initialHsva = InitialHsvaColor; + initialRgba = ColorHelpers.HsvToRgb(initialHsva); + + colorPicker = new ColorPickerWidget { + Position = ContentStartPosition, + Size = ContentSize, + }; + colorPicker.AttachNode(this); + + colorPicker.ColorPreviewed += hsva => OnHsvaColorPreviewed?.Invoke(hsva); + colorPicker.RgbaColorPreviewed += rgba => OnColorPreviewed?.Invoke(rgba); + + colorPicker.SetColor(initialRgba); + + horizontalLine = new HorizontalLineNode { + Position = ContentStartPosition + new Vector2(2.0f, ContentSize.Y - 40.0f), + Size = new Vector2(ContentSize.X - 4.0f, 2.0f), + }; + horizontalLine.AttachNode(this); + + confirmButton = new TextButtonNode { + Position = ContentStartPosition + new Vector2(0.0f, ContentSize.Y - 24.0f), + Size = new Vector2(100.0f, 24.0f), + String = "Confirm", + OnClick = OnConfirmClicked, + }; + confirmButton.AttachNode(this); + + if (DefaultHsvaColor is { } defaultColor) { + defaultColorPreview = new ColorOptionTextButtonNode { + Size = new Vector2(100.0f, 24.0f), + Position = ContentStartPosition + new Vector2(ContentSize.X / 2.0f - 50.0f, ContentSize.Y - 24.0f), + String = "Default", + OnClick = OnDefaultClicked, + DefaultHsvaColor = defaultColor, + }; + defaultColorPreview.AttachNode(this); + } + + cancelButton = new TextButtonNode { + Position = ContentStartPosition + new Vector2(ContentSize.X - 100.0f, ContentSize.Y - 24.0f), + Size = new Vector2(100.0f, 24.0f), + String = "Cancel", + OnClick = OnCancelClicked, + }; + cancelButton.AttachNode(this); + } + + protected override unsafe void OnHide(AtkUnitBase* addon) { + if (!isCancelClicked) { + OnHsvaColorPreviewed?.Invoke(initialHsva); + OnColorPreviewed?.Invoke(initialRgba); + + OnColorCancelled?.Invoke(); + } + } + + private void OnConfirmClicked() { + if (colorPicker is null) return; + + var rgba = ColorHelpers.HsvToRgb(colorPicker.CurrentColor); + OnColorConfirmed?.Invoke(rgba); + OnHsvaColorConfirmed?.Invoke(colorPicker.CurrentColor); + + isCancelClicked = true; + + Close(); + } + + private void OnDefaultClicked() { + if (colorPicker is null) return; + + if (DefaultHsvaColor is { } defaultColor) { + colorPicker.SetColor(defaultColor); + } + } + + private void OnCancelClicked() { + isCancelClicked = true; + + OnHsvaColorPreviewed?.Invoke(initialHsva); + OnColorPreviewed?.Invoke(initialRgba); + + OnColorCancelled?.Invoke(); + Close(); + } + + public Action? OnColorPreviewed { get; set; } + public Action? OnHsvaColorPreviewed { get; set; } + + public Action? OnColorConfirmed { get; set; } + public Action? OnHsvaColorConfirmed { get; set; } + public Action? OnColorCancelled { get; set; } + + public ColorHelpers.HsvaColor? DefaultHsvaColor { get; set; } + + public Vector4? DefaultColor { + get; + set { + field = value; + DefaultHsvaColor = value is null ? null : ColorHelpers.RgbaToHsv(value.Value); + } + } + + public Vector4 InitialColor { + get => ColorHelpers.HsvToRgb(InitialHsvaColor); + set => InitialHsvaColor = ColorHelpers.RgbaToHsv(value); + } + + public ColorHelpers.HsvaColor InitialHsvaColor { get; set; } = + ColorHelpers.RgbaToHsv(new Vector4(1.0f, 0.0f, 0.0f, 1.0f)); +} diff --git a/KamiToolKit/Premade/Color/ColorPickerWidget.cs b/KamiToolKit/Premade/Color/ColorPickerWidget.cs new file mode 100644 index 0000000..d2857c5 --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorPickerWidget.cs @@ -0,0 +1,173 @@ +using System; +using System.Numerics; +using Dalamud.Interface; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using KamiToolKit.Premade.Nodes; + +namespace KamiToolKit.Premade.Color; + +public class ColorPickerWidget : SimpleComponentNode { + public readonly ColorRingWithSquareNode ColorPickerNode; + public readonly AlphaBarNode AlphaBarNode; + public readonly ColorPreviewWithInput ColorPreviewWithInput; + + public ColorHelpers.HsvaColor CurrentColor { get; private set; } + + public Action? ColorPreviewed; + public Action? RgbaColorPreviewed; + + private int batchDepth; + private bool previewDirty; + + public ColorPickerWidget() { + ColorPickerNode = new ColorRingWithSquareNode { + OnHueChanged = SetHue, + OnSaturationChanged = SetSaturation, + OnValueChanged = SetValue, + }; + ColorPickerNode.AttachNode(this); + + AlphaBarNode = new AlphaBarNode { + OnAlphaChanged = SetAlpha, + }; + AlphaBarNode.AttachNode(this); + + ColorPreviewWithInput = new ColorPreviewWithInput { + OnHsvaColorChanged = newColor => { + using (BeginBatchUpdate()) { + SetHue(newColor.H); + SetSaturation(newColor.S); + SetValue(newColor.V); + SetAlpha(newColor.A); + } + }, + }; + ColorPreviewWithInput.AttachNode(this); + + CurrentColor = ColorHelpers.RgbaToHsv(new Vector4(1.0f, 0.0f, 0.0f, 1.0f)); + + using (BeginBatchUpdate()) { + SetHue(CurrentColor.H); + SetSaturation(CurrentColor.S); + SetValue(CurrentColor.V); + SetAlpha(CurrentColor.A); + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + var mainWidgetWidth = Width * 3.0f / 4.0f; + + ColorPickerNode.Size = new Vector2(mainWidgetWidth, mainWidgetWidth); + + AlphaBarNode.Size = new Vector2(Width / 16.0f, mainWidgetWidth - 60.0f); + AlphaBarNode.Position = new Vector2(mainWidgetWidth + (Width - mainWidgetWidth) / 3.0f - AlphaBarNode.Width / 2.0f, 30.0f); + + ColorPreviewWithInput.Size = new Vector2(150.0f, 32.0f); + ColorPreviewWithInput.Position = new Vector2(Width / 2.0f - 75.0f, ColorPickerNode.Y + ColorPickerNode.Height - 1.0f); + } + + private IDisposable BeginBatchUpdate() { + batchDepth++; + return new BatchToken(this); + } + + internal void EndBatchUpdate() { + batchDepth--; + if (batchDepth <= 0) { + batchDepth = 0; + + if (previewDirty) { + previewDirty = false; + RaisePreview(); + } + } + } + + private void RaisePreviewMaybe() { + if (batchDepth > 0) { + previewDirty = true; + return; + } + + RaisePreview(); + } + + private void RaisePreview() { + var hsva = CurrentColor; + ColorPreviewed?.Invoke(hsva); + RgbaColorPreviewed?.Invoke(ColorHelpers.HsvToRgb(hsva)); + } + + public void SetAlpha(float alpha) { + CurrentColor = CurrentColor with { A = alpha }; + + ColorPreviewWithInput.ColorHsva = CurrentColor; + AlphaBarNode.ColorHsva = CurrentColor; + + RaisePreviewMaybe(); + } + + public void SetHue(float hue) { + CurrentColor = CurrentColor with { H = hue }; + + ColorPickerNode.RotationDegrees = hue * 360.0f; + ColorPickerNode.SelectorColor = CurrentColor; + ColorPickerNode.SquareColor = CurrentColor with { S = 1.0f, V = 1.0f }; + + ColorPreviewWithInput.ColorHsva = CurrentColor; + AlphaBarNode.ColorHsva = CurrentColor; + + RaisePreviewMaybe(); + } + + public void SetSaturation(float saturation) { + CurrentColor = CurrentColor with { S = saturation }; + + ColorPreviewWithInput.ColorHsva = CurrentColor; + ColorPickerNode.SelectorColor = CurrentColor; + + ColorPickerNode.SquareColor = CurrentColor; + ColorPickerNode.SquareSaturationValue = CurrentColor; + + AlphaBarNode.ColorHsva = CurrentColor; + + RaisePreviewMaybe(); + } + + public void SetValue(float value) { + CurrentColor = CurrentColor with { V = value }; + + ColorPreviewWithInput.ColorHsva = CurrentColor; + ColorPickerNode.SelectorColor = CurrentColor; + + ColorPickerNode.SquareColor = CurrentColor; + ColorPickerNode.SquareSaturationValue = CurrentColor; + + AlphaBarNode.ColorHsva = CurrentColor; + + RaisePreviewMaybe(); + } + + public void SetColor(Vector4 color) { + var converted = ColorHelpers.RgbaToHsv(color); + + using (BeginBatchUpdate()) { + SetHue(converted.H); + SetSaturation(converted.S); + SetValue(converted.V); + SetAlpha(converted.A); + } + } + + public void SetColor(ColorHelpers.HsvaColor color) { + using (BeginBatchUpdate()) { + SetHue(color.H); + SetSaturation(color.S); + SetValue(color.V); + SetAlpha(color.A); + } + } +} diff --git a/KamiToolKit/Premade/Color/ColorPreviewNode.cs b/KamiToolKit/Premade/Color/ColorPreviewNode.cs new file mode 100644 index 0000000..c3c275b --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorPreviewNode.cs @@ -0,0 +1,55 @@ +using System.Drawing; +using System.Numerics; +using Dalamud.Interface; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Color; + +public class ColorPreviewNode : SimpleComponentNode { + public readonly BackgroundImageNode SelectedColorPreviewNode; + public readonly ImGuiImageNode AlphaLayerPreviewNode; + public readonly BackgroundImageNode SelectedColorPreviewBorderNode; + + public ColorPreviewNode() { + SelectedColorPreviewBorderNode = new BackgroundImageNode { + Color = KnownColor.White.Vector(), + }; + SelectedColorPreviewBorderNode.AttachNode(this); + + AlphaLayerPreviewNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("alpha_background.png"), + WrapMode = WrapMode.Tile, + }; + AlphaLayerPreviewNode.AttachNode(this); + + SelectedColorPreviewNode = new BackgroundImageNode { + Color = new Vector4(1.0f, 0.0f, 0.0f, 1.0f), + }; + SelectedColorPreviewNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + SelectedColorPreviewBorderNode.Size = new Vector2(Height - 4.0f, Width - 4.0f); + SelectedColorPreviewBorderNode.Position = new Vector2(2.0f, 2.0f); + + AlphaLayerPreviewNode.Size = new Vector2(Height - 6.0f, Width - 6.0f); + AlphaLayerPreviewNode.Position = new Vector2(3.0f, 3.0f); + + SelectedColorPreviewNode.Size = new Vector2(Height - 6.0f, Width - 6.0f); + SelectedColorPreviewNode.Position = new Vector2(3.0f, 3.0f); + } + + public override Vector4 Color { + get => SelectedColorPreviewNode.Color; + set => SelectedColorPreviewNode.Color = value; + } + + public override ColorHelpers.HsvaColor ColorHsva { + get => SelectedColorPreviewNode.ColorHsva; + set => SelectedColorPreviewNode.ColorHsva = value; + } +} diff --git a/KamiToolKit/Premade/Color/ColorPreviewWithInput.cs b/KamiToolKit/Premade/Color/ColorPreviewWithInput.cs new file mode 100644 index 0000000..210e64f --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorPreviewWithInput.cs @@ -0,0 +1,88 @@ +using System; +using System.Globalization; +using System.Numerics; +using Dalamud.Interface; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Premade.Color; + +public class ColorPreviewWithInput : SimpleComponentNode { + public readonly ColorPreviewNode ColorPreviewNode; + public readonly TextInputNode ColorInputNode; + + public ColorPreviewWithInput() { + ColorPreviewNode = new ColorPreviewNode(); + ColorPreviewNode.AttachNode(this); + + ColorInputNode = new TextInputNode { + AutoSelectAll = true, + OnInputComplete = OnTextInputComplete, + }; + ColorInputNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ColorPreviewNode.Size = new Vector2(Height, Height); + ColorPreviewNode.Position = Vector2.Zero; + + ColorInputNode.Size = new Vector2(Width - Height - 8.0f, Height - 2.0f); + ColorInputNode.Position = new Vector2(Height + 8.0f, 1.0f); + } + + public Action? OnHsvaColorChanged { get; set; } + public Action? OnColorChanged { get; set; } + + public ReadOnlySeString String { + get => ColorInputNode.String; + set => ColorInputNode.String = value; + } + + public override Vector4 Color { + get => ColorPreviewNode.Color; + set { + ColorPreviewNode.Color = value; + UpdateColorText(); + } + } + + public override ColorHelpers.HsvaColor ColorHsva { + get => ColorPreviewNode.ColorHsva; + set { + ColorPreviewNode.ColorHsva = value; + UpdateColorText(); + } + } + + private void OnTextInputComplete(ReadOnlySeString obj) { + var str = obj.ToString(); + + if (string.IsNullOrEmpty(str) || !str.StartsWith('#')) return; + + var hexString = str.TrimStart('#'); + + // Allow #RRGGBB and #RRGGBBAA only + if (hexString.Length != 6 && hexString.Length != 8) return; + + const NumberStyles style = NumberStyles.HexNumber; + var culture = CultureInfo.InvariantCulture; + + if (!byte.TryParse(hexString[0..2], style, culture, out var r)) return; + if (!byte.TryParse(hexString[2..4], style, culture, out var g)) return; + if (!byte.TryParse(hexString[4..6], style, culture, out var b)) return; + + byte a = 255; + if (hexString.Length == 8 && !byte.TryParse(hexString[6..8], style, culture, out a)) return; + + var newColor = new Vector4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f); + + Color = newColor; + OnColorChanged?.Invoke(newColor); + OnHsvaColorChanged?.Invoke(ColorHelpers.RgbaToHsv(newColor)); + } + + private void UpdateColorText() + => ColorInputNode.String = $"#{(int)(Color.X * 255):X2}{(int)(Color.Y * 255):X2}{(int)(Color.Z * 255):X2}{(int)(Color.W * 255):X2}"; +} diff --git a/KamiToolKit/Premade/Color/ColorRingWithSquareNode.cs b/KamiToolKit/Premade/Color/ColorRingWithSquareNode.cs new file mode 100644 index 0000000..9f31a1a --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorRingWithSquareNode.cs @@ -0,0 +1,201 @@ +using System; +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Enums; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Color; + +public unsafe class ColorRingWithSquareNode : SimpleComponentNode { + public readonly ColorSquareNode ColorSquareNode; + public readonly ImGuiImageNode ColorRingNode; + public readonly ImGuiImageNode ColorRingSelectorNode; + + private bool isRingDrag; + private bool isSquareDrag; + + private readonly ViewportEventListener eventListener; + + public ColorRingWithSquareNode() { + eventListener = new ViewportEventListener(SquareEventHandler); + + ColorSquareNode = new ColorSquareNode { + DrawFlags = DrawFlags.UseTransformedCollision, + }; + ColorSquareNode.AttachNode(this); + + ColorRingNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("color_ring.png"), + FitTexture = true, + ImageNodeFlags = ImageNodeFlags.FlipV, + }; + ColorRingNode.AttachNode(this); + + ColorRingSelectorNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("color_ring_selector.png"), + FitTexture = true, + MultiplyColor = new Vector3(1.0f, 0.0f, 0.0f), + }; + ColorRingSelectorNode.AttachNode(this); + + AddEvent(AtkEventType.MouseDown, OnMouseDown); + AddEvent(AtkEventType.MouseUp, OnMouseUp); + AddEvent(AtkEventType.MouseMove, OnMouseMove); + AddEvent(AtkEventType.MouseOut, OnMouseOut); + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + base.Dispose(disposing, isNativeDestructor); + eventListener.Dispose(); + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + ColorSquareNode.Size = Size / 2.0f - new Vector2(24.0f, 24.0f); + ColorSquareNode.Position = Size / 4.0f + new Vector2(12.0f, 12.0f); + ColorSquareNode.RotationDegrees = 45.0f; + + ColorRingNode.Size = Size; + + ColorRingSelectorNode.Size = Size; + ColorRingSelectorNode.Origin = Size / 2.0f; + } + + private bool IsRingClicked(AtkEventData* data) { + var clickPosition = data->MousePosition; + var scale = ParentAddon is not null ? ParentAddon->Scale : 1.0f; + var center = ColorRingNode.ScreenPosition + ColorRingNode.Size * scale / 2.0f; + var distance = Vector2.Distance(clickPosition, center); + var scaledDistance = distance / (Width * scale / 256.0f); + + return scaledDistance is >= 82.0f and <= 99.0f; + } + + private float GetRingClickAngle(AtkEventData* data) { + var clickPosition = data->MousePosition; + var scale = ParentAddon is not null ? ParentAddon->Scale : 1.0f; + var center = ColorRingNode.ScreenPosition + ColorRingNode.Size * scale / 2.0f; + var relativePosition = clickPosition - center; + var calculatedAngle = MathF.Atan2(relativePosition.Y, relativePosition.X) * 180.0f / MathF.PI; + + return calculatedAngle; + } + + private void OnMouseDown(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + if (ColorSquareNode.CheckCollision(atkEventData)) { + UpdateSquareColor(atkEventData->MousePosition); + + if (!isSquareDrag) { + isSquareDrag = true; + eventListener.AddEvent(AtkEventType.MouseMove, ColorSquareNode); + eventListener.AddEvent(AtkEventType.MouseUp, ColorSquareNode); + } + } + + if (IsRingClicked(atkEventData)) { + isRingDrag = true; + UpdateRingColor(atkEventData); + } + } + + private void OnMouseMove(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + if (isRingDrag && !isSquareDrag) { + UpdateRingColor(atkEventData); + } + } + + private void OnMouseUp() { + isRingDrag = false; + isSquareDrag = false; + } + + private void OnMouseOut() { + isRingDrag = false; + isSquareDrag = false; + } + + private void UpdateRingColor(AtkEventData* data) { + var angle = GetRingClickAngle(data); + + if (angle < 0) { + angle += 360.0f; + } + + OnHueChanged?.Invoke(angle / 360.0f); + } + + private void UpdateSquareColor(Vector2 clickPosition) { + // Note: ColorSquareNode.ScreenPosition changes as the node rotates + // However, Position does not change + var scale = ParentAddon is not null ? ParentAddon->Scale : 1.0f; + var center = ScreenPosition + (ColorSquareNode.Position + ColorSquareNode.Origin) * scale; + + var relativePosition = clickPosition - center; + var rotatedPoint = RotatePoint(relativePosition / scale, Vector2.Zero, -ColorSquareNode.RotationDegrees) / ColorSquareNode.Scale; + + var xClamped = Math.Clamp(rotatedPoint.X, -ColorSquareNode.Width / 2, ColorSquareNode.Width / 2); + var yClamped = Math.Clamp(rotatedPoint.Y, -ColorSquareNode.Height / 2, ColorSquareNode.Height / 2); + + ColorSquareNode.ColorDotPosition = new Vector2(xClamped, yClamped) + ColorSquareNode.Origin; + + var saturation = ColorSquareNode.ColorDotPosition.X / ColorSquareNode.Width; + var lightness = 1 - ColorSquareNode.ColorDotPosition.Y / ColorSquareNode.Height; + + OnSaturationChanged?.Invoke(saturation); + OnValueChanged?.Invoke(lightness); + } + + private void SquareEventHandler(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + if (eventType is AtkEventType.MouseMove && isSquareDrag && !isRingDrag) { + UpdateSquareColor(new Vector2(atkEventData->MouseData.PosX, atkEventData->MouseData.PosY)); + } + + if (eventType is AtkEventType.MouseUp) { + isSquareDrag = false; + eventListener.RemoveEvent(AtkEventType.MouseMove); + eventListener.RemoveEvent(AtkEventType.MouseUp); + } + } + + private static Vector2 RotatePoint(Vector2 pointToRotate, Vector2 centerPoint, float angleInDegrees) { + var angleInRadians = angleInDegrees * (MathF.PI / 180); + var cosTheta = MathF.Cos(angleInRadians); + var sinTheta = MathF.Sin(angleInRadians); + return new Vector2 { + X = cosTheta * (pointToRotate.X - centerPoint.X) - sinTheta * (pointToRotate.Y - centerPoint.Y) + centerPoint.X, + Y = sinTheta * (pointToRotate.X - centerPoint.X) + cosTheta * (pointToRotate.Y - centerPoint.Y) + centerPoint.Y, + }; + } + + public Action? OnHueChanged { get; init; } + public Action? OnSaturationChanged { get; init; } + public Action? OnValueChanged { get; init; } + + public override float RotationDegrees { + get => ColorSquareNode.RotationDegrees; + set { + ColorSquareNode.RotationDegrees = value + 45.0f; + ColorRingSelectorNode.RotationDegrees = value; + } + } + + public ColorHelpers.HsvaColor SelectorColor { + get => ColorRingSelectorNode.MultiplyColorHsva; + set => ColorRingSelectorNode.MultiplyColorHsva = value; + } + + public ColorHelpers.HsvaColor SquareColor { + get => ColorSquareNode.MultiplyColorHsva; + set => ColorSquareNode.MultiplyColorHsva = value with { S = 1.0f, V = 1.0f }; + } + + public ColorHelpers.HsvaColor SquareSaturationValue { + get => ColorSquareNode.MultiplyColorHsva; + set => ColorSquareNode.ColorDotPosition = new Vector2(ColorSquareNode.Width * value.S, ColorSquareNode.Height - ColorSquareNode.Height * value.V); + } +} diff --git a/KamiToolKit/Premade/Color/ColorSquareNode.cs b/KamiToolKit/Premade/Color/ColorSquareNode.cs new file mode 100644 index 0000000..b76f3d9 --- /dev/null +++ b/KamiToolKit/Premade/Color/ColorSquareNode.cs @@ -0,0 +1,65 @@ +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Color; + +public class ColorSquareNode : SimpleComponentNode { + public readonly ImGuiImageNode WhiteGradientNode; + public readonly ImGuiImageNode ColorGradientNode; + public readonly ImGuiImageNode BlackGradientNode; + public readonly ImGuiImageNode ColorDotNode; + + public ColorSquareNode() { + WhiteGradientNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("HorizontalGradient_WhiteToAlpha.png"), + FitTexture = true, + }; + WhiteGradientNode.AttachNode(this); + + ColorGradientNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("HorizontalGradient_WhiteToAlpha.png"), + FitTexture = true, + ImageNodeFlags = ImageNodeFlags.FlipH, + }; + ColorGradientNode.AttachNode(this); + + BlackGradientNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("VerticalGradient_AlphaToBlack.png"), + FitTexture = true, + }; + BlackGradientNode.AttachNode(this); + + ColorDotNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("color_select_dot.png"), + FitTexture = true, + }; + ColorDotNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + WhiteGradientNode.Size = Size; + ColorGradientNode.Size = Size; + BlackGradientNode.Size = Size; + + Origin = Size / 2.0f; + + ColorDotNode.Size = new Vector2(16.0f, 16.0f); + ColorDotNode.Origin = ColorDotNode.Size / 2.0f; + ColorDotNode.Position = new Vector2(Width, 0.0f) - ColorDotNode.Origin; + } + + public override ColorHelpers.HsvaColor MultiplyColorHsva { + get => ColorGradientNode.MultiplyColorHsva; + set => ColorGradientNode.MultiplyColorHsva = value; + } + + public Vector2 ColorDotPosition { + get => ColorDotNode.Position + ColorDotNode.Origin; + set => ColorDotNode.Position = value - ColorDotNode.Origin; + } +} diff --git a/KamiToolKit/Premade/GenericListItemNodes/GenericListItemNode.cs b/KamiToolKit/Premade/GenericListItemNodes/GenericListItemNode.cs new file mode 100644 index 0000000..81937a1 --- /dev/null +++ b/KamiToolKit/Premade/GenericListItemNodes/GenericListItemNode.cs @@ -0,0 +1,81 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.GenericListItemNodes; + +public abstract class GenericListItemNode : ListItemNode { + public override float ItemHeight => 48.0f; + + protected readonly IconImageNode IconNode; + protected readonly TextNode LabelTextNode; + protected readonly TextNode SubLabelTextNode; + protected readonly TextNode IdTextNode; + + protected GenericListItemNode() { + IconNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + IconNode.AttachNode(this); + + LabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.BottomLeft, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + LabelTextNode.AttachNode(this); + + SubLabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 12, + LineSpacing = 12, + AlignmentType = AlignmentType.TopLeft, + TextColor = ColorHelper.GetColor(3), + TextOutlineColor = ColorHelper.GetColor(7), + }; + SubLabelTextNode.AttachNode(this); + + IdTextNode = new TextNode { + TextFlags = TextFlags.Emboss, + FontSize = 10, + AlignmentType = AlignmentType.BottomRight, + TextColor = ColorHelper.GetColor(3), + }; + IdTextNode.AttachNode(this); + + CollisionNode.ShowClickableCursor = true; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + IconNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + IconNode.Position = new Vector2(2.0f, 2.0f); + + LabelTextNode.Size = new Vector2(Width - Height - 2.0f - 30.0f, Height / 2.0f); + LabelTextNode.Position = new Vector2(Height + 2.0f, 0.0f); + + SubLabelTextNode.Size = new Vector2(Width - Height - 2.0f - 10.0f, Height / 2.0f); + SubLabelTextNode.Position = new Vector2(Height + 2.0f + 10.0f, Height / 2.0f); + + IdTextNode.Size = new Vector2(30.0f, Height / 2.0f); + IdTextNode.Position = new Vector2(Width - 30.0f, 0.0f); + } + + protected override void SetNodeData(T itemData) { + IconNode.IconId = GetIconId(itemData); + LabelTextNode.String = GetLabelText(itemData); + SubLabelTextNode.String = GetSubLabelText(itemData); + IdTextNode.String = GetId(itemData).ToString() ?? string.Empty; + } + + protected abstract uint GetIconId(T data); + protected abstract string GetLabelText(T data); + protected abstract string GetSubLabelText(T data); + protected abstract uint? GetId(T data); +} diff --git a/KamiToolKit/Premade/GenericListItemNodes/GenericSimpleListItemNode.cs b/KamiToolKit/Premade/GenericListItemNodes/GenericSimpleListItemNode.cs new file mode 100644 index 0000000..3fc02a6 --- /dev/null +++ b/KamiToolKit/Premade/GenericListItemNodes/GenericSimpleListItemNode.cs @@ -0,0 +1,43 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.GenericListItemNodes; + +public abstract class GenericSimpleListItemNode : ListItemNode { + public override float ItemHeight => 48.0f; + + protected readonly IconImageNode IconNode; + protected readonly TextNode LabelTextNode; + + protected GenericSimpleListItemNode() { + IconNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + IconNode.AttachNode(this); + + LabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + LabelTextNode.AttachNode(this); + + CollisionNode.ShowClickableCursor = true; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + IconNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + IconNode.Position = new Vector2(2.0f, 2.0f); + + LabelTextNode.Size = new Vector2(Width - IconNode.Width - 6.0f, Height); + LabelTextNode.Position = new Vector2(IconNode.Bounds.Right + 6.0f, 0.0f); + } +} diff --git a/KamiToolKit/Premade/GenericListItemNodes/GenericStringListItemNode.cs b/KamiToolKit/Premade/GenericListItemNodes/GenericStringListItemNode.cs new file mode 100644 index 0000000..2be89ac --- /dev/null +++ b/KamiToolKit/Premade/GenericListItemNodes/GenericStringListItemNode.cs @@ -0,0 +1,31 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.GenericListItemNodes; + +public abstract class GenericStringListItemNode : ListItemNode { + public override float ItemHeight => 24.0f; + + protected readonly TextNode StringNode; + + protected GenericStringListItemNode() { + StringNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + StringNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + StringNode.Size = new Vector2(Width - 20.0f, Height); + StringNode.Position = new Vector2(10.0f, 0.0f); + } +} diff --git a/KamiToolKit/Premade/ListItemNodes/AddonListItemNode.cs b/KamiToolKit/Premade/ListItemNodes/AddonListItemNode.cs new file mode 100644 index 0000000..48a3515 --- /dev/null +++ b/KamiToolKit/Premade/ListItemNodes/AddonListItemNode.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.Interop; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.ListItemNodes; + +public unsafe class AddonListItemNode : ListItemNode> { + public override float ItemHeight => 48.0f; + + protected readonly IconImageNode IconNode; + protected readonly TextNode LabelTextNode; + + public AddonListItemNode() { + IconNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + IconNode.AttachNode(this); + + LabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + LabelTextNode.AttachNode(this); + + CollisionNode.ShowClickableCursor = true; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + IconNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + IconNode.Position = new Vector2(2.0f, 2.0f); + + LabelTextNode.Size = new Vector2(Width - IconNode.Width - 6.0f, Height); + LabelTextNode.Position = new Vector2(IconNode.Bounds.Right + 6.0f, 0.0f); + } + + protected override void SetNodeData(Pointer itemData) { + if (itemData.Value is null) return; + + IconNode.IconId = itemData.Value->IsVisible ? (uint) 60071 : 60072; + LabelTextNode.String = itemData.Value->NameString; + } +} diff --git a/KamiToolKit/Premade/ListItemNodes/CurrencyListItemNode.cs b/KamiToolKit/Premade/ListItemNodes/CurrencyListItemNode.cs new file mode 100644 index 0000000..7052585 --- /dev/null +++ b/KamiToolKit/Premade/ListItemNodes/CurrencyListItemNode.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.ListItemNodes; + +public class CurrencyListItemNode : ListItemNode { + public override float ItemHeight => 48.0f; + + protected readonly IconImageNode IconNode; + protected readonly TextNode LabelTextNode; + + public CurrencyListItemNode() { + IconNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + IconNode.AttachNode(this); + + LabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + LabelTextNode.AttachNode(this); + + CollisionNode.ShowClickableCursor = true; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + IconNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + IconNode.Position = new Vector2(2.0f, 2.0f); + + LabelTextNode.Size = new Vector2(Width - IconNode.Width - 6.0f, Height); + LabelTextNode.Position = new Vector2(IconNode.Bounds.Right + 6.0f, 0.0f); + } + + protected override void SetNodeData(Item itemData) { + if (itemData.RowId is 0) return; + + IconNode.IconId = itemData.Icon; + LabelTextNode.String = itemData.Name.ToString(); + } +} diff --git a/KamiToolKit/Premade/ListItemNodes/ItemListItemNode.cs b/KamiToolKit/Premade/ListItemNodes/ItemListItemNode.cs new file mode 100644 index 0000000..ab74421 --- /dev/null +++ b/KamiToolKit/Premade/ListItemNodes/ItemListItemNode.cs @@ -0,0 +1,78 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.ListItemNodes; + +public class ItemListItemNode : ListItemNode { + public override float ItemHeight => 32.0f; + + protected readonly IconImageNode IconNode; + protected readonly TextNode LabelTextNode; + protected readonly TextNode SubLabelTextNode; + protected readonly TextNode IdTextNode; + + public ItemListItemNode() { + IconNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + IconNode.AttachNode(this); + + LabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.BottomLeft, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + LabelTextNode.AttachNode(this); + + SubLabelTextNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 12, + LineSpacing = 12, + AlignmentType = AlignmentType.TopLeft, + TextColor = ColorHelper.GetColor(3), + TextOutlineColor = ColorHelper.GetColor(7), + }; + SubLabelTextNode.AttachNode(this); + + IdTextNode = new TextNode { + TextFlags = TextFlags.Emboss, + FontSize = 10, + AlignmentType = AlignmentType.BottomRight, + TextColor = ColorHelper.GetColor(3), + }; + IdTextNode.AttachNode(this); + + CollisionNode.ShowClickableCursor = true; + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + IconNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + IconNode.Position = new Vector2(2.0f, 2.0f); + + LabelTextNode.Size = new Vector2(Width - Height - 2.0f - 30.0f, Height / 2.0f); + LabelTextNode.Position = new Vector2(Height + 2.0f, 0.0f); + + SubLabelTextNode.Size = new Vector2(Width - Height - 2.0f - 10.0f, Height / 2.0f); + SubLabelTextNode.Position = new Vector2(Height + 2.0f + 10.0f, Height / 2.0f); + + IdTextNode.Size = new Vector2(30.0f, Height / 2.0f); + IdTextNode.Position = new Vector2(Width - 30.0f, 0.0f); + } + + protected override void SetNodeData(Item itemData) { + if (itemData.RowId is 0) return; + + IconNode.IconId = itemData.Icon; + LabelTextNode.String = itemData.Name.ToString(); + SubLabelTextNode.String = itemData.ItemSearchCategory.ValueNullable?.Name.ToString() ?? string.Empty; + } +} diff --git a/KamiToolKit/Premade/ListItemNodes/StatusListItemNode.cs b/KamiToolKit/Premade/ListItemNodes/StatusListItemNode.cs new file mode 100644 index 0000000..b53a621 --- /dev/null +++ b/KamiToolKit/Premade/ListItemNodes/StatusListItemNode.cs @@ -0,0 +1,47 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.ListItemNodes; + +public class StatusListItemNode : ListItemNode { + public override float ItemHeight => 48.0f; + + protected readonly IconImageNode IconImageNode; + protected readonly TextNode StatusLabelNode; + + public StatusListItemNode() { + IconImageNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + IconImageNode.AttachNode(this); + + StatusLabelNode = new TextNode { + TextFlags = TextFlags.Ellipsis | TextFlags.Emboss, + FontSize = 14, + LineSpacing = 14, + AlignmentType = AlignmentType.Left, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + }; + StatusLabelNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + IconImageNode.Size = new Vector2((Height - 4.0f) * 0.75f , Height - 4.0f); + IconImageNode.Position = new Vector2(2.0f, 2.0f); + + StatusLabelNode.Size = new Vector2(Width - IconImageNode.Width - 6.0f, Height); + StatusLabelNode.Position = new Vector2(IconImageNode.Bounds.Right + 6.0f, 0.0f); + } + + protected override void SetNodeData(Status itemData) { + IconImageNode.IconId = itemData.Icon; + StatusLabelNode.String = itemData.Name; + } +} diff --git a/KamiToolKit/Premade/ListItemNodes/StringListItemNode.cs b/KamiToolKit/Premade/ListItemNodes/StringListItemNode.cs new file mode 100644 index 0000000..7e755be --- /dev/null +++ b/KamiToolKit/Premade/ListItemNodes/StringListItemNode.cs @@ -0,0 +1,8 @@ +using KamiToolKit.Premade.GenericListItemNodes; + +namespace KamiToolKit.Premade.ListItemNodes; + +public class StringListItemNode : GenericStringListItemNode { + protected override void SetNodeData(string itemData) + => StringNode.String = itemData; +} diff --git a/KamiToolKit/Premade/ListItemNodes/TerritoryTypeListItemNode.cs b/KamiToolKit/Premade/ListItemNodes/TerritoryTypeListItemNode.cs new file mode 100644 index 0000000..cee25ed --- /dev/null +++ b/KamiToolKit/Premade/ListItemNodes/TerritoryTypeListItemNode.cs @@ -0,0 +1,89 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.ListItemNodes; + +public class TerritoryTypeListItemNode : ListItemNode { + public override float ItemHeight => 64.0f; + + private readonly SimpleImageNode territoryImageNode; + private readonly SimpleImageNode placeholderImageNode; + private readonly TextNode territoryTitleNode; + private readonly TextNode territoryDescriptionNode; + private readonly TextNode territoryIdNode; + + public TerritoryTypeListItemNode() { + territoryImageNode = new SimpleImageNode { + FitTexture = true, + IsVisible = false, + }; + territoryImageNode.AttachNode(this); + + placeholderImageNode = new IconImageNode { + FitTexture = true, + IconId = 60072, + }; + placeholderImageNode.AttachNode(this); + + territoryTitleNode = new TextNode { + TextFlags = TextFlags.Ellipsis, + AlignmentType = AlignmentType.BottomLeft, + String = "None Selected", + }; + territoryTitleNode.AttachNode(this); + + territoryDescriptionNode = new TextNode { + TextFlags = TextFlags.Ellipsis, + AlignmentType = AlignmentType.TopLeft, + TextColor = ColorHelper.GetColor(2), + }; + territoryDescriptionNode.AttachNode(this); + + territoryIdNode = new TextNode { + AlignmentType = AlignmentType.Right, + TextColor = ColorHelper.GetColor(3), + }; + territoryIdNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + territoryImageNode.Size = new Vector2((Height - 4.0f) * 1.777f, Height - 4.0f); + territoryImageNode.Position = new Vector2(2.0f, 2.0f); + + territoryIdNode.Size = new Vector2(30.0f, 30.0f); + territoryIdNode.Position = new Vector2(Width - territoryIdNode.Width, 0.0f); + + placeholderImageNode.Size = new Vector2(Height - 4.0f, Height - 4.0f); + placeholderImageNode.Position = new Vector2(2.0f, 2.0f); + + territoryTitleNode.Size = new Vector2(Width - territoryImageNode.Width - 10.0f - territoryIdNode.Width - 4.0f, Height / 2.0f); + territoryTitleNode.Position = new Vector2(territoryImageNode.Bounds.Right + 8.0f, 0.0f); + + territoryDescriptionNode.Size = territoryTitleNode.Size; + territoryDescriptionNode.Position = new Vector2(territoryTitleNode.Bounds.Left, Height / 2.0f); + } + + protected override void SetNodeData(TerritoryType territory) { + if (territory.RowId is 0) return; + + territoryIdNode.String = territory.RowId.ToString(); + + if (territory.LoadingImage.ValueNullable?.FileName is { IsEmpty: false } filePath) { + territoryImageNode.LoadTexture($"ui/loadingimage/{filePath}_hr1.tex"); + territoryImageNode.IsVisible = true; + } + else { + territoryImageNode.IsVisible = false; + } + + placeholderImageNode.IsVisible = !territoryImageNode.IsVisible; + + territoryTitleNode.String = territory.PlaceName.ValueNullable?.Name.ToString() ?? string.Empty; + territoryDescriptionNode.String = territory.ContentFinderCondition.RowId is 0 ? string.Empty : territory.ContentFinderCondition.Value.Name.ToString(); + } +} diff --git a/KamiToolKit/Premade/Nodes/AlphaBarNode.cs b/KamiToolKit/Premade/Nodes/AlphaBarNode.cs new file mode 100644 index 0000000..65ae142 --- /dev/null +++ b/KamiToolKit/Premade/Nodes/AlphaBarNode.cs @@ -0,0 +1,117 @@ +using System; +using System.Numerics; +using Dalamud.Interface; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Nodes; + +public unsafe class AlphaBarNode : SimpleComponentNode { + public readonly ImGuiImageNode AlphaBarBackgroundNode; + public readonly ImGuiImageNode AlphaBarGradientNode; + public readonly ImGuiImageNode AlphaBarSelectorNode; + + private readonly ViewportEventListener alphaEventListener; + private bool isAlphaDragging; + + public AlphaBarNode() { + alphaEventListener = new ViewportEventListener(AlphaSliderEvent); + + AlphaBarBackgroundNode = new AlphaImageNode(); + AlphaBarBackgroundNode.AttachNode(this); + + AlphaBarGradientNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("VerticalGradient_WhiteToAlpha.png"), + FitTexture = true, + }; + AlphaBarGradientNode.AttachNode(this); + AlphaBarGradientNode.AddEvent(AtkEventType.MouseDown, OnAlphaBarMouseDown); + + AlphaBarSelectorNode = new ImGuiImageNode { + TexturePath = DalamudInterface.Instance.GetAssetPath("alpha_selector.png"), + FitTexture = true, + }; + AlphaBarSelectorNode.AttachNode(this); + AlphaBarSelectorNode.AddEvent(AtkEventType.MouseDown, OnAlphaBarMouseDown); + } + + protected override void Dispose(bool disposing, bool isNativeDestructor) { + if (disposing) { + base.Dispose(disposing, isNativeDestructor); + + alphaEventListener.Dispose(); + } + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + AlphaBarBackgroundNode.Size = Size; + AlphaBarGradientNode.Size = Size; + + AlphaBarSelectorNode.Size = new Vector2(Width + 4.0f, 10.0f); + AlphaBarSelectorNode.Position = new Vector2(-2.0f, 0.0f); + } + + private void OnAlphaBarMouseDown() { + if (!isAlphaDragging) { + alphaEventListener.AddEvent(AtkEventType.MouseMove, AlphaBarGradientNode); + alphaEventListener.AddEvent(AtkEventType.MouseUp, AlphaBarGradientNode); + isAlphaDragging = true; + } + } + + private void AlphaSliderEvent(AtkEventListener* thisPtr, AtkEventType eventType, int eventParam, AtkEvent* atkEvent, AtkEventData* atkEventData) { + switch (eventType) { + case AtkEventType.MouseUp: + alphaEventListener.RemoveEvent(AtkEventType.MouseMove); + alphaEventListener.RemoveEvent(AtkEventType.MouseUp); + isAlphaDragging = false; + break; + + case AtkEventType.MouseMove: { + var mousePosition = new Vector2(atkEventData->MouseData.PosX, atkEventData->MouseData.PosY); + var scale = ParentAddon is not null ? ParentAddon->Scale : 1.0f; + var scaledHeight = AlphaBarGradientNode.Height * scale; + var minY = AlphaBarGradientNode.ScreenY; + var maxY = AlphaBarGradientNode.ScreenY + scaledHeight; + + if (mousePosition.Y >= minY && mousePosition.Y <= maxY) { + var alphaRatio = 1.0f - (mousePosition.Y - AlphaBarGradientNode.ScreenY) / scaledHeight; + + AlphaBarSelectorNode.Y = Height - Height * alphaRatio - 5.0f; + OnAlphaChanged?.Invoke(alphaRatio); + } + else if (mousePosition.Y < minY) { + AlphaBarSelectorNode.Y = -4.0f; + OnAlphaChanged?.Invoke(1.0f); + } + else if (mousePosition.Y > maxY) { + AlphaBarSelectorNode.Y = Height - 4.0f; + OnAlphaChanged?.Invoke(0.0f); + } + + break; + } + } + } + + public Action? OnAlphaChanged { get; init; } + + public override Vector4 Color { + get => AlphaBarGradientNode.Color; + set { + AlphaBarGradientNode.MultiplyColor = value.AsVector3(); + AlphaBarSelectorNode.Y = Height - Height * value.W - 5.0f; + } + } + + public override ColorHelpers.HsvaColor ColorHsva { + get => AlphaBarGradientNode.MultiplyColorHsva; + set { + AlphaBarGradientNode.MultiplyColorHsva = value with { A = 1.0f }; + AlphaBarSelectorNode.Y = Height - Height * value.A - 5.0f; + } + } +} diff --git a/KamiToolKit/Premade/Nodes/ConfigNode.cs b/KamiToolKit/Premade/Nodes/ConfigNode.cs new file mode 100644 index 0000000..cbe8eb9 --- /dev/null +++ b/KamiToolKit/Premade/Nodes/ConfigNode.cs @@ -0,0 +1,18 @@ +using System; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Nodes; + +public abstract class ConfigNode : SimpleComponentNode { + public T? ConfigurationOption { + get; + set { + field = value; + OptionChanged(value); + } + } + + protected abstract void OptionChanged(T? option); + + public Action? OnConfigChanged { get; set; } +} diff --git a/KamiToolKit/Premade/Nodes/ModifyListNode.cs b/KamiToolKit/Premade/Nodes/ModifyListNode.cs new file mode 100644 index 0000000..658534d --- /dev/null +++ b/KamiToolKit/Premade/Nodes/ModifyListNode.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Utility; +using KamiToolKit.Nodes; +using KamiToolKit.Premade.Widgets; + +namespace KamiToolKit.Premade.Nodes; + +/// +/// A non-owning list node that supports searching, and various callbacks for easily editing a list. +/// +/// Data type to display the data for. +/// ListItemNode derived type, for defining the result view. +public class ModifyListNode : SimpleComponentNode where TU : ListItemNode, new() { + private readonly SearchWidget searchWidget; + private readonly ListNode listNode; + + private readonly TextButtonNode addButton; + private readonly TextButtonNode editButton; + private readonly TextButtonNode removeButton; + + public ModifyListNode() { + searchWidget = new SearchWidget { + OnSortOrderChanged = OnSortOrderChanged, + OnSearchUpdated = OnSearchUpdated, + }; + searchWidget.AttachNode(this); + + listNode = new ListNode { + OptionsList = [], + OnItemSelected = OnListItemSelected, + }; + listNode.AttachNode(this); + + addButton = new TextButtonNode { + String = "Add", + OnClick = OnAddClicked, + IsEnabled = false, + }; + addButton.AttachNode(this); + + editButton = new TextButtonNode { + String = "Edit", + OnClick = OnEditClicked, + IsEnabled = false, + }; + editButton.AttachNode(this); + + removeButton = new TextButtonNode { + String = "Remove", + OnClick = OnRemoveClicked, + IsEnabled = false, + }; + removeButton.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + searchWidget.Size = new Vector2(Width, 65.0f); + searchWidget.Position = Vector2.Zero; + + listNode.Size = new Vector2(Width, Height - searchWidget.Height - 40.0f); + listNode.Position = new Vector2(0.0f, searchWidget.Y + searchWidget.Height + 8.0f); + + const float buttonPadding = 5.0f; + var buttonWidth = (Width - buttonPadding * 2.0f) / 3.0f; + + addButton.Size = new Vector2(buttonWidth, 24.0f); + addButton.Position = new Vector2(0.0f, Height - 24.0f); + + editButton.Size = new Vector2(buttonWidth, 24.0f); + editButton.Position = new Vector2(buttonWidth + buttonPadding, Height - 24.0f); + + removeButton.Size = new Vector2(buttonWidth, 24.0f); + removeButton.Position = new Vector2(buttonWidth * 2.0f + buttonPadding * 2.0f, Height - 24.0f); + } + + public List Options { + get; + set { + field = value; + listNode.OptionsList = value; + } + } = []; + + public List? SortOptions { + get => searchWidget.SortingOptions; + set { + searchWidget.SortingOptions = value ?? []; + OnSizeChanged(); + + if (value is not null && value.Count > 0) { + OnSortOrderChanged(value.First(), false); + } + } + } + + public Action? SelectionChanged { get; init; } + + public Action? AddNewEntry { + get; + set { + field = value; + addButton.IsEnabled = value is not null; + } + } + + public Action? RemoveEntry { + get; + set { + field = value; + removeButton.IsEnabled = value is not null && SelectedOption is not null; + } + } + + public Action? EditEntry { + get; + set { + field = value; + editButton.IsEnabled = value is not null && SelectedOption is not null; + } + } + + public delegate int ItemCompareDelegate(T left, T right, string sortingMode); + public ItemCompareDelegate? ItemComparer { get; set; } + + public delegate bool IsSearchMatchDelegate(T obj, string searchString); + public IsSearchMatchDelegate? IsSearchMatch { get; set; } + + public T? SelectedOption { get; private set; } + + public float ItemSpacing { + get => listNode.ItemSpacing; + set { + listNode.ItemSpacing = value; + OnSizeChanged(); + } + } + + private void OnSortOrderChanged(string sortingString, bool reversed) { + if (ItemComparer is null) return; + + var listCopy = Options.ToList(); + listCopy.Sort((left, right) => ItemComparer.Invoke(left, right, sortingString) * (reversed ? -1 : 1)); + listNode.OptionsList = listCopy; + UpdateButtonStates(); + } + + private void OnSearchUpdated(string searchString) { + if (IsSearchMatch is null) return; + + if (searchString.IsNullOrEmpty()) { + listNode.OptionsList = Options; + } + else { + listNode.OptionsList = Options.Where(item => IsSearchMatch(item, searchString)).ToList(); + } + } + + private void OnListItemSelected(T? obj) { + SelectedOption = obj; + SelectionChanged?.Invoke(SelectedOption); + + UpdateButtonStates(); + } + + private void OnAddClicked() { + AddNewEntry?.Invoke(); + RefreshList(); + } + + private void OnEditClicked() { + if (SelectedOption is null) return; + + EditEntry?.Invoke(SelectedOption); + RefreshList(); + } + + private void OnRemoveClicked() { + if (SelectedOption is null) return; + + RemoveEntry?.Invoke(SelectedOption); + RefreshList(); + } + + private void UpdateButtonStates() { + editButton.IsEnabled = SelectedOption is not null && EditEntry is not null; + removeButton.IsEnabled = SelectedOption is not null && RemoveEntry is not null; + } + + /// + /// Refreshes the displayed list data. + /// This resets scroll position, so don't spam it. + /// + public void RefreshList() { + OnSortOrderChanged(searchWidget.SortMode, searchWidget.IsReversed); + OnSearchUpdated(searchWidget.SearchText); + listNode.FullRebuild(); + } +} diff --git a/KamiToolKit/Premade/Nodes/MultiStateButtonNode.cs b/KamiToolKit/Premade/Nodes/MultiStateButtonNode.cs new file mode 100644 index 0000000..6774a35 --- /dev/null +++ b/KamiToolKit/Premade/Nodes/MultiStateButtonNode.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Nodes; + +/// +/// A TextButton that has a configurable set of states +/// +public class MultiStateButtonNode : TextButtonNode where T : notnull { + public Action? OnStateChanged { get; set; } + + public MultiStateButtonNode() + => OnClick = CycleState; + + public required List States { + get; + set { + field = value; + UpdateDisplay(); + } + } + + private int SelectedIndex { + get; + set { + field = value; + UpdateDisplay(); + } + } + + public T SelectedState { + get => States[SelectedIndex]; + set => SelectedIndex = States.IndexOf(value); + } + + private void CycleState() { + if (States.Count is 0) return; + + SelectedIndex = (SelectedIndex + 1) % States.Count; + OnStateChanged?.Invoke(SelectedState); + } + + private void UpdateDisplay() { + if (SelectedIndex < 0) return; + if (SelectedIndex > States.Count - 1) return; + + String = GetStateText(States[SelectedIndex]); + } + + protected virtual string GetStateText(T state) { + if (state is Enum enumState) { + return enumState.Description; + } + + return state.ToString() ?? "Unable to Parse Type"; + } +} diff --git a/KamiToolKit/Premade/Nodes/UnderlinedTextNode.cs b/KamiToolKit/Premade/Nodes/UnderlinedTextNode.cs new file mode 100644 index 0000000..228a6a4 --- /dev/null +++ b/KamiToolKit/Premade/Nodes/UnderlinedTextNode.cs @@ -0,0 +1,44 @@ +using System.Numerics; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Premade.Nodes; + +public class UnderlinedTextNode : SimpleComponentNode { + + public readonly CategoryTextNode LabelTextNode; + public readonly HorizontalLineNode LineNode; + + public UnderlinedTextNode() { + LabelTextNode = new CategoryTextNode(); + LabelTextNode.AttachNode(this); + + LineNode = new HorizontalLineNode { + Height = 4.0f, + }; + LineNode.AttachNode(this); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + LabelTextNode.Size = new Vector2(Width, Height - 4.0f); + LabelTextNode.Position = new Vector2(0.0f, 0.0f); + + LineNode.Position = new Vector2(0.0f, LabelTextNode.Bounds.Bottom - 4.0f); + RecalculateLineSize(); + } + + public ReadOnlySeString String { + get => LabelTextNode.String; + set { + LabelTextNode.String = value; + RecalculateLineSize(); + } + } + + private void RecalculateLineSize() { + var textSize = LabelTextNode.GetTextDrawSize(); + LineNode.Width = textSize.X + 32.0f; + } +} diff --git a/KamiToolKit/Premade/SearchAddons/AddonSearchAddon.cs b/KamiToolKit/Premade/SearchAddons/AddonSearchAddon.cs new file mode 100644 index 0000000..e078e1a --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/AddonSearchAddon.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.Interop; +using KamiToolKit.Premade.ListItemNodes; + +namespace KamiToolKit.Premade.SearchAddons; + +public unsafe class AddonSearchAddon : BaseSearchAddon, AddonListItemNode> { + + public AddonSearchAddon() { + SearchOptions = GetAllAddons(); + SortingOptions = [ "Visibility", "Alphabetical" ]; + ItemSpacing = 3.0f; + } + + protected override int Comparer(Pointer left, Pointer right, string sortingString, bool reversed) { + if (left.Value is null || right.Value is null) return 0; + + switch (sortingString) { + case "Alphabetical": + return string.CompareOrdinal(left.Value->NameString, right.Value->NameString) * (reversed ? -1 : 1); + + case "Visibility": + var visibilityComparison = right.Value->IsVisible.CompareTo(left.Value->IsVisible); + if (visibilityComparison is 0) { + visibilityComparison = string.CompareOrdinal(left.Value->NameString, right.Value->NameString); + } + + return visibilityComparison * (reversed ? -1 : 1); + } + + return 0; + } + + protected override bool IsMatch(Pointer item, string searchString) { + var regex = new Regex(searchString,RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + return regex.IsMatch(item.Value->NameString); + } + + private static List> GetAllAddons() { + List> addons = []; + + foreach (var entry in RaptureAtkUnitManager.Instance()->AllLoadedUnitsList.Entries) { + if (entry.Value is null) continue; + addons.Add(entry); + } + + return addons; + } +} diff --git a/KamiToolKit/Premade/SearchAddons/BaseSearchAddon.cs b/KamiToolKit/Premade/SearchAddons/BaseSearchAddon.cs new file mode 100644 index 0000000..90b87e9 --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/BaseSearchAddon.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Nodes; +using KamiToolKit.Premade.Widgets; + +namespace KamiToolKit.Premade.SearchAddons; + +public abstract class BaseSearchAddon : NativeAddon where TU : ListItemNode, new() { + + private SearchWidget? searchWidget; + private ListNode? listNode; + + private TextButtonNode? cancelButton; + private TextButtonNode? confirmButton; + + private T? selectedOption; + + protected override unsafe void OnSetup(AtkUnitBase* addon) { + searchWidget = new SearchWidget { + Size = ContentSize, + Position = ContentStartPosition, + SortingOptions = SortingOptions, + OnSortOrderChanged = OnSortOrderUpdated, + OnSearchUpdated = OnSearchUpdated, + }; + searchWidget.AttachNode(this); + + listNode = new ListNode { + Position = new Vector2(ContentStartPosition.X, searchWidget.Y + searchWidget.Height + 8.0f), + Size = new Vector2(ContentSize.X, ContentSize.Y - searchWidget.Height - 16.0f - 24.0f - 8.0f), + ItemSpacing = ItemSpacing, + OptionsList = SearchOptions, + OnItemSelected = item => { + selectedOption = item; + confirmButton?.IsEnabled = true; + }, + }; + listNode.AttachNode(this); + + const float buttonPadding = 20.0f; + var contentWidth = ContentSize.X - buttonPadding * 2; + var buttonWidth = contentWidth / 3.0f; + + cancelButton = new TextButtonNode { + Size = new Vector2(buttonWidth, 24.0f), + Position = new Vector2(ContentStartPosition.X, ContentStartPosition.Y + ContentSize.Y - 24.0f - 8.0f), + String = "Cancel", + OnClick = OnCancelClicked, + }; + cancelButton.AttachNode(this); + + confirmButton = new TextButtonNode { + Size = new Vector2(buttonWidth, 24.0f), + Position = new Vector2(ContentStartPosition.X + buttonWidth * 2 + buttonPadding * 2, ContentStartPosition.Y + ContentSize.Y - 24.0f - 8.0f), + IsEnabled = false, + String = "Confirm", + OnClick = OnConfirmClicked, + }; + confirmButton.AttachNode(this); + + if (SortingOptions.Count > 0) { + OnSortOrderUpdated(SortingOptions.First(), false); + } + } + + private void OnCancelClicked() { + selectedOption = default; + Close(); + } + + private void OnConfirmClicked() { + if (selectedOption is not null) { + SelectionResult?.Invoke(selectedOption); + } + + selectedOption = default; + Close(); + } + + private void OnSortOrderUpdated(string sortingString, bool reversed) { + var resortedList = SearchOptions.ToList(); + resortedList.Sort((left, right) => Comparer(left, right, sortingString, reversed)); + + listNode?.OptionsList = resortedList; + } + + private void OnSearchUpdated(string searchString) { + listNode?.OptionsList = SearchOptions.Where(item => IsMatch(item, searchString)).ToList(); + } + + protected abstract int Comparer(T left, T right, string sortingString, bool reversed); + protected abstract bool IsMatch(T item, string searchString); + + public List SortingOptions { get; init; } = [ "Alphabetical", "Id" ]; + + public List SearchOptions { + get; + set { + field = value; + listNode?.OptionsList = value; + } + } = []; + + public float ItemSpacing { + get; + set { + field = value; + listNode?.ItemSpacing = value; + } + } = 6.0f; + + public Action? SelectionResult { get; set; } +} diff --git a/KamiToolKit/Premade/SearchAddons/CurrencySearchAddon.cs b/KamiToolKit/Premade/SearchAddons/CurrencySearchAddon.cs new file mode 100644 index 0000000..22696d3 --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/CurrencySearchAddon.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Linq; +using KamiToolKit.Classes; +using KamiToolKit.Premade.ListItemNodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.SearchAddons; + +public class CurrencySearchAddon : ItemSearchAddonBase { + public CurrencySearchAddon() + => SearchOptions = GetCurrencyItems().ToList(); + + private static IEnumerable GetCurrencyItems() { + var dataManager = DalamudInterface.Instance.DataManager; + + var obsoleteTomes = dataManager.GetExcelSheet() + .Where(item => item.Tomestones.RowId is 0) + .Select(item => item.Item.Value) + .ToHashSet(EqualityComparer.Create( + (x, y) => x.RowId == y.RowId, + obj => obj.RowId.GetHashCode() + )); + + return dataManager.GetExcelSheet() + .Where(item => item is { Name.IsEmpty: false, ItemUICategory.RowId: 100 } or { RowId: >= 1 and < 100, Name.IsEmpty: false }) + .Where(item => !obsoleteTomes.Contains(item)); + } +} diff --git a/KamiToolKit/Premade/SearchAddons/ItemSearchAddon.cs b/KamiToolKit/Premade/SearchAddons/ItemSearchAddon.cs new file mode 100644 index 0000000..312fb62 --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/ItemSearchAddon.cs @@ -0,0 +1,5 @@ +using KamiToolKit.Premade.ListItemNodes; + +namespace KamiToolKit.Premade.SearchAddons; + +public class ItemSearchAddon : ItemSearchAddonBase; diff --git a/KamiToolKit/Premade/SearchAddons/ItemSearchAddonBase.cs b/KamiToolKit/Premade/SearchAddons/ItemSearchAddonBase.cs new file mode 100644 index 0000000..64df5d7 --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/ItemSearchAddonBase.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; +using KamiToolKit.Nodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.SearchAddons; + +public class ItemSearchAddonBase : BaseSearchAddon where T : ListItemNode, new() { + protected override int Comparer(Item left, Item right, string sortingString, bool reversed) { + var result = sortingString switch { + "Alphabetical" => string.CompareOrdinal(left.Name.ToString(), right.Name.ToString()), + "Id" => left.RowId.CompareTo(right.RowId), + _ => 0, + }; + + return reversed ? -result : result; + } + + protected override bool IsMatch(Item item, string searchString) { + var isDescriptionSearch = searchString.StartsWith('$'); + + if (isDescriptionSearch) { + searchString = searchString[1..]; + } + + var regex = new Regex(searchString,RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (regex.IsMatch(item.RowId.ToString())) return true; + if (regex.IsMatch(item.Name.ToString())) return true; + if (regex.IsMatch(item.Description.ToString()) && isDescriptionSearch) return true; + if (regex.IsMatch(item.LevelEquip.ToString())) return true; + if (regex.IsMatch(item.LevelItem.RowId.ToString())) return true; + if (regex.IsMatch(item.ClassJobCategory.Value.Name.ToString())) return true; + if (regex.IsMatch(item.ItemUICategory.Value.Name.ToString())) return true; + + return false; + } +} diff --git a/KamiToolKit/Premade/SearchAddons/StatusSearchAddon.cs b/KamiToolKit/Premade/SearchAddons/StatusSearchAddon.cs new file mode 100644 index 0000000..aa57f64 --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/StatusSearchAddon.cs @@ -0,0 +1,35 @@ +using System.Linq; +using System.Text.RegularExpressions; +using KamiToolKit.Classes; +using KamiToolKit.Premade.ListItemNodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.SearchAddons; + +public class StatusSearchAddon : BaseSearchAddon { + public StatusSearchAddon() { + SearchOptions = DalamudInterface.Instance.DataManager.GetExcelSheet() + .Where(territory => territory.RowId is not 0) + .Where(territory => !territory.Name.IsEmpty) + .ToList(); + } + + protected override int Comparer(Status left, Status right, string sortingString, bool reversed){ + var result = sortingString switch { + "Alphabetical" => string.CompareOrdinal(left.Name.ToString(), right.Name.ToString()), + "Id" => left.RowId.CompareTo(right.RowId), + _ => 0, + }; + + return reversed ? -result : result; + } + + protected override bool IsMatch(Status item, string searchString) { + var regex = new Regex(searchString,RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (regex.IsMatch(item.RowId.ToString())) return true; + if (regex.IsMatch(item.Name.ToString())) return true; + + return false; + } +} diff --git a/KamiToolKit/Premade/SearchAddons/TerritorySearchAddon.cs b/KamiToolKit/Premade/SearchAddons/TerritorySearchAddon.cs new file mode 100644 index 0000000..7070c33 --- /dev/null +++ b/KamiToolKit/Premade/SearchAddons/TerritorySearchAddon.cs @@ -0,0 +1,38 @@ +using System.Linq; +using System.Text.RegularExpressions; +using Dalamud.Utility; +using KamiToolKit.Classes; +using KamiToolKit.Premade.ListItemNodes; +using Lumina.Excel.Sheets; + +namespace KamiToolKit.Premade.SearchAddons; + +public class TerritorySearchAddon : BaseSearchAddon { + public TerritorySearchAddon() { + SearchOptions = DalamudInterface.Instance.DataManager.GetExcelSheet() + .Where(territory => territory.RowId is not 0) + .Where(territory => territory.LoadingImage.RowId is not 0) + .Where(territory => !territory.PlaceName.ValueNullable?.Name.ToString().IsNullOrEmpty() ?? false) + .ToList(); + } + + protected override int Comparer(TerritoryType left, TerritoryType right, string sortingString, bool reversed) { + var result = sortingString switch { + "Alphabetical" => string.CompareOrdinal(left.Name.ToString(), right.Name.ToString()), + "Id" => left.RowId.CompareTo(right.RowId), + _ => 0, + }; + + return reversed ? -result : result; + } + + protected override bool IsMatch(TerritoryType item, string searchString) { + var regex = new Regex(searchString,RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + if (regex.IsMatch(item.RowId.ToString())) return true; + if (regex.IsMatch(item.PlaceName.ValueNullable?.Name.ToString() ?? string.Empty)) return true; + if (regex.IsMatch(item.ContentFinderCondition.ValueNullable?.Name.ToString() ?? string.Empty)) return true; + + return false; + } +} diff --git a/KamiToolKit/Premade/Widgets/SearchWidget.cs b/KamiToolKit/Premade/Widgets/SearchWidget.cs new file mode 100644 index 0000000..1b6848c --- /dev/null +++ b/KamiToolKit/Premade/Widgets/SearchWidget.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using KamiToolKit.Nodes; +using Lumina.Text.ReadOnly; + +namespace KamiToolKit.Premade.Widgets; + +/// +/// Represents a search element that has a searchbar, and a dropdown for reordering elements. +/// +public unsafe class SearchWidget : SimpleComponentNode { + public readonly TextInputNode InputNode; + public readonly TextDropDownNode SortOrderDropDown; + public readonly CircleButtonNode ReverseButtonNode; + + public bool IsReversed { get; private set; } + public string SearchText { get; private set; } = string.Empty; + public string SortMode { get; private set; } = string.Empty; + + public delegate void SearchUpdated(string searchString); + public delegate void SortUpdated(string sortingString, bool reversed); + + public SearchWidget() { + InputNode = new TextInputNode { + PlaceholderString = "Search . . .", + String = SearchText, + OnInputReceived = SearchTextChanged, + }; + InputNode.AttachNode(this); + + SortOrderDropDown = new TextDropDownNode { + MaxListOptions = 0, + Options = [], + IsVisible = false, + SelectedOption = SortMode == string.Empty ? null : SortMode, + OnOptionSelected = DropDownChanged, + }; + SortOrderDropDown.AttachNode(this); + + ReverseButtonNode = new CircleButtonNode { + Icon = ButtonIcon.Sort, + OnClick = OnReverseButtonClicked, + TextTooltip = "Reverse Sort Direction", + IsVisible = false, + }; + ReverseButtonNode.AttachNode(this); + + ResNode->SetHeight(38); + } + + public required SortUpdated OnSortOrderChanged { get; set; } + + private void OnReverseButtonClicked() { + IsReversed = !IsReversed; + OnSortOrderChanged(SortMode, IsReversed); + } + + private void DropDownChanged(string newOption) { + SortMode = newOption; + OnSortOrderChanged(SortMode, IsReversed); + } + + public required SearchUpdated OnSearchUpdated { get; set; } + + private void SearchTextChanged(ReadOnlySeString newSearchString) { + SearchText = newSearchString.ToString(); + OnSearchUpdated(SearchText); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + InputNode.Size = new Vector2(Width - 10.0f, 28.0f); + InputNode.Position = new Vector2(5.0f, 5.0f); + + ReverseButtonNode.Size = new Vector2(28.0f, 28.0f); + ReverseButtonNode.Position = new Vector2(Width - 5.0f - ReverseButtonNode.Width, InputNode.Height + 8.0f); + + SortOrderDropDown.Size = new Vector2(Width - 5.0f - ReverseButtonNode.Width - 5.0f - 5.0f, 28.0f); + SortOrderDropDown.Position = new Vector2(5.0f, InputNode.Height + 8.0f); + } + + // Disallow modifying the height of this element. + public override float Height { get => base.Height; set { } } + + public int MaxDropdownOptions { + get => SortOrderDropDown.MaxListOptions; + set => SortOrderDropDown.MaxListOptions = value; + } + + public List SortingOptions { + get => SortOrderDropDown.Options ?? []; + set { + SortOrderDropDown.Options = value; + SortOrderDropDown.MaxListOptions = value.Count / 2 + 1; + SortOrderDropDown.IsVisible = value.Count > 0; + ReverseButtonNode.IsVisible = value.Count > 0; + + ResNode->SetHeight((ushort)(value.Count > 0 ? 69 : 38)); + + if (SortingOptions.Count > 0) { + SortMode = value.First(); + } + } + } + + public string? SelectedOption => SortOrderDropDown.SelectedOption; +} diff --git a/KamiToolKit/Premade/Widgets/Vector2EditWidget.cs b/KamiToolKit/Premade/Widgets/Vector2EditWidget.cs new file mode 100644 index 0000000..2631199 --- /dev/null +++ b/KamiToolKit/Premade/Widgets/Vector2EditWidget.cs @@ -0,0 +1,94 @@ +using System; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; +using KamiToolKit.Nodes; + +namespace KamiToolKit.Premade.Widgets; + +public class Vector2EditWidget : SimpleComponentNode { + public readonly GridNode GridNode; + public readonly TextNode WidthTextNode; + public readonly TextNode HeightTextNode; + public readonly NumericInputNode WidthInputNode; + public readonly NumericInputNode HeightInputNode; + + public Vector2EditWidget() { + GridNode = new GridNode { + GridSize = new GridSize(2, 2), + }; + GridNode.AttachNode(this); + + WidthTextNode = new TextNode { + AlignmentType = AlignmentType.Bottom, + FontType = FontType.Axis, + FontSize = 14, + LineSpacing = 14, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + TextFlags = TextFlags.Edge | TextFlags.AutoAdjustNodeSize, + String = XLabel ?? "Width", + }; + WidthTextNode.AttachNode(GridNode[0, 0]); + + HeightTextNode = new TextNode { + AlignmentType = AlignmentType.Bottom, + FontType = FontType.Axis, + FontSize = 14, + LineSpacing = 14, + TextColor = ColorHelper.GetColor(8), + TextOutlineColor = ColorHelper.GetColor(7), + TextFlags = TextFlags.Edge | TextFlags.AutoAdjustNodeSize, + String = YLabel ?? "Height", + }; + HeightTextNode.AttachNode(GridNode[1, 0]); + + WidthInputNode = new NumericInputNode { + Position = new Vector2(2.0f, 2.0f), + OnValueUpdate = OnXValueUpdated, + }; + WidthInputNode.AttachNode(GridNode[0, 1]); + + HeightInputNode = new NumericInputNode { + Position = new Vector2(2.0f, 2.0f), + OnValueUpdate = OnYValueUpdated, + }; + HeightInputNode.AttachNode(GridNode[1, 1]); + } + + protected override void OnSizeChanged() { + base.OnSizeChanged(); + + GridNode.Size = Size; + + WidthTextNode.Size = GridNode[0, 0].Size; + HeightTextNode.Size = GridNode[1, 0].Size; + + WidthInputNode.Size = GridNode[0, 1].Size; + HeightInputNode.Size = GridNode[1, 1].Size; + } + + private void OnXValueUpdated(int newValue) { + Value = Value with { X = newValue }; + OnValueChanged?.Invoke(Value); + } + + private void OnYValueUpdated(int newValue) { + Value = Value with { Y = newValue }; + OnValueChanged?.Invoke(Value); + } + + public Vector2 Value { + get; + set { + field = value; + WidthInputNode.Value = (int) value.X; + HeightInputNode.Value = (int) value.Y; + } + } + + public Action? OnValueChanged { get; set; } + + public string? XLabel { get; set; } + public string? YLabel { get; set; } +} diff --git a/KamiToolKit/README.md b/KamiToolKit/README.md new file mode 100644 index 0000000..83dd6cc --- /dev/null +++ b/KamiToolKit/README.md @@ -0,0 +1,2 @@ +# KamiToolKit +C# Wrapper for FFXIV's Native UI AddonToolKit diff --git a/KamiToolKit/Timelines/FrameSetBuilder.cs b/KamiToolKit/Timelines/FrameSetBuilder.cs new file mode 100644 index 0000000..4be81f2 --- /dev/null +++ b/KamiToolKit/Timelines/FrameSetBuilder.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Timelines; + +public class FrameSetBuilder(TimelineBuilder parent, int startFrameId, int endFrameId) { + + private readonly List animationKeyFrames = []; + private readonly List labelKeyFrames = []; + + public FrameSetBuilder AddFrame(params TimelineKeyFrame[] keyFrame) { + foreach (var frame in keyFrame) { + + switch (frame.GroupType) { + case AtkTimelineKeyGroupType.Label: + labelKeyFrames.Add(frame); + break; + + case AtkTimelineKeyGroupType.Float2: + case AtkTimelineKeyGroupType.Float: + case AtkTimelineKeyGroupType.Byte: + case AtkTimelineKeyGroupType.NodeTint: + case AtkTimelineKeyGroupType.UShort: + case AtkTimelineKeyGroupType.RGB: + case AtkTimelineKeyGroupType.Short: + case AtkTimelineKeyGroupType.None: + default: + animationKeyFrames.Add(frame); + break; + } + } + + return this; + } + + public FrameSetBuilder AddEmptyFrame(int frameId) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, GroupType = AtkTimelineKeyGroupType.None, + }); + + return this; + } + + public FrameSetBuilder AddFrame( + int frameId, Vector2? position = null, byte? alpha = null, Vector3? addColor = null, Vector3? multiplyColor = null, + float? rotation = null, Vector2? scale = null, Vector3? textColor = null, Vector3? textOutlineColor = null, uint? partId = null, AtkTimelineInterpolation? interpolation = null, + float? rotationDegrees = null) { + if (position is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, Position = position.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (alpha is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, Alpha = alpha.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (addColor is not null || multiplyColor is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, AddColor = addColor ?? new Vector3(0.0f, 0.0f, 0.0f), MultiplyColor = multiplyColor ?? new Vector3(100.0f, 100.0f, 100.0f), Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (rotation is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, Rotation = rotation.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (rotationDegrees is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, Rotation = rotationDegrees.Value * MathF.PI / 180.0f, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (scale is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, Scale = scale.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (textColor is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, TextColor = textColor.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (textOutlineColor is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, TextEdgeColor = textOutlineColor.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + if (partId is not null) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frameId, PartId = partId.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }); + } + + return this; + } + + public FrameSetBuilder AddLabel(int frameId, int labelId, AtkTimelineJumpBehavior jumpBehavior, int labelTarget) { + labelKeyFrames.Add(new TimelineLabelSetKeyFrame { + FrameIndex = frameId, + GroupType = AtkTimelineKeyGroupType.Label, + JumpBehavior = jumpBehavior, + LabelId = labelId, + JumpLabelId = labelTarget, + }); + + return this; + } + + public FrameSetBuilder AddLabelPair(int frameStart, int frameStop, int labelId) { + labelKeyFrames.Add(new TimelineLabelSetKeyFrame { + FrameIndex = frameStart, + GroupType = AtkTimelineKeyGroupType.Label, + JumpBehavior = AtkTimelineJumpBehavior.Start, + LabelId = labelId, + }); + + labelKeyFrames.Add(new TimelineLabelSetKeyFrame { + FrameIndex = frameStop, + GroupType = AtkTimelineKeyGroupType.Label, + JumpBehavior = AtkTimelineJumpBehavior.PlayOnce, + LabelId = 0, + JumpLabelId = 0, + }); + + return this; + } + + public KeyFrameBuilder BeginFrameBuilder(int frame) + => new(this, frame); + + public TimelineBuilder EndFrameSet() { + if (labelKeyFrames.Count != 0) { + parent.LabelSets.Add(new TimelineLabelSet { + StartFrameId = startFrameId, EndFrameId = endFrameId, Labels = labelKeyFrames, + }); + } + + if (animationKeyFrames.Count != 0) { + parent.Animations.Add(new TimelineAnimation { + StartFrameId = startFrameId, EndFrameId = endFrameId, KeyFrames = animationKeyFrames, + }); + } + + return parent; + } +} diff --git a/KamiToolKit/Timelines/KeyFrameBuilder.cs b/KamiToolKit/Timelines/KeyFrameBuilder.cs new file mode 100644 index 0000000..0a8d7cf --- /dev/null +++ b/KamiToolKit/Timelines/KeyFrameBuilder.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Common.Math; + +namespace KamiToolKit.Timelines; + +public class KeyFrameBuilder(FrameSetBuilder parent, int frame) { + + private readonly List animationKeyFrames = []; + + public KeyFrameBuilder Position(Vector2 position) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, Position = position, + }); + + return this; + } + + public KeyFrameBuilder Alpha(byte alpha) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, Alpha = alpha, + }); + + return this; + } + + public KeyFrameBuilder AddColor(Vector3 color) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, AddColor = color, + }); + + return this; + } + + public KeyFrameBuilder MultiplyColor(Vector3 color) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, MultiplyColor = color, + }); + + return this; + } + + public KeyFrameBuilder MultiplyColor(float color) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, MultiplyColor = new Vector3(color, color, color), + }); + + return this; + } + + public KeyFrameBuilder Rotation(float rotation) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, Rotation = rotation, + }); + + return this; + } + + public KeyFrameBuilder Scale(Vector2 scale) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, Scale = scale, + }); + + return this; + } + + public KeyFrameBuilder Scale(float scale) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, Scale = new Vector2(scale, scale), + }); + + return this; + } + + public KeyFrameBuilder TextColor(Vector3 textColor) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, TextColor = textColor, + }); + + return this; + } + + public KeyFrameBuilder TextOutlineColor(Vector3 textColor) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, TextEdgeColor = textColor, + }); + + return this; + } + + public KeyFrameBuilder Part(uint partId) { + animationKeyFrames.Add(new TimelineAnimationKeyFrame { + FrameIndex = frame, PartId = partId, + }); + + return this; + } + + public FrameSetBuilder EndFrameBuilder() { + parent.AddFrame(animationKeyFrames.ToArray()); + return parent; + } +} diff --git a/KamiToolKit/Timelines/NodeTint.cs b/KamiToolKit/Timelines/NodeTint.cs new file mode 100644 index 0000000..90b5411 --- /dev/null +++ b/KamiToolKit/Timelines/NodeTint.cs @@ -0,0 +1,31 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Client.Graphics; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Timelines; + +public class NodeTint { + + public Vector3 AddColor; + + public Vector3 MultiplyColor; + + public static implicit operator AtkTimelineNodeTint(NodeTint tint) => new() { + MultiplyRGB = new ByteColor { + R = (byte)tint.MultiplyColor.X, G = (byte)tint.MultiplyColor.Y, B = (byte)tint.MultiplyColor.Z, + }, + AddRGBBitfield = Convert(tint.AddColor), + }; + + public static implicit operator NodeTint(AtkTimelineNodeTint tint) => new() { + AddColor = new Vector3(tint.AddR, tint.AddG, tint.AddB), MultiplyColor = tint.MultiplyRGB.ToVector4().AsVector3(), + }; + + private static uint Convert(Vector3 color) { + var red = (short)(color.X + 255); + var green = (short)(color.Y + 255); + var blue = (short)(color.Z + 255); + + return (uint)(red & 0x3FF | (green & 0xFFF) << 10 | (blue & 0x3FF) << 22); + } +} diff --git a/KamiToolKit/Timelines/Timeline.cs b/KamiToolKit/Timelines/Timeline.cs new file mode 100644 index 0000000..c42820d --- /dev/null +++ b/KamiToolKit/Timelines/Timeline.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.Interop; +using KamiToolKit.Classes; + +namespace KamiToolKit.Timelines; + +public unsafe class Timeline : IDisposable { + + private readonly TimelineResource internalTimelineResource; + + internal AtkTimeline* InternalTimeline; + + public Timeline() { + InternalTimeline = NativeMemoryHelper.UiAlloc(); + + internalTimelineResource = new TimelineResource(); + InternalTimeline->Resource = internalTimelineResource.InternalResource; + InternalTimeline->LabelResource = null; + InternalTimeline->ActiveAnimation = null; + InternalTimeline->OwnerNode = null; + } + + internal AtkResNode* OwnerNode { + get => InternalTimeline->OwnerNode; + set => InternalTimeline->OwnerNode = value; + } + + public float FrameTime { + get => InternalTimeline->FrameTime; + set => InternalTimeline->FrameTime = value; + } + + public float ParentFrameTime { + get => InternalTimeline->ParentFrameTime; + set => InternalTimeline->ParentFrameTime = value; + } + + public int LabelFrameIdxDuration { + get => InternalTimeline->LabelFrameIdxDuration; + set => InternalTimeline->LabelFrameIdxDuration = (ushort)value; + } + + public int LabelEndFrameIdx { + get => InternalTimeline->LabelEndFrameIdx; + set => InternalTimeline->LabelEndFrameIdx = (ushort)value; + } + + public int ActiveLabelId { + get => InternalTimeline->ActiveLabelId; + set => InternalTimeline->ActiveLabelId = (ushort)value; + } + + public AtkTimelineMask Mask { + get => InternalTimeline->Mask; + set => InternalTimeline->Mask = value; + } + + public AtkTimelineFlags Flags { + get => InternalTimeline->Flags; + set => InternalTimeline->Flags = value; + } + + public List Animations { + set => internalTimelineResource.Animations = value; + } + + public List LabelSets { + set => internalTimelineResource.LabelSets = value; + } + + public void Dispose() { + internalTimelineResource.Dispose(); + + NativeMemoryHelper.UiFree(InternalTimeline); + InternalTimeline = null; + } + + /// + /// Plays the specified animation via label ID + /// + /// The label ID to play + /// Force the animation to restart even if it was already playing + public void PlayAnimation(int labelId, bool force = false) + => PlayAnimation(AtkTimelineJumpBehavior.Start, labelId, force); + + public void PlayAnimation(AtkTimelineJumpBehavior behavior, int labelId, bool force = false) { + if (InternalTimeline is null) return; + + if (InternalTimeline->ActiveLabelId != labelId || force) { + InternalTimeline->PlayAnimation(behavior, (ushort)labelId); + } + } + + public void StopAnimation() { + if (InternalTimeline is null) return; + + InternalTimeline->PlayAnimation(AtkTimelineJumpBehavior.Start, 0); + } + + public void UpdateKeyFrame( + int frameId, KeyFrameGroupType groupType, Vector2? position = null, byte? alpha = null, Vector3? addColor = null, Vector3? multiplyColor = null, + float? rotation = null, Vector2? scale = null, Vector3? textColor = null, Vector3? textOutlineColor = null, uint? partId = null, AtkTimelineInterpolation? interpolation = null) { + + var keyFrame = GetKeyFrame(groupType, frameId); + if (keyFrame is null) return; + + if (position is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, Position = position.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (alpha is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, Alpha = alpha.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (addColor is not null || multiplyColor is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, AddColor = addColor ?? new Vector3(0.0f, 0.0f, 0.0f), MultiplyColor = multiplyColor ?? new Vector3(100.0f, 100.0f, 100.0f), Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (rotation is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, Rotation = rotation.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (scale is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, Scale = scale.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (textColor is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, TextColor = textColor.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (textOutlineColor is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, TextEdgeColor = textOutlineColor.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + + if (partId is not null) { + *keyFrame = new TimelineAnimationKeyFrame { + FrameIndex = frameId, PartId = partId.Value, Interpolation = interpolation ?? AtkTimelineInterpolation.Linear, + }; + } + } + + private AtkTimelineKeyFrame* GetKeyFrame(KeyFrameGroupType type, int frameIndex) { + var animation = GetAnimationForFrameId(frameIndex); + if (animation is null) return null; + + var keyGroup = animation->KeyGroups.GetPointer((int)type); + for (var i = 0; i < keyGroup->KeyFrameCount; i++) { + var keyFrame = &keyGroup->KeyFrames[i]; + + if (keyFrame->FrameIdx == frameIndex) { + return keyFrame; + } + } + + return null; + } + + private AtkTimelineAnimation* GetAnimationForFrameId(int frameId) { + if (InternalTimeline is null) return null; + if (InternalTimeline->Resource is null) return null; + + for (var index = 0; index < InternalTimeline->Resource->AnimationCount; index++) { + var animation = &InternalTimeline->Resource->Animations[index]; + + if (animation->StartFrameIdx <= frameId && frameId <= animation->EndFrameIdx) + return animation; + } + + return null; + } +} diff --git a/KamiToolKit/Timelines/TimelineAnimation.cs b/KamiToolKit/Timelines/TimelineAnimation.cs new file mode 100644 index 0000000..ecb24bc --- /dev/null +++ b/KamiToolKit/Timelines/TimelineAnimation.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Timelines; + +public unsafe class TimelineAnimation : IDisposable { + + internal AtkTimelineAnimation* InternalAnimation; + + private List internalKeyFrames = []; + + public TimelineAnimation() { + InternalAnimation = NativeMemoryHelper.UiAlloc(); + + InternalAnimation->StartFrameIdx = 0; + InternalAnimation->EndFrameIdx = 0; + + foreach (ref var value in InternalAnimation->KeyGroups) { + value.Type = AtkTimelineKeyGroupType.None; + } + } + + public int StartFrameId { + get => InternalAnimation->StartFrameIdx; + set => InternalAnimation->StartFrameIdx = (ushort)value; + } + + public int EndFrameId { + get => InternalAnimation->EndFrameIdx; + set => InternalAnimation->EndFrameIdx = (ushort)value; + } + + public List KeyFrames { + get => internalKeyFrames; + set { + internalKeyFrames = value; + Resync(); + } + } + + public void Dispose() { + if (InternalAnimation is null) return; + + foreach (ref var spanGroup in InternalAnimation->KeyGroups) { + NativeMemoryHelper.UiFree(spanGroup.KeyFrames); + spanGroup.KeyFrames = null; + spanGroup.KeyFrameCount = 0; + } + + NativeMemoryHelper.UiFree(InternalAnimation); + InternalAnimation = null; + } + + private void Resync() { + foreach (var keyFrameSet in internalKeyFrames.GroupBy(frame => frame.GroupSelector)) { + ref var keyFrameGroup = ref InternalAnimation->KeyGroups[(int)keyFrameSet.Key]; + keyFrameGroup.Type = keyFrameSet.First().GroupType; + + if (keyFrameGroup.KeyFrames is not null) { + NativeMemoryHelper.UiFree(keyFrameGroup.KeyFrames, keyFrameGroup.KeyFrameCount); + keyFrameGroup.KeyFrames = null; + } + + keyFrameGroup.KeyFrames = NativeMemoryHelper.UiAlloc(keyFrameSet.Count()); + + var index = 0; + foreach (var keyframe in keyFrameSet) { + keyFrameGroup.KeyFrames[index] = keyframe; + index++; + } + + keyFrameGroup.KeyFrameCount = (ushort)keyFrameSet.Count(); + } + } +} + +public enum KeyFrameGroupType { + Position = 0, + Rotation = 1, + Scale = 2, + Alpha = 3, + Tint = 4, + + PartId = 5, + TextColor = 5, + + TextEdge = 6, + TextLabel = 7, +} diff --git a/KamiToolKit/Timelines/TimelineAnimationArray.cs b/KamiToolKit/Timelines/TimelineAnimationArray.cs new file mode 100644 index 0000000..3d1a8f1 --- /dev/null +++ b/KamiToolKit/Timelines/TimelineAnimationArray.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Timelines; + +public unsafe class TimelineAnimationArray : IDisposable { + + internal AtkTimelineAnimation* InternalTimelineArray = null; + + private List timelineAnimations = []; + public uint Count { get; private set; } + + public List Animations { + get => timelineAnimations; + set { + timelineAnimations = value; + Resync(); + } + } + + public void Dispose() { + foreach (var animation in timelineAnimations) { + animation.Dispose(); + } + + NativeMemoryHelper.UiFree(InternalTimelineArray, Count); + InternalTimelineArray = null; + } + + private void Resync() { + // Free existing array, we will completely rebuild it + if (InternalTimelineArray is not null) { + NativeMemoryHelper.UiFree(InternalTimelineArray, Count); + InternalTimelineArray = null; + } + + // Allocate new array + InternalTimelineArray = NativeMemoryHelper.UiAlloc(timelineAnimations.Count); + + // Copy all Animations into it + foreach (var index in Enumerable.Range(0, timelineAnimations.Count)) { + InternalTimelineArray[index] = *timelineAnimations[index].InternalAnimation; + } + + Count = (uint)timelineAnimations.Count; + } +} diff --git a/KamiToolKit/Timelines/TimelineAnimationKeyFrame.cs b/KamiToolKit/Timelines/TimelineAnimationKeyFrame.cs new file mode 100644 index 0000000..8508b53 --- /dev/null +++ b/KamiToolKit/Timelines/TimelineAnimationKeyFrame.cs @@ -0,0 +1,116 @@ +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.STD; + +namespace KamiToolKit.Timelines; + +public class TimelineAnimationKeyFrame : TimelineKeyFrame { + + private readonly NodeTint nodeTint = new(); + + public Vector2 Position { + get => new(Value.Float2.Item1, Value.Float2.Item2); + set { + Value = new AtkTimelineKeyValue { + Float2 = new StdPair(value.X, value.Y), + }; + + GroupSelector = KeyFrameGroupType.Position; + GroupType = AtkTimelineKeyGroupType.Float2; + } + } + + public byte Alpha { + get => Value.Byte; + set { + Value = new AtkTimelineKeyValue { + Byte = value, + }; + + GroupType = AtkTimelineKeyGroupType.Byte; + GroupSelector = KeyFrameGroupType.Alpha; + } + } + + public Vector3 AddColor { + set { + nodeTint.AddColor = value; + UpdateNodeTint(); + } + } + + public Vector3 MultiplyColor { + set { + nodeTint.MultiplyColor = value; + UpdateNodeTint(); + } + } + + public float Rotation { + get => Value.Float; + set { + Value = new AtkTimelineKeyValue { + Float = value, + }; + + GroupType = AtkTimelineKeyGroupType.Float; + GroupSelector = KeyFrameGroupType.Rotation; + } + } + + public Vector2 Scale { + get => new(Value.Float2.Item1, Value.Float2.Item2); + set { + Value = new AtkTimelineKeyValue { + Float2 = new StdPair(value.X, value.Y), + }; + + GroupType = AtkTimelineKeyGroupType.Float2; + GroupSelector = KeyFrameGroupType.Scale; + } + } + + public Vector3 TextColor { + get => new Vector3(Value.RGB.R, Value.RGB.G, Value.RGB.B) * 255.0f; + set { + Value = new AtkTimelineKeyValue { + RGB = value.AsVector4().ToByteColor(), + }; + + GroupType = AtkTimelineKeyGroupType.RGB; + GroupSelector = KeyFrameGroupType.TextColor; + } + } + + public Vector3 TextEdgeColor { + get => new Vector3(Value.RGB.R, Value.RGB.G, Value.RGB.B) * 255.0f; + set { + Value = new AtkTimelineKeyValue { + RGB = value.AsVector4().ToByteColor(), + }; + + GroupType = AtkTimelineKeyGroupType.RGB; + GroupSelector = KeyFrameGroupType.TextEdge; + } + } + + public uint PartId { + set { + Value = new AtkTimelineKeyValue { + UShort = (ushort)value, + }; + + GroupType = AtkTimelineKeyGroupType.UShort; + GroupSelector = KeyFrameGroupType.PartId; + } + } + + private void UpdateNodeTint() { + Value = new AtkTimelineKeyValue { + NodeTint = nodeTint, + }; + + GroupType = AtkTimelineKeyGroupType.NodeTint; + GroupSelector = KeyFrameGroupType.Tint; + } +} diff --git a/KamiToolKit/Timelines/TimelineBuilder.cs b/KamiToolKit/Timelines/TimelineBuilder.cs new file mode 100644 index 0000000..8fa805a --- /dev/null +++ b/KamiToolKit/Timelines/TimelineBuilder.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace KamiToolKit.Timelines; + +public class TimelineBuilder { + + internal List Animations = []; + internal List LabelSets = []; + + public FrameSetBuilder BeginFrameSet(int startFrameId, int endFrameId) + => new(this, startFrameId, endFrameId); + + public TimelineBuilder AddFrameSetWithFrame( + int startFrameId, int endFrameId, int frameId, Vector2? position = null, byte? alpha = null, Vector3? addColor = null, Vector3? multiplyColor = null, + float? rotation = null, Vector2? scale = null, Vector3? textColor = null, Vector3? textOutlineColor = null, uint? partId = null) { + + new FrameSetBuilder(this, startFrameId, endFrameId) + .AddFrame(frameId, position, alpha, addColor, multiplyColor, rotation, scale, textColor, textOutlineColor, partId) + .EndFrameSet(); + + return this; + } + + public KeyFrameBuilder AddFrame(int frameSetStart, int frameSetEnd, int frameIndex) + => new(new FrameSetBuilder(this, frameSetStart, frameSetEnd), frameIndex); + + public Timeline Build() { + var newTimeline = new Timeline(); + + if (LabelSets.Count != 0) { + newTimeline.LabelSets = LabelSets; + newTimeline.LabelFrameIdxDuration = LabelSets.Max(label => label.EndFrameId) - 1; + newTimeline.LabelEndFrameIdx = LabelSets.Max(label => label.EndFrameId); + } + + if (Animations.Count != 0) { + newTimeline.Animations = Animations; + } + + return newTimeline; + } +} diff --git a/KamiToolKit/Timelines/TimelineKeyFrame.cs b/KamiToolKit/Timelines/TimelineKeyFrame.cs new file mode 100644 index 0000000..ff1bc48 --- /dev/null +++ b/KamiToolKit/Timelines/TimelineKeyFrame.cs @@ -0,0 +1,23 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Timelines; + +public abstract class TimelineKeyFrame { + + public KeyFrameGroupType GroupSelector { get; set; } + public AtkTimelineKeyGroupType GroupType { get; set; } + + public float SpeedStart { get; set; } = 0.0f; + public float SpeedEnd { get; set; } = 1.0f; + public required int FrameIndex { get; set; } + public AtkTimelineInterpolation Interpolation { get; set; } = AtkTimelineInterpolation.Linear; + public AtkTimelineKeyValue Value { get; set; } + + public static implicit operator AtkTimelineKeyFrame(TimelineKeyFrame frame) => new() { + Interpolation = frame.Interpolation, + SpeedCoefficient1 = frame.SpeedStart, + SpeedCoefficient2 = frame.SpeedEnd, + FrameIdx = (ushort)frame.FrameIndex, + Value = frame.Value, + }; +} diff --git a/KamiToolKit/Timelines/TimelineLabelSet.cs b/KamiToolKit/Timelines/TimelineLabelSet.cs new file mode 100644 index 0000000..fbc3b2d --- /dev/null +++ b/KamiToolKit/Timelines/TimelineLabelSet.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Timelines; + +public unsafe class TimelineLabelSet : IDisposable { + + private List internalKeyFrames = []; + + internal AtkTimelineLabelSet* InternalLabelSet; + + public TimelineLabelSet() { + InternalLabelSet = NativeMemoryHelper.UiAlloc(); + + InternalLabelSet->StartFrameIdx = 0; + InternalLabelSet->EndFrameIdx = 0; + InternalLabelSet->LabelKeyGroup.Type = AtkTimelineKeyGroupType.Label; + } + + public int StartFrameId { + get => InternalLabelSet->StartFrameIdx; + set => InternalLabelSet->StartFrameIdx = (ushort)value; + } + + public int EndFrameId { + get => InternalLabelSet->EndFrameIdx; + set => InternalLabelSet->EndFrameIdx = (ushort)value; + } + + public List Labels { + get => internalKeyFrames; + set { + internalKeyFrames = value; + Resync(); + } + } + + public void Dispose() { + NativeMemoryHelper.UiFree(InternalLabelSet); + InternalLabelSet = null; + } + + private void Resync() { + ref var keyGroup = ref InternalLabelSet->LabelKeyGroup; + + // Free existing array, we will completely rebuild it + if (keyGroup.KeyFrames is null) { + NativeMemoryHelper.UiFree(keyGroup.KeyFrames, keyGroup.KeyFrameCount); + keyGroup.KeyFrames = null; + } + + // Allocate new array + keyGroup.KeyFrames = NativeMemoryHelper.UiAlloc(internalKeyFrames.Count); + + var index = 0; + foreach (var keyFrame in internalKeyFrames) { + keyGroup.KeyFrames[index] = keyFrame; + index++; + } + + keyGroup.KeyFrameCount = (ushort)internalKeyFrames.Count; + } +} diff --git a/KamiToolKit/Timelines/TimelineLabelSetArray.cs b/KamiToolKit/Timelines/TimelineLabelSetArray.cs new file mode 100644 index 0000000..944475b --- /dev/null +++ b/KamiToolKit/Timelines/TimelineLabelSetArray.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Timelines; + +public unsafe class TimelineLabelSetArray : IDisposable { + + internal AtkTimelineLabelSet* InternalLabelSetArray = null; + + private List labelSets = []; + + public uint Count { get; private set; } + + public List LabelSets { + get => labelSets; + set { + labelSets = value; + Resync(); + } + } + + public void Dispose() { + foreach (var labelSet in labelSets) { + labelSet.Dispose(); + } + + NativeMemoryHelper.UiFree(InternalLabelSetArray, Count); + InternalLabelSetArray = null; + } + + private void Resync() { + // Free existing array, we will completely rebuild it + if (InternalLabelSetArray is not null) { + NativeMemoryHelper.UiFree(InternalLabelSetArray, Count); + InternalLabelSetArray = null; + } + + // Allocate new array + InternalLabelSetArray = NativeMemoryHelper.UiAlloc(labelSets.Count); + + // Copy all Animations into it + foreach (var index in Enumerable.Range(0, labelSets.Count)) { + InternalLabelSetArray[index] = *labelSets[index].InternalLabelSet; + } + + Count = (uint)labelSets.Count; + } +} diff --git a/KamiToolKit/Timelines/TimelineLabelSetKeyFrame.cs b/KamiToolKit/Timelines/TimelineLabelSetKeyFrame.cs new file mode 100644 index 0000000..f53c530 --- /dev/null +++ b/KamiToolKit/Timelines/TimelineLabelSetKeyFrame.cs @@ -0,0 +1,43 @@ +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace KamiToolKit.Timelines; + +public class TimelineLabelSetKeyFrame : TimelineKeyFrame { + + private AtkTimelineLabel data; + + public AtkTimelineJumpBehavior JumpBehavior { + get => data.JumpBehavior; + set { + data.JumpBehavior = value; + UpdateValue(); + } + } + + public int LabelId { + get => data.LabelId; + set { + data.LabelId = (ushort)value; + UpdateValue(); + } + } + + public int JumpLabelId { + get => data.JumpLabelId; + set { + data.JumpLabelId = (byte)value; + UpdateValue(); + } + } + + private void UpdateValue() { + Value = new AtkTimelineKeyValue { + Label = data, + }; + + GroupType = AtkTimelineKeyGroupType.Label; + SpeedEnd = 0.0f; + Interpolation = AtkTimelineInterpolation.None; + GroupSelector = KeyFrameGroupType.TextLabel; + } +} diff --git a/KamiToolKit/Timelines/TimelineResource.cs b/KamiToolKit/Timelines/TimelineResource.cs new file mode 100644 index 0000000..2e96291 --- /dev/null +++ b/KamiToolKit/Timelines/TimelineResource.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit.Classes; + +namespace KamiToolKit.Timelines; + +public unsafe class TimelineResource : IDisposable { + + private readonly TimelineAnimationArray animationArray; + private readonly TimelineLabelSetArray labelsArray; + + internal AtkTimelineResource* InternalResource; + + public TimelineResource() { + InternalResource = NativeMemoryHelper.UiAlloc(); + + InternalResource->Id = 2; + InternalResource->AnimationCount = 0; + InternalResource->LabelSetCount = 0; + + animationArray = new TimelineAnimationArray(); + InternalResource->Animations = animationArray.InternalTimelineArray; + + labelsArray = new TimelineLabelSetArray(); + InternalResource->LabelSets = labelsArray.InternalLabelSetArray; + } + + public List Animations { + get => animationArray.Animations; + set { + animationArray.Animations = value; + InternalResource->Animations = animationArray.InternalTimelineArray; + InternalResource->AnimationCount = (ushort)animationArray.Count; + } + } + + public List LabelSets { + get => labelsArray.LabelSets; + set { + labelsArray.LabelSets = value; + InternalResource->LabelSets = labelsArray.InternalLabelSetArray; + InternalResource->LabelSetCount = (ushort)labelsArray.Count; + } + } + + public int Id { + get => (int)InternalResource->Id; + set => InternalResource->Id = (uint)value; + } + + public void Dispose() { + animationArray.Dispose(); + labelsArray.Dispose(); + + NativeMemoryHelper.UiFree(InternalResource); + InternalResource = null; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..91ce31c --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# AetherBags + +A Final Fantasy XIV Dalamud Plugin that enhances your inventory by organizing and displaying bag contents using native UI elements (made possible by KTK, KamiToolKit). + +It supports user-defined categories with custom names, ordering/priority, colors, and rule-based item filtering (e.g., by item ID, name patterns, UI category, rarity, level/item level, vendor price, and various flags). + +![example](Images/example.png) + +[![Download count](https://img.shields.io/endpoint?url=https://qzysathwfhebdai6xgauhz4q7m0mzmrf.lambda-url.us-east-1.on.aws/AetherBags)](https://github.com/Zeffuro/AetherBags)