From 8db4ce6094e5c20bf5afb959de526515640e1e95 Mon Sep 17 00:00:00 2001 From: Knack117 Date: Sun, 8 Feb 2026 14:46:31 -0500 Subject: [PATCH] Initial commit: AetherBags + KamiToolKit for FC Gitea Co-authored-by: Cursor --- .github/FUNDING.yml | 12 + .github/ISSUE_TEMPLATE/BUG-REPORT.yaml | 57 + .github/ISSUE_TEMPLATE/SUGGESTION.yaml | 12 + .github/workflows/build-debug.yml | 120 ++ .gitignore | 404 +++++++ .gitmodules | 3 + AetherBags.sln | 22 + AetherBags/.gitignore | 1 + .../AddonCategoryConfigurationWindow.cs | 163 +++ AetherBags/Addons/AddonConfigurationWindow.cs | 89 ++ AetherBags/Addons/AddonCurrencyPicker.cs | 28 + AetherBags/Addons/AddonInventoryWindow.cs | 197 +++ AetherBags/Addons/AddonItemPicker.cs | 7 + AetherBags/Addons/AddonRetainerWindow.cs | 191 +++ AetherBags/Addons/AddonSaddleBagWindow.cs | 120 ++ AetherBags/Addons/AddonUICategoryPicker.cs | 14 + AetherBags/Addons/CategoryListItemNode.cs | 14 + AetherBags/Addons/CategoryWrapper.cs | 25 + AetherBags/Addons/IInventoryWindow.cs | 14 + AetherBags/Addons/InventoryAddonBase.cs | 713 +++++++++++ .../Addons/InventoryAddonContextMenu.cs | 83 ++ AetherBags/Addons/ItemContextMenuHandler.cs | 48 + AetherBags/AetherBags.csproj | 33 + AetherBags/Assets/Icons/download.png | Bin 0 -> 1680 bytes AetherBags/Assets/Icons/upload.png | Bin 0 -> 1615 bytes AetherBags/Commands/CommandHandler.cs | 196 +++ AetherBags/Configuration/CategorySettings.cs | 93 ++ AetherBags/Configuration/CurrencySettings.cs | 23 + AetherBags/Configuration/GeneralSettings.cs | 41 + .../Import/SortaKindaCategory.cs | 61 + .../Configuration/SystemConfiguration.cs | 39 + AetherBags/Currency/CurrencyInfo.cs | 11 + AetherBags/Currency/CurrencyState.cs | 188 +++ .../Extensions/AddonLifecycleExtensions.cs | 55 + .../Extensions/AgentInterfaceExtensions.cs | 23 + AetherBags/Extensions/AtkStageExtensions.cs | 34 + .../Extensions/DragDropPayloadExtensions.cs | 73 ++ AetherBags/Extensions/EnumExtensions.cs | 52 + .../Extensions/InventoryItemExtensions.cs | 124 ++ .../Extensions/InventoryTypeExtensions.cs | 218 ++++ AetherBags/Extensions/ItemExtensions.cs | 18 + .../ItemOrderModuleSorterExtensions.cs | 26 + AetherBags/Extensions/LoggerExtensions.cs | 28 + AetherBags/Extensions/NodeBaseExtensions.cs | 12 + AetherBags/GlobalUsing.cs | 2 + AetherBags/Helpers/BackupHelper.cs | 124 ++ .../Helpers/Import/SortaKindaImportExport.cs | 237 ++++ AetherBags/Helpers/ImportExportResetHelper.cs | 89 ++ AetherBags/Helpers/InventoryMoveHelper.cs | 50 + AetherBags/Helpers/JsonFileHelper.cs | 70 ++ AetherBags/Helpers/RegexCache.cs | 47 + AetherBags/Helpers/Util.cs | 104 ++ AetherBags/Hooks/InventoryHook.cs | 140 +++ .../IPC/AetherBagsAPI/AetherBagsAPIImpl.cs | 96 ++ .../AetherBagsAPI/AetherBagsIPCProvider.cs | 83 ++ .../IPC/AetherBagsAPI/IAetherBagsAPI.cs | 26 + AetherBags/IPC/AllaganToolsIPC.cs | 310 +++++ AetherBags/IPC/BisBuddyIPC.cs | 349 ++++++ .../ExternalCategoryManager.cs | 297 +++++ .../IExternalItemSource.cs | 103 ++ AetherBags/IPC/IPCService.cs | 54 + AetherBags/IPC/WotsItIPC.cs | 80 ++ .../Categories/CategorizedInventory.cs | 6 + .../Inventory/Categories/CategoryBucket.cs | 33 + .../Categories/CategoryBucketManager.cs | 481 ++++++++ .../Inventory/Categories/CategoryInfo.cs | 12 + .../Inventory/Categories/InventoryFilter.cs | 65 + .../Categories/UserCategoryMatcher.cs | 115 ++ .../Inventory/Context/HighlightState.cs | 188 +++ .../Context/InventoryContextState.cs | 157 +++ .../Context/InventoryNotificationState.cs | 98 ++ AetherBags/Inventory/InventoryLocation.cs | 25 + AetherBags/Inventory/InventoryOrchestrator.cs | 93 ++ AetherBags/Inventory/Items/InventoryStats.cs | 21 + AetherBags/Inventory/Items/ItemInfo.cs | 222 ++++ AetherBags/Inventory/Items/LootedItemInfo.cs | 5 + .../Inventory/Scanning/AggregatedItem.cs | 9 + .../Inventory/Scanning/InventoryScanner.cs | 219 ++++ .../Inventory/Scanning/InventorySource.cs | 84 ++ .../Inventory/State/InventoryStateBase.cs | 252 ++++ AetherBags/Inventory/State/MainBagState.cs | 17 + AetherBags/Inventory/State/RetainerState.cs | 65 + AetherBags/Inventory/State/SaddleBagState.cs | 27 + AetherBags/Monitoring/InventoryMonitor.cs | 255 ++++ AetherBags/Monitoring/LootedItemsTracker.cs | 229 ++++ AetherBags/Nodes/Color/ColorInputRow.cs | 107 ++ .../Nodes/Color/ColorPreviewButtonNode.cs | 41 + AetherBags/Nodes/Color/ColorPreviewNode.cs | 112 ++ .../Category/BasicSettingsSection.cs | 141 +++ .../Category/CategoryConfigurationNode.cs | 57 + .../CategoryDefinitionConfigurationNode.cs | 118 ++ .../CategoryGeneralConfigurationNode.cs | 177 +++ .../Category/CategoryScrollingAreaNode.cs | 51 + .../Category/ExperimentalConfigurationNode.cs | 50 + .../Category/ListFiltersSection.cs | 114 ++ .../Configuration/Category/RangeFilterRow.cs | 207 ++++ .../Category/RangeFiltersSection.cs | 77 ++ .../Category/RarityEditorNode.cs | 85 ++ .../Category/StateFilterRowNode.cs | 63 + .../Category/StateFiltersSection.cs | 49 + .../Category/StringListEditorNode.cs | 153 +++ .../Category/UICategoryListItemNode.cs | 31 + .../Category/UintListEditorNode.cs | 193 +++ .../CurrencyGeneralConfigurationNode.cs | 194 +++ .../Currency/CurrencyScrollingAreaNode.cs | 15 + .../General/FunctionalConfigurationNode.cs | 173 +++ .../General/GeneralScrollingAreaNode.cs | 34 + .../General/ImportExportResetNode.cs | 71 ++ .../Layout/CompactLookaheadNode.cs | 61 + .../Layout/LayoutConfigurationNode.cs | 95 ++ AetherBags/Nodes/Currency/CurrencyListNode.cs | 10 + AetherBags/Nodes/Currency/CurrencyNode.cs | 58 + .../Nodes/Input/LabeledEnumDropdownNode.cs | 82 ++ .../Nodes/Input/TextInputWithButtonNode.cs | 55 + .../InventoryCategoryHoverCoordinator.cs | 99 ++ .../Nodes/Inventory/InventoryCategoryNode.cs | 551 +++++++++ .../Inventory/InventoryCategoryNodeBase.cs | 24 + .../InventoryCategoryPinCoordinator.cs | 45 + .../Nodes/Inventory/InventoryDragDropNode.cs | 282 +++++ .../Nodes/Inventory/InventoryFooterNode.cs | 78 ++ .../Inventory/InventoryNotificationNode.cs | 121 ++ .../Nodes/Inventory/LootedItemDisplayNode.cs | 106 ++ .../Inventory/LootedItemsCategoryNode.cs | 314 +++++ .../Nodes/Inventory/SaddleBagFooterNode.cs | 32 + .../Nodes/Layout/CollapsibleSectionNode.cs | 192 +++ .../Nodes/Layout/DeferrableLayoutListNode.cs | 615 ++++++++++ AetherBags/Nodes/Layout/FlexGrowDirection.cs | 9 + .../Nodes/Layout/HybridDirectionalFlexNode.cs | 130 ++ .../Layout/HybridDirectionalStackNode.cs | 114 ++ AetherBags/Nodes/Layout/SharedNodePool.cs | 107 ++ .../Nodes/Layout/VirtualizationState.cs | 146 +++ AetherBags/Nodes/Layout/WrappingGridNode.cs | 1052 +++++++++++++++++ AetherBags/Plugin.cs | 123 ++ AetherBags/Services.cs | 26 + AetherBags/System.cs | 20 + AetherBags/changelog.md | 2 + AetherBags/packages.lock.json | 30 + Images/example.png | Bin 0 -> 382849 bytes KamiToolKit/.editorconfig | 108 ++ KamiToolKit/.gitignore | 3 + .../HorizontalGradient_WhiteToAlpha.png | Bin 0 -> 393 bytes .../Assets/VerticalGradient_AlphaToBlack.png | Bin 0 -> 396 bytes .../Assets/VerticalGradient_WhiteToAlpha.png | Bin 0 -> 825 bytes KamiToolKit/Assets/alpha_background.png | Bin 0 -> 406 bytes KamiToolKit/Assets/alpha_selector.png | Bin 0 -> 737 bytes KamiToolKit/Assets/color_ring.png | Bin 0 -> 13621 bytes KamiToolKit/Assets/color_ring_selector.png | Bin 0 -> 5291 bytes KamiToolKit/Assets/color_select_dot.png | Bin 0 -> 754 bytes KamiToolKit/Classes/AddonConfig.cs | 8 + KamiToolKit/Classes/BatchToken.cs | 8 + KamiToolKit/Classes/Bounds.cs | 23 + KamiToolKit/Classes/ColorHelper.cs | 18 + KamiToolKit/Classes/CustomEventInterface.cs | 44 + KamiToolKit/Classes/CustomEventListener.cs | 35 + KamiToolKit/Classes/DalamudInterface.cs | 76 ++ KamiToolKit/Classes/DragDropPayload.cs | 69 ++ KamiToolKit/Classes/Experimental.cs | 39 + KamiToolKit/Classes/FlagHelper.cs | 23 + KamiToolKit/Classes/GenericUtil.cs | 18 + KamiToolKit/Classes/ListPopulatorData.cs | 11 + KamiToolKit/Classes/NativeMemoryHelper.cs | 75 ++ KamiToolKit/Classes/NodeLinker.cs | 199 ++++ KamiToolKit/Classes/Part.cs | 34 + KamiToolKit/Classes/PartsList.cs | 83 ++ KamiToolKit/Classes/TabbedNodeEntry.cs | 3 + KamiToolKit/Classes/ViewportEventListener.cs | 26 + KamiToolKit/ContextMenu/ContextMenu.cs | 147 +++ KamiToolKit/ContextMenu/ContextMenuItem.cs | 11 + KamiToolKit/ContextMenu/ContextMenuSubItem.cs | 21 + KamiToolKit/Controllers/AddonController.cs | 125 ++ .../Controllers/AddonEventController.cs | 40 + .../Controllers/DynamicAddonController.cs | 119 ++ .../Controllers/MultiAddonController.cs | 64 + .../Controllers/NativeListController.cs | 157 +++ KamiToolKit/Enums/CounterFont.cs | 6 + KamiToolKit/Enums/DrawFlags.cs | 21 + KamiToolKit/Enums/FlexFlags.cs | 31 + KamiToolKit/Enums/HorizontalListAnchor.cs | 11 + KamiToolKit/Enums/LayoutAnchor.cs | 17 + KamiToolKit/Enums/LayoutOrientation.cs | 6 + KamiToolKit/Enums/NodeEditMode.cs | 9 + KamiToolKit/Enums/OverlayAddonState.cs | 7 + KamiToolKit/Enums/OverlayControllerState.cs | 7 + KamiToolKit/Enums/OverlayLayer.cs | 51 + KamiToolKit/Enums/ResizeDirection.cs | 6 + KamiToolKit/Enums/TextInputFlags.cs | 20 + KamiToolKit/Enums/VerticalListAlignment.cs | 11 + KamiToolKit/Enums/VerticalListAnchor.cs | 11 + KamiToolKit/Enums/WrapMode.cs | 8 + .../Extensions/AtkEventDataExtensions.cs | 20 + .../Extensions/AtkImageNodeExtensions.cs | 19 + .../Extensions/AtkResNodeExtensions.cs | 140 +++ KamiToolKit/Extensions/AtkStageExtensions.cs | 39 + .../Extensions/AtkUldManagerExtensions.cs | 137 +++ .../Extensions/AtkUldPartExtensions.cs | 110 ++ .../Extensions/AtkUnitBaseExtensions.cs | 42 + KamiToolKit/Extensions/ByteColorExtensions.cs | 12 + KamiToolKit/Extensions/EnumExtensions.cs | 52 + .../Extensions/KnownColorExtensions.cs | 16 + KamiToolKit/Extensions/MainThreadSafety.cs | 23 + .../Extensions/ReadOnlySpanExtensions.cs | 10 + KamiToolKit/Extensions/StopwatchExtensions.cs | 13 + KamiToolKit/GlobalUsings.cs | 1 + KamiToolKit/KamiToolKit.csproj | 32 + KamiToolKit/KamiToolKit.csproj.DotSettings | 32 + KamiToolKit/KamiToolKit.sln | 14 + KamiToolKit/KamiToolKitLibrary.cs | 66 ++ KamiToolKit/LICENSE | 21 + .../NativeAddon/NativeAddon.AddonConfig.cs | 61 + .../NativeAddon/NativeAddon.CloseCallback.cs | 37 + .../NativeAddon/NativeAddon.Disposal.cs | 54 + KamiToolKit/NativeAddon/NativeAddon.Flags.cs | 66 ++ .../NativeAddon/NativeAddon.Functions.cs | 150 +++ .../NativeAddon/NativeAddon.Properties.cs | 58 + .../NativeAddon/NativeAddon.VirtualTable.cs | 60 + KamiToolKit/NativeAddon/NativeAddon.cs | 215 ++++ KamiToolKit/NodeBase/NodeBase.Dispose.cs | 180 +++ KamiToolKit/NodeBase/NodeBase.Edit.cs | 205 ++++ KamiToolKit/NodeBase/NodeBase.Events.cs | 181 +++ KamiToolKit/NodeBase/NodeBase.Linking.cs | 260 ++++ .../NodeBase/NodeBase.NativeProperties.cs | 244 ++++ KamiToolKit/NodeBase/NodeBase.Timeline.cs | 19 + KamiToolKit/NodeBase/NodeBase.Tooltips.cs | 151 +++ KamiToolKit/NodeBase/NodeBase.cs | 56 + KamiToolKit/Nodes/Basic/AlphaImageNode.cs | 11 + .../Nodes/Basic/AlternateCooldownNode.cs | 47 + KamiToolKit/Nodes/Basic/AntsNode.cs | 36 + .../Nodes/Basic/BackgroundImageNode.cs | 26 + KamiToolKit/Nodes/Basic/BorderNineGridNode.cs | 24 + KamiToolKit/Nodes/Basic/CategoryTextNode.cs | 23 + KamiToolKit/Nodes/Basic/CheckboxNode.cs | 230 ++++ KamiToolKit/Nodes/Basic/ClippingMaskNode.cs | 36 + KamiToolKit/Nodes/Basic/CollisionNode.cs | 20 + KamiToolKit/Nodes/Basic/CooldownNode.cs | 63 + KamiToolKit/Nodes/Basic/CounterNode.cs | 155 +++ KamiToolKit/Nodes/Basic/CursorNode.cs | 30 + KamiToolKit/Nodes/Basic/DragDropNode.cs | 271 +++++ KamiToolKit/Nodes/Basic/GifImageNode.cs | 120 ++ .../Nodes/Basic/HoldButtonProgressNode.cs | 63 + KamiToolKit/Nodes/Basic/HorizontalLineNode.cs | 13 + KamiToolKit/Nodes/Basic/IconExtras.cs | 216 ++++ KamiToolKit/Nodes/Basic/IconImageNode.cs | 28 + KamiToolKit/Nodes/Basic/IconIndicator.cs | 44 + .../Nodes/Basic/IconNodeTextureHelper.cs | 78 ++ KamiToolKit/Nodes/Basic/ImGuiImageNode.cs | 64 + KamiToolKit/Nodes/Basic/ImageNode.cs | 61 + KamiToolKit/Nodes/Basic/LabelTextNode.cs | 15 + KamiToolKit/Nodes/Basic/NineGridNode.cs | 78 ++ .../Nodes/Basic/NodeEditOverlayNode.cs | 153 +++ KamiToolKit/Nodes/Basic/NumericInputNode.cs | 190 +++ KamiToolKit/Nodes/Basic/ResNode.cs | 8 + KamiToolKit/Nodes/Basic/ResizeNineGridNode.cs | 40 + .../Nodes/Basic/SimpleClippingMaskNode.cs | 59 + .../Nodes/Basic/SimpleComponentNode.cs | 22 + KamiToolKit/Nodes/Basic/SimpleCounterNode.cs | 14 + KamiToolKit/Nodes/Basic/SimpleImageNode.cs | 64 + KamiToolKit/Nodes/Basic/SimpleNineGridNode.cs | 51 + KamiToolKit/Nodes/Basic/SimpleOverlayNode.cs | 6 + .../Nodes/Basic/TextInputSelectionListNode.cs | 49 + KamiToolKit/Nodes/Basic/TextNineGridNode.cs | 93 ++ KamiToolKit/Nodes/Basic/TextNode.cs | 154 +++ KamiToolKit/Nodes/Basic/TextureImageNode.cs | 26 + .../Nodes/Basic/TreeListCategoryNode.cs | 401 +++++++ KamiToolKit/Nodes/Basic/TreeListHeaderNode.cs | 45 + KamiToolKit/Nodes/Basic/VerticalLineNode.cs | 17 + .../Nodes/Basic/WindowBackgroundNode.cs | 22 + KamiToolKit/Nodes/Component/ButtonBase.cs | 93 ++ KamiToolKit/Nodes/Component/ButtonListNode.cs | 261 ++++ .../Nodes/Component/CircleButtonNode.cs | 150 +++ .../Component/ColorOptionTextButtonNode.cs | 116 ++ KamiToolKit/Nodes/Component/ComponentNode.cs | 112 ++ KamiToolKit/Nodes/Component/DropDownNode.cs | 461 ++++++++ .../Nodes/Component/EnumButtonListNode.cs | 9 + .../Nodes/Component/EnumDropDownNode.cs | 32 + KamiToolKit/Nodes/Component/HoldButtonNode.cs | 270 +++++ KamiToolKit/Nodes/Component/IconButtonNode.cs | 51 + KamiToolKit/Nodes/Component/IconNode.cs | 133 +++ KamiToolKit/Nodes/Component/IconToggleNode.cs | 81 ++ .../Nodes/Component/ImGuiIconButtonNode.cs | 59 + KamiToolKit/Nodes/Component/ListButtonNode.cs | 192 +++ .../Nodes/Component/LuminaButtonListNode.cs | 40 + .../Nodes/Component/LuminaDropDownNode.cs | 47 + .../Nodes/Component/ProgressBarCastNode.cs | 95 ++ .../Component/ProgressBarEnemyCastNode.cs | 57 + .../Nodes/Component/ProgressBarNode.cs | 51 + KamiToolKit/Nodes/Component/ProgressNode.cs | 9 + .../Nodes/Component/RadioButtonGroupNode.cs | 104 ++ .../Nodes/Component/RadioButtonNode.cs | 309 +++++ .../Nodes/Component/ResizeButtonNode.cs | 58 + .../ScrollBarBackgroundButtonNode.cs | 16 + .../ScrollBarForegroundButtonNode.cs | 88 ++ KamiToolKit/Nodes/Component/ScrollBarNode.cs | 128 ++ .../Nodes/Component/ScrollingAreaNode.cs | 100 ++ KamiToolKit/Nodes/Component/SelectableNode.cs | 95 ++ .../Component/SliderBackgroundButtonNode.cs | 83 ++ .../Component/SliderForegroundButtonNode.cs | 78 ++ KamiToolKit/Nodes/Component/SliderNode.cs | 181 +++ KamiToolKit/Nodes/Component/TabBarNode.cs | 130 ++ .../Nodes/Component/TabBarRadioButtonNode.cs | 287 +++++ .../Nodes/Component/TextButtonListNode.cs | 5 + KamiToolKit/Nodes/Component/TextButtonNode.cs | 51 + .../Nodes/Component/TextDropDownNode.cs | 32 + .../Nodes/Component/TextInputButton.cs | 77 ++ KamiToolKit/Nodes/Component/TextInputNode.cs | 318 +++++ .../Nodes/Component/TextMultiLineInputNode.cs | 97 ++ .../TextMultiLineInputNodeScrollable.cs | 184 +++ .../Nodes/Component/TextureButtonNode.cs | 44 + KamiToolKit/Nodes/Component/TreeListNode.cs | 57 + KamiToolKit/Nodes/Component/WindowNode.cs | 265 +++++ KamiToolKit/Nodes/Component/WindowNodeBase.cs | 19 + .../Nodes/Layout/AlignedHorizontalListNode.cs | 7 + .../Nodes/Layout/AlignedVerticalListNode.cs | 7 + KamiToolKit/Nodes/Layout/GridNode.cs | 70 ++ .../Nodes/Layout/HorizontalFlexNode.cs | 49 + .../Nodes/Layout/HorizontalListNode.cs | 66 ++ KamiToolKit/Nodes/Layout/LabelLayoutNode.cs | 29 + KamiToolKit/Nodes/Layout/LayoutListNode.cs | 317 +++++ KamiToolKit/Nodes/Layout/ListBoxNode.cs | 200 ++++ KamiToolKit/Nodes/Layout/ListItemNode.cs | 40 + KamiToolKit/Nodes/Layout/ListNode.cs | 181 +++ .../Nodes/Layout/OrderedVerticalListNode.cs | 46 + KamiToolKit/Nodes/Layout/ScrollingListNode.cs | 111 ++ KamiToolKit/Nodes/Layout/ScrollingTreeNode.cs | 52 + .../Nodes/Layout/TabbedVerticalListNode.cs | 99 ++ KamiToolKit/Nodes/Layout/VerticalListNode.cs | 82 ++ .../Overlay/OverlayController.Addon.cs | 3 + KamiToolKit/Overlay/OverlayController.Node.cs | 32 + KamiToolKit/Overlay/OverlayController.cs | 228 ++++ KamiToolKit/Premade/Addons/ListConfigAddon.cs | 157 +++ KamiToolKit/Premade/Color/ColorEditNode.cs | 93 ++ KamiToolKit/Premade/Color/ColorPickerAddon.cs | 135 +++ .../Premade/Color/ColorPickerWidget.cs | 173 +++ KamiToolKit/Premade/Color/ColorPreviewNode.cs | 55 + .../Premade/Color/ColorPreviewWithInput.cs | 88 ++ .../Premade/Color/ColorRingWithSquareNode.cs | 201 ++++ KamiToolKit/Premade/Color/ColorSquareNode.cs | 65 + .../GenericListItemNode.cs | 81 ++ .../GenericSimpleListItemNode.cs | 43 + .../GenericStringListItemNode.cs | 31 + .../ListItemNodes/AddonListItemNode.cs | 51 + .../ListItemNodes/CurrencyListItemNode.cs | 51 + .../Premade/ListItemNodes/ItemListItemNode.cs | 78 ++ .../ListItemNodes/StatusListItemNode.cs | 47 + .../ListItemNodes/StringListItemNode.cs | 8 + .../TerritoryTypeListItemNode.cs | 89 ++ KamiToolKit/Premade/Nodes/AlphaBarNode.cs | 117 ++ KamiToolKit/Premade/Nodes/ConfigNode.cs | 18 + KamiToolKit/Premade/Nodes/ModifyListNode.cs | 203 ++++ .../Premade/Nodes/MultiStateButtonNode.cs | 58 + .../Premade/Nodes/UnderlinedTextNode.cs | 44 + .../Premade/SearchAddons/AddonSearchAddon.cs | 53 + .../Premade/SearchAddons/BaseSearchAddon.cs | 116 ++ .../SearchAddons/CurrencySearchAddon.cs | 28 + .../Premade/SearchAddons/ItemSearchAddon.cs | 5 + .../SearchAddons/ItemSearchAddonBase.cs | 37 + .../Premade/SearchAddons/StatusSearchAddon.cs | 35 + .../SearchAddons/TerritorySearchAddon.cs | 38 + KamiToolKit/Premade/Widgets/SearchWidget.cs | 109 ++ .../Premade/Widgets/Vector2EditWidget.cs | 94 ++ KamiToolKit/README.md | 2 + KamiToolKit/Timelines/FrameSetBuilder.cs | 156 +++ KamiToolKit/Timelines/KeyFrameBuilder.cs | 102 ++ KamiToolKit/Timelines/NodeTint.cs | 31 + KamiToolKit/Timelines/Timeline.cs | 188 +++ KamiToolKit/Timelines/TimelineAnimation.cs | 92 ++ .../Timelines/TimelineAnimationArray.cs | 50 + .../Timelines/TimelineAnimationKeyFrame.cs | 116 ++ KamiToolKit/Timelines/TimelineBuilder.cs | 44 + KamiToolKit/Timelines/TimelineKeyFrame.cs | 23 + KamiToolKit/Timelines/TimelineLabelSet.cs | 65 + .../Timelines/TimelineLabelSetArray.cs | 51 + .../Timelines/TimelineLabelSetKeyFrame.cs | 43 + KamiToolKit/Timelines/TimelineResource.cs | 59 + LICENSE | 661 +++++++++++ README.md | 9 + 375 files changed, 34124 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/BUG-REPORT.yaml create mode 100644 .github/ISSUE_TEMPLATE/SUGGESTION.yaml create mode 100644 .github/workflows/build-debug.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 AetherBags.sln create mode 100644 AetherBags/.gitignore create mode 100644 AetherBags/Addons/AddonCategoryConfigurationWindow.cs create mode 100644 AetherBags/Addons/AddonConfigurationWindow.cs create mode 100644 AetherBags/Addons/AddonCurrencyPicker.cs create mode 100644 AetherBags/Addons/AddonInventoryWindow.cs create mode 100644 AetherBags/Addons/AddonItemPicker.cs create mode 100644 AetherBags/Addons/AddonRetainerWindow.cs create mode 100644 AetherBags/Addons/AddonSaddleBagWindow.cs create mode 100644 AetherBags/Addons/AddonUICategoryPicker.cs create mode 100644 AetherBags/Addons/CategoryListItemNode.cs create mode 100644 AetherBags/Addons/CategoryWrapper.cs create mode 100644 AetherBags/Addons/IInventoryWindow.cs create mode 100644 AetherBags/Addons/InventoryAddonBase.cs create mode 100644 AetherBags/Addons/InventoryAddonContextMenu.cs create mode 100644 AetherBags/Addons/ItemContextMenuHandler.cs create mode 100644 AetherBags/AetherBags.csproj create mode 100644 AetherBags/Assets/Icons/download.png create mode 100644 AetherBags/Assets/Icons/upload.png create mode 100644 AetherBags/Commands/CommandHandler.cs create mode 100644 AetherBags/Configuration/CategorySettings.cs create mode 100644 AetherBags/Configuration/CurrencySettings.cs create mode 100644 AetherBags/Configuration/GeneralSettings.cs create mode 100644 AetherBags/Configuration/Import/SortaKindaCategory.cs create mode 100644 AetherBags/Configuration/SystemConfiguration.cs create mode 100644 AetherBags/Currency/CurrencyInfo.cs create mode 100644 AetherBags/Currency/CurrencyState.cs create mode 100644 AetherBags/Extensions/AddonLifecycleExtensions.cs create mode 100644 AetherBags/Extensions/AgentInterfaceExtensions.cs create mode 100644 AetherBags/Extensions/AtkStageExtensions.cs create mode 100644 AetherBags/Extensions/DragDropPayloadExtensions.cs create mode 100644 AetherBags/Extensions/EnumExtensions.cs create mode 100644 AetherBags/Extensions/InventoryItemExtensions.cs create mode 100644 AetherBags/Extensions/InventoryTypeExtensions.cs create mode 100644 AetherBags/Extensions/ItemExtensions.cs create mode 100644 AetherBags/Extensions/ItemOrderModuleSorterExtensions.cs create mode 100644 AetherBags/Extensions/LoggerExtensions.cs create mode 100644 AetherBags/Extensions/NodeBaseExtensions.cs create mode 100644 AetherBags/GlobalUsing.cs create mode 100644 AetherBags/Helpers/BackupHelper.cs create mode 100644 AetherBags/Helpers/Import/SortaKindaImportExport.cs create mode 100644 AetherBags/Helpers/ImportExportResetHelper.cs create mode 100644 AetherBags/Helpers/InventoryMoveHelper.cs create mode 100644 AetherBags/Helpers/JsonFileHelper.cs create mode 100644 AetherBags/Helpers/RegexCache.cs create mode 100644 AetherBags/Helpers/Util.cs create mode 100644 AetherBags/Hooks/InventoryHook.cs create mode 100644 AetherBags/IPC/AetherBagsAPI/AetherBagsAPIImpl.cs create mode 100644 AetherBags/IPC/AetherBagsAPI/AetherBagsIPCProvider.cs create mode 100644 AetherBags/IPC/AetherBagsAPI/IAetherBagsAPI.cs create mode 100644 AetherBags/IPC/AllaganToolsIPC.cs create mode 100644 AetherBags/IPC/BisBuddyIPC.cs create mode 100644 AetherBags/IPC/ExternalCategorySystem/ExternalCategoryManager.cs create mode 100644 AetherBags/IPC/ExternalCategorySystem/IExternalItemSource.cs create mode 100644 AetherBags/IPC/IPCService.cs create mode 100644 AetherBags/IPC/WotsItIPC.cs create mode 100644 AetherBags/Inventory/Categories/CategorizedInventory.cs create mode 100644 AetherBags/Inventory/Categories/CategoryBucket.cs create mode 100644 AetherBags/Inventory/Categories/CategoryBucketManager.cs create mode 100644 AetherBags/Inventory/Categories/CategoryInfo.cs create mode 100644 AetherBags/Inventory/Categories/InventoryFilter.cs create mode 100644 AetherBags/Inventory/Categories/UserCategoryMatcher.cs create mode 100644 AetherBags/Inventory/Context/HighlightState.cs create mode 100644 AetherBags/Inventory/Context/InventoryContextState.cs create mode 100644 AetherBags/Inventory/Context/InventoryNotificationState.cs create mode 100644 AetherBags/Inventory/InventoryLocation.cs create mode 100644 AetherBags/Inventory/InventoryOrchestrator.cs create mode 100644 AetherBags/Inventory/Items/InventoryStats.cs create mode 100644 AetherBags/Inventory/Items/ItemInfo.cs create mode 100644 AetherBags/Inventory/Items/LootedItemInfo.cs create mode 100644 AetherBags/Inventory/Scanning/AggregatedItem.cs create mode 100644 AetherBags/Inventory/Scanning/InventoryScanner.cs create mode 100644 AetherBags/Inventory/Scanning/InventorySource.cs create mode 100644 AetherBags/Inventory/State/InventoryStateBase.cs create mode 100644 AetherBags/Inventory/State/MainBagState.cs create mode 100644 AetherBags/Inventory/State/RetainerState.cs create mode 100644 AetherBags/Inventory/State/SaddleBagState.cs create mode 100644 AetherBags/Monitoring/InventoryMonitor.cs create mode 100644 AetherBags/Monitoring/LootedItemsTracker.cs create mode 100644 AetherBags/Nodes/Color/ColorInputRow.cs create mode 100644 AetherBags/Nodes/Color/ColorPreviewButtonNode.cs create mode 100644 AetherBags/Nodes/Color/ColorPreviewNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/BasicSettingsSection.cs create mode 100644 AetherBags/Nodes/Configuration/Category/CategoryConfigurationNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/CategoryDefinitionConfigurationNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/CategoryGeneralConfigurationNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/CategoryScrollingAreaNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/ExperimentalConfigurationNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/ListFiltersSection.cs create mode 100644 AetherBags/Nodes/Configuration/Category/RangeFilterRow.cs create mode 100644 AetherBags/Nodes/Configuration/Category/RangeFiltersSection.cs create mode 100644 AetherBags/Nodes/Configuration/Category/RarityEditorNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/StateFilterRowNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/StateFiltersSection.cs create mode 100644 AetherBags/Nodes/Configuration/Category/StringListEditorNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/UICategoryListItemNode.cs create mode 100644 AetherBags/Nodes/Configuration/Category/UintListEditorNode.cs create mode 100644 AetherBags/Nodes/Configuration/Currency/CurrencyGeneralConfigurationNode.cs create mode 100644 AetherBags/Nodes/Configuration/Currency/CurrencyScrollingAreaNode.cs create mode 100644 AetherBags/Nodes/Configuration/General/FunctionalConfigurationNode.cs create mode 100644 AetherBags/Nodes/Configuration/General/GeneralScrollingAreaNode.cs create mode 100644 AetherBags/Nodes/Configuration/General/ImportExportResetNode.cs create mode 100644 AetherBags/Nodes/Configuration/Layout/CompactLookaheadNode.cs create mode 100644 AetherBags/Nodes/Configuration/Layout/LayoutConfigurationNode.cs create mode 100644 AetherBags/Nodes/Currency/CurrencyListNode.cs create mode 100644 AetherBags/Nodes/Currency/CurrencyNode.cs create mode 100644 AetherBags/Nodes/Input/LabeledEnumDropdownNode.cs create mode 100644 AetherBags/Nodes/Input/TextInputWithButtonNode.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryCategoryHoverCoordinator.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryCategoryNode.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryCategoryNodeBase.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryCategoryPinCoordinator.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryDragDropNode.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryFooterNode.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryNotificationNode.cs create mode 100644 AetherBags/Nodes/Inventory/LootedItemDisplayNode.cs create mode 100644 AetherBags/Nodes/Inventory/LootedItemsCategoryNode.cs create mode 100644 AetherBags/Nodes/Inventory/SaddleBagFooterNode.cs create mode 100644 AetherBags/Nodes/Layout/CollapsibleSectionNode.cs create mode 100644 AetherBags/Nodes/Layout/DeferrableLayoutListNode.cs create mode 100644 AetherBags/Nodes/Layout/FlexGrowDirection.cs create mode 100644 AetherBags/Nodes/Layout/HybridDirectionalFlexNode.cs create mode 100644 AetherBags/Nodes/Layout/HybridDirectionalStackNode.cs create mode 100644 AetherBags/Nodes/Layout/SharedNodePool.cs create mode 100644 AetherBags/Nodes/Layout/VirtualizationState.cs create mode 100644 AetherBags/Nodes/Layout/WrappingGridNode.cs create mode 100644 AetherBags/Plugin.cs create mode 100644 AetherBags/Services.cs create mode 100644 AetherBags/System.cs create mode 100644 AetherBags/changelog.md create mode 100644 AetherBags/packages.lock.json create mode 100644 Images/example.png create mode 100644 KamiToolKit/.editorconfig create mode 100644 KamiToolKit/.gitignore create mode 100644 KamiToolKit/Assets/HorizontalGradient_WhiteToAlpha.png create mode 100644 KamiToolKit/Assets/VerticalGradient_AlphaToBlack.png create mode 100644 KamiToolKit/Assets/VerticalGradient_WhiteToAlpha.png create mode 100644 KamiToolKit/Assets/alpha_background.png create mode 100644 KamiToolKit/Assets/alpha_selector.png create mode 100644 KamiToolKit/Assets/color_ring.png create mode 100644 KamiToolKit/Assets/color_ring_selector.png create mode 100644 KamiToolKit/Assets/color_select_dot.png create mode 100644 KamiToolKit/Classes/AddonConfig.cs create mode 100644 KamiToolKit/Classes/BatchToken.cs create mode 100644 KamiToolKit/Classes/Bounds.cs create mode 100644 KamiToolKit/Classes/ColorHelper.cs create mode 100644 KamiToolKit/Classes/CustomEventInterface.cs create mode 100644 KamiToolKit/Classes/CustomEventListener.cs create mode 100644 KamiToolKit/Classes/DalamudInterface.cs create mode 100644 KamiToolKit/Classes/DragDropPayload.cs create mode 100644 KamiToolKit/Classes/Experimental.cs create mode 100644 KamiToolKit/Classes/FlagHelper.cs create mode 100644 KamiToolKit/Classes/GenericUtil.cs create mode 100644 KamiToolKit/Classes/ListPopulatorData.cs create mode 100644 KamiToolKit/Classes/NativeMemoryHelper.cs create mode 100644 KamiToolKit/Classes/NodeLinker.cs create mode 100644 KamiToolKit/Classes/Part.cs create mode 100644 KamiToolKit/Classes/PartsList.cs create mode 100644 KamiToolKit/Classes/TabbedNodeEntry.cs create mode 100644 KamiToolKit/Classes/ViewportEventListener.cs create mode 100644 KamiToolKit/ContextMenu/ContextMenu.cs create mode 100644 KamiToolKit/ContextMenu/ContextMenuItem.cs create mode 100644 KamiToolKit/ContextMenu/ContextMenuSubItem.cs create mode 100644 KamiToolKit/Controllers/AddonController.cs create mode 100644 KamiToolKit/Controllers/AddonEventController.cs create mode 100644 KamiToolKit/Controllers/DynamicAddonController.cs create mode 100644 KamiToolKit/Controllers/MultiAddonController.cs create mode 100644 KamiToolKit/Controllers/NativeListController.cs create mode 100644 KamiToolKit/Enums/CounterFont.cs create mode 100644 KamiToolKit/Enums/DrawFlags.cs create mode 100644 KamiToolKit/Enums/FlexFlags.cs create mode 100644 KamiToolKit/Enums/HorizontalListAnchor.cs create mode 100644 KamiToolKit/Enums/LayoutAnchor.cs create mode 100644 KamiToolKit/Enums/LayoutOrientation.cs create mode 100644 KamiToolKit/Enums/NodeEditMode.cs create mode 100644 KamiToolKit/Enums/OverlayAddonState.cs create mode 100644 KamiToolKit/Enums/OverlayControllerState.cs create mode 100644 KamiToolKit/Enums/OverlayLayer.cs create mode 100644 KamiToolKit/Enums/ResizeDirection.cs create mode 100644 KamiToolKit/Enums/TextInputFlags.cs create mode 100644 KamiToolKit/Enums/VerticalListAlignment.cs create mode 100644 KamiToolKit/Enums/VerticalListAnchor.cs create mode 100644 KamiToolKit/Enums/WrapMode.cs create mode 100644 KamiToolKit/Extensions/AtkEventDataExtensions.cs create mode 100644 KamiToolKit/Extensions/AtkImageNodeExtensions.cs create mode 100644 KamiToolKit/Extensions/AtkResNodeExtensions.cs create mode 100644 KamiToolKit/Extensions/AtkStageExtensions.cs create mode 100644 KamiToolKit/Extensions/AtkUldManagerExtensions.cs create mode 100644 KamiToolKit/Extensions/AtkUldPartExtensions.cs create mode 100644 KamiToolKit/Extensions/AtkUnitBaseExtensions.cs create mode 100644 KamiToolKit/Extensions/ByteColorExtensions.cs create mode 100644 KamiToolKit/Extensions/EnumExtensions.cs create mode 100644 KamiToolKit/Extensions/KnownColorExtensions.cs create mode 100644 KamiToolKit/Extensions/MainThreadSafety.cs create mode 100644 KamiToolKit/Extensions/ReadOnlySpanExtensions.cs create mode 100644 KamiToolKit/Extensions/StopwatchExtensions.cs create mode 100644 KamiToolKit/GlobalUsings.cs create mode 100644 KamiToolKit/KamiToolKit.csproj create mode 100644 KamiToolKit/KamiToolKit.csproj.DotSettings create mode 100644 KamiToolKit/KamiToolKit.sln create mode 100644 KamiToolKit/KamiToolKitLibrary.cs create mode 100644 KamiToolKit/LICENSE create mode 100644 KamiToolKit/NativeAddon/NativeAddon.AddonConfig.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.CloseCallback.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.Disposal.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.Flags.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.Functions.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.Properties.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.VirtualTable.cs create mode 100644 KamiToolKit/NativeAddon/NativeAddon.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.Dispose.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.Edit.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.Events.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.Linking.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.NativeProperties.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.Timeline.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.Tooltips.cs create mode 100644 KamiToolKit/NodeBase/NodeBase.cs create mode 100644 KamiToolKit/Nodes/Basic/AlphaImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/AlternateCooldownNode.cs create mode 100644 KamiToolKit/Nodes/Basic/AntsNode.cs create mode 100644 KamiToolKit/Nodes/Basic/BackgroundImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/BorderNineGridNode.cs create mode 100644 KamiToolKit/Nodes/Basic/CategoryTextNode.cs create mode 100644 KamiToolKit/Nodes/Basic/CheckboxNode.cs create mode 100644 KamiToolKit/Nodes/Basic/ClippingMaskNode.cs create mode 100644 KamiToolKit/Nodes/Basic/CollisionNode.cs create mode 100644 KamiToolKit/Nodes/Basic/CooldownNode.cs create mode 100644 KamiToolKit/Nodes/Basic/CounterNode.cs create mode 100644 KamiToolKit/Nodes/Basic/CursorNode.cs create mode 100644 KamiToolKit/Nodes/Basic/DragDropNode.cs create mode 100644 KamiToolKit/Nodes/Basic/GifImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/HoldButtonProgressNode.cs create mode 100644 KamiToolKit/Nodes/Basic/HorizontalLineNode.cs create mode 100644 KamiToolKit/Nodes/Basic/IconExtras.cs create mode 100644 KamiToolKit/Nodes/Basic/IconImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/IconIndicator.cs create mode 100644 KamiToolKit/Nodes/Basic/IconNodeTextureHelper.cs create mode 100644 KamiToolKit/Nodes/Basic/ImGuiImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/ImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/LabelTextNode.cs create mode 100644 KamiToolKit/Nodes/Basic/NineGridNode.cs create mode 100644 KamiToolKit/Nodes/Basic/NodeEditOverlayNode.cs create mode 100644 KamiToolKit/Nodes/Basic/NumericInputNode.cs create mode 100644 KamiToolKit/Nodes/Basic/ResNode.cs create mode 100644 KamiToolKit/Nodes/Basic/ResizeNineGridNode.cs create mode 100644 KamiToolKit/Nodes/Basic/SimpleClippingMaskNode.cs create mode 100644 KamiToolKit/Nodes/Basic/SimpleComponentNode.cs create mode 100644 KamiToolKit/Nodes/Basic/SimpleCounterNode.cs create mode 100644 KamiToolKit/Nodes/Basic/SimpleImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/SimpleNineGridNode.cs create mode 100644 KamiToolKit/Nodes/Basic/SimpleOverlayNode.cs create mode 100644 KamiToolKit/Nodes/Basic/TextInputSelectionListNode.cs create mode 100644 KamiToolKit/Nodes/Basic/TextNineGridNode.cs create mode 100644 KamiToolKit/Nodes/Basic/TextNode.cs create mode 100644 KamiToolKit/Nodes/Basic/TextureImageNode.cs create mode 100644 KamiToolKit/Nodes/Basic/TreeListCategoryNode.cs create mode 100644 KamiToolKit/Nodes/Basic/TreeListHeaderNode.cs create mode 100644 KamiToolKit/Nodes/Basic/VerticalLineNode.cs create mode 100644 KamiToolKit/Nodes/Basic/WindowBackgroundNode.cs create mode 100644 KamiToolKit/Nodes/Component/ButtonBase.cs create mode 100644 KamiToolKit/Nodes/Component/ButtonListNode.cs create mode 100644 KamiToolKit/Nodes/Component/CircleButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ColorOptionTextButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ComponentNode.cs create mode 100644 KamiToolKit/Nodes/Component/DropDownNode.cs create mode 100644 KamiToolKit/Nodes/Component/EnumButtonListNode.cs create mode 100644 KamiToolKit/Nodes/Component/EnumDropDownNode.cs create mode 100644 KamiToolKit/Nodes/Component/HoldButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/IconButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/IconNode.cs create mode 100644 KamiToolKit/Nodes/Component/IconToggleNode.cs create mode 100644 KamiToolKit/Nodes/Component/ImGuiIconButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ListButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/LuminaButtonListNode.cs create mode 100644 KamiToolKit/Nodes/Component/LuminaDropDownNode.cs create mode 100644 KamiToolKit/Nodes/Component/ProgressBarCastNode.cs create mode 100644 KamiToolKit/Nodes/Component/ProgressBarEnemyCastNode.cs create mode 100644 KamiToolKit/Nodes/Component/ProgressBarNode.cs create mode 100644 KamiToolKit/Nodes/Component/ProgressNode.cs create mode 100644 KamiToolKit/Nodes/Component/RadioButtonGroupNode.cs create mode 100644 KamiToolKit/Nodes/Component/RadioButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ResizeButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ScrollBarBackgroundButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ScrollBarForegroundButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/ScrollBarNode.cs create mode 100644 KamiToolKit/Nodes/Component/ScrollingAreaNode.cs create mode 100644 KamiToolKit/Nodes/Component/SelectableNode.cs create mode 100644 KamiToolKit/Nodes/Component/SliderBackgroundButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/SliderForegroundButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/SliderNode.cs create mode 100644 KamiToolKit/Nodes/Component/TabBarNode.cs create mode 100644 KamiToolKit/Nodes/Component/TabBarRadioButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/TextButtonListNode.cs create mode 100644 KamiToolKit/Nodes/Component/TextButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/TextDropDownNode.cs create mode 100644 KamiToolKit/Nodes/Component/TextInputButton.cs create mode 100644 KamiToolKit/Nodes/Component/TextInputNode.cs create mode 100644 KamiToolKit/Nodes/Component/TextMultiLineInputNode.cs create mode 100644 KamiToolKit/Nodes/Component/TextMultiLineInputNodeScrollable.cs create mode 100644 KamiToolKit/Nodes/Component/TextureButtonNode.cs create mode 100644 KamiToolKit/Nodes/Component/TreeListNode.cs create mode 100644 KamiToolKit/Nodes/Component/WindowNode.cs create mode 100644 KamiToolKit/Nodes/Component/WindowNodeBase.cs create mode 100644 KamiToolKit/Nodes/Layout/AlignedHorizontalListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/AlignedVerticalListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/GridNode.cs create mode 100644 KamiToolKit/Nodes/Layout/HorizontalFlexNode.cs create mode 100644 KamiToolKit/Nodes/Layout/HorizontalListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/LabelLayoutNode.cs create mode 100644 KamiToolKit/Nodes/Layout/LayoutListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/ListBoxNode.cs create mode 100644 KamiToolKit/Nodes/Layout/ListItemNode.cs create mode 100644 KamiToolKit/Nodes/Layout/ListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/OrderedVerticalListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/ScrollingListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/ScrollingTreeNode.cs create mode 100644 KamiToolKit/Nodes/Layout/TabbedVerticalListNode.cs create mode 100644 KamiToolKit/Nodes/Layout/VerticalListNode.cs create mode 100644 KamiToolKit/Overlay/OverlayController.Addon.cs create mode 100644 KamiToolKit/Overlay/OverlayController.Node.cs create mode 100644 KamiToolKit/Overlay/OverlayController.cs create mode 100644 KamiToolKit/Premade/Addons/ListConfigAddon.cs create mode 100644 KamiToolKit/Premade/Color/ColorEditNode.cs create mode 100644 KamiToolKit/Premade/Color/ColorPickerAddon.cs create mode 100644 KamiToolKit/Premade/Color/ColorPickerWidget.cs create mode 100644 KamiToolKit/Premade/Color/ColorPreviewNode.cs create mode 100644 KamiToolKit/Premade/Color/ColorPreviewWithInput.cs create mode 100644 KamiToolKit/Premade/Color/ColorRingWithSquareNode.cs create mode 100644 KamiToolKit/Premade/Color/ColorSquareNode.cs create mode 100644 KamiToolKit/Premade/GenericListItemNodes/GenericListItemNode.cs create mode 100644 KamiToolKit/Premade/GenericListItemNodes/GenericSimpleListItemNode.cs create mode 100644 KamiToolKit/Premade/GenericListItemNodes/GenericStringListItemNode.cs create mode 100644 KamiToolKit/Premade/ListItemNodes/AddonListItemNode.cs create mode 100644 KamiToolKit/Premade/ListItemNodes/CurrencyListItemNode.cs create mode 100644 KamiToolKit/Premade/ListItemNodes/ItemListItemNode.cs create mode 100644 KamiToolKit/Premade/ListItemNodes/StatusListItemNode.cs create mode 100644 KamiToolKit/Premade/ListItemNodes/StringListItemNode.cs create mode 100644 KamiToolKit/Premade/ListItemNodes/TerritoryTypeListItemNode.cs create mode 100644 KamiToolKit/Premade/Nodes/AlphaBarNode.cs create mode 100644 KamiToolKit/Premade/Nodes/ConfigNode.cs create mode 100644 KamiToolKit/Premade/Nodes/ModifyListNode.cs create mode 100644 KamiToolKit/Premade/Nodes/MultiStateButtonNode.cs create mode 100644 KamiToolKit/Premade/Nodes/UnderlinedTextNode.cs create mode 100644 KamiToolKit/Premade/SearchAddons/AddonSearchAddon.cs create mode 100644 KamiToolKit/Premade/SearchAddons/BaseSearchAddon.cs create mode 100644 KamiToolKit/Premade/SearchAddons/CurrencySearchAddon.cs create mode 100644 KamiToolKit/Premade/SearchAddons/ItemSearchAddon.cs create mode 100644 KamiToolKit/Premade/SearchAddons/ItemSearchAddonBase.cs create mode 100644 KamiToolKit/Premade/SearchAddons/StatusSearchAddon.cs create mode 100644 KamiToolKit/Premade/SearchAddons/TerritorySearchAddon.cs create mode 100644 KamiToolKit/Premade/Widgets/SearchWidget.cs create mode 100644 KamiToolKit/Premade/Widgets/Vector2EditWidget.cs create mode 100644 KamiToolKit/README.md create mode 100644 KamiToolKit/Timelines/FrameSetBuilder.cs create mode 100644 KamiToolKit/Timelines/KeyFrameBuilder.cs create mode 100644 KamiToolKit/Timelines/NodeTint.cs create mode 100644 KamiToolKit/Timelines/Timeline.cs create mode 100644 KamiToolKit/Timelines/TimelineAnimation.cs create mode 100644 KamiToolKit/Timelines/TimelineAnimationArray.cs create mode 100644 KamiToolKit/Timelines/TimelineAnimationKeyFrame.cs create mode 100644 KamiToolKit/Timelines/TimelineBuilder.cs create mode 100644 KamiToolKit/Timelines/TimelineKeyFrame.cs create mode 100644 KamiToolKit/Timelines/TimelineLabelSet.cs create mode 100644 KamiToolKit/Timelines/TimelineLabelSetArray.cs create mode 100644 KamiToolKit/Timelines/TimelineLabelSetKeyFrame.cs create mode 100644 KamiToolKit/Timelines/TimelineResource.cs create mode 100644 LICENSE create mode 100644 README.md 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 0000000000000000000000000000000000000000..1d02268707a026bb19c709a56278dc4346d11410 GIT binary patch literal 1680 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 C4c3tW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..690b3cfd8dc2ac81752998569b84971eac917898 GIT binary patch literal 382849 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

P`kXFOuMW zVEZzDE(VMJ^a?+D?F{d}agN*9_jBUNCQcq)CjoqET{r_#e*M}$ z-hbyD|L3BUB>0wn8AW@RLh*>hiXkbUCbxyPjdohp(WHitlaITfdi-&| z`>k&=dGuqXD#B>@R$$3Wrlv3%lO`IoA(=Y2g;}*uIvU(0#Y~Z%N5mpjRoH2q;i0)n zjK%dZL*6M?;;kw39e}71j|y!RIDAdgAGT;8_ips{qZB`Ir*D%`t5rlrMiLhhggP~z zxQJjHn>@75nJG`*>M|5qOv%h_cGKMArlC<}m26bHjIs@4U7dp}cP`D%l~P`>fN#+Y zJV*Ak_Rc7wy0#E!zQ_s%U^D9}FSk=t`e@$8WB4E4JRt$(=RXj=j<3kaZ-##rZ3TPyKR@r_cP|(7n`;i< z*k|M)Uvlw}x663rxJ8zxys#^m)`}S3c)69gUv0-_S5aG8Kud#Dwx$w?IuC5^pu5?H zr%cpQnn%3u_s6?Sv_rC+P$VWumFA~oH>t4em88UmpiB@wl?}{ZxCpb;N#%?t@`_6d zi;ZW(lrYNe7MYER3>=5kW|2>LMAw^qi&6HI6^}SeOA0XP6M5qCZ<3uFg)S+KDU-&~ z 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

!+l^lkM-s2t( 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%0Dy9~{D@sIt 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%0D3n^`P0 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 a{m-!{%%1{|I4=W+GJ~h9pUXO@geCy4?tuFM literal 0 HcmV?d00001 diff --git a/KamiToolKit/Assets/VerticalGradient_WhiteToAlpha.png b/KamiToolKit/Assets/VerticalGradient_WhiteToAlpha.png new file mode 100644 index 0000000000000000000000000000000000000000..cc516f3ad50df6c87915f9b92385637f6dbd39ac GIT binary patch literal 825 zcmXxiUr19?9Ki82%TX>bJw#v#z1)W$io}OKij&E_4_$`DFokGfNX1YPM$*GZK17L& zv~VJVrkRwEEWJkgXE0)olL zr9z}6G^gYG{hM1;-uPLo$p7B2YIUlX4Rz{Q`5e6++gN&da6GE@YH`Ev)kIZJ^Ti75 z{$F2iQN~(XNp8lP3m!lT3>(4h=qxnA2E2wiJc3>L42#eNKjAHmLJjP|2bhL>Scf)< zK{f2dAS^>OY(Y1SK`s1-KA3?Q@B?1K7Z~n2>~5VHOr)8kS)O_$)jLD`3I`Oab?W zx$jTgm6kJEJ IB=6S4|5=<%?f?J) literal 0 HcmV?d00001 diff --git a/KamiToolKit/Assets/alpha_background.png b/KamiToolKit/Assets/alpha_background.png new file mode 100644 index 0000000000000000000000000000000000000000..c239751af15397e1c89e94ce9da0f98266da74d9 GIT binary patch literal 406 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 Gg#QJVwGwv# literal 0 HcmV?d00001 diff --git a/KamiToolKit/Assets/color_ring_selector.png b/KamiToolKit/Assets/color_ring_selector.png new file mode 100644 index 0000000000000000000000000000000000000000..dbdfb009215988e069098a10e5beafa076320436 GIT binary patch literal 5291 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 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)