From 896f46f0d198d19243f6f651fcf91a06f8988e19 Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Fri, 30 Oct 2026 15:55:43 +0000 Subject: [PATCH] Bagshui 1.6.4 --- .git-blame-ignore-revs | 4 + .github/DISCUSSION_TEMPLATE/ideas.yaml | 30 + .github/ISSUE_TEMPLATE/bug_report.yaml | 45 + .github/ISSUE_TEMPLATE/config.yml | 5 + .../ISSUE_TEMPLATE/miscategorized_item.yaml | 46 + .github/Pull_Request_Template.md | 35 + .../workflows/AnnounceLocalizationChanges.yml | 82 + .github/workflows/AutoReleaseOnVersionTag.yml | 76 + .../CreateAddonReleaseFromChangelog.yml | 164 ++ .github/workflows/LockClosedThreads.yml | 25 + .github/workflows/UpdateIssueOnClose.yml | 18 + .github/workflows/UpdateIssueOnOpen.yml | 19 + Bagshui.lua | 1203 ++++++++ Bagshui.toc | 10 + Bagshui.xml | 171 ++ Bindings.xml | 36 + Changelog.md | 644 ++++ Components/API.lua | 106 + Components/About.lua | 90 + Components/ActiveQuestItems.lua | 140 + Components/Bags.lua | 161 + Components/Bagshui.BlizzFixes.lua | 50 + Components/Bagshui.Cursor.lua | 310 ++ Components/Bagshui.Events.lua | 180 ++ Components/Bagshui.Menus.lua | 444 +++ Components/Bagshui.Output.lua | 219 ++ Components/Bagshui.Tooltips.lua | 746 +++++ Components/Bagshui.Windows.lua | 114 + Components/Bank.lua | 267 ++ Components/Catalog.lua | 1387 +++++++++ Components/Categories.Ui.lua | 895 ++++++ Components/Categories.lua | 796 +++++ Components/Character.lua | 682 +++++ Components/CharacterData.lua | 265 ++ Components/Compat.lua | 221 ++ Components/GameInfo.lua | 386 +++ Components/GameReport.lua | 68 + Components/Hooks.lua | 192 ++ Components/Inventory.Actions.lua | 798 +++++ Components/Inventory.Cache.lua | 598 ++++ Components/Inventory.EditMode.lua | 668 +++++ Components/Inventory.Layout.lua | 2619 +++++++++++++++++ Components/Inventory.Menus.Settings.lua | 699 +++++ Components/Inventory.Menus.lua | 1242 ++++++++ Components/Inventory.Ui.BagButton.lua | 663 +++++ Components/Inventory.Ui.Group.lua | 489 +++ Components/Inventory.Ui.ItemButton.lua | 1273 ++++++++ Components/Inventory.Ui.lua | 1647 +++++++++++ Components/Inventory.lua | 1027 +++++++ Components/ItemInfo.lua | 936 ++++++ Components/Keyring.lua | 14 + Components/Localization.lua | 242 ++ Components/LogWindow.lua | 74 + Components/Menus.lua | 1244 ++++++++ Components/ObjectList.lua | 1223 ++++++++ Components/ObjectManager.lua | 1687 +++++++++++ Components/Profiles.Ui.lua | 223 ++ Components/Profiles.lua | 837 ++++++ Components/Rules.lua | 1190 ++++++++ Components/Settings.lua | 761 +++++ Components/Share.lua | 581 ++++ Components/Skins.lua | 20 + Components/Slash.lua | 166 ++ Components/SortOrders.Ui.lua | 350 +++ Components/SortOrders.lua | 410 +++ Components/TrackedItemBar.lua | 373 +++ Components/Ui.Button.lua | 708 +++++ Components/Ui.Dialog.lua | 399 +++ Components/Ui.EditBox.lua | 214 ++ Components/Ui.ItemButton.lua | 1062 +++++++ Components/Ui.ScrollFrame.lua | 208 ++ Components/Ui.ScrollableList.lua | 1966 +++++++++++++ Components/Ui.ScrollableTextWindow.lua | 147 + Components/Ui.SearchBox.lua | 194 ++ Components/Ui.TextAndTextures.lua | 196 ++ Components/Ui.Util.lua | 344 +++ Components/Ui.Window.lua | 306 ++ Components/Ui.lua | 94 + Components/Util.lua | 1233 ++++++++ Config/Categories.lua | 643 ++++ Config/Inventory.lua | 223 ++ Config/ItemFixes.lua | 117 + Config/PeriodicTable.lua | 1201 ++++++++ Config/Profiles.lua | 575 ++++ Config/RuleFunctionTemplates.lua | 126 + Config/RuleFunctions.lua | 1112 +++++++ Config/Settings.lua | 1561 ++++++++++ Config/Skins.lua | 535 ++++ Config/SortOrders.lua | 135 + Contributing.md | 32 + Credits.md | 92 + Debug/!BagshuiDebug/!BagshuiDebug_toc | 6 + Debug/!BagshuiDebug/BagshuiDebug.lua | 1 + Debug/!BagshuiDebug/Readme.md | 11 + Fonts/Inconsolata_Condensed-Regular.ttf | Bin 0 -> 101808 bytes Images/Icons/Add.blp | Bin 0 -> 6676 bytes Images/Icons/Asterisk.blp | Bin 0 -> 6676 bytes Images/Icons/Bags.blp | Bin 0 -> 6676 bytes Images/Icons/Bank.blp | Bin 0 -> 6676 bytes Images/Icons/Catalog.blp | Bin 0 -> 6676 bytes Images/Icons/Category.blp | Bin 0 -> 6676 bytes Images/Icons/Character.blp | Bin 0 -> 6676 bytes Images/Icons/CharacterRobed.blp | Bin 0 -> 6676 bytes Images/Icons/Characters.blp | Bin 0 -> 6676 bytes Images/Icons/Check.blp | Bin 0 -> 6676 bytes Images/Icons/Clam.blp | Bin 0 -> 6676 bytes Images/Icons/Compile.blp | Bin 0 -> 6676 bytes Images/Icons/Delete.blp | Bin 0 -> 6676 bytes Images/Icons/Disenchant.blp | Bin 0 -> 6676 bytes Images/Icons/Down.blp | Bin 0 -> 6676 bytes Images/Icons/Duplicate.blp | Bin 0 -> 6676 bytes Images/Icons/Edit.blp | Bin 0 -> 6676 bytes Images/Icons/Ellipsis.blp | Bin 0 -> 6676 bytes Images/Icons/Exclamation.blp | Bin 0 -> 6676 bytes Images/Icons/Group.blp | Bin 0 -> 6676 bytes Images/Icons/Hearthstone.blp | Bin 0 -> 6676 bytes Images/Icons/HighlightChanges.blp | Bin 0 -> 6676 bytes Images/Icons/Import.blp | Bin 0 -> 6676 bytes Images/Icons/Info.blp | Bin 0 -> 6676 bytes Images/Icons/Keyring.blp | Bin 0 -> 6676 bytes Images/Icons/Mask.blp | Bin 0 -> 6676 bytes Images/Icons/Offline.blp | Bin 0 -> 6676 bytes Images/Icons/PickLock.blp | Bin 0 -> 6676 bytes Images/Icons/Question.blp | Bin 0 -> 6676 bytes Images/Icons/Remove.blp | Bin 0 -> 6676 bytes Images/Icons/Replace.blp | Bin 0 -> 6676 bytes Images/Icons/Resort.blp | Bin 0 -> 6676 bytes Images/Icons/Restack.blp | Bin 0 -> 6676 bytes Images/Icons/RuleFunction.blp | Bin 0 -> 6676 bytes Images/Icons/Save.blp | Bin 0 -> 6676 bytes Images/Icons/Search.blp | Bin 0 -> 6676 bytes Images/Icons/Settings.blp | Bin 0 -> 6676 bytes Images/Icons/Share.blp | Bin 0 -> 6676 bytes Images/Icons/Show.blp | Bin 0 -> 6676 bytes Images/Icons/SortOrder.blp | Bin 0 -> 6676 bytes Images/Icons/Up.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Check.blp | Bin 0 -> 2580 bytes Images/ItemSlot/EmptySlot-AmmoPouch.blp | Bin 0 -> 6676 bytes Images/ItemSlot/EmptySlot-EnchantingBag.blp | Bin 0 -> 6676 bytes Images/ItemSlot/EmptySlot-HerbBag.blp | Bin 0 -> 6676 bytes Images/ItemSlot/EmptySlot-Quiver.blp | Bin 0 -> 6676 bytes Images/ItemSlot/EmptySlot-SoulBag.blp | Bin 0 -> 6676 bytes Images/ItemSlot/EmptySlot.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Flat-Checked.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Flat-Highlight.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Flat-InnerGlow.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Flat-Pushed.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Quest.blp | Bin 0 -> 6676 bytes Images/ItemSlot/Stock-Down.blp | Bin 0 -> 2580 bytes Images/ItemSlot/Stock-New.blp | Bin 0 -> 2580 bytes Images/ItemSlot/Stock-Up.blp | Bin 0 -> 2580 bytes Images/Logo.svg | 27 + Images/UI/Clear.blp | Bin 0 -> 2580 bytes Images/UI/Logo_Ba.blp | Bin 0 -> 23060 bytes Images/UI/Logo_gs.blp | Bin 0 -> 23060 bytes Images/UI/Logo_hu.blp | Bin 0 -> 23060 bytes Images/UI/Logo_i.blp | Bin 0 -> 23060 bytes Images/UI/ReverseWordsOff.blp | Bin 0 -> 2580 bytes Images/UI/ReverseWordsOn.blp | Bin 0 -> 2580 bytes Images/UI/SortColumnAsc.blp | Bin 0 -> 2580 bytes Images/UI/SortColumnDesc.blp | Bin 0 -> 2580 bytes Images/UI/SortDirectionAsc.blp | Bin 0 -> 2580 bytes Images/UI/SortDirectionDesc.blp | Bin 0 -> 2580 bytes Images/UI/WhiteSemiOpaque8x8.tga | Bin 0 -> 300 bytes LICENSE | 32 + Libs/PeriodicTable.xml | 12 + Libs/PeriodicTable/Core/PTEmbed.lua | 509 ++++ .../Elements/AQ/PTEmbedElemAhnQiraj.lua | 239 ++ .../EquipSets/PTEmbedElemEquipSets.lua | 253 ++ .../Elements/Food/PTEmbedElemFood.lua | 148 + .../Elements/Instance/PTEmbedElemInstance.lua | 499 ++++ .../Elements/Misc/PTEmbedElemMisc.lua | 292 ++ .../Elements/Raid/PTEmbedElemRaid.lua | 264 ++ .../Tradeskills/PTEmbedElemTradeskill.lua | 297 ++ .../Elements/ZG/PTEmbedElemZulGurub.lua | 68 + .../Tradeskills/PTTradeskillsEmbed.lua | 278 ++ Locale/Locales.xml | 6 + Locale/Readme.md | 79 + Locale/enUS.lua | 1401 +++++++++ 179 files changed, 50231 insertions(+) create mode 100644 .git-blame-ignore-revs create mode 100644 .github/DISCUSSION_TEMPLATE/ideas.yaml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/miscategorized_item.yaml create mode 100644 .github/Pull_Request_Template.md create mode 100644 .github/workflows/AnnounceLocalizationChanges.yml create mode 100644 .github/workflows/AutoReleaseOnVersionTag.yml create mode 100644 .github/workflows/CreateAddonReleaseFromChangelog.yml create mode 100644 .github/workflows/LockClosedThreads.yml create mode 100644 .github/workflows/UpdateIssueOnClose.yml create mode 100644 .github/workflows/UpdateIssueOnOpen.yml create mode 100644 Bagshui.lua create mode 100644 Bagshui.toc create mode 100644 Bagshui.xml create mode 100644 Bindings.xml create mode 100644 Changelog.md create mode 100644 Components/API.lua create mode 100644 Components/About.lua create mode 100644 Components/ActiveQuestItems.lua create mode 100644 Components/Bags.lua create mode 100644 Components/Bagshui.BlizzFixes.lua create mode 100644 Components/Bagshui.Cursor.lua create mode 100644 Components/Bagshui.Events.lua create mode 100644 Components/Bagshui.Menus.lua create mode 100644 Components/Bagshui.Output.lua create mode 100644 Components/Bagshui.Tooltips.lua create mode 100644 Components/Bagshui.Windows.lua create mode 100644 Components/Bank.lua create mode 100644 Components/Catalog.lua create mode 100644 Components/Categories.Ui.lua create mode 100644 Components/Categories.lua create mode 100644 Components/Character.lua create mode 100644 Components/CharacterData.lua create mode 100644 Components/Compat.lua create mode 100644 Components/GameInfo.lua create mode 100644 Components/GameReport.lua create mode 100644 Components/Hooks.lua create mode 100644 Components/Inventory.Actions.lua create mode 100644 Components/Inventory.Cache.lua create mode 100644 Components/Inventory.EditMode.lua create mode 100644 Components/Inventory.Layout.lua create mode 100644 Components/Inventory.Menus.Settings.lua create mode 100644 Components/Inventory.Menus.lua create mode 100644 Components/Inventory.Ui.BagButton.lua create mode 100644 Components/Inventory.Ui.Group.lua create mode 100644 Components/Inventory.Ui.ItemButton.lua create mode 100644 Components/Inventory.Ui.lua create mode 100644 Components/Inventory.lua create mode 100644 Components/ItemInfo.lua create mode 100644 Components/Keyring.lua create mode 100644 Components/Localization.lua create mode 100644 Components/LogWindow.lua create mode 100644 Components/Menus.lua create mode 100644 Components/ObjectList.lua create mode 100644 Components/ObjectManager.lua create mode 100644 Components/Profiles.Ui.lua create mode 100644 Components/Profiles.lua create mode 100644 Components/Rules.lua create mode 100644 Components/Settings.lua create mode 100644 Components/Share.lua create mode 100644 Components/Skins.lua create mode 100644 Components/Slash.lua create mode 100644 Components/SortOrders.Ui.lua create mode 100644 Components/SortOrders.lua create mode 100644 Components/TrackedItemBar.lua create mode 100644 Components/Ui.Button.lua create mode 100644 Components/Ui.Dialog.lua create mode 100644 Components/Ui.EditBox.lua create mode 100644 Components/Ui.ItemButton.lua create mode 100644 Components/Ui.ScrollFrame.lua create mode 100644 Components/Ui.ScrollableList.lua create mode 100644 Components/Ui.ScrollableTextWindow.lua create mode 100644 Components/Ui.SearchBox.lua create mode 100644 Components/Ui.TextAndTextures.lua create mode 100644 Components/Ui.Util.lua create mode 100644 Components/Ui.Window.lua create mode 100644 Components/Ui.lua create mode 100644 Components/Util.lua create mode 100644 Config/Categories.lua create mode 100644 Config/Inventory.lua create mode 100644 Config/ItemFixes.lua create mode 100644 Config/PeriodicTable.lua create mode 100644 Config/Profiles.lua create mode 100644 Config/RuleFunctionTemplates.lua create mode 100644 Config/RuleFunctions.lua create mode 100644 Config/Settings.lua create mode 100644 Config/Skins.lua create mode 100644 Config/SortOrders.lua create mode 100644 Contributing.md create mode 100644 Credits.md create mode 100644 Debug/!BagshuiDebug/!BagshuiDebug_toc create mode 100644 Debug/!BagshuiDebug/BagshuiDebug.lua create mode 100644 Debug/!BagshuiDebug/Readme.md create mode 100644 Fonts/Inconsolata_Condensed-Regular.ttf create mode 100644 Images/Icons/Add.blp create mode 100644 Images/Icons/Asterisk.blp create mode 100644 Images/Icons/Bags.blp create mode 100644 Images/Icons/Bank.blp create mode 100644 Images/Icons/Catalog.blp create mode 100644 Images/Icons/Category.blp create mode 100644 Images/Icons/Character.blp create mode 100644 Images/Icons/CharacterRobed.blp create mode 100644 Images/Icons/Characters.blp create mode 100644 Images/Icons/Check.blp create mode 100644 Images/Icons/Clam.blp create mode 100644 Images/Icons/Compile.blp create mode 100644 Images/Icons/Delete.blp create mode 100644 Images/Icons/Disenchant.blp create mode 100644 Images/Icons/Down.blp create mode 100644 Images/Icons/Duplicate.blp create mode 100644 Images/Icons/Edit.blp create mode 100644 Images/Icons/Ellipsis.blp create mode 100644 Images/Icons/Exclamation.blp create mode 100644 Images/Icons/Group.blp create mode 100644 Images/Icons/Hearthstone.blp create mode 100644 Images/Icons/HighlightChanges.blp create mode 100644 Images/Icons/Import.blp create mode 100644 Images/Icons/Info.blp create mode 100644 Images/Icons/Keyring.blp create mode 100644 Images/Icons/Mask.blp create mode 100644 Images/Icons/Offline.blp create mode 100644 Images/Icons/PickLock.blp create mode 100644 Images/Icons/Question.blp create mode 100644 Images/Icons/Remove.blp create mode 100644 Images/Icons/Replace.blp create mode 100644 Images/Icons/Resort.blp create mode 100644 Images/Icons/Restack.blp create mode 100644 Images/Icons/RuleFunction.blp create mode 100644 Images/Icons/Save.blp create mode 100644 Images/Icons/Search.blp create mode 100644 Images/Icons/Settings.blp create mode 100644 Images/Icons/Share.blp create mode 100644 Images/Icons/Show.blp create mode 100644 Images/Icons/SortOrder.blp create mode 100644 Images/Icons/Up.blp create mode 100644 Images/ItemSlot/Check.blp create mode 100644 Images/ItemSlot/EmptySlot-AmmoPouch.blp create mode 100644 Images/ItemSlot/EmptySlot-EnchantingBag.blp create mode 100644 Images/ItemSlot/EmptySlot-HerbBag.blp create mode 100644 Images/ItemSlot/EmptySlot-Quiver.blp create mode 100644 Images/ItemSlot/EmptySlot-SoulBag.blp create mode 100644 Images/ItemSlot/EmptySlot.blp create mode 100644 Images/ItemSlot/Flat-Checked.blp create mode 100644 Images/ItemSlot/Flat-Highlight.blp create mode 100644 Images/ItemSlot/Flat-InnerGlow.blp create mode 100644 Images/ItemSlot/Flat-Pushed.blp create mode 100644 Images/ItemSlot/Quest.blp create mode 100644 Images/ItemSlot/Stock-Down.blp create mode 100644 Images/ItemSlot/Stock-New.blp create mode 100644 Images/ItemSlot/Stock-Up.blp create mode 100644 Images/Logo.svg create mode 100644 Images/UI/Clear.blp create mode 100644 Images/UI/Logo_Ba.blp create mode 100644 Images/UI/Logo_gs.blp create mode 100644 Images/UI/Logo_hu.blp create mode 100644 Images/UI/Logo_i.blp create mode 100644 Images/UI/ReverseWordsOff.blp create mode 100644 Images/UI/ReverseWordsOn.blp create mode 100644 Images/UI/SortColumnAsc.blp create mode 100644 Images/UI/SortColumnDesc.blp create mode 100644 Images/UI/SortDirectionAsc.blp create mode 100644 Images/UI/SortDirectionDesc.blp create mode 100644 Images/UI/WhiteSemiOpaque8x8.tga create mode 100644 LICENSE create mode 100644 Libs/PeriodicTable.xml create mode 100644 Libs/PeriodicTable/Core/PTEmbed.lua create mode 100644 Libs/PeriodicTable/Elements/AQ/PTEmbedElemAhnQiraj.lua create mode 100644 Libs/PeriodicTable/Elements/EquipSets/PTEmbedElemEquipSets.lua create mode 100644 Libs/PeriodicTable/Elements/Food/PTEmbedElemFood.lua create mode 100644 Libs/PeriodicTable/Elements/Instance/PTEmbedElemInstance.lua create mode 100644 Libs/PeriodicTable/Elements/Misc/PTEmbedElemMisc.lua create mode 100644 Libs/PeriodicTable/Elements/Raid/PTEmbedElemRaid.lua create mode 100644 Libs/PeriodicTable/Elements/Tradeskills/PTEmbedElemTradeskill.lua create mode 100644 Libs/PeriodicTable/Elements/ZG/PTEmbedElemZulGurub.lua create mode 100644 Libs/PeriodicTable/Tradeskills/PTTradeskillsEmbed.lua create mode 100644 Locale/Locales.xml create mode 100644 Locale/Readme.md create mode 100644 Locale/enUS.lua diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..3499119 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# .git-blame-ignore-revs + +# Reformat codebase +a1293a6ae3ad827543767c105f19c75364768c6e diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yaml b/.github/DISCUSSION_TEMPLATE/ideas.yaml new file mode 100644 index 0000000..f0ade8d --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yaml @@ -0,0 +1,30 @@ +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + # Suggest an idea to make Bagshui better. + - type: textarea + attributes: + label: What will your feature request solve? + description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]" + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like + description: "A clear and concise description of what you want to happen." + validations: + required: true + - type: textarea + attributes: + label: Describe alternatives you've considered + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: "Add any other context or screenshots about the feature request here." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..f443b0e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,45 @@ +name: "🪲 Bug report" +description: "Report a problem" +labels: ["bug"] +body: + - type: textarea + attributes: + label: Describe the bug + description: "A clear and concise description of what the problem is." + validations: + required: true + - type: textarea + attributes: + label: To reproduce + description: "Steps to reproduce the behavior." + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + description: "A clear and concise description of what you expected to happen." + validations: + required: true + - type: textarea + attributes: + label: Screenshots + description: "If applicable, add screenshots to help explain the problem." + validations: + required: false + - type: textarea + attributes: + label: Environment + description: "In-game, type `/bagshui report`, copy the result, and paste it here." + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: "Add any other information about the problem here." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..dd54b66 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 📕 Read the documentation (upstream Bagshui wiki) + url: https://github.com/veechs/Bagshui/wiki + about: FAQ, walkthroughs, and more diff --git a/.github/ISSUE_TEMPLATE/miscategorized_item.yaml b/.github/ISSUE_TEMPLATE/miscategorized_item.yaml new file mode 100644 index 0000000..ca4bac0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/miscategorized_item.yaml @@ -0,0 +1,46 @@ +name: "🗃️ Miscategorized item" +description: "Request a built-in Category change" +title: "[Miscategorized] " +labels: ["miscategorized item"] +body: + - type: input + attributes: + label: Item ID + description: "Hold **Ctrl+Alt** and hover over the item. Make note of the **ID** and enter it here." + validations: + required: true + - type: input + attributes: + label: Item name + validations: + required: true + - type: input + attributes: + label: Locale (Language) + description: "`enUS`, `deDE`, etc.
To find your locale, use this chat command in-game: /run DEFAULT_CHAT_FRAME:AddMessage(GetLocale())
*Some categorizations are locale-dependent and the localization may need updating.*" + validations: + required: true + - type: input + attributes: + label: Current Category + description: "Determine this by **Alt+right-clicking** the item and reviewing the *Matched Categories* list.
*If you're not sure, that's OK.*" + validations: + required: false + - type: input + attributes: + label: Proposed Category + description: "Where should the item be moved?" + validations: + required: true + - type: input + attributes: + label: Server + description: "What WoW server are you playing on? (i.e. WoW 1.18.1 Clients, etc.)
*This is important because some servers have custom items.*" + validations: + required: true + - type: textarea + attributes: + label: Reasoning + description: "Why should the item be moved?" + validations: + required: true \ No newline at end of file diff --git a/.github/Pull_Request_Template.md b/.github/Pull_Request_Template.md new file mode 100644 index 0000000..67fc768 --- /dev/null +++ b/.github/Pull_Request_Template.md @@ -0,0 +1,35 @@ + + +## Description + + + +## Motivation and context + + + + +## Testing + + + +## Screenshots (if appropriate) + + +## Types of changes + + +- Bug fix +- New feature +- Code style update +- Refactoring +- Localization +- Item categorization +- Other (please describe): + + +## Checklist + +- [ ] My code follows the code style of this project. +- [ ] (If applicable) I have proposed changes to the [documentation](/veechs/Bagshui/wiki). +- [ ] I have read the [Contributing](/veechs/Bagshui/master/blob/.github/Contributing.md) document. diff --git a/.github/workflows/AnnounceLocalizationChanges.yml b/.github/workflows/AnnounceLocalizationChanges.yml new file mode 100644 index 0000000..fc45084 --- /dev/null +++ b/.github/workflows/AnnounceLocalizationChanges.yml @@ -0,0 +1,82 @@ +# Based on https://github.com/marketplace/actions/create-discussion-comment +name: enUS Localization Update Announcement +on: + push: + branches: + - main + - dev + paths: + - 'Locale/enUS.lua' +jobs: + localization-update-announcement: + # Don't run on forks by default. + if: github.repository_owner == 'veechs' + permissions: + discussions: write + env: + body: ${{ format('enUS localization updated on {0} branch in {1}.', github.ref_name, github.sha) }} + discussionId: D_kwDOJ3GOZc4Aep4W + replyToId: ${{ github.ref_name == 'dev' && 'DC_kwDOJ3GOZc4AvO_3' || 'DC_kwDOJ3GOZc4AvO_1' }} + clientMutationId: 302201 + runs-on: ubuntu-latest + steps: + - name: Add Discussion Comment + shell: bash + id: discusscomment + run: | + RESPONSE=$(curl -v -X POST -H "Authorization: bearer ${{ secrets.GITHUB_TOKEN }}" -H "Content-Type: application/json" -d '{"query": "mutation { addDiscussionComment(input: {body: \"${{env.body}}\" discussionId: \"${{env.discussionId}}\" replyToId: \"${{env.replyToId}}\" clientMutationId: \"${{env.clientMutationId}}\"}) { clientMutationId comment { id body } } }"}' https://api.github.com/graphql) + echo $RESPONSE + echo "Comment ID:" + echo $RESPONSE | jq -r '.data.addDiscussionComment.comment.id' + echo "Comment Body:" + echo $RESPONSE | jq -r '.data.addDiscussionComment.comment.body' + + +# GraphQL queries to find required data. +# https://docs.github.com/en/graphql/overview/explorer +# +#query { +# repository(owner: "", name: "") { +# discussions(first: 5) { +# edges { +# node { +# # number parameter for 2nd query: +# number +# # discussion-id: +# id +# title +# category { +# id +# name +# } +# body +# id +# } +# } +# } +# } +#} +# +#query{ +# repository(owner: "", name: "") { +# discussion(number: 6) { +# title +# createdAt +# # first 100 results +# comments(first: 100) { +# # edges.node is where the actual 'Comment' object is +# edges { +# node { +# # replyToId: +# id +# author { +# login +# url +# } +# body +# } +# } +# } +# } +# } +#} diff --git a/.github/workflows/AutoReleaseOnVersionTag.yml b/.github/workflows/AutoReleaseOnVersionTag.yml new file mode 100644 index 0000000..6899ce1 --- /dev/null +++ b/.github/workflows/AutoReleaseOnVersionTag.yml @@ -0,0 +1,76 @@ +name: Automatic Release on vX.Y.Z Tag +on: + push: + tags: + # Trailing * also allows suffixes such as v1.6.2-beta.1. + - "v[0-9]+.[0-9]+.[0-9]+*" + # Manual run from the Actions tab: enter an existing tag (for example v1.6.2). + workflow_dispatch: + inputs: + tag: + description: "Existing tag to release (for example v1.6.2)" + required: true +jobs: + release: + # Override repository workflow security settings so the release can be created. + permissions: + contents: write + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Create Zip + uses: thedoctor0/zip-release@0.7.5 + with: + type: 'zip' + filename: 'Bagshui.zip' + exclusions: '*.git* .vscode .editorconfig' + + # Release notes are the changelog section whose heading starts with the tag's version + # (for example "## 1.6.2 - 2026-10-19" for tag v1.6.2). + - name: Get release notes + id: notes + shell: bash + run: | + VERSION="${RELEASE_TAG#v}" + awk -v v="$VERSION" ' + /^## / { + if (found) exit + if ($0 == "## " v || index($0, "## " v " ") == 1) found = 1 + next + } + found + ' Changelog.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "No Changelog.md section found for $VERSION" >&2 + exit 1 + fi + # A release you already wrote by hand keeps its title and notes. + if gh release view "$RELEASE_TAG" > /dev/null 2>&1; then + echo "keep_existing=true" >> "$GITHUB_OUTPUT" + else + echo "keep_existing=false" >> "$GITHUB_OUTPUT" + fi + # Anything with a suffix (v1.6.2-beta.1) is a pre-release. + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: ${{ steps.notes.outputs.keep_existing == 'false' && format('Bagshui {0}', env.RELEASE_TAG) || '' }} + body_path: ${{ steps.notes.outputs.keep_existing == 'false' && 'release-notes.md' || '' }} + prerelease: ${{ steps.notes.outputs.prerelease }} + files: Bagshui.zip + fail_on_unmatched_files: true diff --git a/.github/workflows/CreateAddonReleaseFromChangelog.yml b/.github/workflows/CreateAddonReleaseFromChangelog.yml new file mode 100644 index 0000000..eb46a17 --- /dev/null +++ b/.github/workflows/CreateAddonReleaseFromChangelog.yml @@ -0,0 +1,164 @@ +# This workflow creates a new release of a WoW addon when the changelog is +# updated with a new version. The repository contents are zipped up +# and added as a release asset. +# +# The changelog is expected to be in https://keepachangelog.com/ +# format (brackets around version numbers are *not* required though). +# +# Just about everything should be determined automatically, but if you want +# to use this for your addon, the two places marked "❇️ Changelog file name" +# must be updated if they don't *exactly* match your changelog file. +# +name: Create WoW Addon Release From Changelog +on: + # Disabled on push in this fork: it needs the upstream DEPLOY_KEY secret and rewrites the TOC version. + # Releases here are made by pushing a vX.Y.Z[-suffix] tag (see AutoReleaseOnVersionTag.yml). + # Restore the original `push` trigger below if you set up a DEPLOY_KEY secret. + workflow_dispatch: + # push: + # branches: + # - main + # paths: + # # ❇️ Changlog file name. Case must match. + # - 'Changelog.md' +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + + steps: + # Must check out repository to query the changelog. + - uses: actions/checkout@v4 + # Required to override main branch protection. + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + + # Get version and releaae notes. + - name: Query Changelog + id: changelog + uses: release-flow/keep-a-changelog-action@v2 + with: + command: query + # ❇️ Changlog file name. Case must match. + changelog: Changelog.md + version: latest + + # Figure out everything we need to know. + - name: Set Variables + id: vars + shell: bash + run: | + # In this section, it's not *necessary* to put things in Bash variables, + # then send to $GITHUB_OUTPUT at the end, but it makes things a little + # more readable. + + # The TOC will decide the name of everything else. + # This is a little lazy, but *most* addons don't have multiple TOCs. + tocFile=$(shopt -s nullglob; ls -bt *.toc | head -n 1) + tocVersion="" + baseName="" + addonName="" + + # Grab info from TOC. + if [[ "$tocFile" != "" ]]; then + addonName=$(perl -lne 'print "$1" and last if /^##\s*Title:\s*(.+?)\s*$/' "$tocFile") + tocVersion=$(perl -lne 'print "$1" and last if /^##\s*Version:\s*(.+?)\s*$/' "$tocFile") + baseName=$(basename "$tocFile" .toc) + fi + + # Couldn't get TOC info, so use repository info. + if [[ "$addonName" == '' ]]; then + addonName=${GITHUB_REPOSITORY#$GITHUB_REPOSITORY_OWNER/} + baseName=$addonName + fi + + zipFile=${baseName}.zip + + currentChangelogVersion=${{ steps.changelog.outputs.version }} + # Alternate method of getting version from changelog. Would also require parsing release notes out. + #currentChangelogVersion=$(perl -lne 'print "$1" and last if /^##\s+\[(\d+\.\d+\.\d+)\]/;' Changelog.md) + + lastReleaseTag=$(gh repo view --json latestRelease --jq '.[] | .tagName') + + # These will be assigned to steps.vars.outputs. + echo "addonName=$addonName" >> $GITHUB_OUTPUT + # Version = X.Y.Z + echo "currentChangelogVersion=$currentChangelogVersion" >> $GITHUB_OUTPUT + # Tag = vX.Y.Z + echo "currentChangelogTag=v$currentChangelogVersion" >> $GITHUB_OUTPUT + echo "lastReleaseTag=$lastReleaseTag" >> $GITHUB_OUTPUT + echo "tocFile=$tocFile" >> $GITHUB_OUTPUT + echo "tocVersion=$tocVersion" >> $GITHUB_OUTPUT + echo "zipFile=$zipFile" >> $GITHUB_OUTPUT + + # Decide whether we should do a release (and do some logging). + # Every step after this must have this condition so changelog updates that dont't contain a new version won't trigger a release: + # ``` + # if: ${{ steps.check.outputs.proceed == 'true' }} + # ``` + - name: Check Variables + id: check + shell: bash + run: | + # The rest of the workflow only needs to run if the changelog version is different. + proceed=${{ steps.vars.outputs.currentChangelogTag != steps.vars.outputs.lastReleaseTag }} + echo "proceed=$proceed" >> $GITHUB_OUTPUT + + # Logging. + echo "addonName: ${{ steps.vars.outputs.addonName }}" + echo "currentChangelogVersion: ${{ steps.vars.outputs.currentChangelogVersion }}" + echo "currentChangelogTag: ${{ steps.vars.outputs.currentChangelogTag }}" + echo "lastReleaseTag: ${{ steps.vars.outputs.lastReleaseTag }}" + echo "tocFile: ${{ steps.vars.outputs.tocFile }}" + echo "tocVersion: ${{ steps.vars.outputs.tocVersion }}" + echo "zipFile: ${{ steps.vars.outputs.zipFile }}" + echo "toc hash: ${{ hashFiles(steps.vars.outputs.tocFile) }}" + echo "🚦proceed: $proceed" + + # TOC needs a version update. + - name: Update TOC + if: ${{ + steps.check.outputs.proceed == 'true' + && hashFiles(steps.vars.outputs.tocFile) != '' + && steps.vars.outputs.tocVersion != steps.vars.outputs.currentChangelogVersion + }} + shell: bash + run: | + sed -i 's/^\(##[[:space:]]*Version:\).*/\1 ${{ steps.vars.outputs.currentChangelogVersion }}/' ${{ steps.vars.outputs.tocFile }} + git config --global user.name '${{ github.actor }}' + git config --global user.email '${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com' + git commit -am '${{ steps.vars.outputs.addonName }} ${{ steps.vars.outputs.currentChangelogVersion }}' + git push + + # This is the vX.Y.Z tag the release will be created from. + - name: Create Tag + if: ${{ steps.check.outputs.proceed == 'true' }} + shell: bash + run: | + git config --global user.name '${{ github.actor }}' + git config --global user.email '${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com' + git tag -a "${{ steps.vars.outputs.currentChangelogTag }}" -m ":bookmark: ${{ steps.vars.outputs.currentChangelogVersion }}" + git push origin ${{ steps.vars.outputs.currentChangelogTag }} + + # This is our release asset. + - name: Create Zip + if: ${{ steps.check.outputs.proceed == 'true' }} + id: create-zip + uses: thedoctor0/zip-release@0.7.5 + with: + type: 'zip' + filename: '${{ steps.vars.outputs.zipFile }}' + exclusions: '*.git* .vscode .editorconfig' + + # Release it! + - name: Create Release + if: ${{ steps.check.outputs.proceed == 'true' }} + uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.changelog.outputs.release-notes }} + files: ${{ steps.vars.outputs.zipFile }} + tag_name: ${{ steps.vars.outputs.currentChangelogTag }} + fail_on_unmatched_files: true diff --git a/.github/workflows/LockClosedThreads.yml b/.github/workflows/LockClosedThreads.yml new file mode 100644 index 0000000..02c1a98 --- /dev/null +++ b/.github/workflows/LockClosedThreads.yml @@ -0,0 +1,25 @@ +name: "Lock Closed Threads" + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + discussions: write + +concurrency: + group: lock-threads + +jobs: + action: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v5 + with: + github-token: ${{ github.token }} + issue-inactive-days: '60' + pr-inactive-days: '60' + discussion-inactive-days: '365' diff --git a/.github/workflows/UpdateIssueOnClose.yml b/.github/workflows/UpdateIssueOnClose.yml new file mode 100644 index 0000000..e61c5ca --- /dev/null +++ b/.github/workflows/UpdateIssueOnClose.yml @@ -0,0 +1,18 @@ +# Clean up labels. +name: Update issue on close +on: + issues: + types: + - closed +jobs: + label_issues: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: gh issue edit "$NUMBER" --remove-label "$LABELS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + LABELS: pending release,help wanted \ No newline at end of file diff --git a/.github/workflows/UpdateIssueOnOpen.yml b/.github/workflows/UpdateIssueOnOpen.yml new file mode 100644 index 0000000..ea1efe3 --- /dev/null +++ b/.github/workflows/UpdateIssueOnOpen.yml @@ -0,0 +1,19 @@ +# Automatically assign if no assignee was chosen. +name: Update issue on open +on: + issues: + types: + - opened +jobs: + label_issues: + if: join(github.event.issue.assignees) == '' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - run: gh issue edit "$NUMBER" --add-assignee "$ASSIGNEE" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + ASSIGNEE: ${{ github.repository_owner }} \ No newline at end of file diff --git a/Bagshui.lua b/Bagshui.lua new file mode 100644 index 0000000..3aa05b1 --- /dev/null +++ b/Bagshui.lua @@ -0,0 +1,1203 @@ +-- Bagshui Core +-- Customizable auto-categorizing and sorting all-in-one inventory replacement for Vanilla WoW 1.12. +-- https://github.com/veechs/Bagshui +-- +-- Most code is split into file in Components; what's here is close to the minimum to get things bootstrapped. +-- +-- Raises: +-- - BAGSHUI_ADDON_LOADED +-- - BAGSHUI_LOG_UPDATE + +-- Provide access to the global environment without using `getglobal()`. +-- Must be declared before `bagshuiEnvironment` because things in there use it. +local _G = _G or getfenv() + +-- This environment will be loaded for all Bagshui code via `setfenv()`. +-- Top-level constants should be declared here as `BS_CONSTANT_NAME`. +-- Other constants can be added later via `Bagshui:AddConstants()`. +local bagshuiEnvironment = { + -- Pull basic information into the environment. + + BS_VERSION = "", -- Intentionally empty; will be filled from Bagshui.toc by `Bagshui:AddonLoaded()`. + + -- Increment to wipe SavedVariables. + BS_DATA_VERSION = 1, + + -- Imports of this version or lower can be processed. + -- Stored in the `bagshuiExportFormat` key of the export object table. + -- This may never change, but it seems like a good thing to track just in case. + BS_EXPORT_VERSION = 1, + + -- Bagshui homepage. + BS_URL = "github.com/veechs/Bagshui", + -- Bagshui wiki root. MUST end in a slash. + BS_WIKI_URL = "github.com/veechs/Bagshui/wiki/", + -- Bagshui wiki pages. Need to be concatenated onto `BS_WIKI_URL`. + BS_WIKI_PAGES = { + EditMode = "Edit-Mode", + Categories = "Categories", + CharacterData = "Searching#managing-character-data", + Profiles = "Profiles", + Rules = "Rules", + Search = "Searching", + Share = "Import-Export", + SortOrders = "Sort-Orders", + }, + + -- Fallback locale, used if no matching locale for the current WoW client is found. + BS_DEFAULT_LOCALE = "enUS", + + -- Defaults for categorizing and sorting. + BS_DEFAULT_CATEGORY_ID = "Uncategorized", + BS_DEFAULT_CATEGORY_SEQUENCE = 30, + BS_DEFAULT_BUILTIN_CATEGORY_SEQUENCE = 90, + -- This has to be here, not in SortOrders.lua, so it's available to Setting configuration. + BS_DEFAULT_SORT_ORDER_ID = "Default", + + -- Default profiles used at first login and for fallback if the configured default profile is unavailable. + -- ``` + -- { [BS_PROFILE_TYPE value] = "ProfileId" } + -- ``` + ---@type table + BS_DEFAULT_PROFILE = { + Structure = "Bagshui", + Design = "Bagshui", + }, + + ---@enum BS_INVENTORY_TYPE + -- Allowed inventory class types. + -- Note that values get lowercased automatically for SavedVariables storage. + -- ``` + -- { UPPERCASE = "PascalCase" } + -- ``` + ---@type table + BS_INVENTORY_TYPE = { + BAGS = "Bags", + BANK = "Bank", + KEYRING = "Keyring", + }, + + -- Control the order in which toolbar icons and menu items for inventory types appear. + -- ``` + -- { [BS_INVENTORY_TYPE value1], ... [BS_INVENTORY_TYPE valueN] } + -- ``` + ---@type string[] + BS_INVENTORY_TYPE_UI_ORDER = { + "Bags", + "Bank", + "Keyring", + }, + + ---@enum BS_PROFILE_TYPE + -- Allowed profile types. + -- ``` + -- { UPPERCASE = "PascalCase" } + -- ``` + ---@type table + BS_PROFILE_TYPE = { + STRUCTURE = "Structure", + DESIGN = "Design", + }, + + -- Control the order in which the Settings menus for default profiles appear. + -- ``` + -- { [BS_PROFILE_TYPE key1], ... [BS_PROFILE_TYPE key2] } + -- ``` + ---@type string[] + BS_PROFILE_ORDER = { + "STRUCTURE", + "DESIGN", + }, + + -- Names of keys in the config file for various types of data. + -- ``` + -- { UPPERCASE = "camelCase" } + -- ``` + ---@type table + BS_CONFIG_KEY = { + CHARACTERS = "characters", + CHARACTER_INFO = "info", + COLORS = "colors", + DATA_VERSION = "dataVersion", + OBJECT_VERSIONS = "objectVersions", + LOG = "log", + }, + + ---@enum BS_SETTING_APPLICABILITY + -- Used in Config\Settings.lua and elsewhere to specify what object a group of settings apply to. + -- ``` + -- { UPPERCASE = "UPPERCASE" } + -- ``` + ---@type table + BS_SETTING_APPLICABILITY = { + INVENTORY = "INVENTORY", + GROUP = "GROUP", + }, + + ---@enum BS_SETTING_SCOPE + -- Used in Config\Settings.lua to specify the `scope` of a setting (i.e. what level it's stored at). + -- See `Settings:InitSettingsInfo()` for more information. + -- ``` + -- { UPPERCASE = "UPPERCASE" } + -- ``` + ---@type table + BS_SETTING_SCOPE = { + ACCOUNT = "ACCOUNT", + CHARACTER = "CHARACTER", + INVENTORY = "INVENTORY", + }, + + ---@enum BS_SETTING_PROFILE_SCOPE + -- Used in Config\Settings.lua to specify the `profileScope` of a setting. Only works when + -- `scope` is "INVENTORY". See `Settings:InitSettingsInfo()` for more information. + -- ``` + -- { UPPERCASE = "UPPERCASE" } + -- ``` + ---@type table + BS_SETTING_PROFILE_SCOPE = { + STRUCTURE = "STRUCTURE", + DESIGN = "DESIGN", + BEHAVIOR = "BEHAVIOR", + }, + + -- Data types for settings, used in a setting's `type` property in Config\Settings.lua. + -- The Settings class expects the values of these entries to correspond to `Settings:Validate()`. + -- For example, `NUMBER = "Number"` would map to `Settings:ValidateNumber()`. + -- ``` + -- { UPPERCASE = "PascalCase" } + -- ``` + ---@type table + BS_SETTING_TYPE = { + BOOL = "Bool", + BOOLEAN = "Bool", + CHOICES = "Choices", + COLOR = "ColorRgba", + NUMBER = "Number", + TRIGGER = "Trigger", + PLACEHOLDER = "Placeholder", + }, + + ---@enum BS_AUTO_SPLIT_MENU_TYPE + -- Globally recognized auto-split menu types. + -- Needs to be defined here so it can be used in Config\Settings.lua. + -- ``` + -- { UPPERCASE = "PascalCase" } + -- ``` + ---@type table + BS_AUTO_SPLIT_MENU_TYPE = { + CATEGORIES = "Categories", + CHARACTERS = "Characters", + PROFILES = "Profiles", + SORT_ORDERS = "SortOrders", + SORT_FIELDS = "SortFields", + }, + ----@alias BS_AUTO_SPLIT_MENU_TYPE table + + -- Custom colors. + -- ``` + -- { UPPERCASE = { r, g, b, [a] } } + -- ``` + ---@type table + BS_COLOR = { + -- This is slightly different from BS_FONT_COLOR.BAGSHUI + -- because colors don't look quite the same when applied to + -- large areas. + BAGSHUI_LOGO = { 0.692, 0.139, 0.416 }, + + -- General colors. + RED = { 0.8, 0, 0 }, + FULL_RED = { 1, 0, 0 }, + DARK_RED = { 1, 0.1, 0.1 }, + GRAY = { 0.5, 0.5, 0.5 }, + GREEN = { 0, 0.8, 0 }, + LIGHT_GREEN = { 0.2, 0.9, 0.2 }, + DARK_GREEN = { 1, 0.8, 0.1 }, + WHITE = { 1, 1, 1 }, + YELLOW = { 0.9, 0.72, 0.15 }, -- Default toolbar button color. + + -- Factions. + FACTION_ALLIANCE = { 0.15, 0.25, 0.95 }, + FACTION_HORDE = { 0.85, 0.15, 0.10 }, + FACTION_UNKNOWN = { 0.60, 0.50, 0.30 }, + + -- Item slot button texture colors. + ITEM_SLOT_STATE_NORMAL = { 1, 1, 1, 1 }, + ITEM_SLOT_STATE_LOCKED = { 0.5, 0.5, 0.5, 1 }, + ITEM_SLOT_HIGHLIGHT = { 1, 1, 0.35 }, + + -- Icon button colors. + UI_GREEN = { 0.6, 0.82, 0.3 }, + UI_ORANGE = { 1, 0.45, 0.1 }, + ICON_BUTTON_HOVER = { 0.95, 0.9, 0.9, 0.25 }, + + CODE_EXAMPLE = { 0.83, 0.47, 0.65 }, + + -- Print() and friends colors. + PRINT_BAGSHUI = { 0.86, 0.259, 0.573 }, + PRINT_ERROR = { 0.95, 0, 0 }, + PRINT_WARNING = { 0.95, 0.45, 0 }, + }, + + -- Custom font colors, defined as "|cff". + -- Everything from `BS_COLOR` will automatically be converted to escape strings + -- populated automatically by `Bagshui:PostUtilInit()`. + ---@type table + BS_FONT_COLOR = { + BAGSHUI = "|cffdb4e97", + }, + + -- Predefined border types, consumed by `Ui:SetFrameBackdrop()`. + -- ``` + -- { UPPERCASE = { `:SetBackdrop()` table } } + -- ``` + ---@type table + BS_BORDER = { + NONE = {}, + CURVED = { + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + edgeSize = 16, + insets = 4, + }, + SOLID = { + edgeFile = "Interface\\Buttons\\WHITE8X8", + edgeSize = 1, + insets = 1, + }, + }, + + -- Tooltip hover delays, used by `Bagshui:ShowTooltipAfterDelay()` and associated functions. + -- ``` + -- { UPPERCASE = } + -- ``` + ---@type table + BS_TOOLTIP_DELAY_SECONDS = { + DEFAULT = 1, + TOOLBAR_DEFAULT = 2, + TOOLBAR_EXPAND_DEFAULT = 4, + -- After a tooltip is shown, how long should the SHORTENED value be used? + USE_SHORTENED_AFTER_LAST_SHOW_FOR = 2, + SHORTENED = 0.3, + }, + + -- It looks nicer to indent some menu values under headers (like the Edit Mode menu for groups). + ---@type string + BS_MENU_SUBTITLE_INDENT = " ", + + -- When checking whether a window with a saved position might be offscreen, + -- how close to the edge does it need to be? + BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD = 50, + + -- In Edit Mode, rows may be at least this many columns wide (keeps the window from getting very tall). + BS_EDIT_MODE_MIN_MAX_COLUMNS = 20, + + -- Empty item used to initialize inventory cache entries. + -- Values here can't be nil or Lua drops them. + -- ``` + -- { camelCase = } + -- ``` + ---@type table + BS_ITEM_SKELETON = { + baseName = "", -- Name without random suffix, if applicable. + bagNum = -99, -- Using an invalid container ID since 0 is Backpack. + bagType = "", + charges = 0, + count = 0, + equipLocation = "", + equipLocationLocalized = "", + equipLocationSort = 0, + emptySlot = 0, -- Using 0/1 instead of true/false for easy sorting. + id = 0, + itemLink = "", -- Item link (|cffffffff|Hitem:12345:0:0:0|h[Name]|h|r). + itemString = "", -- Item string (item:12345:0:0:0). + locked = 0, + lockPickable = 0, + maxStackCount = 0, + minLevel = "", + name = "", + openable = 0, + quality = 1, + qualityLocalized = "", + readable = 0, + slotNum = 0, + subtype = "", + suffixName = "", -- Random suffix, if applicable. + tooltip = "", + texture = "Interface\\Icons\\INV_Misc_QuestionMark", + type = "", + uncategorized = 0, -- Using 0/1 instead of true/false for easy sorting. + + bagshuiGroupId = "", + bagshuiCategoryId = "", + bagshuiDate = -1, + bagshuiInventoryType = "", + bagshuiStockState = "", + + -- Disabled properties and reasons for disabling. + + -- itemStringGeneric is itemString without enchantId and uniqueId. This + -- was never used anywhere in EngInventory, so there doesn't seem to be + -- any real use for it. It's commented out throughout Bagshui in case + -- there's ever a reason to bring it back. + -- itemStringGeneric = "", + }, + + -- Which properties can be used in sort orders? + -- ``` + -- { [BS_ITEM_SKELETON key1], ... [BS_ITEM_SKELETON keyN] } + -- ``` + ---@type string[] + BS_ITEM_PROPERTIES_ALLOWED_IN_SORT_ORDERS = { + "bagNum", + "bagType", + "charges", + "count", + "equipLocationSort", + "emptySlot", + "id", + "itemLink", + "itemString", + "minLevel", + "name", + "quality", + "slotNum", + "subtype", + "tooltip", + "type", + "uncategorized", + }, + + -- ItemInfo:InitializeItem() will never reset these properties. + ---@type table + BS_ITEM_PROTECTED_PROPERTIES = { + bagNum = true, + bagType = true, + slotNum = true, + }, + + -- ItemInfo:InitializeItem() will not set these properties to default if nil. + -- Typically needed for properties that are used for empty slot identification. + ---@type table + BS_ITEM_NIL_PROPERTIES = { + itemLink = true, + itemString = true, + texture = true, -- Without this, empty slots for offline inventories will become ? icons. + }, + + -- Rule functions that correspond to item properties. + -- This controls what appears in the Item Information menu, tooltip, and window. + -- ``` + -- { [BS_ITEM_SKELETON key] = { "Function1()", "FunctionN(%s)" }|true } + -- ``` + -- * `%s` will be replaced with the item property value. + -- * Set `true` if there is no rule function but the property should still appear. + ---@type table + BS_ITEM_PROPERTIES_TO_FUNCTIONS = { + activeQuest = { "ActiveQuest()" }, + bagNum = { "Bag(%s)" }, + bagType = { 'BagType("%s")' }, + bindsOnEquip = { "BindsOnEquip()" }, + count = { "Count(%s)", "Count(Min, Max)" }, + emptySlot = { "EmptySlot()" }, + equipLocation = { 'EquipLocation("%s")' }, + equipLocationLocalized = { 'EquipLocation("%s")' }, + id = { "Id(%s)" }, + itemString = { 'ItemString("%s")' }, + minLevel = { "MinLevel(%s)", "CharacterLevelRange(±levels)", "CharacterLevelRange(LevelsBelow, LevelsAbove)" }, + name = { 'Name("%s")', 'NameExact("%s")' }, + periodicTable = { 'PeriodicTable("%s")' }, + quality = { "Quality(%s)" }, + qualityLocalized = { 'Quality("%s")' }, + soulbound = { "Soulbound()" }, + stacks = { "Stacks()" }, + subtype = { 'Subtype("%s")' }, + tooltip = { 'Tooltip("Search String")' }, + type = { 'Type("%s")' }, + uncategorized = true, + }, + + -- These will supplement and/or override the item's properties in the + -- Item Information menu/tooltip/window. + -- + -- All references must be explicit here since the Bagshui environment isn't built up yet. + -- In other words, use `Bagshui.environment.L` instead of `L`, `Bagshui.components.Rules` instead of `BsRules`, etc. + -- + -- ``` + -- { [BS_ITEM_SKELETON key] = function(item, BS_ITEM_INFO_DISPLAY_TYPE) -> string|string[] } + -- ``` + ---@type table + BS_REALTIME_ITEM_INFO_PROPERTIES = { + + activeQuest = function(item) + return tostring(Bagshui.components.Rules:Match("ActiveQuest()", item)) + end, + + bindsOnEquip = function(item) + return tostring(Bagshui.components.Rules:Match("BindsOnEquip()", item)) + end, + + emptySlot = function(item) + return tostring(item.emptySlot == 1) + end, + + periodicTable = function(item, displayType) + Bagshui.components.ItemInfo:GetPeriodicTableSets(item) + -- Only show the count of sets in the right-click menu or tooltip. + if displayType ~= Bagshui.environment.BS_ITEM_INFO_DISPLAY_TYPE.TEXT then + return string.format( + Bagshui.environment.L.Suffix_Sets, + table.getn(Bagshui.components.ItemInfo.periodicTableSetCache) + ) + end + -- Text mode (Item Information window) should show the entire list of sets. + return Bagshui.components.ItemInfo.periodicTableSetCache + end, + + soulbound = function(item) + return tostring(Bagshui.components.Rules:Match("Soulbound()", item)) + end, + + stacks = function(item) + return tostring(Bagshui.components.Rules:Match("Stacks()", item)) + end, + + tooltip = function(item, displayType) + if item.tooltip == "" then + return nil + end + -- Text mode (Item Information window) should show the entire tooltip. + if displayType == Bagshui.environment.BS_ITEM_INFO_DISPLAY_TYPE.TEXT then + return "\n" .. item.tooltip + end + -- Only show the first line of the tooltip in the right-click menu or tooltip. + -- (%C is non-control characters, which excludes newline.) + local _, _, tooltip = string.find(item.tooltip, "^(%C+)") + if tooltip ~= item.tooltip then + tooltip = string.format(Bagshui.environment.L.Symbol_Ellipsis, tooltip) + end + return tooltip + end, + + uncategorized = function(item) + return tostring(item.uncategorized == 1) + end, + }, + + -- Item properties that will be hidden from the Bagshui info tooltip when the item isn't in a bag. + ---@type table + BS_ITEM_PROPERTIES_SUPPRESSED_IN_TOOLTIP_OUTSIDE_INVENTORY = { + bagNum = true, + count = true, -- Count is always 0 and looks silly. + emptySlot = true, + periodicTable = true, -- Could allow this in the future if a way to open the info window for items not in inventory is devised. + tooltip = true, -- Same as periodicTable. + slotNum = true, + uncategorized = true, + }, + + -- List of item properties sorted by friendly (localized) name. + -- Automatically built out by `ItemInfo:BuildSortedItemTemplatePropertyList()` the first time it's needed. + ---@type string[] + BS_ITEM_PROPERTIES_SORTED = nil, + + -- Used to get numbers for slot names so they can be sorted. + ---@type table + BS_INVENTORY_EQUIP_LOCATION_SORT_ORDER = { + INVTYPE_HEAD = 1, + INVTYPE_NECK = 2, + INVTYPE_SHOULDER = 3, + INVTYPE_CLOAK = 4, -- Back + INVTYPE_CHEST = 5, + INVTYPE_ROBE = 5, -- Chest + INVTYPE_BODY = 6, -- Shirt + INVTYPE_TABARD = 7, + INVTYPE_WRIST = 8, + INVTYPE_HAND = 9, + INVTYPE_WAIST = 10, + INVTYPE_LEGS = 11, + INVTYPE_FEET = 12, + INVTYPE_FINGER = 13, + INVTYPE_FINGER_OTHER = 14, + INVTYPE_TRINKET = 15, + INVTYPE_TRINKET_OTHER = 16, + INVTYPE_2HWEAPON = 17, -- Main Hand + INVTYPE_WEAPON = 17, -- Main Hand + INVTYPE_WEAPONMAINHAND = 17, -- Main Hand + INVTYPE_HOLDABLE = 18, -- Offhand + INVTYPE_SHIELD = 18, -- Offhand + INVTYPE_WEAPON_OTHER = 18, -- Offhand + INVTYPE_WEAPONOFFHAND = 18, -- Offhand + INVTYPE_RANGED = 19, + INVTYPE_RELIC = 19, -- Ranged + INVTYPE_WAND = 19, -- Ranged + INVTYPE_GUN = 19, -- Ranged + INVTYPE_CROSSBOW = 19, -- Ranged + INVTYPE_THROWN = 19, -- Ranged + INVTYPE_PROJECTILE = 20, -- Ammo + }, + + -- In addition to the bagshuiStockState item property, stock state table values are used + -- to determine the following stock badge attributes, so they must be PascalCase: + -- - Textures: `ItemSlot\Stock-.tga` + -- - Colors: `settings.itemStockBadgeColor` + -- - Tooltip strings: `L["Stock_" .. ]` + -- ``` + -- { UPPERCASE = "PascalCase" } + -- ``` + ---@type table + BS_ITEM_STOCK_STATE = { + NEW = "New", + UP = "Up", + DOWN = "Down", + NO_CHANGE = "", + }, + + ---@enum BS_INVENTORY_BAG_USAGE_DISPLAY + -- Control when bag usage is displayed. + -- Values must match Setting_BagUsageDisplay_* localization entries. + ---@type table + BS_INVENTORY_BAG_USAGE_DISPLAY = { + ALWAYS = "Always", + SMART = "Smart", + NEVER = "Never", + }, + + ---@enum BS_INVENTORY_BAG_USAGE_FORMAT + -- Control how bag usage is displayed. + -- Values must match Setting_BagUsageFormat_* localization entries. + ---@type table + BS_INVENTORY_BAG_USAGE_FORMAT = { + EMPTY = "Empty", + EMPTY_TOTAL = "Empty_Total", + EMPTY_USED_TOTAL = "Empty_Used_Total", + USED = "Used", + USED_TOTAL = "Used_Total", + USED_EMPTY_TOTAL = "Used_Empty_Total", + }, + + ---@enum BS_GAME_PLAYER_GROUP_TYPE + -- Used by GameInfo for the `playerGroupType` property. + ---@type table + BS_GAME_PLAYER_GROUP_TYPE = { + PARTY = "Party", + RAID = "Raid", + SOLO = "Solo", + }, + + ---@enum BS_RULE_ENVIRONMENT_VARIABLES + -- Variables that are pre-populated into the Rule evaluation environment. + -- Rule functions can add their own as well. + ---@type table + BS_RULE_ENVIRONMENT_VARIABLES = { + Active = "~Active~", + Eligible = "~Eligible~", + Inactive = "~Inactive~", + Ineligible = "~Ineligible~", + }, + + ---@enum BS_RULE_ARGUMENT_TYPE + -- Define what argument types are accepted by the `validArgumentTypes` parameter of `Rules:TestItemAttribute()`. + -- - Keys are one or more Lua types **in UPPERCASE** separated by commas with **no spaces**. + -- - Every possible order of key types must be included, so if `STRING,NUMBER` is a key, `NUMBER,STRING` must also be a key. + -- (This could be automated but it's not really worth it.) + -- - Values are tables with keys as Lua types and values of true. + -- ``` + -- { ["TYPE1,TYPE2"] = { type1 = true, typeN = true} } + -- ``` + ---@type table> + BS_RULE_ARGUMENT_TYPE = { + ["STRING"] = { + string = true, + }, + ["NUMBER"] = { + number = true, + }, + ["STRING,NUMBER"] = { + string = true, + number = true, + }, + ["NUMBER,STRING"] = { + string = true, + number = true, + }, + }, + + ---@enum BS_RULE_MATCH_TYPE + -- What types of matches will `Rules:TestItemAttribute()` accept? + -- ``` + -- { UPPERCASE = "UPPERCASE" } + -- ``` + ---@type table + BS_RULE_MATCH_TYPE = { + EQUALS = "EQUALS", + CONTAINS = "CONTAINS", + BETWEEN = "BETWEEN", + }, + + ---@enum BS_HOOK_ACTION + -- Hook class action parameter values. + -- ``` + -- { UPPERCASE = "PascalCase" } + -- ``` + ---@type table + BS_HOOK_ACTION = { + REGISTER = "Register", + UNREGISTER = "Unregister", + CHECK = "Check", + }, + + -- Output indentation. + BS_INDENT = " ", + + ---@enum BS_LOG_MESSAGE_TYPE + -- Log message severities. + -- ``` + -- { UPPERCASE = number } + -- ``` + ---@type table + BS_LOG_MESSAGE_TYPE = { + INFORMATION = 1, + WARNING = 2, + ERROR = 3, + }, + + -- How to localize log message severities. + -- - Array index is severity number from `BS_LOG_MESSAGE_TYPE`, + -- - Value is the string to look up in `L`. + ---@type string[] + BS_LOG_TYPE_LOCALIZATION_KEY = { + "Log_Info", + "Log_Warn", + "Log_Error", + }, + + -- How to color log messages based on severity. + -- - Array index is severity number from `BS_LOG_MESSAGE_TYPE`, + -- - Value is the font formatting string. + ---@type string[] + BS_LOG_MESSAGE_COLOR = { + _G.HIGHLIGHT_FONT_COLOR_CODE, + "|cFFFF5F00", + _G.RED_FONT_COLOR_CODE, + }, + + -- Keep this many log entries. + ---@type number + BS_LOG_LIMIT = 75, + + -- Date formatting for log entries. + ---@type string + BS_LOG_DATE_STRING = "%Y-%m-%d %H:%M:%S", + + -- Ensure debug is never turned on in production by requiring another addon to set BAGSHUI_DEBUG_ON to true prior to Bagshui loading. + -- The !BagshuiDebug addon (in the Debug folder) is an easy way to enable this. + ---@type boolean + BS_DEBUG = _G.BAGSHUI_DEBUG_ON or false, + + -- Is SuperWoW loaded? Since it's a DLL injection, not an addon, IsAddOnLoaded() won't help here. + ---@type boolean + BS_SUPER_WOW_LOADED = type(_G.SetAutoloot) == "function", + + -- Localization helpers. + + BS_NEWLINE = "\n", + + -- These are accessible via metatable, but explicit access is going to be slightly faster. + -- Not capturing font colors in case they're changed. + + _G = _G, + error = _G.error, + ipairs = _G.ipairs, + pairs = _G.pairs, + loadstring = _G.loadstring, + math = _G.math, + mod = _G.mod, + tonumber = _G.tonumber, + tostring = _G.tostring, + string = _G.string, + table = _G.table, + type = _G.type, + + -- Other addons. + -- Intensive check for pfUI to ensure it's the real thing and not an addon that + -- provides pfUI API compatibility. (And false is used since nil will just + -- end up with an empty table entry and it will metatable its way up into _G). + pfUI = (_G.IsAddOnLoaded("pfUI") and _G.pfUI and _G.pfUI.uf and _G.pfUI.env and _G.pfUI.env.C) and _G.pfUI or false, + + -- Contain the _ dummy variable within the Bagshui environment. + _ = 0, +} + +-- Configure environment so global variables are still accessible. +setmetatable(bagshuiEnvironment, { __index = _G }) + +-- Replace current execution environment with Bagshui environment. +---@diagnostic disable-next-line: param-type-mismatch +setfenv(1, bagshuiEnvironment) + +-- Namespace for everything Bagshui +local Bagshui = { + + -- Class instances go here. + -- ``` + -- { PascalCase = class } + -- ``` + ---@type table + components = {}, + + -- Class prototypes go here. + -- ``` + -- { PascalCase = prototype } + -- ``` + ---@type table + prototypes = {}, + + -- Configuration files in the Config folder should add data here. + ---@type table + config = {}, + + -- Embedded libraries. + ---@type table + libs = { + -- PeriodicTableEmbed is added when Libs\PeriodicTable.xml is processed, prior to Bagshui.lua. + PT = _G.PeriodicTableEmbed:GetInstance("1"), + }, + + -- Name and realm identifier of the current character. + -- Populated in `Bagshui:AddonLoaded()`. + ---@type string + currentCharacterId = nil, + + -- Pointer to SavedVariables data for the current character. + ---@type table + currentCharacterData = {}, + + -- Relationship between docked inventory class instances. + -- For example, if Keyring is docked to Bags, `{ "Bags" = "Keyring" }`. + -- This only allows for one docking per class, but that's fine for now. + ---@type table + dockedInventories = {}, + + -- List of active quest items by name. + -- See Components\ActiveQuestItems.lua for details. + ---@type table + activeQuestItems = nil, + + -- WoW API functions that need to be hooked at the Bagshui level. + -- ``` + -- { ApiFunctionName = "BagshuiClassFunctionName" } + -- ``` + ---@type table + apiFunctionsToHook = { + ClearCursor = "ClearCursor", + CloseAllWindows = "CloseAllWindows", + DeleteCursorItem = "ClearCursor", + MoneyFrame_UpdateMoney = "MoneyFrame_UpdateMoney", + OpenStackSplitFrame = "OpenStackSplitFrame", + PickupBagFromSlot = "PickupInventoryItem", + PickupInventoryItem = "PickupInventoryItem", + PutItemInBag = "PickupInventoryItem", + ToggleDropDownMenu = "ToggleDropDownMenu", + UIDropDownMenu_AddButton = "UIDropDownMenu_AddButton", + }, + + -- Bagshui's hidden frame for event handling, declared in `Bagshui:Init()`. + ---@type table + eventFrame = nil, + + -- List of components and events for which to call the OnEvent function. + -- After calling `Bagshui:RegisterEvent()`, the table will start looking something like this: + -- ``` + -- { + -- [classObject Pointer] = { + -- _eventFunctionName = function, + -- event1 = true, + -- event2 = true + -- } + -- } + -- ``` + ---@type table + eventConsumers = {}, + + -- Reusable tables for the Bagshui event queue. + -- Keys for each sub-table will be the unique event identifier. + -- See `Bagshui:QueueEvent()`, `Bagshui:ProcessEventQueue()`, and `Bagshui:RaiseEvent()`. + ---@type table + queuedEvents = { + -- Events that are waiting to be raised and the time at which to raise them. + ---@type table + events = {}, + -- First parameter for the callback function. + ---@type table + arg1 = {}, + -- Second parameter for the callback function. + ---@type table + arg2 = {}, + -- Third parameter for the callback function. + ---@type table + arg3 = {}, + -- Fourth parameter for the callback function. + ---@type table + arg4 = {}, + + -- Class function callback tables, populated by Bagshui:QueueClassCallback() + -- and consumed by Bagshui:RaiseEvent(). + + -- Class references. + ---@type table + class = {}, + -- Class function references. + ---@type table + classFunction = {}, + }, + + -- Window Management. + + -- Frames that should be closed when `CloseAllWindows()` fires. + ---@type table + framesToCloseOnEscape = {}, + + -- "Child Frames" prevent Esc from closing Inventory frames when visible. + ---@type table + childWindowFrames = {}, + + -- Tooltips can be shared across all Bagshui code. + ---@type table + tooltips = {}, + + -- Ui class instance, basically just used for access to the Bagshui Info tooltip (instantiated during `Init()`). + ui = nil, + + -- List of log messages. + ---@type string[] + log = nil, -- Initialized in `Bagshui:AddonLoaded()`. + + -- Expose the Bagshui environment table so things can be added to it. + environment = bagshuiEnvironment, + + -- Debugging. + debug = BS_DEBUG, + debugWipeConfigOnLoad = false and BS_DEBUG, +} + +-- Expose Bagshui globally. +_G.Bagshui = Bagshui + +-- Add Bagshui to environment for quick access by other components. +Bagshui.environment.Bagshui = Bagshui +Bagshui.environment.Bs = Bagshui + +--- Basic initialization. Called when BAGSHUI_CORE_EVENT_FUNCTIONS_LOADED is raised. +function Bagshui:Init() + -- self:PrintDebug("Bagshui:Init()") + + -- Hidden frame for event handling (see `Bagshui:OnEvent()`). + self.eventFrame = _G.CreateFrame("Frame") + self.eventFrame.bagshuiData = { + registeredEvents = {}, + } + self.eventFrame:SetScript("OnEvent", function() + -- Vanilla WoW "passes" event parameters via global variables. + self:OnEvent(_G.event, _G.arg1, _G.arg2, _G.arg3, _G.arg4) + end) + self.eventFrame:SetScript("OnUpdate", function() + -- Check the event queue on every frame. + self:ProcessEventQueue() + end) + + -- Enable for event spelunking only. + -- self.eventFrame:RegisterAllEvents() + + -- Bagshui itself needs these events. Other classes register their own events. + self:RegisterEvent("ADDON_LOADED") + self:RegisterEvent("PLAYER_LOGIN") + self:RegisterEvent("BAGSHUI_UTIL_LOADED") + self:RegisterEvent("BAGSHUI_LOCALIZATION_LOADED") + self:RegisterEvent("PLAYER_LOGOUT") + + -- Shared menu hosting frame. + self.menuFrame = _G.CreateFrame("Frame", "BagshuiMenuHostFrame", nil, "UIDropDownMenuTemplate") + self.menuFrame.bagshuiData = { + name = self.menuFrame:GetName(), + -- Populated by `Menus:ShowMenu()` and consumed by `Bagshui:IsMenuOpen()`. + lastMenuTypeLoaded = nil, + -- Can be set to true before calling `Menus:ShowMenu()` to avoid changing + -- level 1 anchors unnecessarily in `Bagshui:ToggleDropDownMenu()`. + noFirstLevelRepositionNeeded = nil, + } + + -- Key binding header; doesn't need localization. + -- Localized versions of inventory key binding names are populated during Inventory class creation. + _G.BINDING_HEADER_Bagshui = "Bagshui" + + -- Create hex font strings for all RGB colors. + if not self.environment.BS_FONT_COLOR then + self.environment.BS_FONT_COLOR = {} + end + for name, rgb in pairs(BS_COLOR) do + if not self.environment.BS_FONT_COLOR[name] then + local hex = BsUtil.RGBPercentToHex(rgb.r or rgb[1], rgb.g or rgb[2], rgb.b or rgb[3]) + -- The ff prefix would be alpha value, but WoW ignores it and it just always needs to be ff. + self.environment.BS_FONT_COLOR[name] = "|cff" .. hex + end + end + + -- Need to adjust this by the scale so it behaves as expected. + BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD = BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD / _G.UIParent:GetScale() +end + +--- Called from `Bagshui:OnEvent()` when ADDON_LOADED is raised. This indicates SavedVariables are safe to use. +--- Bagshui components are loaded immediately after this. +function Bagshui:AddonLoaded() + -- self:PrintDebug("Bagshui:AddonLoaded()") + + -- Grab version number from TOC file. + self.environment.BS_VERSION = tostring(_G.GetAddOnMetadata("Bagshui", "Version")) + + -- Prepare game function hooks. + self.hooks = Bagshui.prototypes.Hooks:New(self.apiFunctionsToHook, self) + + local dataVersionKey = BS_CONFIG_KEY.DATA_VERSION + local charactersKey = BS_CONFIG_KEY.CHARACTERS + + -- "[Name] - [Realm]" + self.currentCharacterId = _G.UnitName("player") .. " - " .. BsUtil.Trim(_G.GetCVar("realmName")) + self.currentRealm = BsUtil.Trim(_G.GetCVar("realmName")) + + -- Base config (if it doesn't exist, version doesn't match, or debug wipe is on). + if + _G.BagshuiData == nil + or (_G.BagshuiData[dataVersionKey] == nil) + or (_G.BagshuiData[dataVersionKey] ~= BS_DATA_VERSION) + or self.debugWipeConfigOnLoad + then + -- Display chat message on version mismatch only. + if + _G.BagshuiData ~= nil + and _G.BagshuiData[dataVersionKey] ~= nil + and _G.BagshuiData[dataVersionKey] ~= BS_DATA_VERSION + then + self:Print(string.format(L.BagshuiDataReset, tostring(_G.BagshuiData[dataVersionKey]), BS_DATA_VERSION)) + end + + _G.BagshuiData = { + [dataVersionKey] = BS_DATA_VERSION, + [charactersKey] = {}, + } + end + + -- Creating the object versions key here because it was added after 1.0. + if not _G.BagshuiData[BS_CONFIG_KEY.OBJECT_VERSIONS] then + _G.BagshuiData[BS_CONFIG_KEY.OBJECT_VERSIONS] = {} + end + self.objectVersions = _G.BagshuiData[BS_CONFIG_KEY.OBJECT_VERSIONS] + + -- Color history (account-wide). + if _G.BagshuiData[BS_CONFIG_KEY.COLORS] == nil then + _G.BagshuiData[BS_CONFIG_KEY.COLORS] = {} + end + self.colorHistory = _G.BagshuiData[BS_CONFIG_KEY.COLORS] + + -- Character data storage. + if _G.BagshuiData[charactersKey] == nil then + _G.BagshuiData[charactersKey] = {} + end + if _G.BagshuiData[charactersKey][self.currentCharacterId] == nil then + _G.BagshuiData[charactersKey][self.currentCharacterId] = {} + end + + self.characters = _G.BagshuiData[charactersKey] + self.currentCharacterData = _G.BagshuiData[charactersKey][self.currentCharacterId] + + -- Characters from other WoW accounts on this computer (read-only). + -- Filled by `Catalog:LoadSharedCharacters()` from files in Nampower's shared CustomData folder. + -- Kept separate from `self.characters` so they are never written to this account's SavedVariables. + self.sharedCharacters = {} + + -- Character info (data will be populated by Character class). + if self.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO] == nil then + self.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO] = {} + end + self.currentCharacterInfo = self.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO] + + -- Inventory validation. + -- This will ensure there are no errors when changes are made to the expected inventory cache structure. + for character, characterData in pairs(self.characters) do + for _, inventoryType in pairs(BS_INVENTORY_TYPE) do + inventoryType = string.lower(inventoryType) + if characterData[inventoryType] and characterData[inventoryType].inventory then + for containerNum, containerContents in pairs(characterData[inventoryType].inventory) do + for slotNum, item in ipairs(containerContents) do + BsItemInfo:InitializeItem(item, false, true) + end + end + end + end + end + + -- Log storage. + if _G.BagshuiData[BS_CONFIG_KEY.LOG] == nil then + _G.BagshuiData[BS_CONFIG_KEY.LOG] = {} + end + self.log = _G.BagshuiData[BS_CONFIG_KEY.LOG] +end + +--- Initialization that can't occur until after Components\Localization.lua has loaded. +--- Called from `Bagshui:OnEvent()` when BAGSHUI_LOCALIZATION_LOADED is raised. +function Bagshui:PostLocalizationInit() + -- Add "About" slash command. + BsSlash:AddHandler("About", function(tokens) + if not tokens[2] or BsUtil.MatchLocalizedOrNon(tokens[2], "dialog") then + BsAboutDialog:Open() + elseif BsUtil.MatchLocalizedOrNon(tokens[2], "text") then + Bagshui:PrintBare("Bagshui " .. BS_VERSION) + elseif BsUtil.MatchLocalizedOrNon(tokens[2], "help") then + BsSlash:PrintHandlers({ L.Dialog, L.Text }, "About") + end + end) + + -- Add "Settings" slash command. + BsSlash:AddHandler("Settings", function(tokens) + for _, inventoryType in ipairs(BS_INVENTORY_TYPE_UI_ORDER) do + if BsUtil.MatchLocalizedOrNon(tokens[2], inventoryType) then + self.components[inventoryType]:Open() + self.components[inventoryType].menus:OpenMenu("Settings") + return + end + end + + if not self._settingsHandler_LocalizedInventoryTypes then + self._settingsHandler_LocalizedInventoryTypes = {} + for _, inventoryType in ipairs(BS_INVENTORY_TYPE_UI_ORDER) do + table.insert(self._settingsHandler_LocalizedInventoryTypes, L[inventoryType]) + end + end + BsSlash:PrintHandlers(self._settingsHandler_LocalizedInventoryTypes, "Settings") + end) +end + +--- Primary event handler called by Bagshui.eventFrame's OnEvent script. +---@param event string Event name. +---@param arg1 any? First event argument, if any. +---@param arg2 any? Second argument, if any. +---@param arg3 any? Third argument, if any. +---@param arg4 any? Fourth argument, if any. +function Bagshui:OnEvent(event, arg1, arg2, arg3, arg4) + -- Bagshui:PrintDebug("Bagshui event " .. event .. " // " .. tostring(arg1) .. " // " .. tostring(arg2) .. " // " .. tostring(arg3) .. " // " .. tostring(arg4)) + + local downstreamEvent = event --[[@as string|nil]] + + if event == "ADDON_LOADED" then + -- Need to check arg1 to avoid responding to this event for other addons. + if arg1 == "Bagshui" then + -- This fork requires ClassicAPI (https://github.com/brues-code/ClassicAPI). + -- Bail out before touching SavedVariables so nothing is lost if the DLL isn't loaded. + if not _G.CLASSIC_API_VERSION or not _G.C_Container or not _G.C_Item then + self.classicApiMissing = true + self.eventFrame:UnregisterAllEvents() + _G.DEFAULT_CHAT_FRAME:AddMessage( + "|cffdb4e97Bagshui|r |cffff4040requires ClassicAPI.|r " + .. "Install ClassicAPI.dll (github.com/brues-code/ClassicAPI), add it to dlls.txt and launch via VanillaFixes. " + .. "Bagshui is disabled until then; your settings are untouched." + ) + return + end + self:AddonLoaded() + self:LoadComponents() + else + return + end + end + + if event == "PLAYER_LOGIN" then + -- Do compatibility check after everything else is definitely loaded. + if not self.startupCompatChecked then + self:QueueClassCallback(self, self.CheckCompat, 1) + self.startupCompatChecked = true + end + end + + if event == "BAGSHUI_CORE_EVENT_FUNCTIONS_LOADED" then + -- Part of Init() is registering events so we can't run that until event functions are available. + self:Init() + end + + if event == "BAGSHUI_LOCALIZATION_LOADED" then + -- Some startup stuff requires localization to be available. + self:PostLocalizationInit() + end + + if event == "PLAYER_LOGOUT" then + -- Used by `Inventory:UpdateCache()` to move `item.bagshuiDate` forward + -- so that only in-game time counts for stock states. + self.currentCharacterData.lastLogout = _G.time() + -- Make sure other accounts see this character's final state. + if self.components.Catalog and self.components.Catalog.WriteSharedCharacter then + self.components.Catalog:WriteSharedCharacter(true) + end + end + + -- Pass events on to consumers. + if downstreamEvent then + for consumer, events in pairs(self.eventConsumers) do + if events[event] then + consumer[self.eventConsumers[consumer]._eventFunctionName](consumer, downstreamEvent, arg1, arg2, arg3, arg4) + end + end + end + + -- Provide an event that happens just after ADDON_LOADED but before anything else + -- so that components split across multiple files can complete initialization. + if downstreamEvent == "ADDON_LOADED" then + self:RaiseEvent("BAGSHUI_ADDON_LOADED") + end +end + +-- Component storage. +-- Doesn't need to be exposed; it's up to individual components to expose themselves on +-- `Bagshui.components`, `Bagshui.prototypes`, and/or `Bagshui.environment` as appropriate. +---@type function[] +local components = {} + +--- Load a component by executing its function within the Bagshui environment. +--- This must be here because it's needed by every other file. +---@param creationFunction function +---@param loadEvent string? Event to raise after `creationFunction` has run. +function Bagshui:LoadComponent(creationFunction, loadEvent) + setfenv(creationFunction, self.environment) + creationFunction() + if loadEvent and self.RaiseEvent then + self:RaiseEvent(loadEvent) + end +end + +--- Queue the creation of a Bagshui component. +--- Components will be loaded FIFO, so load order is controlled by the TOC/XML. +---@param creationFunction function Component code to execute within the Bagshui environment. +function Bagshui:AddComponent(creationFunction) + table.insert(components, creationFunction) +end + +--- Execute the component creation functions queued by AddComponent. +function Bagshui:LoadComponents() + for _, creationFunction in ipairs(components) do + self:LoadComponent(creationFunction) + end +end + +--- Add additional constants to the Bagshui environment. Keys in the `constantTable` +--- should start with `BS_` and will be prefixed with it if they don't. +---@param constantTable table List of `{ name = value }`. +function Bagshui:AddConstants(constantTable) + for name, value in pairs(constantTable) do + local constantName = string.find(name, "^BS_") and name or "BS_" .. name + if self.environment[constantName] then + self:PrintError("Constant " .. constantName .. " is already defined as " .. tostring(value)) + else + self.environment[constantName] = value + end + end +end + +-- lua-language-server hack to remove "undefined-field" errors when accessing WoW API via _G. +---@class _G +---@field [string] any diff --git a/Bagshui.toc b/Bagshui.toc new file mode 100644 index 0000000..1d2b879 --- /dev/null +++ b/Bagshui.toc @@ -0,0 +1,10 @@ +## Interface: 11200 +## Title: Bagshui +## Notes: |cffdb4e97Feng shui for your bags|r|nAll-in-one autosorting inventory replacement|nWoW 1.18.1 Clients fork - requires ClassicAPI +## Author: veechs | txtsd | WoW 1.18.1 Clients fork +## Version: 1.6.4 +## OptionalDeps: AtlasLoot, ItemRack, Outfitter, pfUI, !!!ClassicAPI +## RequiredMods: ClassicAPI.dll (https://github.com/brues-code/ClassicAPI) +## SavedVariables: BagshuiData + +Bagshui.xml diff --git a/Bagshui.xml b/Bagshui.xml new file mode 100644 index 0000000..b5ace8c --- /dev/null +++ b/Bagshui.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Bindings.xml b/Bindings.xml new file mode 100644 index 0000000..0800f94 --- /dev/null +++ b/Bindings.xml @@ -0,0 +1,36 @@ + + + + Bagshui.components.Bags:Toggle() + + + Bagshui.components.Bank:Toggle() + + + Bagshui.components.Keyring:Toggle() + + + Bagshui.components.Catalog:Toggle() + + + Bagshui:ResortAll() + + + Bagshui:RestackAll() + + + Bagshui.components.Bags:Search() + + + Bagshui.components.Bank:Search() + + + Bagshui.components.Catalog:Search() + + diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 0000000..c1275ad --- /dev/null +++ b/Changelog.md @@ -0,0 +1,644 @@ +# Bagshui Changelog + +## Unreleased + +### Fixed + +- A window anchored to the Right or Top jumped toward the left/bottom edge whenever it refreshed if the game's UI scale wasn't 1. The saved position is now calculated the same way it is restored. + +## 1.6.4 - 2026-10-27 + +### Fixed + +- Edit Mode made the window extremely tall because every group, including empty ones, was squeezed into Maximum Columns. Edit Mode now allows rows at least 20 columns wide. +- Maximum Columns was ignored for rows with wide groups (for example a large gear group in the Bank). A guard added in 1.6.3 stopped the layout from making groups taller too early. It now only stops when every group is already one column wide. + +## 1.6.3 - 2026-10-24 + +### Added + +- **Tracked Item Bar:** a small movable bar that shows the items you track with how many of each are in your bags (a farming counter). Track an item from its item menu (Alt+right-click > Track Item). Click a button to use the item, Alt+click to stop tracking, Shift+drag to move the bar. Settings: Show Tracked Item Bar, Button Size and Orientation (horizontal or vertical) (Settings > General). Tracked items and position are saved per character. Inspired by Guda. +- **Auto-Compact Layout** (Settings > Design > Sizing, off by default): rearranges the visible groups into rows to make the inventory window as small as possible. It doesn't change your saved Structure layout and is turned off in Edit Mode. From [selfinterest/Bagshui](https://github.com/selfinterest/Bagshui). + +### Fixed + +- A window (for example the Bank) could end up off the screen and unreachable, for instance if the game hitched while dragging it. Windows are now kept on the screen, a window dropped off the edge is moved back, a stuck drag state is cleared when the window closes, and the rescue distance is larger (50 pixels). If it still happens, `/bs bank resetposition` fixes it. +- Possible game freeze when a row had more visible groups than Maximum Columns allows. The layout loop now stops instead of repeating forever. From [selfinterest/Bagshui](https://github.com/selfinterest/Bagshui). + +## 1.6.2 - 2026-10-19 + +### Added + +- **Classic theme:** Bagshui windows now use the stock Blizzard dialog frame (menus and dialogs) and the silver tooltip border (bag, bank and keyring windows). pfUI users keep the pfUI look when pfUI is skinning Bagshui. Set `useClassicSkin = false` in `Config/Skins.lua` to go back to the original flat look. + +### Changed + +- The repository's main branch is now `main` (it was `vanilla`). +- README: reorganized, Nampower listed as an optional requirement, and links to the deleted upstream wiki now point to a web archive snapshot. +- Credits: added Bagertz (cross-account sharing) and GudaBags / Guda (per-bag hide). + +### Removed + +- The Chinese (zhCN) translation. This fork supports English only. Other clients fall back to English. + +## 1.6.1 - 2026-10-19 + +First stable release. The earlier 1.6.0-octo.N builds were betas; their notes are kept below. + +### Added + +- **Hide a bag's items:** right-click or Ctrl+Click a bag icon to hide or show everything in that bag (inventory and bank). Saved per character; Show Hidden and Edit Mode reveal hidden items. +- **Cross-account sharing** of the Catalog, gold, equipped gear and full inventories between accounts on the same computer (requires Nampower). + +### Fixed + +- Freeze when a frame-scanning profiler such as FpsWatch ran with Bagshui loaded. +- Hitching from the shared-data writer, which now writes in small staged steps and skips unchanged data. + +## 1.6.0-octo.12 - 2026-10-18 + +### Fixed + +- Right-click and Ctrl+Click on a Bank bag button picked the bag up instead of hiding its items. The Bank now uses the same hide/show handling as the inventory (and it works offline). + +## 1.6.0-octo.11 - 2026-10-18 + +### Fixed + +- Hiding a bag with right-click or Ctrl+Click failed with "Invalid setting hideBag4" because the per-bag settings weren't registered. They are now. + +## 1.6.0-octo.10 - 2026-10-18 + +### Added + +- **Hide a bag's items:** right-click (or Ctrl+Click) a bag button in the bag bar to hide everything in that bag, such as profession bags, quivers, ammo pouches or soul bags. The bag's icon is dimmed while hidden. The setting is saved per character and works on every bag except the Backpack, the main Bank container and the Keyring. Hidden items come back with **Show Hidden** (or while the bag's highlight is locked), and empty slots in hidden bags aren't counted in the empty-slot stacks. Idea from GudaBags. + +## 1.6.0-octo.9 - 2026-10-17 + +### Fixed + +- Addons that walk through every frame (such as FpsWatch's combat profiler) could freeze the game, because Bagshui's helper proxy frames for item buttons made that walk loop forever. The proxies are now plain tables instead of real frames, which also removes two frames per item slot. + +## 1.6.0-octo.8 - 2026-10-17 + +### Changed + +- Your character's shared file is now only rebuilt when your items, equipment, money or character info actually changed, instead of every minute. Idle play does no sharing work at all. + +## 1.6.0-octo.7 - 2026-10-17 + +### Fixed + +- Other accounts' characters showed "Offline inventory not available" because octo.6 wrote an empty inventory section. Each character needs to log in once with this version to rewrite its file. + +## 1.6.0-octo.6 - 2026-10-16 + +### Changed + +- Writing your character's data for other accounts is now spread over many frames (one bag at a time) instead of one long pause, which could cause a brief hitch about once a minute. The final write at logout is unchanged. + +## 1.6.0-octo.5 - 2026-10-16 + +### Fixed + +- Viewing another account's character failed with "is not a known character". Shared files are now written with a serializer that handles line breaks in item tooltips, and the error now says why a character can't be shown. Each character needs to log in once with this version to rewrite its file. + +## 1.6.0-octo.4 - 2026-10-16 + +### Added + +- Characters from your other accounts now appear in the Character menu, so you can view their bags, bank and keyring the same way as your own alts (offline view, read-only). Their full inventory is stored in the shared file and only read when you select them. + +### Changed + +- Shared files are rewritten at most once per minute (was 30 seconds) because they now hold the full inventory. + +## 1.6.0-octo.3 - 2026-10-16 + +### Added + +- Characters from your other accounts are now listed in the Character Data manager, marked "other account". They are view-only there and can only be deleted from the account they belong to. + +### Changed + +- Deleting an alt in the Character Data manager now also stops sharing it with your other accounts (its shared file is cleared and it's removed from the shared roster). + +## 1.6.0-octo.2 - 2026-10-16 + +### Added + +- **Share the Catalog between accounts on the same computer** using Nampower's `WriteCustomFile`/`ReadCustomFile`. Each character writes its items, equipped gear and gold to the shared `CustomData` folder, and characters from your other accounts appear in the Catalog, Catalog search and item tooltips (read-only). Requires Nampower; without it nothing changes. See the README. + +## 1.6.0-octo.1 - 2026-10-16 + +Fork of Bagshui 1.5.16 (last Vanilla release of The-Kludge-Bureau/Bagshui) for WoW 1.18.1 Clients, built on [ClassicAPI](https://github.com/brues-code/ClassicAPI) by Brues-Code. + +### Fixed + +- Scrollable edit boxes (such as in the Edit Category window) no longer spam `GetVerticalScrollRange` errors when ClassicAPI is loaded. ClassicAPI passes the frame as the first script argument, which Blizzard's `ScrollingEdit_OnUpdate()` doesn't expect. + +### Changed + +- **ClassicAPI is now required.** Without `ClassicAPI.dll` loaded (via VanillaFixes), Bagshui prints an explanation and stays disabled without touching SavedVariables. +- Item move queue now checks slot locks through `C_Container.GetContainerItemInfo()`. +- Item charges are now read from `C_Container.GetContainerItemCharges()` when SuperWoW hasn't already provided them, avoiding tooltip parsing for charged items. +- Catalog: build the account-wide item list with a lookup table instead of rescanning the list for every item (it was quadratic and ran on every Catalog update), and preload other characters' item data once instead of once per character. +- SavedVariables name (`BagshuiData`) and addon folder name (`Bagshui`) are unchanged, so this is a drop-in replacement for upstream Bagshui. Do not run both. + +## 1.5.16 - 2026-03-21 + +### Changed + +- Improve the 1.5.x inventory update pipeline by tracking dirty state so Bagshui can skip redundant categorize, sort, lookup-table, and window rebuild work. + +### Fixed + +- Ensure forced resorts and lookup table rebuilds are preserved through dry-run, event, and Edit Mode update paths so category, sort order, profile, and setting changes still produce correct layouts. +- Ensure cache changes still rebuild the window when needed, even if the dry run reports no layout change. +- Refresh item button cooldowns immediately on `BAG_UPDATE_COOLDOWN`. + +## 1.5.15 - 2025-08-23 + +### Fixed + +- _Actually_ fix [empty slot backgrounds in non-enUS clients that don't have a Bagshui localization](https://github.com/veechs/Bagshui/issues/175) (was working in 1.5.13, but only due to the bug that was resolved in 1.5.14). 🪲 [@Trust-WoW](https://github.com/Trust-WoW) +- _Actually_ move WoW 1.18.1 Clients' **Verdant Rune** [to the Teleports Category](https://github.com/veechs/Bagshui/issues/179). 🗃️ [@xeropresence](https://github.com/xeropresence) + +## 1.5.14 - 2025-08-23 + +### Fixed + +- Remove stray double negative in localization checks introduced by the #175 fix. + +## 1.5.13 - 2025-08-23 + +### Fixed + +- Empty slot backgrounds in non-enUS clients that don't have a Bagshui localization [shouldn't go on vacation anymore](https://github.com/veechs/Bagshui/issues/175). 🪲 [@Trust-WoW](https://github.com/Trust-WoW) +- Prevent [an error](https://github.com/veechs/Bagshui/issues/177) during the bank slot purchase flow. 🪲 [@Arcitec](https://github.com/Arcitec) +- Fix for [rare occurrence of missing toolbars](https://github.com/veechs/Bagshui/issues/172). 🪲 [@Sleepybear](https://github.com/Sleepybear) +- WoW 1.18.1 Clients' **Verdant Rune** is now [correctly placed in the Teleports Category](https://github.com/veechs/Bagshui/issues/179). 🗃️ [@xeropresence](https://github.com/xeropresence) + +## 1.5.12 - 2025-08-20 + +### Fixed + +- The new "Concoctions" in WoW 1.18.1 Clients are now [correctly categorized with elixirs](https://github.com/veechs/Bagshui/issues/173). 🗃️ [@xeropresence](https://github.com/xeropresence) + [@Sunelegy](https://github.com/Sunelegy) for zhCN + +## 1.5.11 - 2025-08-04 + +### Fixed + +- [Sharing works consistently now](https://github.com/veechs/Bagshui/issues/160) (previously there were errors if all objects of a given type were selected). 🪲 [@Szalor](https://github.com/Szalor) + +## 1.5.10 - 2025-04-26 + +### Fixed + +- Prevent lag while in a raid group. Additional protections for in-combat performance are being tested, but this should resolve the worst of the [issues](https://github.com/veechs/Bagshui/issues/153). Thanks to jj for their patient testing that helped me locate the root cause. + +## 1.5.9 - 2025-04-26 + +### Fixed + +- Avoid localization errors. Whoops (x2). + +## 1.5.7 - 2025-04-26 + +### Changes + +- zhCN localization now includes everything added/changed in 1.5. Many thanks to [@Sunelegy](https://github.com/Sunelegy) for keeping up with this! + +## 1.5.6 - 2025-04-26 + +### Fixed + +- Matching item properties that contain Lua pattern characters (most notably `-` but could be any of `^$()%.[]*+-?`) [now works as it should](https://github.com/veechs/Bagshui/issues/149). 🪲 [@AsunaSalata](https://github.com/AsunaSalata) + +## 1.5.5 - 2025-04-15 + +### Fixed + +- Prevent [doubled cooldown text](https://github.com/veechs/Bagshui/issues/143) when ShaguPlates, ShaguTweaks, and pfQuest are enabled. Thanks to [@shagu](https://github.com/shagu) for identifying the cause and suggesting how to fix it. 🪲 [@Vymya](https://github.com/Vymya) +- Capture custom WoW 1.18.1 Clients recipes that are [wrongly categorized on the server side](https://github.com/veechs/Bagshui/issues/144). 🗃️ [@Szalor](https://github.com/Szalor) + +## 1.5.4 - 2025-04-12 + +### Fixed + +- Ensure Alt+click bag highlights [persist correctly](https://github.com/veechs/Bagshui/issues/141). + +## 1.5.3 - 2025-04-12 + +### Fixed + +- Prevent [items from getting stuck in locked (grayed-out) state](https://github.com/veechs/Bagshui/issues/138). 🪲 [@Szalor](https://github.com/Szalor) + +## 1.5.2 - 2025-04-06 + +### Fixed + +- Avoid [error from Manage Character Data window](https://github.com/veechs/Bagshui/issues/132) when a character has never logged out. 🪲 [@Szalor](https://github.com/Szalor) + +## 1.5.1 - 2025-04-06 + +### Fixed + +- Prevent [error when unequipping a tabard](https://github.com/veechs/Bagshui/issues/130). 🪲 bonho + +## 1.5.0 - 2025-04-05 + +### Changes + +- **Automated bag swapping**
One of the most-requested features! Just equip a new bag and Bagshui [takes care of the rest](https://github.com/veechs/Bagshui/wiki/FAQ#how-do-i-swap-bags-when-i-get-bigger-ones).
Sufficient free space is required, but you can visit the Bank to supplement without cleaning your Bags.
🫶 [@Nikki1993](https://github.com/Nikki1993), Kralomax, and everyone on the TW Discord who has asked +- **Shiny new toolbar buttons**
**Clam** (Open Container), **Disenchant**, and **Pick Lock**. 🫶 [@melbaa](https://github.com/melbaa), [@Azzc0](https://github.com/Azzc0) +- **Selling protection for your valuables**
Ever accidentally right-clicked the wrong thing at a vendor? High quality, active quest, and soulbound items now require confirmation.🫶 [@selax1](https://github.com/selax1)
Configurable in **Settings** > **General**. Can also include anything you've ever equipped. +- **More sensible gear slot sorting**
Equipment is now sorted by character sheet order (head first, fingers last) instead of slot name, which should be a little more intuitive. 🫶 Kord2998 +- **Improved utilization display**
Even if you hide bag slots, you can now show free/used totals. 🫶 [@thecreth](https://github.com/thecreth)
Super customizable in **Settings** > **View** > **Utilization**. +- **Powerful new [rule functions](https://github.com/veechs/Bagshui/wiki/Rules#available-rule-functions)**
`MatchCategory()`, `RecentlyChanged()`, `Zone()`/`Subzone()`, `PlayerInGroup()`, and `LootMethod()`/`LootMaster()` allow more dynamic categorization than ever before. Plus `Openable()` for chests and lockboxes. 🫶 [@melbaa](https://github.com/melbaa) +- **But wait, there’s more…** + - Synchronized searches between open inventory windows. 🫶 [@thecreth](https://github.com/thecreth) + - The **Organize** toolbar button is smarter and should only light up if there's something to do. + - New **Openable** default Category and Group. + - Hidden Groups have an indicator in Edit Mode. + - `/Bagshui Info ` will open the Item Information window for an arbitrary item. + - Empty slot graphics can be hidden and a custom background color set. 🫶 [@RetroCro](https://github.com/RetroCro) + - Settings menu has been given a thorough polishing. + +### Fixed + +- Dropping a profession now correctly removes it from Bagshui's reagents and crafts tracking. +- Highlight Changes mode should be much more reliable. +- Equipping gear should now more consistently update the history. +- Key internal data is now validated to avoid errors (things you should hopefully never need to care about). + +## 1.4.11 - 2025-03-29 + +### Fixed + +- Mounts, companions, and toys should all be [correctly categorized now](https://github.com/veechs/Bagshui/issues/116) on both WoW 1.18.1 Clients and true Vanilla. Thanks as always to [@Sunelegy](https://github.com/Sunelegy) for zhCN guidance.🗃️ [@jilinge2](https://github.com/jilinge2) +- Attempt to protect against [rare errors during sorting](https://github.com/veechs/Bagshui/issues/127). 🪲 bonho + +## 1.4.10 - 2025-03-28 + +### Fixed + +- Locking a bag's highlight via Alt+Click now [temporarily unhides all items in that bag](https://github.com/veechs/Bagshui/issues/125). 🪲 [@RetroCro](https://github.com/RetroCro) + +## 1.4.9 - 2025-03-27 + +### Changed + +- zh-CN localization updates by [@Sunelegy](https://github.com/Sunelegy). + +### Fixed + +- ItemRack integration now also ignores enchant codes since, like Outfitter, it seems they aren't always updated. 🪲 Surtugal + +## 1.4.8 - 2025-03-26 + +### Changed + +- pfUI tooltip "Dodge"-mode lovers, rejoice! Bagshui now tells pfUI when to anchor the tooltip to its windows. Thanks to [@shagu](https://github.com/shagu) for [making this possible](https://github.com/shagu/pfUI/issues/1391). 🫶 Bahamutxd + +## 1.4.7 - 2025-03-26 + +### Fixed + +- The first time a character logged in, [using Edit Mode in Bags would not change the Structure for Bank and vice-versa](https://github.com/veechs/Bagshui/issues/121). 🪲 [@Szalor](https://github.com/Szalor) +- Renaming a group could throw an error under certain circumstances. +- Tooltips in Edit Mode for left-anchored inventory windows will no longer decide to be super wide. + +## 1.4.6 - 2025-03-15 + +### Fixed + +- Alt/Ctrl+click and right-click [compatibility](https://github.com/veechs/Bagshui/issues/118) with ["Old Interface" Aux](https://github.com/mrrosh/aux-addon_old-interface/). 🪲 [@StrayDemon-13](https://github.com/StrayDemon-13) + +## 1.4.5 - 2025-03-04 + +### Fixed + +- Picking up the Hearthstone by dragging the toolbar button [works again](https://github.com/veechs/Bagshui/issues/111). 🪲 [@p3isman](https://github.com/p3isman) + +## 1.4.4 - 2025-03-02 + +### Fixed + +- Keep Bags and Bank settings [in sync](https://github.com/veechs/Bagshui/issues/108) when they are using the same profiles (hard to believe I missed this for so long). 🪲 Evilko +- Ensure bottom toolbar size doesn't [do silly things](https://github.com/veechs/Bagshui/issues/107) when bag slots are hidden. 🪲 Evilko + +## 1.4.3 - 2025-03-01 + +### Fixed + +- Clicking a bag slot with Outfitter installed will no longer [error](https://github.com/veechs/Bagshui/issues/101) (this was somehow an issue since 1.0‽). 🪲 bonho +- Prevent [errors](https://github.com/veechs/Bagshui/issues/102) while processing items with very long tooltips. 🪲 bonho + +## 1.4.2 - 2025-02-22 + +### Fixed + +- Hearthstone toolbar button won't throw errors anymore. + +## 1.4.1 - 2025-02-22 + +### Fixed + +- Small improvement to item slot button proxying to avoid errors from Blizzard code. + +## 1.4.0 - 2025-02-22 + +### Changed + +- Added [window strata option](https://github.com/veechs/Bagshui/issues/91). Find it at **[Settings](https://github.com/veechs/Bagshui/wiki/Home#settings)** > **Advanced** > **Window** • **Strata**. 🫶 [@Nikki1993](https://github.com/Nikki1993) + +### Fixed + +- Yet another rework of how Bagshui's item slot buttons interact with Blizzard code. Hopefully third time's the charm! This also fixes [partial stack selling](https://github.com/veechs/Bagshui/issues/93). + - If you're interested in what's going on with this, [here's some exciting reading](https://github.com/veechs/Bagshui/pull/95). + +## 1.3.0 - 2025-02-17 + +### Changed + +- Accuracy of unusable item tinting has been vastly improved (things like fist weapons that gave it fits before now work correctly).
Thanks to [@Sunelegy](https://github.com/Sunelegy) for bringing the problems to my attention and [@shagu](https://github.com/shagu) for the pfUI code pointing me to the right solution. + +### Fixed + +- Significant zh-CN localization refinements by [@Sunelegy](https://github.com/Sunelegy). +- No more errors in FrameXML.log. 🪲 [@RetroCro](https://github.com/RetroCro) + +## 1.2.29 - 2025-02-16 + +### Fixed + +- Another rule function menu fix. Hopefully the last one. + +## 1.2.28 - 2025-02-16 + +### Fixed + +- The rule function menu in the Category editor has been broken since 1.1. Big oops. + +## 1.2.27 - 2025-02-15 + +### Changed + +- Add [Gemstone of Ysera](https://github.com/veechs/Bagshui/issues/84) to default Keys Category. 🗃️ [@Mats391](https://github.com/Mats391) + +## 1.2.26 - 2025-02-13 + +### Fixed + +- Avoid [errors](https://github.com/veechs/Bagshui/issues/73) when making bulk purchases at merchants. 🪲 Roido + +## 1.2.25 - 2025-02-12 + +### Changed + +- Added zh-CN localization. Many thanks to [@Sunelegy](https://github.com/Sunelegy)! + +## 1.2.24 - 2025-02-10 + +### Fixed + +- Multiple objects (Categories, Sort Orders, etc.) created simultaneously will no longer run the risk of overwriting each other. + +## 1.2.23 - 2025-02-09 + +### Fixed + +- Unequipping a bag now [correctly unlocks slot highlighting](https://github.com/veechs/Bagshui/issues/70). 🪲 [@Nikki1993](https://github.com/Nikki1993) + +## 1.2.22 - 2025-02-05 + +### Fixed + +- [Don't reset window position while drag is in progress](https://github.com/veechs/Bagshui/issues/69). 🪲 [@Nikki1993](https://github.com/Nikki1993) + +## 1.2.21 - 2025-02-04 + +### Changed + +- Added `Lock` and `Unlock` parameters to `/Bagshui Bags/Bank`. + +### Fixed + +- Significant improvements to Settings menu positioning to keep it onscreen at all times. 🪲 leiaravdenheilagekyrkja +- Resetting the window position via `/Bagshui Bags/Bank ResetPosition` now works correctly regardless of window anchoring. 🪲 leiaravdenheilagekyrkja + +## 1.2.20 - 2025-01-25 + +### Fixed + +- Ensure [stack splitting targets the correct item](https://github.com/veechs/Bagshui/issues/63) (and don't break everything like 1.2.17 did). + +## 1.2.19 - 2025-01-25 + +### Fixed + +- Temporarily revert change from 1.2.17 because it seems to be [breaking some things](https://github.com/veechs/Bagshui/issues/65). + +## 1.2.18 - 2025-01-25 + +### Fixed + +- Outfitter integration now [ignores enchant codes](https://github.com/veechs/Bagshui/issues/62) since those don't always seem to be updated on the Outfitter side. 🪲 bonho + +## 1.2.17 - 2025-01-25 + +### Fixed (but not really, since it caused all kinds of issues) + +- Ensure [stack splitting targets the correct item](https://github.com/veechs/Bagshui/issues/63). 🪲 bonho + +## 1.2.16 - 2025-01-21 + +### Fixed + +- [Offline tooltips in Bags](https://github.com/veechs/Bagshui/issues/60) work, which were broken since 1.2.4. 🪲 [@Kirius88](https://github.com/Kirius88) + +## 1.2.15 - 2025-01-19 + +### Fixed + +- Improve [accuracy of unusable item coloring](https://github.com/veechs/Bagshui/issues/58). 🪲 bonho + +## 1.2.14 - 2025-01-19 + +### Fixed + +- Prevent errors when opening menus. 🪲 Miwi + +## 1.2.13 - 2025-01-18 + +### Changed + +- Item stock change badges (new/increased/decreased) [will not disappear immediately when the item is clicked](https://github.com/veechs/Bagshui/issues/57) by default. + ℹ️ If you prefer the old behavior, it's available by enabling **Clear on Click** under **Settings** > **More** > **Stock Change Timers**. + +## 1.2.12 - 2025-01-18 + +### Fixed + +- Locked items now [dim like they should](https://github.com/veechs/Bagshui/issues/56). + +## 1.2.11 - 2025-01-18 + +### Fixed + +- Edit Mode [Direct Assignment](https://github.com/veechs/Bagshui/wiki/Edit-Mode#managing-direct-assignment) didn't work correctly with Class Categories. [Now it does](https://github.com/veechs/Bagshui/issues/55). + +## 1.2.10 - 2025-01-17 + +### Fixed + +- Better error handling [when a quest link is clicked in chat and pfQuest isn't installed](https://github.com/veechs/Bagshui/issues/52). 🪲 [@doctorwizz](https://github.com/doctorwizz) +- Really truly [prevent built-in Categories from being edited](https://github.com/veechs/Bagshui/issues/35). 🪲 bonho + +## 1.2.9 - 2025-01-16 + +### Changed + +- [Add Tokens to default Profiles](https://github.com/veechs/Bagshui/issues/42) to capture most pseudo-currency items like reputation and battleground turn-ins. 🗃️ [@KameleonUK](https://github.com/KameleonUK)
**_Please note:_** + - If the Profiles you're using are still pretty close to the default, you should get Tokens added automatically. + - If you don't receive a Tokens Group and want one, [it's pretty easy](https://github.com/veechs/Bagshui/wiki/Walkthroughs#creating-a-group) to create a Group and assign the Tokens Category. + +### Fixed + +- Fix [Bank bag slot highlighting](https://github.com/veechs/Bagshui/issues/50) and a [possible tooltip error](https://github.com/veechs/Bagshui/issues/51). 🪲 [@Nikki1993](https://github.com/Nikki1993) +- Fix Edit Mode Group tooltips potentially not displaying all Categories. + +## 1.2.8 - 2025-01-15 + +### Fixed + +- [Improved offscreen window detection](https://github.com/veechs/Bagshui/issues/49). 🪲 [@doctorwizz](https://github.com/doctorwizz) + +## 1.2.7 - 2025-01-15 + +### Fixed + +- Stop item charge counts from [disappearing](https://github.com/veechs/Bagshui/issues/36). + +## 1.2.6 - 2025-01-15 + +### Fixed + +- 🚨 Important bug fix to avoid [the wrong quest being abandoned](https://github.com/veechs/Bagshui/issues/48). 🪲 [@Nikki1993](https://github.com/Nikki1993) +- Item categorization updates for [Bright Dream Shard](https://github.com/veechs/Bagshui/issues/44) and [Arena Mark of Honor](https://github.com/veechs/Bagshui/issues/41). 🗃️ bonho and [@KameleonUK](https://github.com/KameleonUK) + +## 1.2.5 - 2025-01-11 + +### Changed + +_The [Info Tooltip Taming](https://github.com/veechs/Bagshui/milestone/2?closed=1) Update_ + +- [Bagshui Info Tooltips](https://github.com/veechs/Bagshui/wiki/Item-Information#info-tooltip) should behave much better with fewer edge cases and improved compatibility. 🫶 [@Distrattos](https://github.com/Distrattos), [@doctorwizz](https://github.com/doctorwizz), and [@thecreth](https://github.com/thecreth) +- It will appear above the item tooltip for auction listings to avoid obscuring the row. +- Listings in [aux](https://github.com/shirsig/aux-addon-vanilla) will now get Info Tooltips too.
This was a _whole thing_ because aux likes to do things its own special way. + +## 1.2.4 - 2025-01-11 + +### Fixed + +- [Improve compatibility with tooltip addons](https://github.com/veechs/Bagshui/issues/11), including **CompareStats** and anything **GFW\_**. 🪲 bonho and [@thecreth](https://github.com/thecreth) + +## 1.2.3 - 2025-01-09 + +### Fixed + +- Catalog won't [annoyingly clear your search text when it shouldn't](https://github.com/veechs/Bagshui/issues/24). 🪲 [@tippfelher](https://github.com/tippfelher) +- [Turn off Highlight Changes when there's nothing left to highlight](https://github.com/veechs/Bagshui/issues/19). 🪲 [@Distrattos](https://github.com/Distrattos)
There's some interplay between Bags and Keyring around this that still needs to be resolved but I don't think anyone uses Keyring enough to _really_ care. +- Tweak Recipes category to include Top Half/Bottom Half Advanced Volumes (thanks Melo) +- Try to make it clear during Direct Assignment in Edit Mode that a [custom Category is required](https://github.com/veechs/Bagshui/wiki/FAQ#why-cant-i-edit-built-in-objects). + +## 1.2.2 - 2025-01-08 + +### Fixed + +- [Windows that try to go for an adventure off the screen](https://github.com/veechs/Bagshui/issues/18) should now be brought back. `/bagshui Bags ResetPosition` (or `Bank`) has also been made available in case manual intervention is required. + +## 1.2.1 - 2025-01-06 + +### Fixed + +- Fix [Direct Assignment bug](https://github.com/veechs/Bagshui/issues/17) that was causing a lot of confusion. Sorry everyone, and thanks to Kord2998, Secrett, and [@saintsareprey](https://github.com/saintsareprey) for bringing this to my attention. + +## 1.2.0 - 2025-01-05 + +### Changed + +- [Add setting](https://github.com/veechs/Bagshui/issues/14) to prevent automatic reorganization when the Inventory window is closed and reopened. 🫶 Serbz and Caveira + - Find it at **[Settings](https://github.com/veechs/Bagshui/wiki/Home#settings)** > **Advanced** > **Behaviors** > **Manual Reorganization**. +- [Add setting](https://github.com/veechs/Bagshui/issues/12) to display [Bagshui Info Tooltip](https://github.com/veechs/Bagshui/wiki/Item-Information#info-tooltip) without holding Alt. 🫶 [@doctorwizz](https://github.com/doctorwizz) + - Find it at **[Settings](https://github.com/veechs/Bagshui/wiki/Home#settings)** > **More** > **Integration** > **Info Tooltip** > **Show Without Alt**. + +## 1.1.1 - 2025-01-04 + +### Fixed + +- [Remove "long cooldown" workaround](https://github.com/veechs/Bagshui/issues/10) (special thanks to [@shagu](https://github.com/shagu) for working through this). + +## 1.1.0 - 2025-01-04 + +### Changed + +- Add `Transmog()` [rule function](https://github.com/veechs/Bagshui/wiki/Rules) stub to support transmog collection data via [Bagshui-Tmog](https://github.com/veechs/Bagshui-Tmog). 🫶 Tyrchast +- Internal rework of rule function management. +- Refactor [3rd party API](https://github.com/veechs/Bagshui/wiki/Developers:-API). + +## 1.0.8 - 2025-01-02 + +### Fixed + +- Fix [Profile Replace error](https://github.com/veechs/Bagshui/issues/9). + +## 1.0.7 - 2025-01-01 + +### Fixed + +- Fixes to [3rd party `Bagshui:AddRuleFunction()` API](https://github.com/veechs/Bagshui/wiki/Developers:-API) (i.e. it'll actually work now). + +## 1.0.6 - 2025-01-01 + +### Fixed + +- Fix [Active Quest Item error](https://github.com/veechs/Bagshui/issues/6). 💞 [absir](https://github.com/absir) + +## 1.0.5 - 2024-12-31 + +### Fixed + +- Fix [tDF MinimapButtonBag tweaks](https://github.com/veechs/Bagshui/issues/5). 🪲 Amon_RA + +## 1.0.4 - 2024-12-31 + +### Changed + +- [Window scale setting added](https://github.com/veechs/Bagshui/issues/4). 🫶 [mmrosh](https://github.com/mrrosh) +- Adjust default categories: 🗃️ [@melba](https://github.com/melbaa) + - Add WoW 1.18.1 Clients items to Teleport and decrease sequence number to run before Soulbound. + - Add missing Anti-Venoms to First Aid (formerly Bandages). + +## 1.0.3 - 2024-12-30 + +### Changed + +- Open All Bags key binding will now [toggle Bags to match default Blizzard behavior](https://github.com/veechs/Bagshui/issues/3). + +## 1.0.2 - 2024-12-30 + +### Fixed + +- Prevent [nil colorStr error](https://github.com/veechs/Bagshui/issues/2). 🪲 Gondoleon + +## 1.0.1 - 2024-12-30 + +### Fixed + +- Improve efficiency of [searching via rules](https://github.com/veechs/Bagshui/wiki/Searching#advanced-searches). + +## 1.0.0 - 2024-12-30 + +- Initial release. diff --git a/Components/API.lua b/Components/API.lua new file mode 100644 index 0000000..ed951ce --- /dev/null +++ b/Components/API.lua @@ -0,0 +1,106 @@ +-- Bagshui API +-- Exposes functions for easy 3rd party integrations. + +Bagshui:LoadComponent(function() + --- Add a new rule function to Bagshui. A simple example is below. `params` + --- uses the same basic format as the built-in rule functions, so you can refer + --- to Config\RuleFunctions.lua for more examples. + --- + --- ## `params` values + --- ``` + --- { + --- -- If the function has no aliases, just pass its name as a string. + --- -- To provide aliases, pass a list of strings, where the first item is + --- -- the primary name and all subsequent values are the aliases. + --- ---@type table|string + --- functionNames, + --- + --- -- The rule function must accept two parameters and return a boolean. + --- ---@type function + --- ---@param rules table The Rules class, with rules.item being the current item under evaluation. + --- ---@param ruleArguments any[] List of all arguments provided by the user to the rule function. + --- ---@return boolean + --- ruleFunction, + --- + --- -- (Optional but recommended) List of examples for use in the Category Editor + --- -- rule function [Fx] menu. + --- -- `code` is the menu text and what will be inserted in the editor; + --- -- `description` will be in the tooltip. + --- ---@type { code: string, description: string }[]? + --- ruleTemplates, + --- + --- -- (Optional) Tooltip text for the top-level rule function menu item in the Category editor. + --- ---@type string? + --- description, + --- + --- -- (Optional) List of variables to add to the rule environment. + --- -- See the BagType rule in Config\RuleFunctions.lua for an example. + --- ---@type table? + --- environmentVariables, + --- } + --- ``` + --- + --- + --- ## Example + --- ``` + --- Bagshui:AddRuleFunction({ + --- functionNames = { + --- "IsSolidStone", + --- "Stone" + --- }, + --- ruleFunction = function(rules, ruleArguments) + --- if rules.item.name == "Solid Stone" then + --- return true + --- end + --- + --- return false + --- end, + --- ruleTemplates = { + --- { + --- code = 'IsSolidStone()', + --- description = 'Check if the item is Solid Stone.', + --- }, + --- } + --- }) + --- ``` + ---@param params table Parameters -- see function comments for details. + function Bagshui:AddRuleFunction(params) + assert(type(params) == "table", "Bagshui:AddRuleFunction() - params must be a table.") + assert( + type(params.functionNames) == "string" or type(params.functionNames) == "table", + "Bagshui:AddRuleFunction() - params.functionNames is required and must be a string or a table." + ) + assert( + type(params.ruleFunction) == "function", + "Bagshui:AddRuleFunction() - params.ruleFunction is required and it must be a function." + ) + assert( + params.environmentVariables == nil or type(params.environmentVariables) == "table", + "Bagshui:AddRuleFunction() - params.environmentVariables must be a table." + ) + assert( + params.ruleTemplates == nil or type(params.ruleTemplates) == "table", + "Bagshui:AddRuleFunction() - params.ruleTemplates must be a table." + ) + + BsRules:AddFunction(params) + end + + --- Notify Bagshui that a change has occurred which requires inventories to refresh. + ---@param delay number? Seconds to wait before starting the update. Useful if there are likely to be a series of events that require updates. + ---@param resortNeeded boolean? Light up the Reorganize toolbar icon if the inventory window is open. Pass `true` when a change has occurred that may require items to be re-categorized or resorted. + ---@param cacheUpdateNeeded boolean? Check all items for changes, but don't refresh them GetItemInfo() unless there's a major change. + ---@param fullCacheUpdateNeeded boolean? Force all item properties to be refreshed, including tooltips. + function Bagshui:QueueInventoryUpdate(delay, resortNeeded, cacheUpdateNeeded, fullCacheUpdateNeeded) + for _, inventoryType in pairs(BS_INVENTORY_TYPE) do + self.components[inventoryType].resortNeeded = self.components[inventoryType].resortNeeded or resortNeeded + self.components[inventoryType].windowUpdateNeeded = self.components[inventoryType].windowUpdateNeeded + or windowUpdateNeeded + self.components[inventoryType].cacheUpdateNeeded = self.components[inventoryType].cacheUpdateNeeded + or cacheUpdateNeeded + self.components[inventoryType].forceFullCacheUpdate = self.components[inventoryType].forceFullCacheUpdate + or fullCacheUpdateNeeded + self.components[inventoryType]:QueueUpdate(delay) + end + end +end) diff --git a/Components/About.lua b/Components/About.lua new file mode 100644 index 0000000..9982a2c --- /dev/null +++ b/Components/About.lua @@ -0,0 +1,90 @@ +-- Bagshui About Dialog +-- Exposes: BsAboutDialog (and Bagshui.components.AboutDialog) + +Bagshui:AddComponent(function() + local About = {} + Bagshui.components.AboutDialog = About + Bagshui.environment.BsAboutDialog = About + + --- Initialize the About dialog. + function About:InitUi() + if self.uiFrame then + return + end + + self.uiFrame = Bagshui.prototypes.Ui:CreateWindowFrame("About", nil, 295, 140, "") + + local nextAnchor = self.uiFrame + local nextAnchorToPoint = "TOPLEFT" + local nextXOffset = 25 + local nextYOffset = -25 + + local logoParts = {} + + -- The Bagshui logo is split into 4 parts to optimize the file sizes. + for i, logoPart in pairs({ "Ba", "gs", "hu", "i" }) do + logoParts[i] = self.uiFrame:CreateTexture(nil, "ARTWORK") + logoParts[i]:SetTexture(BsUtil.GetFullTexturePath("UI\\Logo_" .. logoPart)) + logoParts[i]:SetWidth(75) + logoParts[i]:SetHeight(75) + logoParts[i]:SetVertexColor(BS_COLOR.BAGSHUI_LOGO[1], BS_COLOR.BAGSHUI_LOGO[2], BS_COLOR.BAGSHUI_LOGO[3]) + logoParts[i]:SetPoint("TOPLEFT", nextAnchor, nextAnchorToPoint, nextXOffset, nextYOffset) + nextAnchor = logoParts[i] + nextAnchorToPoint = "TOPRIGHT" + nextXOffset = 0 + nextYOffset = 0 + end + + local version = self.uiFrame:CreateFontString(nil, nil, "GameFontNormalSmall") + version:SetText(BS_FONT_COLOR.BAGSHUI .. BS_VERSION .. FONT_COLOR_CODE_CLOSE) + version:SetJustifyH("RIGHT") + version:SetPoint("TOPRIGHT", logoParts[4], "BOTTOMLEFT", 13, 17) + + local url = Bagshui.prototypes.Ui:CreateEditBox("AboutUrl", self.uiFrame, nil, nil, nil, true, true) + Bagshui.prototypes.Ui:SetEditBoxTextReadOnly(url, BS_URL) + url:SetWidth(150) + url:SetHeight(16) + url:SetTextInsets(0, 0, 0, 0) + url:SetAlpha(0.5) + url:SetJustifyH("LEFT") + url:SetPoint("TOPLEFT", logoParts[1], "BOTTOMLEFT", 0, -5) + url:SetPoint("RIGHT", self.uiFrame, "RIGHT", -25, 0) + local oldOnFocusGained = url:GetScript("OnEditFocusGained") + url:SetScript("OnEditFocusGained", function() + oldOnFocusGained() + _G.this:SetAlpha(1) + end) + local oldOnFocusLost = url:GetScript("OnEditFocusLost") + url:SetScript("OnEditFocusLost", function() + oldOnFocusLost() + _G.this:SetAlpha(0.5) + end) + + -- Unfocus the URL when clicking outside it. + self.uiFrame:SetScript("OnMouseUp", function() + url:HighlightText(0, 0) + url:ClearFocus() + end) + end + + --- Open the window. + function About:Open() + self:InitUi() + + -- Move to center of screen and open. + self:Close() + if not self.uiFrame:IsVisible() then + self.uiFrame:SetPoint("CENTER", 0, 0) + self.uiFrame:Show() + else + self.uiFrame:Raise() + end + end + + --- Close the window. + function About:Close() + if self.uiFrame then + self.uiFrame:Hide() + end + end +end) diff --git a/Components/ActiveQuestItems.lua b/Components/ActiveQuestItems.lua new file mode 100644 index 0000000..8c46cb0 --- /dev/null +++ b/Components/ActiveQuestItems.lua @@ -0,0 +1,140 @@ +-- Bagshui Active Quest Item Manager +-- Exposes: Bagshui.components.ActiveQuestItemManager +-- Raises: BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE +-- +-- Contains code based on QuestItem: https://github.com/wow-vanilla-addons/QuestItem/blob/master/QuestItem/QuestItem.lua +-- +-- Keeps Bagshui.activeQuestItems updated with the NAMES of items which are objectives of current quests. +-- (We can't get item IDs due to Vanilla limitations). +-- +-- Raises BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE when changes to Bagshui.activeQuestItems occur. +-- +-- This works by receiving QUEST_LOG_UPDATE events, which tend to come in fast and furious. To avoid constantly +-- rebuilding the list of active quest items, we're using an event queue: +-- 1. QUEST_LOG_UPDATE event comes in and BAGSHUI_QUEST_LOG_UPDATE event is queued to fire 1 second later. +-- If another QUEST_LOG_UPDATE event arrives before that 1 second is up, the timer is reset. +-- 2. BAGSHUI_QUEST_LOG_UPDATE event fires and triggers the actual parsing of the quest log. +-- After that is complete, BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE event is fired immediately. +-- 3. Other components that have registered to received BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE are now notified +-- and can trigger recategorization and resorting. + +Bagshui:AddComponent(function() + -- Key in _G.BagshuiData where active quest items will be stored. + local ACTIVE_QUEST_ITEMS_DATA_STORAGE_KEY = "activeQuestItems" + + -- Initialization (happens when this component is loaded during `ADDON_LOADED`) + if not Bagshui.currentCharacterData[ACTIVE_QUEST_ITEMS_DATA_STORAGE_KEY] then + Bagshui.currentCharacterData[ACTIVE_QUEST_ITEMS_DATA_STORAGE_KEY] = {} + end + + local ActiveQuestItemManager = { + + -- List of active quest items by item name. + -- + -- Items must be stored in SavedVariables to to prevent inventory items from + -- jumping around during UI reload, because there is a delay before the quest + -- log can be read. The list is when QUEST_LOG_UPDATE fires for the first time. + -- ``` + -- { + -- ["Item Name"] = { + -- questName = "Quest Name", + -- needed = #, + -- obtained = #, + -- } + -- } + -- + -- ``` + ---@type table + items = Bagshui.currentCharacterData[ACTIVE_QUEST_ITEMS_DATA_STORAGE_KEY], + } + + --- Event handling. + ---@param event string Event name. + ---@param arg1 any Event argument. + function ActiveQuestItemManager:OnEvent(event, arg1) + -- WoW says "Something has happened with the quest log!" (it says this a lot). + -- Queue an event so that we only do our update after all the events have finished firing + -- This will be the most common event received, by far, so it goes first. + if event == "QUEST_LOG_UPDATE" then + Bagshui:QueueEvent("BAGSHUI_QUEST_LOG_UPDATE", 1) + return + end + + -- Enough time has elapsed since the last QUEST_LOG_UPDATE event to actually do our update. + if event == "BAGSHUI_QUEST_LOG_UPDATE" then + self:Update() + Bagshui:RaiseEvent("BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE") + return + end + end + + --- Parse the quest log to find the names of items that are quest objectives. + function ActiveQuestItemManager:Update() + -- The quest log is one of those annoying things where you can't directly ask + -- about a quest; you have the tell the client to *select* the quest, and then + -- all the quest info functions work on that. This becomes a problem if the + -- Quest Log is open and we want to update, since we need to loop through + -- all quests, we're going to have to change the selected quest. What ends up + -- happening is that once we're done, the very last quest in the log remains + -- selected and is out of sync with the Quest Log UI. This matters when someone + -- abandons multiple quests in a row, because the first abandonment will be + -- accurate, but then the next Abandon Quest click will target the last quest + -- in the list instead of the one that looks like it's selected. + -- All that to say -- we need to store the currently selected quest number + -- before we start so it can be restored once we're done. + local previouslySelectedQuestNum = _G.GetQuestLogSelection() + + -- In theory we could keep a list of the items we've actually seen and remove the ones + -- we don't see, but erasing it is easier. (Can always change if it's not performant enough). + BsUtil.TableClear(self.items) + + local questName, isHeader, objectiveText, itemType, itemName, numNeeded, numObtained + + for questNum = 1, _G.GetNumQuestLogEntries(), 1 do + -- Only need a couple pieces of information from GetQuestLogTitle. + questName, _, _, isHeader, _, _ = _G.GetQuestLogTitle(questNum) + + if not isHeader then + itemName = nil + numNeeded = 1 + numObtained = 0 + + -- All subsequent function calls depend on selecting the quest log entry. + _G.SelectQuestLogEntry(questNum) + + -- If description and quest objective text is ever needed, here's how to get it: + -- local questionDescription, questObjectiveText = _G.GetQuestLogQuestText() + + -- Quest objectives are in the "Leader Boards". + for i = 1, _G.GetNumQuestLeaderBoards() do + objectiveText, itemType = _G.GetQuestLogLeaderBoard(i) + + -- We only care if type is item/object (not monster, event, etc.). + if itemType ~= nil and (itemType == "item" or itemType == "object") then + -- Parse and store the item information from the objective text, which is formatted as: + -- `: /` + _, _, itemName, numObtained, numNeeded = string.find(objectiveText, "(.+): (%d+)/(%d+)") + if itemName then + self.items[itemName] = { + questName = questName, + needed = numNeeded, + obtained = numObtained, + } + end + end + end + end + end + + -- Restore Quest Log selection, if any (see the dissertation at the beginning of this function). + if previouslySelectedQuestNum then + _G.SelectQuestLogEntry(previouslySelectedQuestNum) + end + end + + -- Exports and registration. + Bagshui.components.ActiveQuestItemManager = ActiveQuestItemManager + Bagshui.activeQuestItems = ActiveQuestItemManager.items + Bagshui:RegisterEvent("QUEST_LOG_UPDATE", ActiveQuestItemManager) -- Quest log changes. + Bagshui:RegisterEvent("BAGSHUI_QUEST_LOG_UPDATE", ActiveQuestItemManager) -- Our custom trigger to parse the quest log. +end) diff --git a/Components/Bags.lua b/Components/Bags.lua new file mode 100644 index 0000000..a9bf2d9 --- /dev/null +++ b/Components/Bags.lua @@ -0,0 +1,161 @@ +-- Bagshui Bags Inventory Class Instance +-- Exposes: Bagshui.components.Bags [via Inventory:New()] + +Bagshui:AddComponent(function() + -- Create class instance. + local Bags = Bagshui.prototypes.Inventory:New(BS_INVENTORY_TYPE.BAGS) + + -- Hook handling. + + -- The original WoW API function needs to be called for Close* and Toggle* if the + -- original container frame is open. If this isn't done, clicking the close button + -- on the original frame will only affect Bagshui and leave the original frame + -- stuck on the screen forever. + + --- Open bag hooks (OpenAllBags, OpenBackpack, OpenBag(bagNum)). + ---@param hookFunctionName string Name of the original WoW API function. + ---@param bagNumParam number Container ID. + function Bags:OpenBag(hookFunctionName, bagNumParam) + self:OpenCloseToggle(BS_INVENTORY_UI_VISIBILITY_ACTION.OPEN, hookFunctionName, bagNumParam) + end + + --- Close bag hooks (CloseBackpack, CloseBag(bagNum)). + ---@param hookFunctionName string Name of the original WoW API function. + ---@param bagNumParam number Container ID. + function Bags:CloseBag(hookFunctionName, bagNumParam) + self:OpenCloseToggle( + BS_INVENTORY_UI_VISIBILITY_ACTION.CLOSE, + hookFunctionName, + bagNumParam, + self:OriginalContainerFrameVisible(bagNumParam) + ) + end + + --- Toggle bag hooks (ToggleBackpack, ToggleBag(bagNum)). + ---@param hookFunctionName string Name of the original WoW API function. + ---@param bagNumParam number Container ID. + function Bags:ToggleBag(hookFunctionName, bagNumParam) + self:OpenCloseToggle( + BS_INVENTORY_UI_VISIBILITY_ACTION.TOGGLE, + hookFunctionName, + bagNumParam, + self:OriginalContainerFrameVisible(bagNumParam) + ) + end + + --- Register additional events and properties to make bag slot buttons "just work" with Blizzard code. + ---@param bagSlotButton table Bag slot button instance. + function Bags:BagSlotButton_Init(bagSlotButton) + bagSlotButton:RegisterEvent("BAG_UPDATE") + bagSlotButton.isBag = 1 -- We're not relying too much on PaperDollItemSlotButton, but let's set this just to be safe. + + local oldOnClick = bagSlotButton:GetScript("OnClick") + --- If there was an item in the cursor when the slot was clicked, catch it and prevent the original + --- from being called, since since PaperDollItemSlotButton will interpret that as trying to *equip* + --- the item in that slot, instead of trying to put it in the bag. Note that due to the behavior of + --- `PutItemInBag()`, this will *not* intercept bags, but that's handled in the bag slot button's OnClick. + bagSlotButton:SetScript("OnClick", function() + if + -- Pass through to the default Bagshui OnClick for bags + -- so native bag swapping can be invoked. + (_G.CursorHasItem() and BsItemInfo:IsContainer(Bagshui.cursorItem)) + -- Otherwise, allow PutItemInBag() to catch cursor items and move them. + or not _G.PutItemInBag(_G.this.bagshuiData.inventorySlotId) + then + oldOnClick() + end + end) + end + + --- Override Inventory:UpdateBagBar() and Inventory:UiFrame_OnHide() so we can correctly set the + --- highlight state of the Blizzard action bar bag slot buttons when our window opens/closes/updates. + function Bags:UpdateBagBar() + self._super.UpdateBagBar(self) + -- Don't update action bar bag slot buttons until the next update tick. + -- This avoids having the Blizzard UI code immediately turn off the checked state. + Bagshui:QueueClassCallback(self, self.UpdateActionBarBagSlotButtonState) + end + + --- Ensure the Blizzard action bar bag buttons are un-highlighted when the Bags window is closed. + function Bags:UiFrame_OnHide() + self._super.UiFrame_OnHide(self) + -- It's safe to instantly un-highlight the action bar bag buttons when the window is closed. + self:UpdateActionBarBagSlotButtonState() + end + + Bags._actionBarButtonWasChecked = {} + --- Set Blizzard action bar bag slot buttons to "checked" (highlighted) when our + --- window is open and unchecked when it's closed. + function Bags:UpdateActionBarBagSlotButtonState() + local shouldBeChecked = self:Visible() + local actionBarButtonName, actionBarButton + + for _, bagNum in pairs(self.containerIds) do + local hookEnabled = self:GetHookEnabled("Bag", bagNum) + -- Only highlight bags in the action bar that we're hooking. + if + (hookEnabled or self._actionBarButtonWasChecked[bagNum]) + and type(self.currentCharacterInventory[bagNum]) == "table" + then + if bagNum == 0 then + actionBarButtonName = "MainMenuBarBackpackButton" + else + actionBarButtonName = string.format("CharacterBag%dSlot", bagNum + self.bagSlotNameNumberOffset) + end + actionBarButton = _G[actionBarButtonName] + -- Avoid messing with the highlight state when the original container frame is open. + if actionBarButton and not self:OriginalContainerFrameVisible(bagNum) then + actionBarButton:SetChecked( + shouldBeChecked + and hookEnabled + -- Only highlight buttons where there are bags. + and (table.getn(self.currentCharacterInventory[bagNum]) > 0) + ) + self._actionBarButtonWasChecked[bagNum] = hookEnabled + end + end + end + end + + --- Helper to determine when when one of the Blizzard bag frames is open. + ---@param bagNum any + ---@return boolean frameVisible + function Bags:OriginalContainerFrameVisible(bagNum) + if not bagNum then + return false + end + return self.ui:IsFrameVisible("ContainerFrame" .. tostring(bagNum)) + end + + --- Execute `self:Open()/Close()` and the superclass version only if there isn't a reason to block it. + ---@param action "Open"|"Close" + ---@return boolean? # false if event was blocked. + function Bags:SmartOpenClose(action) + -- Block based on settings. + if + type(_G.event) == "string" + and ( + (string.find(_G.event, "^AUCTION_HOUSE_") and self.settings.toggleBagsWithAuctionHouse == false) + or (string.find(_G.event, "^BANKFRAME_") and self.settings.toggleBagsWithBankFrame == false) + or (string.find(_G.event, "^MAIL_") and self.settings.toggleBagsWithMailFrame == false) + or (string.find(_G.event, "^TRADE_") and self.settings.toggleBagsWithTradeFrame == false) + ) + then + return + end + + -- Proceed with action. + self._super[action](self) + end + + --- Add intelligence to Open(). + function Bags:Open() + self:SmartOpenClose("Open") + end + + --- Add intelligence to Close(). + function Bags:Close() + self:SmartOpenClose("Close") + self.lastOpenEventTrigger = nil + end +end) diff --git a/Components/Bagshui.BlizzFixes.lua b/Components/Bagshui.BlizzFixes.lua new file mode 100644 index 0000000..3559ce6 --- /dev/null +++ b/Components/Bagshui.BlizzFixes.lua @@ -0,0 +1,50 @@ +-- Bagshui Core: Blizzard FrameXML code fixes +-- Patches to work around issues in game code that don't fit anywhere else go here. + +Bagshui:LoadComponent(function() + --- Stupid monkeypatch for a difficult-to-reproduce bug in Blizzard's FrameXML code + --- that intermittently leads to this error when calling `CreateFrame()` with + --- `GameTooltipTemplate` as the frame template: + --- ```text + --- Message: Interface\FrameXML\MoneyFrame.lua:185: attempt to perform arithmetic on local `money' (a nil value) + --- Stack: Interface\FrameXML\MoneyFrame.lua:185: in function `MoneyFrame_Update' + --- Interface\FrameXML\MoneyFrame.lua:168: in function `MoneyFrame_UpdateMoney' + --- Interface\FrameXML\MoneyFrame.lua:161: in function `MoneyFrame_SetType' + --- [string "MoneyFrame:OnLoad"]:3: in main chunk + --- [C]: in function `CreateFrame' + --- ``` + ---@param wowApiFunctionName string Hooked WoW API function that triggered this call. + function Bagshui:MoneyFrame_UpdateMoney(wowApiFunctionName) + -- There doesn't seem to be anything that initializes the `staticMoney` property + -- of money frames, but this is only a problem sometimes? It's confusing. + -- Regardless, this prevents the error from happening. + if _G.this.moneyType == "STATIC" and _G.this.staticMoney == nil then + _G.this.staticMoney = 0 + end + self.hooks:OriginalHook(wowApiFunctionName) + end + + --- Ensure the stack split frame stays onscreen. + ---@param wowApiFunctionName string Hooked WoW API function that triggered this call. + ---@param maxStack any `OpenStackSplitFrame()` parameter. + ---@param parent any `OpenStackSplitFrame()` parameter. + ---@param anchor any `OpenStackSplitFrame()` parameter. + ---@param anchorTo any `OpenStackSplitFrame()` parameter. + function Bagshui:OpenStackSplitFrame(wowApiFunctionName, maxStack, parent, anchor, anchorTo) + -- Pass along to the normal `OpenStackSplitFrame()` to handle everything. + self.hooks:OriginalHook(wowApiFunctionName, maxStack, parent, anchor, anchorTo) + -- Reposition if needed. + if BsUtil.GetFrameOffscreenAmount(_G.StackSplitFrame, "y") < 0 then + self:PrintDebug(anchor) + self:PrintDebug(BsUtil.FlipAnchorPointComponent(anchor, 1)) + _G.StackSplitFrame:ClearAllPoints() + _G.StackSplitFrame:SetPoint( + BsUtil.FlipAnchorPointComponent(anchor, 1), + parent, + BsUtil.FlipAnchorPointComponent(anchorTo, 1), + 0, + 0 + ) + end + end +end) diff --git a/Components/Bagshui.Cursor.lua b/Components/Bagshui.Cursor.lua new file mode 100644 index 0000000..bec535d --- /dev/null +++ b/Components/Bagshui.Cursor.lua @@ -0,0 +1,310 @@ +-- Bagshui Core: Cursor +-- Picking up, putting down, tracking what's being held. + +Bagshui:LoadComponent(function() + --- There's no `GetCursorInfo()` in 1.12 so we're going to do a little trickery in order to accomplish a couple of things: + --- 1. Use escape to clear the cursor. + --- 2. Allow dragging items from bags to category editors to populate item lists. + --- This is NOT hooking the global `PickupContainerItem()`, so only Bagshui item slots are going to use this. + ---@param item table Populated by `ItemInfo:Get()`. + ---@param inventoryClass table? Inventory class instance that owns the item being picked up or put down. + ---@param itemSlotButton table? + ---@param callPickupContainerItem boolean? + function Bagshui:PickupItem(item, inventoryClass, itemSlotButton, callPickupContainerItem) + -- Track where the pickup call came from so we know whether it's being put down + -- in the same inventory or moved to a different one. + local owningFrame = inventoryClass and inventoryClass.uiFrame or _G.this + + -- Reasons to update or clear tracking properties. + if + -- Item was put in a different spot in the same inventory, so hold onto the + -- "pickedUp" values and add "putDown" values. Note that these may be changed + -- in the loop that chooses an empty slot below. + _G.CursorHasItem() + and self.cursorItem ~= nil + and self.pickedUpItemBagNum ~= nil + and self.pickedUpItemSlotNum ~= nil + and (self.pickedUpItemBagNum ~= item.bagNum or self.pickedUpItemSlotNum ~= item.slotNum) + and self.cursorItemOwningFrame == owningFrame + then + self.putDownItemBagNum = item.bagNum + self.putDownItemSlotNum = item.slotNum + elseif + -- Tracking needs to be cleared when... + ( + not _G.CursorHasItem() + and ( + ( + -- Item was put back down in the same spot. +self.cursorItemOwningFrame == owningFrame + and self.pickedUpItemBagNum == item.bagNum + and self.pickedUpItemSlotNum == item.slotNum + ) + or ( + -- First call with nothing on cursor but tracking hasn't been reset. +self.pickedUpItemBagNum + and self.pickedUpItemSlotNum + and self.putDownItemBagNum + and self.putDownItemSlotNum + ) + ) + ) + or ( + -- Item was moved to a different inventory. +_G.CursorHasItem() + and self.cursorItemOwningFrame ~= nil + and self.cursorItemOwningFrame ~= owningFrame + ) + then + -- Clear tracking. + self.pickedUpItemBagNum = nil + self.pickedUpItemSlotNum = nil + self.putDownItemBagNum = nil + self.putDownItemSlotNum = nil + if self.cursorItemOwningFrame then + self.cursorItemOwningFrame.bagshuiData.hasCursorItem = false + end + end + + -- Every time we're called, start fresh with the basic tracking properties. + -- These get updated at the end when we check whether anything was picked up. + self.cursorItem = nil + self.cursorItemOwningFrame = nil + if not owningFrame.bagshuiData then + owningFrame.bagshuiData = {} + end + owningFrame.bagshuiData.hasCursorItem = false + + if + inventoryClass + and itemSlotButton + and itemSlotButton.bagshuiData + and itemSlotButton.bagshuiData.isEmptySlotStack + then + -- This is an empty slot stack. + -- Instead of just putting the item in whatever slot happens to represent the empty slot stack, we're + -- going to iterate all containers and find the first one with an empty slot. This also gives us a chance + -- to work around accidentally trying to put a container into itself (see comment below). + + -- Only iterate containers that match the generic bag type of this slot. + local allowedGenericBagType = inventoryClass.containers[item.bagNum].genericType + + -- When a bag is picked up from the bag bar and an attempt is made to place it into an empty slot, + -- it's possible that the empty slot may belong to the bag that was picked up, especially if it's + -- being put into an empty slot stack. This triggers an error message about not being able to put + -- a bag into itself, which is unexpected if there are empty slots in other bags. To work around + -- this, we iterate all empty slots that belong to non-profession bags looking for somewhere to + -- place the bag that's on the cursor. + local bagNumToAvoid + + -- Special handling when a bag has been picked up. + if self.cursorBagSlotNum and _G.CursorHasItem() and inventoryClass then + self:PrintDebug(self.cursorBagSlotNum) + self:PrintDebug(inventoryClass.inventoryIdsToContainerIds[self.cursorBagSlotNum]) + local bagNum = inventoryClass.inventoryIdsToContainerIds[self.cursorBagSlotNum] + if bagNum and inventoryClass.containers[bagNum].slotsFilled > 0 then + -- Bag needs to be emptied before it can be unequipped. + if inventoryClass:GetAdjustedEmptySlotCount(bagNum) > inventoryClass.containers[bagNum].slotsFilled then + -- Empty the bag. + local bagSlotNum = self.cursorBagSlotNum + local targetBagNum = item.bagNum + local targetSlotNum = item.slotNum + _G.ClearCursor() + inventoryClass:EmptyBag(inventoryClass.inventoryIdsToContainerIds[bagSlotNum], nil, function(success) + if success then + _G.PickupBagFromSlot(bagSlotNum) + self:PickupItem(item, inventoryClass, itemSlotButton, callPickupContainerItem) + end + end) + else + -- Not enough empty slots. + self:ShowErrorMessage(_G.INVENTORY_FULL, (inventoryClass and inventoryClass.inventoryType)) + end + return + end + -- Skip this bag when finding empty slots. + bagNumToAvoid = inventoryClass.inventoryIdsToContainerIds[self.cursorBagSlotNum] + end + + -- Find the best slot to use. + local noEmptySlots = true + for _, bagNum in ipairs(inventoryClass.containerIds) do + if + bagNum ~= bagNumToAvoid -- This will work fine if bagNumToAvoid is nil. + and inventoryClass.containers[bagNum].genericType == allowedGenericBagType + then + for slotNum, slotContents in ipairs(inventoryClass.inventory[bagNum]) do + if + slotContents.emptySlot == 1 + -- When moving an item into an empty slot stack, always peel off + -- a new slot instead of using any that have already been peeled off. + and not slotContents._bagshuiPreventEmptySlotStack + then + -- Update tracking variables so that inventory classes know that this item should + -- have its stock state restored. + self.putDownItemBagNum = bagNum + self.putDownItemSlotNum = slotNum + -- Move the item. + _G.PickupContainerItem(bagNum, slotNum) + noEmptySlots = false + break + end + end + end + if not noEmptySlots then + break + end + end + + -- Couldn't find anywhere to put it. + if noEmptySlots then + self:ShowErrorMessage(_G.INVENTORY_FULL, (inventoryClass and inventoryClass.inventoryType)) + _G.ClearCursor() + end + else + -- Avoid some "can't put a bag in itself" type bag-related errors. + -- This is sort of a duplicate of some of the empty slot stack code but + -- I can't be bothered to refactor at the moment. + -- It's worth doing this check instead of letting the game catch it because + -- there can be weird cursor behavior right afterwards where picking up + -- a bag with the left mouse button equips it, at least on local VMaNGOS. + if self.cursorBagSlotNum and _G.CursorHasItem() and inventoryClass then + local bagNum = inventoryClass.inventoryIdsToContainerIds[self.cursorBagSlotNum] + if item.bagNum == bagNum then + self:ShowErrorMessage(_G.ERR_BAG_IN_BAG, (inventoryClass and inventoryClass.inventoryType)) + _G.ClearCursor() + return + end + end + + -- Not an empty slot stack (normal behavior). + if callPickupContainerItem then + _G.PickupContainerItem(item.bagNum, item.slotNum) + else + _G.ContainerFrameItemButton_OnClick("LeftButton") + end + end + + -- Store tracking info if we picked something up. + if _G.CursorHasItem() then + self.cursorItem = item + owningFrame.bagshuiData.hasCursorItem = true + self.cursorItemOwningFrame = owningFrame + self.lastCursorItemUniqueId = BsItemInfo:GetUniqueItemId(self.cursorItem) + self.pickedUpItemBagNum = item.bagNum + self.pickedUpItemSlotNum = item.slotNum + else + self:ClearCursor() + end + end + + --- Reset our cursor item tracking when the cursor is emptied. + ---@param wowApiFunctionName string? Hooked WoW API function that triggered this call. + function Bagshui:ClearCursor(wowApiFunctionName) + self.cursorItem = nil + if self.cursorItemOwningFrame then + self.cursorItemOwningFrame.bagshuiData.hasCursorItem = nil + end + self.cursorItemOwningFrame = nil + self.cursorBagSlotNum = nil + + -- When the item was deleted, it can't exist any longer and lastCursorItemUniqueId + -- needs to be cleared so that acquiring the same item again will cause it + -- to be correctly seen as new. + if wowApiFunctionName == "DeleteCursorItem" then + self.lastCursorItemUniqueId = nil + end + self.hooks:OriginalHook(wowApiFunctionName) + end + + --- Clear our cursor tracking if nothing is held. + function Bagshui:CheckCursor() + if not _G.CursorHasItem() then + self:ClearCursor() + end + end + + --- Return the inventory item that was picked up via `Bagshui:PickupItem()`, if anything. + --- Will *not* work with `PickupContainerItem()`. + ---@return table? + function Bagshui:GetCursorItem() + if _G.CursorHasItem() and self.cursorItem then + return self.cursorItem + end + end + + --- When containers are moved between slots, we need to do a fresh cache initialization for every `[bagNum][slotNum]`. + --- This results in the stock states being reset and every item becoming "new" when someone reorders their bags. + --- To prevent this, a shadow cache of stock state info is stored in `Inventory.shadowStockState`/`shadowBagshuiDate`, + --- and the restore is triggered by `Bagshui.pickedUpBagSlotNum`/`putDownBagSlotNum`, which are set here. + --- + --- By hooking `PickupBagFromSlot()`, `PickupInventoryItem()`, and `PutItemInBag()`, we can cover all the ways containers are + --- picked up/put down, and track the changes that occur. Then `Inventory:Update()` can use that information to better + --- manage the stock state. + --- + --- Note that since these API functions receive inventory slot IDs, not container numbers, the `Inventory.inventoryIdsToContainerIds` + --- table must be used to translate into something that will match `Inventory.containerIds`. + ---@param wowApiFunctionName string Hooked WoW API function that triggered this call. + ---@param invSlotId number Inventory slot ID to pick up the item from. + ---@return any wowApiFunctionReturnValue Return value from hooked WoW API function. + function Bagshui:PickupInventoryItem(wowApiFunctionName, invSlotId) + --self:PrintDebug("Bagshui:PickupInventoryItem() called from " .. tostring(wowApiFunctionName) .. " with invSlotId " .. tostring(invSlotId)) + + -- Only store bag slot information if there's currently a bag on the cursor + -- and the destination is different from the source. + if + _G.CursorHasItem() + and self.cursorItem == nil + and self.cursorBagSlotNum ~= nil + and self.cursorBagSlotNum ~= invSlotId + then + self.pickedUpBagSlotNum = self.cursorBagSlotNum + self.putDownBagSlotNum = invSlotId + elseif self.cursorBagSlotNum == invSlotId then + -- Bag was put back down in the same place. + self.pickedUpBagSlotNum = nil + self.putDownBagSlotNum = nil + end + + -- cursorBagSlotNum will be compared with arg1 (invSlotId) of the second call to an API function to determine whether a change was made. + self.cursorBagSlotNum = nil + + -- Let WoW handle all the actual work. + local ret = self.hooks:OriginalHook(wowApiFunctionName, invSlotId) + + -- A bag was picked up. + if + (wowApiFunctionName == "PickupBagFromSlot" or wowApiFunctionName == "PickupInventoryItem") + and _G.CursorHasItem() + and self.cursorItem == nil + then + if wowApiFunctionName == "PickupBagFromSlot" then + self.cursorBagSlotNum = invSlotId + else + -- This could be a bag or an inventory item. Make sure it's a bag + -- or there will be errors from Bagshui:PickupItem(). + for _, inventoryType in pairs(BS_INVENTORY_TYPE) do + if + self.components[inventoryType] + and self.components[inventoryType].inventoryIdsToContainerIds + and self.components[inventoryType].inventoryIdsToContainerIds[invSlotId] + then + --self:PrintDebug("> " .. inventoryType .. " has matching invSlotId for bag slot") + self.cursorBagSlotNum = invSlotId + break + end + end + end + end + + --self:PrintDebug("> cursorBagSlotNum is now " .. tostring(self.cursorBagSlotNum)) + + -- PutItemInBag() doesn't trigger any of our normal cursor clearing methods, + -- so a manual check on the next frame is needed to ensure accuracy. + -- (Just calling it for all the hooks to be safe). + self:QueueClassCallback(self, self.CheckCursor) + + -- Some of the API functions expect return values, so always return what we got back from the original call. + return ret + end +end) diff --git a/Components/Bagshui.Events.lua b/Components/Bagshui.Events.lua new file mode 100644 index 0000000..58bfef3 --- /dev/null +++ b/Components/Bagshui.Events.lua @@ -0,0 +1,180 @@ +-- Bagshui Core: Events +-- Event registration, raising, queuing, and callbacks. +-- Raises: BAGSHUI_CORE_EVENT_FUNCTIONS_LOADED + +Bagshui:LoadComponent( + function() + --- Request to receive event notifications of the specified type. Supports both WoW and Bagshui events. + ---@param event string Name of event (ADDON_LOADED, etc.). + ---@param classObject table? Class that will consume the event (must have an `OnEvent()` function if `eventFunctionName` isn't specified). + ---@param classEventFunctionName string? Class function to use instead of `OnEvent`. + function Bagshui:RegisterEvent(event, classObject, classEventFunctionName) + assert(type(event) == "string", "Bagshui:RegisterEvent() - event is required and must be a string") + + -- The Bagshui class internally uses RegisterEvent, so bypass consumer registration when there isn't a classObject. + if classObject then + self:RegisterClassForEvents(classObject, classEventFunctionName) + self.eventConsumers[classObject][event] = true + end + + -- Don't try to register internal events with the game (doesn't seem to cause a problem but is also pointless). + if string.find(event, "^BAGSHUI_") then + return + end + + -- This probably isn't a big deal, but let's avoid registering events multiple times. + if not self.eventFrame.bagshuiData.registeredEvents[event] then + self.eventFrame:RegisterEvent(event) + self.eventFrame.bagshuiData.registeredEvents[event] = true + end + end + + --- Add a class to the `Bagshui.eventConsumers` table, populating the `_eventFunctionName` property. + ---@param classObject table Class that will consume the event (must have an `OnEvent()` function if `eventFunctionName` isn't specified). + ---@param eventFunctionName string? Class function to use instead of `OnEvent`. + function Bagshui:RegisterClassForEvents(classObject, eventFunctionName) + -- Already registered. + if self.eventConsumers[classObject] then + return + end + + eventFunctionName = eventFunctionName or "OnEvent" + + assert( + classObject[eventFunctionName], + "Bagshui:RegisterClassForEvents(): " + .. tostring(eventFunctionName) + .. " not found on classObject " + .. tostring(classObject) + ) + + self.eventConsumers[classObject] = { + _eventFunctionName = eventFunctionName, + } + end + + --- Stop receiving notifications for the specified event. + ---@param event string Name of event (ADDON_LOADED, etc.). + ---@param classObject table Class that consumes the event. + function Bagshui:UnregisterEvent(event, classObject) + self.eventConsumers[classObject][event] = false + end + + --- Add a custom event to the internal queue. + ---@param event string|function Unique event identifier or function to call. + ---@param delaySeconds number? Time after which the event should be triggered (next frame if not specified). + ---@param noReset boolean? `true` if an already-queued event's delay should *not* be reset. + ---@param arg1 any? First argument to pass when the event is raised. + ---@param arg2 any? Second argument to pass when the event is raised. + ---@param arg3 any? Third argument to pass when the event is raised. + ---@param arg4 any? Fourth argument to pass when the event is raised. + ---@return boolean queued Whether the event was queued. + function Bagshui:QueueEvent(event, delaySeconds, noReset, arg1, arg2, arg3, arg4) + -- Add to queue if not currently queued or the delay should be reset. + if not self.queuedEvents.events[event] or (self.queuedEvents.events[event] and not noReset) then + self.queuedEvents.events[event] = _G.GetTime() + (delaySeconds or 0.00001) + self.queuedEvents.arg1[event] = arg1 + self.queuedEvents.arg2[event] = arg2 + self.queuedEvents.arg3[event] = arg3 + self.queuedEvents.arg4[event] = arg4 + return true + end + return false + end + + --- Call the given class function after a delay. + ---@param classInstance table Class that owns the function. + ---@param classFunction function Function to call. + ---@param delaySeconds number? Parameter for `Bagshui:QueueEvent()`. + ---@param noReset boolean? Parameter for `Bagshui:QueueEvent()`. + ---@param arg1 any? Parameter for `Bagshui:QueueEvent()`. + ---@param arg2 any? Parameter for `Bagshui:QueueEvent()`. + ---@param arg3 any? Parameter for `Bagshui:QueueEvent()`. + ---@param arg4 any? Parameter for `Bagshui:QueueEvent()`. + function Bagshui:QueueClassCallback(classInstance, classFunction, delaySeconds, noReset, arg1, arg2, arg3, arg4) + -- Create a unique identifier for this event since it's not a predefined event string. + local eventId = tostring(classInstance) .. ":" .. tostring(classFunction) + if self:QueueEvent(eventId, delaySeconds, noReset, arg1, arg2, arg3, arg4) then + -- When the event is successfully queued, store callback information so RaiseEvent() + -- knows to handle this as a class callback, not a standard event. + self.queuedEvents.class[eventId] = classInstance + self.queuedEvents.classFunction[eventId] = classFunction + end + end + + --- Process the Bagshui event queue. + --- Declaring variables outside to make the Lua garbage collector happier since this is called from + --- OnUpdate() [per Blizzard comment in UIPanelTemplates.lua]. Can't really do anything about the + --- variables in the for loop though. + local processEventQueue_success, processEventQueue_errorMessage + function Bagshui:ProcessEventQueue() + -- Loop through queued events and fire them if enough time has passed. + for event, raiseAfter in pairs(self.queuedEvents.events) do + if raiseAfter <= _G.GetTime() then + -- Fire the event and capture the status + -- We need to prevent RaiseEvent from erroring so we can remove the event from the queue, so we'll handle errors here + processEventQueue_success, processEventQueue_errorMessage = self:RaiseEvent( + event, + true, + self.queuedEvents.arg1[event], + self.queuedEvents.arg2[event], + self.queuedEvents.arg3[event], + self.queuedEvents.arg4[event] + ) + + -- Only remove from queue if the delay hasn't changed (i.e. the event hasn't been re-queued) + if raiseAfter == self.queuedEvents.events[event] then + self.queuedEvents.events[event] = nil + self.queuedEvents.arg1[event] = nil + self.queuedEvents.arg2[event] = nil + self.queuedEvents.arg3[event] = nil + self.queuedEvents.arg4[event] = nil + self.queuedEvents.class[event] = nil + self.queuedEvents.classFunction[event] = nil + end + + assert(processEventQueue_success, processEventQueue_errorMessage) + end + end + end + + -- Declaring variables for RaiseEvent outside to make the Lua garbage collector happier + -- since this is called from OnUpdate() [per Blizzard comment in UIPanelTemplates.lua]. + local raiseEvent_success, raiseEvent_errorMessage + + --- There are several ways an event can be raised: + --- 1. Calling a class function (event was set up via `Bagshui:QueueClassCallback()`). + --- 2. Calling a potentially anonymous function (event was set up via `Bagshui:QueueEvent()`). + --- 3. Passing off to `Bagshui:OnEvent()`, which can then pass on to registered consumers (event was + --- registered via `Bagshui:RegisterEvent()` and may have been raised by WoW or a call to `Bagshui:RaiseEvent()`). + ---@param event string|function Name of event (WoW or Bagshui). + ---@param returnStatus boolean? If true, `returnValue` and `errorMessage` from the event call will be returned. + ---@param arg1 any? First event argument, if any. + ---@param arg2 any? Second event argument, if any. + ---@param arg3 any? Third event argument, if any. + ---@param arg4 any? Fourth event argument, if any. + ---@return boolean? returnValue Value returned from the event call, if any, when `returnStatus` is true. + ---@return string? errorMessage Error message returned from the event call, if any, when `returnStatus` is true. + function Bagshui:RaiseEvent(event, returnStatus, arg1, arg2, arg3, arg4) + if self.queuedEvents.class[event] then + assert(self.queuedEvents.classFunction[event], "Class does not have the provided function!") + -- Call `:()` by calling `.()`. + raiseEvent_success, raiseEvent_errorMessage = + pcall(self.queuedEvents.classFunction[event], self.queuedEvents.class[event], arg1, arg2, arg3, arg4) + elseif type(event) == "function" then + raiseEvent_success, raiseEvent_errorMessage = pcall(event, arg1, arg2, arg3, arg4) + else + raiseEvent_success, raiseEvent_errorMessage = pcall(self.OnEvent, self, event, arg1, arg2, arg3, arg4) + end + + -- Error handling. + if returnStatus then + return raiseEvent_success, raiseEvent_errorMessage + elseif not raiseEvent_success then + assert(false, raiseEvent_errorMessage) + end + end + end, + -- Event to raise. This will trigger Bagshui:Init(). + "BAGSHUI_CORE_EVENT_FUNCTIONS_LOADED" +) diff --git a/Components/Bagshui.Menus.lua b/Components/Bagshui.Menus.lua new file mode 100644 index 0000000..e0a6152 --- /dev/null +++ b/Components/Bagshui.Menus.lua @@ -0,0 +1,444 @@ +-- Bagshui Core: Menus +-- Menu-related functionality that makes sense at the Bagshui level instead of on the Menus class. + +Bagshui:LoadComponent(function() + -- If there's an open menu that we own, close it. + ---@param startLevel number? Close all menus at this level and higher. Will close all menus if not specified. + ---@param force boolean? Close menus even if `Bagshui:IsMenuOpen()` returns false. + ---@param keepLowerLevelsOpen boolean? When true, call `Bagshui:BlockAutoMenuClose()` for all levels lower than `startLevel`. + function Bagshui:CloseMenus(startLevel, force, keepLowerLevelsOpen) + if not self:IsMenuOpen() and not force then + return + end + _G.CloseDropDownMenus(startLevel) + if keepLowerLevelsOpen and type(startLevel) == "number" and startLevel > 1 then + self:BlockAutoMenuClose(1, startLevel - 1) + end + end + + -- Return true if one of our menus is open (can be restricted to a specific menu type if needed). + -- This is done by checking the `UIDROPDOWNMENU_OPEN_MENU` global variable to see if it contains our + -- menu frame's name. We also need to be sure that dropdown level 1 is visible, because + -- `UIDROPDOWNMENU_OPEN_MENU` doesn't get cleared when menus are closed. + ---@param menuType string? Only return true if this specific menu type is open. + ---@return boolean + function Bagshui:IsMenuOpen(menuType) + return + -- Bagshui menu was the last one opened. + (self.menuFrame and _G.UIDROPDOWNMENU_OPEN_MENU == self.menuFrame.bagshuiData.name) + -- Menu is actually open. + and _G.DropDownList1:IsVisible() + -- menuType filter. + and ((menuType and self.menuFrame.bagshuiData.lastMenuTypeLoaded == menuType) or not menuType) + -- Nothing was open. + or false + end + + --- Prevent the automatic 2-second menu close countdown from kicking in. + --- Primarily used to keep the Settings menu open when the mouse cursor isn't over it. + ---@param startLevel number? Lowest level to keep open, inclusive (1-3). + ---@param endLevel number? Highest level to keep open, inclusive (1-3). + ---@param menuType string? If provided, only stop the countdown when this menu is open. + function Bagshui:BlockAutoMenuClose(startLevel, endLevel, menuType) + if menuType and not self:IsMenuOpen(menuType) then + return + end + + for level = startLevel or 1, endLevel or 3, 1 do + _G.UIDropDownMenu_StopCounting(_G["DropDownList" .. level]) + end + + -- Need a second pass to ensure nothing has started the countdown again. + if menuType then + self:QueueClassCallback(self, self.BlockAutoMenuClose, 1.5, nil, startLevel, endLevel, menuType) + end + end + + local TOGGLE_DROPDOWN_MENU_FIRST_PASS = "BAGSHUI_TOGGLE_DROPDOWN_MENU_FIRST_PASS" + local TOGGLE_DROPDOWN_MENU_LEVEL_1_RECHECK = "BAGSHUI_TOGGLE_DROPDOWN_MENU_LEVEL_1_RECHECK" + local TOGGLE_DROPDOWN_MENU_SECOND_PASS = "BAGSHUI_TOGGLE_DROPDOWN_MENU_THIRD_PASS" + + --- Fun hack time! + --- 1.12's ToggleDropDownMenu doesn't accurately correct for menus going off the edge of the + --- screen due to some apparent weirdness with frame GetRight/Bottom() vs GetScreenWidth/Height(), + --- so I guess we'll do it. The basic technique comes from EngInventory -- thanks to whoever figured this out! + ---@param wowApiFunctionName string Hooked WoW API function that triggered this call. + ---@param level number Menu level parameter for the original `ToggleDropDownMenu()`. + ---@param value any Menu value parameter for the original `ToggleDropDownMenu()`. + ---@param dropDownFrame table Parameter for the original `ToggleDropDownMenu()`. + ---@param anchorName string? Parameter for the original `ToggleDropDownMenu()`. + ---@param xOffset number? Parameter for the original `ToggleDropDownMenu()`. + ---@param yOffset number? Parameter for the original `ToggleDropDownMenu()`. + ---@param bagshuiAdditionalPassReason string? When another pass needs to be made to re-check the menu's position, this extra parameter is added. + function Bagshui:ToggleDropDownMenu( + wowApiFunctionName, + level, + value, + dropDownFrame, + anchorName, + xOffset, + yOffset, + _, + _, + _, + bagshuiAdditionalPassReason + ) + -- Record whether we're in one of the additional passes. + local phase = 0 + if bagshuiAdditionalPassReason == TOGGLE_DROPDOWN_MENU_FIRST_PASS then + phase = 1 + elseif bagshuiAdditionalPassReason == TOGGLE_DROPDOWN_MENU_LEVEL_1_RECHECK then + phase = 1.5 + elseif bagshuiAdditionalPassReason == TOGGLE_DROPDOWN_MENU_SECOND_PASS then + phase = 2 + end + + -- Let Blizzard code open the menu on first pass. + -- Additional passes must avoid calling this or the menu will be closed. + -- Calling on an additional pass can also trigger errors because the meaning of `this` will have + -- changed, and ToggleDropDownMenu() won't be able to populate its tempFrame variable. + if phase == 0 then + self.hooks:OriginalHook(wowApiFunctionName, level, value, dropDownFrame, anchorName, xOffset, yOffset) + -- At one point there were crashes happening when right-clicking inventory windows to open the menu. + -- It SEEMS like they were fixed by avoiding touching cursor-anchored menus during the level 1 checks. + -- If there are still crashing issues reported, this may need to be enabled in lieu of the + -- immediate call above. (It's not ideal, since there will be a flash of the menu in the wrong position.) + -- Bagshui:QueueEvent(function() + -- self:ToggleDropDownMenu(wowApiFunctionName, level, value, dropDownFrame, anchorName, xOffset, yOffset, nil, nil, nil, TOGGLE_DROPDOWN_MENU_FIRST_PASS) + -- end) + -- return + end + + -- Don't mess with anyone else's menus. + if not self:IsMenuOpen() then + return + end + + -- Track whether we need to make a second pass. + local secondPassNeeded = false + + -- Find the open menu frame. + local frame = _G["DropDownList" .. level] + local parentFrame = _G["DropDownList" .. (level - 1)] + if not frame or (level > 1 and not parentFrame) then + return + end + + -- Special stuff for top-level menus -- but not Settings, because + -- Inventory:FixSettingsMenuPosition() takes care of that. + if level == 1 then + -- Blizzard's ToggleDropDownMenu() code always forces a TOPLEFT anchor when the menu doesn't go off the screen. + -- We really don't want that, so we're going to re-anchor to the desired point if it was messed with. + if + anchorName ~= "cursor" -- Game can crash if we mess with cursor-anchored menus. + and phase < 1.5 + and not self.menuFrame.bagshuiData.noFirstLevelRepositionNeeded -- The code opening the menu will take care of any positioning issues. + and (frame:GetNumPoints() or 0) > 0 + then + local point = frame:GetPoint(1) + local correctedAnchorPoint, correctedAnchorToPoint + + if + self.menuFrame.bagshuiData.anchorPoint ~= nil + and point ~= nil + and point ~= self.menuFrame.bagshuiData.anchorPoint + then + if point == "TOPLEFT" then + -- Blizz's ToggleDropDownMenu() moved the menu to a TOPLEFT anchor and we didn't want that. Move it back. + correctedAnchorPoint = self.menuFrame.bagshuiData.anchorPoint + correctedAnchorToPoint = self.menuFrame.bagshuiData.anchorToPoint + elseif string.find(self.menuFrame.bagshuiData.anchorPoint, "RIGHT$") and string.find(point, "LEFT$") then + -- Blizz's ToggleDropDownMenu() moved the menu to a LEFT anchor due to it going offscreen, + -- but we wanted a RIGHT anchor, so we'll move from bottom to top or vice-versa but keep the RIGHT anchoring. + correctedAnchorPoint = BsUtil.FlipAnchorPointComponent(self.menuFrame.bagshuiData.anchorPoint, 1) + correctedAnchorToPoint = BsUtil.FlipAnchorPointComponent(self.menuFrame.bagshuiData.anchorToPoint, 1) + end + end + + -- Need to apply the positioning fix. + if correctedAnchorPoint and correctedAnchorToPoint then + -- self:PrintDebug("!!! need to reposition from " .. point .. " to " .. correctedAnchorPoint) + frame:ClearAllPoints() + frame:SetPoint( + correctedAnchorPoint, + (self.menuFrame.bagshuiData.anchorToFrame or anchorName), + correctedAnchorToPoint, + xOffset, + yOffset + ) + -- We still need to do all the normal checks since this may move the frame off the screen. + Bagshui:QueueEvent(function() + self:ToggleDropDownMenu( + wowApiFunctionName, + level, + value, + dropDownFrame, + anchorName, + xOffset, + yOffset, + nil, + nil, + nil, + TOGGLE_DROPDOWN_MENU_LEVEL_1_RECHECK + ) + end) + return + end + end + + -- Check level 1 to see if its right edge is past the horizontal halfway point, and if so, expand menus to the left. + self._toggleDropDownMenu_ExpandMenusLeft = ( + frame:GetLeft() + and ((frame:GetRight() * frame:GetScale()) > (_G.UIParent:GetRight() * _G.UIParent:GetScale()) / 2) + ) + end + + -- Figure out what adjustments are required. + local adjustX = BsUtil.GetFrameOffscreenAmount(frame, "x") + local adjustY = BsUtil.GetFrameOffscreenAmount(frame, "y") + local fullMoveLeft = 0 -- Assume a flip to left-expansion is not necessary. + + -- Shift a submenu to the left of the parent if it's being X-adjusted by a negative amount or we're in left-expansion mode. + if + phase < 2 + and level > 1 -- Submenu + and (adjustX < 0 or self._toggleDropDownMenu_ExpandMenusLeft) + then + -- This check is needed to ensure we don't double-move a menu that Blizzard code has already moved to the left of the parent. + if frame:GetLeft() * frame:GetScale() > parentFrame:GetLeft() * parentFrame:GetScale() then + -- Move to the left of the parent menu. + fullMoveLeft = -((parentFrame:GetWidth() * parentFrame:GetScale()) + (frame:GetWidth() * frame:GetScale())) + + BsSkin.menuShiftLeft + secondPassNeeded = true + elseif adjustX == 0 then + -- Submenu is already to the left, but let's fix Blizzard's weird mismatched extra space for left-expanding menus. + adjustX = BsSkin.menuShiftLeftFix + end + end + + -- Tweak submenu position if the skin requires it. + if level > 1 and adjustX == 0 and BsSkin.menuShiftRight ~= 0 then + adjustX = BsSkin.menuShiftRight + end + + -- Special checks for level 1. + if level == 1 then + local mouseX, mouseY = _G.GetCursorPosition() + + -- Move menus to the other side of the cursor instead of just shifting them slightly. + if anchorName == "cursor" and adjustX < 0 and mouseX < (frame:GetRight() * frame:GetScale()) then + adjustX = -(frame:GetWidth() * frame:GetScale()) + end + + -- Open menus upwards when they're too close to the bottom right of the screen and could be covered by GameTooltip. + if + phase < 2 + and (frame:GetBottom() * frame:GetScale()) - (100 * _G.UIParent:GetScale()) < (_G.UIParent:GetBottom() * _G.UIParent:GetScale()) + and (frame:GetRight() * frame:GetScale()) > (_G.UIParent:GetRight() * _G.UIParent:GetScale()) - (300 * _G.UIParent:GetScale()) + and mouseY > (frame:GetBottom() * frame:GetScale()) + then + -- It feels like this should be something like (frame:GetHeight() * frame:GetScale()) or even + -- ((frame:GetHeight() * frame:GetScale()) / _G.UIParent:GetScale()), but that gives + -- inconsistent results at different scales and this simpler solution seems to work so WHO CARES. + adjustY = frame:GetHeight() + -- When anchoring to another frame, move the menu above that frame. + if anchorName ~= "cursor" and type(anchorName) == "table" and anchorName.GetParent then + adjustY = adjustY + ((anchorName:GetHeight() or 0) * anchorName:GetScale()) + ((yOffset or 0) * -1) + end + -- Since we're flipping the menu upwards, a second pass is needed in case it goes off the top. + secondPassNeeded = true + end + end + + -- Make adjustments by shifting the menu exactly the right amount. + if adjustY ~= 0 or adjustX ~= 0 or fullMoveLeft ~= 0 then + -- It's preferable to move menu level 2+ frames relative to their parent so they stay anchored + -- correctly if the UI shifts around. However, WoW will crash on a blind call to GetPoint(1) + -- if GetNumPoints() is 0, so we need just adjust against UIParent in that case. + -- (Trying to do this with level 1 seems to be unreliable, so we'll force the use of UIParent for that.) + if level > 1 and (frame:GetNumPoints() or 0) > 0 then + -- Grab the menu's current anchor point + local point, anchorToFrame, anchorToPoint, xOffset, yOffset = frame:GetPoint(1) + + -- Move to the left of the parent anchor frame + if fullMoveLeft ~= 0 then + adjustX = -BsSkin.menuShiftLeft + point = BsUtil.FlipAnchorPointComponent(point, 2) -- LEFT -> RIGHT + anchorToPoint = BsUtil.FlipAnchorPointComponent(anchorToPoint, 2) -- LEFT -> RIGHT + end + + -- Set new, adjusted point. + frame:ClearAllPoints() + frame:SetPoint(point, anchorToFrame, anchorToPoint, (xOffset or 0) + adjustX, (yOffset or 0) + adjustY) + else + -- No current parent frame (or level 1) -- just anchor to UIParent and adjust. + adjustX = frame:GetLeft() + (fullMoveLeft ~= 0 and fullMoveLeft or adjustX) + adjustY = frame:GetTop() + adjustY + frame:ClearAllPoints() + frame:SetPoint("TOPLEFT", _G.UIParent, "BOTTOMLEFT", adjustX, adjustY) + end + end + + -- Make a second check in case we accidentally shifted the menu offscreen when flipping it to the left. + if phase < 2 and secondPassNeeded then + Bagshui:QueueEvent(function() + self:ToggleDropDownMenu( + wowApiFunctionName, + level, + value, + dropDownFrame, + anchorName, + xOffset, + yOffset, + nil, + nil, + nil, + TOGGLE_DROPDOWN_MENU_SECOND_PASS + ) + end) + end + + -- Reset parameter. + self.menuFrame.bagshuiData.noFirstLevelRepositionNeeded = nil + end + + -- Another hack, albeit much more about vanity and not actually fixing anything. + -- Here, we're filling some missing functionality in `UIDropDownMenu_AddButton()`, + -- namely icon colors, position, and size. This function will set or reset values as appropriate. + ---@param wowApiFunctionName string Hooked WoW API function that triggered this call. + ---@param menuItem table Menu entry. + ---@param level number? Menu level. + function Bagshui:UIDropDownMenu_AddButton(wowApiFunctionName, menuItem, level) + self.hooks:OriginalHook(wowApiFunctionName, menuItem, level) + + -- Grab info about current menu item. + -- This is the same as what's done in UIDropDownMenu_AddButton(), + -- but without incrementing numButtons to the next value. + + local listFrame = _G["DropDownList" .. (level or 1)] + if not listFrame then + return + end + local listFrameName = listFrame:GetName() + if not listFrameName then + return + end + local index = listFrame.numButtons + if not index then + return + end + local button = _G[listFrameName .. "Button" .. index] + if not button then + return + end + + local xOffset + + -- Set/reset opacity. + button:SetAlpha(menuItem._bagshuiAlpha or 1) + + -- Arrow adjustments. + + local expandArrow = _G[listFrameName .. "Button" .. index .. "ExpandArrow"] + if expandArrow then + if not self._originalExpandArrowXOffset then + _, _, _, self._originalExpandArrowXOffset = expandArrow:GetPoint(1) + end + + -- Fix arrow position on notCheckable items so the arrow aligns with that of checkable items. + + xOffset = (menuItem._bagshui and menuItem.hasArrow and menuItem.notCheckable) and -10 + or self._originalExpandArrowXOffset + expandArrow:SetPoint("RIGHT", xOffset, 0) + + -- Hide if requested. + if menuItem._bagshuiHideArrow then + expandArrow:Hide() + end + + -- Disable when menu item is disabled. WHY IS THIS NOT THE DEFAULT. + if menuItem.disabled then + expandArrow:Disable() + expandArrow:SetAlpha(0.5) + else + expandArrow:Enable() + expandArrow:SetAlpha(1) + end + end + + -- Icon adjustments. + + local icon = _G[button:GetName() .. "Icon"] + if menuItem.icon and icon then + -- Store original values if we don't yet know them. + if not self._originalMenuIconSize then + self._originalMenuIconSize = icon:GetWidth() + end + if not self._originalMenuIconXOffset then + _, _, _, self._originalMenuIconXOffset = icon:GetPoint(1) + end + + -- Icon colors. + local r = menuItem._bagshuiTextureR or (menuItem._bagshui and BS_COLOR.YELLOW[1]) or 1 + local g = menuItem._bagshuiTextureG or (menuItem._bagshui and BS_COLOR.YELLOW[2]) or 1 + local b = menuItem._bagshuiTextureB or (menuItem._bagshui and BS_COLOR.YELLOW[3]) or 1 + local a = (menuItem._bagshui and menuItem.disabled and not menuItem.isTitle) and 0.5 + or menuItem._bagshuiTextureA + or 1 + icon:SetVertexColor(r, g, b, a) + + -- Icon size. + local iconSize = menuItem._bagshuiTextureSize or (menuItem._bagshui and 12) or self._originalMenuIconSize + icon:SetWidth(iconSize) + icon:SetHeight(iconSize) + + -- Icon position. + xOffset = menuItem._bagshuiTextureXOffset + or (menuItem._bagshui and ((menuItem.hasArrow and not menuItem._bagshuiHideArrow) and (menuItem.notCheckable and -28 or -24) or menuItem._bagshuiFakeTitle and -14 or menuItem.notCheckable and -16) or -12) + or self._originalMenuIconXOffset + icon:SetPoint("RIGHT", xOffset, 0) + end + + -- Color Swatch + Arrow hack - hide the arrow. + + if menuItem.hasColorSwatch and menuItem.hasArrow and _G[button:GetName() .. "ExpandArrow"] then + _G[button:GetName() .. "ExpandArrow"]:Hide() + end + + -- Other Color Swatch stuff. + + local colorSwatch = _G[button:GetName() .. "ColorSwatch"] + if menuItem.hasColorSwatch and colorSwatch then + -- Inexplicably, Blizzard doesn't disable the swatch when the menu item is disabled. + if menuItem.disabled then + colorSwatch:Disable() + else + colorSwatch:Enable() + end + + -- Color Swatch custom function hack. + if menuItem._bagshuiColorSwatchFunc then + -- Store the Blizzard OnClick function only once so we don't overwrite it with one of ours. + if not colorSwatch._bagshuiOldOnClick then + colorSwatch._bagshuiOldOnClick = colorSwatch:GetScript("OnClick") + end + colorSwatch:SetScript("OnClick", menuItem._bagshuiColorSwatchFunc) + elseif colorSwatch._bagshuiOldOnClick then + -- Restore the Blizzard OnClick. + colorSwatch:SetScript("OnClick", colorSwatch._bagshuiOldOnClick) + colorSwatch._bagshuiOldOnClick = nil + end + + -- Color Swatch position hack. + if menuItem._bagshuiColorSwatchRightAnchor then + if not colorSwatch._bagshuiOldRightAnchor and colorSwatch:GetNumPoints() > 0 then + _, _, _, colorSwatch._bagshuiOldRightAnchor, _ = colorSwatch:GetPoint(1) + end + colorSwatch:SetPoint("RIGHT", menuItem._bagshuiColorSwatchRightAnchor, 0) + elseif colorSwatch._bagshuiOldRightAnchor then + colorSwatch:SetPoint("RIGHT", colorSwatch._bagshuiOldRightAnchor, 0) + menuItem._bagshuiOldRightAnchor = nil + end + end + end +end) diff --git a/Components/Bagshui.Output.lua b/Components/Bagshui.Output.lua new file mode 100644 index 0000000..b60672b --- /dev/null +++ b/Components/Bagshui.Output.lua @@ -0,0 +1,219 @@ +-- Bagshui Core: Output +-- Logging and printing. + +Bagshui:LoadComponent(function() + --- Log a message, formatted as: `[ ] : `. + --- When component is not present, the message will be `[ ] `. + ---@param msg string + ---@param component string? Prefix for the log message. + ---@param type BS_LOG_MESSAGE_TYPE Log message type (severity). + function Bagshui:Log(msg, component, type) + if not msg or (msg and string.len(tostring(msg)) == 0) then + return + end + + self:PruneLog() + + -- Ensure type can be localized. + if not BS_LOG_TYPE_LOCALIZATION_KEY[type] then + type = BS_LOG_MESSAGE_TYPE.INFORMATION + end + + -- Save log string. + table.insert( + self.log, + string.format( + "%s[%s %s]%s%s%s %s%s", + BS_LOG_MESSAGE_COLOR[type], -- Color opener. + L[BS_LOG_TYPE_LOCALIZATION_KEY[type]], -- Localized log type. + _G.date(BS_LOG_DATE_STRING), -- Timestamp. + FONT_COLOR_CODE_CLOSE, + HIGHLIGHT_FONT_COLOR_CODE, + (component and string.format(" %s:", tostring(component)) or ""), -- Component. + tostring(msg), -- Message. + FONT_COLOR_CODE_CLOSE + ) + ) + + self:RaiseEvent("BAGSHUI_LOG_UPDATE") + end + + --- Remove any log messages over the configured limit. + function Bagshui:PruneLog() + if table.getn(self.log) > BS_LOG_LIMIT then + for i = 1, (table.getn(self.log) - BS_LOG_LIMIT) do + table.remove(self.log, i) + end + end + end + + --- Reset the log. + function Bagshui:ClearLog() + BsUtil.TableClear(self.log) + self:RaiseEvent("BAGSHUI_LOG_UPDATE") + end + + --- Obtain the log as a single newline-concatenated string. + ---@return string + function Bagshui:GetLogText() + return table.concat(self.log, BS_NEWLINE) + end + + --- Add a line of text to the specified frame (default to the primary chat frame), + --- formatted as: `[Bagshui Component] ` or `[Bagshui] `. + ---@param msg string Text to add. + ---@param component string? If specified, add this to the message prefix. + ---@param r number? Text color: red component. + ---@param g number? Text color: green component. + ---@param b number? Text color: blue component. + ---@param a number? Text color: alpha component. + ---@param frame table? Alternate frame to use instead of the default chat frame. + ---@param duration number? Time in seconds after which the message should fade out. + ---@param noPrefix boolean? When true, omit the `[Bagshui]` prefix. + function Bagshui:Print(msg, component, r, g, b, a, frame, duration, noPrefix) + frame = frame or _G.DEFAULT_CHAT_FRAME + if frame then + frame:AddMessage( + string.format( + "%s%s", + ( + noPrefix and "" + or string.format("[Bagshui%s] ", (component and string.format(" %s", tostring(component)) or "")) + ), + tostring(msg) + ), + r or BS_COLOR.PRINT_BAGSHUI[1], + g or BS_COLOR.PRINT_BAGSHUI[2], + b or BS_COLOR.PRINT_BAGSHUI[3], + a, + duration + ) + end + end + + --- Wrapper for `Bagshui:Print()` with `noPrefix` set to `true`. + ---@param msg string Parameter for `Bagshui:Print()`. + ---@param r number? Parameter for `Bagshui:Print()`. + ---@param g number? Parameter for `Bagshui:Print()`. + ---@param b number? Parameter for `Bagshui:Print()`. + ---@param a number? Parameter for `Bagshui:Print()`. + ---@param component string? Parameter for `Bagshui:Print()`. + ---@param frame table? Parameter for `Bagshui:Print()`. + ---@param duration number? Parameter for `Bagshui:Print()`. + function Bagshui:PrintBare(msg, r, g, b, a, component, frame, duration) + self:Print(msg, component, r, g, b, a, frame, duration, true) + end + + --- Display and log an information-type message. + ---@param msg string + ---@param component string? If specified, add this to the message prefix. + function Bagshui:PrintInfo(msg, component) + self:Log(msg, component, BS_LOG_MESSAGE_TYPE.INFORMATION) + self:Print(msg, component) + end + + --- Display and log an error-type message. + ---@param msg string + ---@param component string? If specified, add this to the message prefix. + function Bagshui:PrintError(msg, component) + self:Log(msg, component, BS_LOG_MESSAGE_TYPE.ERROR) + self:Print(msg, component, BS_COLOR.PRINT_ERROR[1], BS_COLOR.PRINT_ERROR[2], BS_COLOR.PRINT_ERROR[3]) + end + + --- Display and log a warning-type message. + ---@param msg string + ---@param component string? If specified, add this to the message prefix. + function Bagshui:PrintWarning(msg, component) + self:Log(msg, component, BS_LOG_MESSAGE_TYPE.WARNING) + self:Print(msg, component, BS_COLOR.PRINT_WARNING[1], BS_COLOR.PRINT_WARNING[2], BS_COLOR.PRINT_WARNING[3]) + end + + --- Display and (optionally) log a transient error message. + ---@param msg string + ---@param component string? If specified, add this to the message prefix. + ---@param r number? Parameter for `Bagshui:Print()`. + ---@param g number? Parameter for `Bagshui:Print()`. + ---@param b number? Parameter for `Bagshui:Print()`. + ---@param a number? Parameter for `Bagshui:Print()`. + ---@param duration number? Time in seconds after which the message should fade out (default: 3 seconds). + ---@param log boolean? `true` save the message to the log. + ---@param noPrefix boolean? Parameter for `Bagshui:Print()`. + function Bagshui:ShowErrorMessage(msg, component, r, g, b, a, duration, log, noPrefix) + if log then + Bagshui:Log(msg, component, BS_LOG_MESSAGE_TYPE.ERROR) + end + self:Print(msg, component, r or 1, g or 0, b or 0, a or 1, _G.UIErrorsFrame, duration or 3, noPrefix) + end + + --- Wrapper for `Bagshui:ShowErrorMessage()` that always logs the message. + ---@param msg string + ---@param component string? + ---@param r number? + ---@param g number? + ---@param b number? + ---@param a number? + ---@param duration number? + function Bagshui:ShowAndLogErrorMessage(msg, component, r, g, b, a, duration) + self:ShowErrorMessage(msg, component, r, g, b, a, duration, true) + end + + --- Add a chat message only if `self.debug` is true. + ---@param msg any Parameter for `Bagshui:Print()`. + ---@param component string? Parameter for `Bagshui:Print()`. + ---@param r number? Parameter for `Bagshui:Print()`. + ---@param g number? Parameter for `Bagshui:Print()`. + ---@param b number? Parameter for `Bagshui:Print()`. + function Bagshui:PrintDebug(msg, component, r, g, b) + if self.debug then + if type(msg) == "table" then + self:PrintTable(msg, component) + end + -- Enable for timestamps + --msg = _G.GetTime() .. ":" .. msg + self:Print(tostring(msg), component, r, g, b) + end + end + + --- Display the contents of a table. + --- Credit: tprint from https://stackoverflow.com/a/47392487 + ---@param t any + ---@param s any + ---@param depth number? + ---@param component string? Parameter for `Bagshui:Print()`. + function Bagshui:PrintTable(t, s, depth, component) + depth = depth or 1 + if depth > 8 then + self:PrintDebug(">> RECURSION STOPPED", component) + return + end + if t == nil then + self:PrintDebug("**nil**", component) + return + end + if type(t) == "function" then + self:PrintDebug("", component) + return + end + -- Special handling for WoW frames. + if type(t) == "table" and t.GetObjectType or t.GetName or (t[0] and type(t[0]) == "userdata") then + self:PrintDebug(type(t) .. (s or "") .. " = " .. t:GetObjectType() .. ": " .. tostring(t:GetName()), component) + return + end + if type(t) ~= "table" then + self:PrintDebug(t, component) + return + end + for k, v in pairs(t) do + local keyPrint = "[" .. BsUtil.ToPrintableString(k) .. "]" + local valuePrint = BsUtil.ToPrintableString(v, true) + if type(v) == "table" then + self:PrintTable(v, (s or "") .. keyPrint, depth + 1, component) + else + if type(v) ~= "string" then + valuePrint = tostring(v) + end + self:PrintDebug(type(t) .. (s or "") .. keyPrint .. " = " .. valuePrint, component) + end + end + end +end) diff --git a/Components/Bagshui.Tooltips.lua b/Components/Bagshui.Tooltips.lua new file mode 100644 index 0000000..37e13bc --- /dev/null +++ b/Components/Bagshui.Tooltips.lua @@ -0,0 +1,746 @@ +-- Bagshui Core: Tooltips +-- General tooltip stuff along with sneaky tooltip hooking to globally display +-- the Bagshui info tooltip when Alt is held. +-- There's some slight redundancy here and in Inventory.Ui.ItemButton.lua, but it +-- doesn't feel like enough to warrant refactoring right now. +-- +-- Exposes: +-- - BsTooltips (table of { name = tooltip }). +-- - BsInfoTooltip, BsCursorTooltip, BsIconButtonTooltip, BsHiddenTooltip (pointers to tooltips within BsTooltips). + +Bagshui:AddComponent(function() + --- Initialize tooltips that are shared across all of Bagshui. + function Bagshui:InitTooltips() + -- Info Tooltip -- because sometimes GameTooltip runs out of space. + self.tooltips.info = self:CreateTooltip("InfoTooltip", nil, true) + + -- Cursor Tooltip for "carrying" items on the cursor in edit mode. + self.tooltips.cursor = self:CreateTooltip("CursorTooltip") + local cursorTooltipName = self.tooltips.cursor.bagshuiData.name + + -- Move texture to top left. + local cursorTooltipTexture1 = _G[cursorTooltipName .. "Texture1"] + cursorTooltipTexture1:ClearAllPoints() + cursorTooltipTexture1:SetPoint("TOPLEFT", self.tooltips.cursor, "TOPLEFT", 10, -9) + cursorTooltipTexture1:SetVertexColor(unpack(BS_COLOR.YELLOW)) + cursorTooltipTexture1:Show() + -- Shift text left so it doesn't overlap with texture. + local textLeft1 = _G[cursorTooltipName .. "TextLeft1"] + local textLeft2 = _G[cursorTooltipName .. "TextLeft2"] + textLeft1:SetPoint("TOPLEFT", self.tooltips.cursor, "TOPLEFT", 26, -10) + textLeft2:ClearAllPoints() + textLeft2:SetPoint("LEFT", cursorTooltipTexture1, "LEFT", 0, 0) + textLeft2:SetPoint("TOP", textLeft1, "BOTTOM", 0, -2) + + self.tooltips.cursor.bagshuiData.textLeft1 = textLeft1 + self.tooltips.cursor.bagshuiData.textLeft2 = textLeft2 + self.tooltips.cursor.bagshuiData.texture1 = cursorTooltipTexture1 + + -- Icon button tooltip for toolbar buttons, etc. + self.tooltips.iconButton = self:CreateTooltip("IconButtonTooltip") + local iconButtonTooltipName = self.tooltips.iconButton.bagshuiData.name + + -- Set all text the same size as the title so that when we scale down the tooltip + -- the non-title text doesn't become unreadable. + local _, titleFontSize = _G[iconButtonTooltipName .. "TextLeft1"]:GetFont() + for i = 2, 10 do + _G[iconButtonTooltipName .. "TextLeft" .. i]:SetFont( + _G[iconButtonTooltipName .. "TextLeft" .. i]:GetFont(), + titleFontSize + ) + end + self.tooltips.iconButton:SetScale(0.65) + + -- Hidden tooltip for retrieving item tooltips and forcing items to load into the local game cache. + self.tooltips.hidden = self:CreateTooltip("HiddenTooltip") + -- This makes looping through the tooltip easier. + self.tooltips.hidden.bagshuiData.textFieldsPerLine = { "Left", "Right" } + -- Must have an owner to function. + self.tooltips.hidden:SetOwner(_G.WorldFrame, "ANCHOR_NONE") + -- These font strings are required to allow tooltip the SetBagItem(), etc. methods to dynamically add new lines. + self.tooltips.hidden:AddFontStrings( + self.tooltips.hidden:CreateFontString("$parentTextLeft1", nil, "GameTooltipText"), + self.tooltips.hidden:CreateFontString("$parentTextRight1", nil, "GameTooltipText") + ) + -- Avoid `SetTooltipMoney()` errors when very long tooltips are loaded. + -- There are probably other ways around this, but we don't (currently) need money info + -- in the hidden tooltip. + -- https://github.com/veechs/Bagshui/issues/102 + self.tooltips.hidden:SetScript("OnTooltipAddMoney", nil) + + -- Expose tooltips. + Bagshui.environment.BsTooltips = self.tooltips + for _, tooltip in pairs(self.tooltips) do + self.environment["Bs" .. tooltip.bagshuiData.shortName] = tooltip + end + end + + --- Create a new tooltip. + ---@param name string Unique name for the tooltip (will be passed to `Ui:CreateElementName()`). + ---@param inherits string? Widget template. + ---@param smallFont boolean? When true, change all text to `GameFontNormalSmall`. + ---@return table tooltip + function Bagshui:CreateTooltip(name, inherits, smallFont) + local tooltipName = Bagshui.prototypes.Ui:CreateElementName(name) + local tooltip = _G.CreateFrame( + "GameTooltip", + tooltipName, + _G.UIParent, -- Required for tooltips to respect UI scale. + inherits or "GameTooltipTemplate" + ) + tooltip.bagshuiData = { + name = tooltipName, + shortName = name, + } + + -- Apply per-skin tooltip styling. + if BsSkin.tooltipSkinFunc then + BsSkin.tooltipSkinFunc(tooltip) + end + + -- Shrink all the tooltip lines + if smallFont then + local i = 1 + while true do + local fontString = _G[tooltip.bagshuiData.name .. "TextLeft" .. i] + if not fontString then + break + end + fontString:SetFontObject("GameFontNormalSmall") + _G[tooltip.bagshuiData.name .. "TextRight" .. i]:SetFontObject("GameFontNormalSmall") + + i = i + 1 + end + end + + return tooltip + end + + --- Wrapper for `Bagshui:FormatTooltipLine()` that automatically adds to the Bagshui info tooltip. + ---@param tooltip table WoW UI tooltip. + ---@param text string? Primary text value. + ---@param label string? Labels to display in front of text, if any. + ---@param title boolean? Use highlight color instead of normal color for `text`. + ---@param indent boolean? Indent the entire line. + function Bagshui:AddTooltipLine(tooltip, text, label, title, indent) + tooltip:AddLine(self:FormatTooltipLine(text, label, title, indent)) + end + + --- Prepare text to be added to a tooltip. Will colorize, label, and indent. + ---@param text string? Primary text value. + ---@param label string? Labels to display in front of text, if any. + ---@param title boolean? Use highlight color instead of normal color for `text`. + ---@param indent boolean? Indent the entire line. + ---@return string tooltipText + function Bagshui:FormatTooltipLine(text, label, title, indent) + local tooltipText = (title and HIGHLIGHT_FONT_COLOR_CODE or NORMAL_FONT_COLOR_CODE) + .. (text or "") + .. FONT_COLOR_CODE_CLOSE + if label then + tooltipText = BsUtil.Trim( + (title and BS_FONT_COLOR.BAGSHUI or HIGHLIGHT_FONT_COLOR_CODE) + .. label + .. FONT_COLOR_CODE_CLOSE + .. " " + .. tooltipText + ) + end + if indent then + tooltipText = BS_INDENT .. tooltipText + end + return tooltipText + end + + --- Display the given tooltip after a delay if it's still owned by the same element. + --- To utilize this, use `:AddLine()`, not `SetText()` since the latter instantly makes the tooltip visible: + --- ``` + --- :SetOwner(, "ANCHOR_TOPLEFT") + --- :ClearLines() + --- :AddLine("Text") + --- Bagshui:ShowTooltipAfterDelay(, ) + --- ``` + ---@param tooltip table Tooltip frame to show. + ---@param expectedOwner table Frame that should own the tooltip. + ---@param tooltipGroupElement table? UI element that conceptually groups multiple items together and will be used to determine when the delay to display tooltips should be shortened. + ---@param delayOverride number? Delay after which the tooltip should be shown. `BS_TOOLTIP_DELAY_SECONDS.DEFAULT` will be used if not provided. + ---@param noTooltipDelayShorting boolean? When true, do not shorten subsequent tooltip display delays. + ---@param postDisplayCallback function? Callback to trigger after the tooltip is shown. + function Bagshui:ShowTooltipAfterDelay( + tooltip, + expectedOwner, + tooltipGroupElement, + delayOverride, + noTooltipDelayShorting, + postDisplayCallback + ) + tooltipGroupElement = tooltipGroupElement or expectedOwner:GetParent() + if not tooltipGroupElement.bagshuiData then + tooltipGroupElement.bagshuiData = {} + end + + -- Set the tooltip group tracking property on the tooltip owner. + if not expectedOwner.bagshuiData then + expectedOwner.bagshuiData = {} + end + expectedOwner.bagshuiData._showTooltipAfterDelay_TooltipGroupElement = tooltipGroupElement + + -- Default delay + local delaySeconds = delayOverride or BS_TOOLTIP_DELAY_SECONDS.DEFAULT + + -- Shorten delay if a tooltip has been displayed for this group in the last X seconds. + if + not noTooltipDelayShorting + and type(tooltipGroupElement.bagshuiData._showTooltipAfterDelay_LastShown) == "number" + and _G.GetTime() - tooltipGroupElement.bagshuiData._showTooltipAfterDelay_LastShown + < BS_TOOLTIP_DELAY_SECONDS.USE_SHORTENED_AFTER_LAST_SHOW_FOR + then + delaySeconds = BS_TOOLTIP_DELAY_SECONDS.SHORTENED + end + + -- Show the tooltip after the delay. + if delayOverride == 0 then + self:ShowTooltipIfStillOwned(tooltip, expectedOwner, postDisplayCallback) + else + Bagshui:QueueClassCallback( + self, + self.ShowTooltipIfStillOwned, + delaySeconds, + false, + tooltip, + expectedOwner, + postDisplayCallback + ) + end + end + + --- Helper function for ShowTooltipAfterDelay that actually makes the tooltip + --- visible if it's still owned by the same element once the delay has expired. + ---@param tooltip table Tooltip frame to show. + ---@param expectedOwner table Frame that should own the tooltip. + ---@param postDisplayCallback function? + function Bagshui:ShowTooltipIfStillOwned(tooltip, expectedOwner, postDisplayCallback) + if tooltip and tooltip.IsOwned and tooltip:IsOwned(expectedOwner) then + tooltip:Show() + self:ShortenTooltipDelay(expectedOwner, true) -- Update the time of last display for this tooltip group. + if postDisplayCallback then + postDisplayCallback() + end + end + end + + -- Helper function for `Bagshui:ShowTooltipAfterDelay()` to update the + -- `_showTooltipAfterDelay_LastShown` property of the tracking element. + ---@param element table Frame triggering the tooltip display. + ---@param shorten boolean? `true` to set the LastShown property, which will shorten the next display of tooltips within this tooltip group. + function Bagshui:ShortenTooltipDelay(element, shorten) + -- Make sure the Bagshui table exists to avoid errors. + if not element.bagshuiData then + element.bagshuiData = {} + end + + -- If this is an element that has a breadcrumb up to a parent element, use that instead. + -- This will be triggered most of the time, but it gives the option to pass in a parent frame + -- and reset tooltip delays directly if desired. (Initially this was going to be how things + -- worked and then it turned out to not be necessary but the functionality is staying I guess?). + if element.bagshuiData._showTooltipAfterDelay_TooltipGroupElement then + element = element.bagshuiData._showTooltipAfterDelay_TooltipGroupElement + + -- Again, make sure the Bagshui table exists since we have changed elements. + if not element.bagshuiData then + element.bagshuiData = {} + end + end + + -- Set or clear the LastShown property. + element.bagshuiData._showTooltipAfterDelay_LastShown = shorten and _G.GetTime() or nil + end + + --- Hide GameTooltip and all Bagshui Info Tooltips if owned by `this` or `force == true`. + ---@param this table? Frame to check for ownership instead of the global `this`. + ---@param force boolean? Hide tooltips without checking ownership. + function Bagshui:HideTooltips(this, force) + this = this or _G.this + if force or _G.GameTooltip:IsOwned(this) then + _G.GameTooltip:Hide() + end + for _, tooltip in pairs(self.tooltips) do + if force or tooltip:IsOwned(this) then + tooltip:Hide() + end + end + end + + --- Prepare an info tooltip to be displayed. Anchors above or below the parent tooltip + --- depending on where there is more vertical space. + ---@param owner table Frame that owns the tooltip (parameter for `:SetOwner()`). + ---@param managedElsewhere boolean? Don't do any automatic filling of tooltip content in `Bagshui:ManageInfoTooltip()`. + ---@param forceBelow boolean? When true, always display info tooltip below `attachToTooltip` regardless of `attachToTooltip`'s vertical position. + ---@param attachToTooltip table? "Parent" tooltip to which the info tooltip will attach (default: `GameTooltip`). + ---@param infoTooltip table? Info tooltip, if `BsInfoTooltip` shouldn't be used. + function Bagshui:SetInfoTooltipPosition(owner, managedElsewhere, forceBelow, attachToTooltip, infoTooltip) + -- Always need an owner. Don't throw an error though. Upstream code in + -- Bagshui:ManageInfoTooltip() tries to guard against this, but let's be sure. + if not owner then + return + end + + attachToTooltip = attachToTooltip or _G.GameTooltip + + -- Sanity check. + if not attachToTooltip.bagshuiData then + return + end + + infoTooltip = infoTooltip or BsInfoTooltip + + infoTooltip:SetOwner(owner, "ANCHOR_PRESERVE") + infoTooltip:ClearAllPoints() + + -- Anchor info tooltip to left/right/center of tooltip. + local leftRight = "" + if (attachToTooltip:GetNumPoints()) or 0 > 0 then + local point = attachToTooltip:GetPoint(1) + leftRight = BsUtil.GetAnchorLeftRight(point) + end + + -- Consumed by `Bagshui:ShowInfoTooltip()`. + infoTooltip.anchorLeftRight = leftRight + + -- Consumed by `Bagshui:ManageInfoTooltip()`. + attachToTooltip.bagshuiData.infoTooltipManagedElsewhere = managedElsewhere + + -- Show info tooltip above or below depending on tooltip position. + if + not forceBelow + and ( + (attachToTooltip:GetTop() and _G.GetScreenHeight() - attachToTooltip:GetTop() > attachToTooltip:GetBottom()) + or attachToTooltip.bagshuiData.displayNextAbove + ) + then + -- Above. + infoTooltip:SetPoint("BOTTOM" .. leftRight, attachToTooltip, "TOP" .. leftRight, 0, -BsSkin.infoTooltipYOffset) + else + -- Below. + infoTooltip:SetPoint("TOP" .. leftRight, attachToTooltip, "BOTTOM" .. leftRight, 0, BsSkin.infoTooltipYOffset) + end + end + + --- Because info tooltips are anchored above/below another tooltip, sometimes + --- things run up against the edge of the screen and the tooltips can overlap. + --- We can (mostly) prevent this by doing some re-anchoring fancy footwork. + ---@param infoTooltip any + function Bagshui:ShowInfoTooltip(infoTooltip) + infoTooltip = infoTooltip or BsInfoTooltip + + -- First, get the tooltip on the screen so its size will be calculated. + infoTooltip:Show() + + -- Info tooltip can't be used. + if not infoTooltip:IsVisible() then + return + end + if not ((infoTooltip:GetNumPoints() or 0) > 0) then + return + end + + -- Determine where the info tooltip is currently anchored. + local point, tooltip = infoTooltip:GetPoint(1) + + -- Anchoring tooltip can't be used. + if not tooltip.bagshuiData then + return + end + if not ((tooltip:GetNumPoints() or 0) > 0) then + return + end + + -- Store anchor points for the tooltip to which the info tooltip is anchored + -- so that `Bagshui:ManageTooltip()` can restore them. + tooltip.bagshuiData.originalPoint, tooltip.bagshuiData.originalAnchorToFrame, tooltip.bagshuiData.originalAnchorToPoint, tooltip.bagshuiData.originalXOffset, tooltip.bagshuiData.originalYOffset = + tooltip:GetPoint(1) + + -- Decide whether the info tooltip is above or below the tooltip to which it's anchored. + local topTooltip, bottomTooltip + if string.find(point, "^BOTTOM") then + -- Above. + topTooltip = infoTooltip + bottomTooltip = tooltip + else + -- Below. + topTooltip = tooltip + bottomTooltip = infoTooltip + end + + -- Left/right/center-aligned? + local leftRight = BsUtil.GetAnchorLeftRight(point) + + -- When one of the tooltips is at the extreme edge, re-anchor the other tooltip + -- to it so one can't slide under the other. + if topTooltip:GetTop() / _G.UIParent:GetEffectiveScale() >= _G.UIParent:GetTop() then + -- Top tooltip is at the top of the screen. + topTooltip:ClearAllPoints() + bottomTooltip:ClearAllPoints() + bottomTooltip:SetPoint("TOP" .. leftRight, topTooltip, "BOTTOM" .. leftRight, 0, BsSkin.infoTooltipYOffset) + elseif bottomTooltip:GetBottom() / _G.UIParent:GetEffectiveScale() <= _G.UIParent:GetBottom() then + -- Bottom tooltip is at the bottom of the screen. + topTooltip:ClearAllPoints() + bottomTooltip:ClearAllPoints() + topTooltip:SetPoint("BOTTOM" .. leftRight, bottomTooltip, "TOP" .. leftRight, 0, -BsSkin.infoTooltipYOffset) + end + end + + --- Called from each hooked tooltip's OnShow, OnHide, and OnUpdate to decide whether + --- the info tooltip should be shown or hidden. + ---@param tooltip any + function Bagshui:ManageInfoTooltip(tooltip) + if + -- Don't do anything if global tooltip hooks are disabled. + not BsSettings.globalInfoTooltips + -- Exception for Bagshui bag slot buttons. This would be nice to refactor out. + and not (tooltip.bagshuiData and tooltip.bagshuiData.infoTooltipAlwayShow) + then + -- When the setting is toggled, we need to hide the info tooltip unless it has a management exemption. + if + tooltip.bagshuiData + and tooltip.bagshuiData.infoTooltip + and not tooltip.bagshuiData.infoTooltipManagedElsewhere + then + tooltip.bagshuiData.infoTooltip:Hide() + end + return + end + + -- Reasons not to proceed. + if + -- Tooltip to which we want to attach isn't visible. + not tooltip:IsVisible() + -- We don't have the information we need. + or not tooltip.bagshuiData + or not tooltip.bagshuiData.lastItemString + or not tooltip.bagshuiData.infoTooltip + -- Exempt from global management (stored when `Bagshui:SetInfoTooltipPosition()` is called). + or tooltip.bagshuiData.infoTooltipManagedElsewhere + then + -- Reset things if there's no exemption. + if tooltip.bagshuiData and not tooltip.bagshuiData.infoTooltipManagedElsewhere then + -- Hide the info tooltip when it's still owned by the previous frame or we don't have ownership data. + if + tooltip.bagshuiData.infoTooltip + and ( + (tooltip.bagshuiData.lastOwner and tooltip.bagshuiData.infoTooltip:IsOwned(tooltip.bagshuiData.lastOwner)) + or not tooltip.bagshuiData.lastOwner + ) + then + tooltip.bagshuiData.infoTooltip:Hide() + end + tooltip.bagshuiData.lastOwner = nil + tooltip.bagshuiData.lastAltKeyState = nil + tooltip.bagshuiData.lastControlKeyState = nil + tooltip.bagshuiData.lastShiftKeyState = nil + end + return + end + + -- Triggers to potentially show or hide tooltip. + if + tooltip.bagshuiData.lastAltKeyState ~= _G.IsAltKeyDown() + or tooltip.bagshuiData.lastControlKeyState ~= _G.IsControlKeyDown() + or tooltip.bagshuiData.lastShiftKeyState ~= _G.IsShiftKeyDown() + then + -- Move the parent tooltip back to its original spot if it was adjusted by `Bagshui:ShowInfoTooltip()`. + if tooltip.bagshuiData.originalPoint then + tooltip:ClearAllPoints() + tooltip:SetPoint( + tooltip.bagshuiData.originalPoint, + tooltip.bagshuiData.originalAnchorToFrame, + tooltip.bagshuiData.originalAnchorToPoint, + tooltip.bagshuiData.originalXOffset, + tooltip.bagshuiData.originalYOffset + ) + end + + -- Should we actually show or hide? + if + tooltip.bagshuiData.lastOwner + and (_G.IsAltKeyDown() or tooltip.bagshuiData.showInfoTooltipWithoutAlt or BsSettings.showInfoTooltipsWithoutAlt) + and not _G.IsShiftKeyDown() + then + -- Need to show. + + -- Reset and position. + tooltip.bagshuiData.infoTooltip:ClearLines() + self:SetInfoTooltipPosition( + tooltip.bagshuiData.lastOwner, + false, + false, + tooltip, + tooltip.bagshuiData.infoTooltip + ) + + -- Determine which version of the tooltip to show. + if _G.IsControlKeyDown() then + -- Item property list. + BsItemInfo:Get(tooltip.bagshuiData.lastItemString, tooltip.bagshuiData.lastItemInfo, true) + BsItemInfo:AddTooltipInfo(tooltip.bagshuiData.lastItemInfo, tooltip.bagshuiData.infoTooltip) + else + -- Basic -- just account-wide counts. + BsCatalog:AddTooltipInfo(tooltip.bagshuiData.lastItemString, tooltip.bagshuiData.infoTooltip) + end + + -- Any content to display? + if tooltip.bagshuiData.infoTooltip:NumLines() > 0 then + self:ShowInfoTooltip(tooltip.bagshuiData.infoTooltip) + end + elseif + not tooltip.bagshuiData.lastOwner or tooltip.bagshuiData.infoTooltip:IsOwned(tooltip.bagshuiData.lastOwner) + then + -- Need to hide. + tooltip.bagshuiData.infoTooltip:Hide() + end + + -- Store states for next check. + + tooltip.bagshuiData.lastAltKeyState = _G.IsAltKeyDown() + tooltip.bagshuiData.lastControlKeyState = _G.IsControlKeyDown() + tooltip.bagshuiData.lastShiftKeyState = _G.IsShiftKeyDown() + end + end + + --- Hook the tooltips where we want to intercept `SetHyperlink()`, `SetBagItem()`, etc. + --- so that our info tooltip can be displayed when Alt is held. + function Bagshui:AddInfoTooltipHooks() + self:HookTooltip(_G.GameTooltip) + self:HookTooltip(_G.ItemRefTooltip, true, true) + end + + --- Shared function for profession-related tooltips. + ---@param self table Tooltip. + ---@param type "Craft"|"TradeSkill" Profession type - used to decide what downstream functions to call. + ---@param skill string Profession name (passed through from the `:Set*()` function). + ---@param slot number? Item slot (passed through from the `:Set*()` function). + ---@param subtype string? If the hooked function ends in something other than "Item", specify that here. + ---@return unknown + local function hookTooltip_SetProfessionItem(self, type, skill, slot, subtype) + if slot then + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G["Get" .. type .. "ReagentItemLink"](skill, slot)) + else + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G["Get" .. type .. "ItemLink"](skill)) + end + return self.bagshuiData.hooked["Set" .. type .. (subtype or "Item")](self, skill, slot) + end + + -- Functions we're hooking on each tooltip. + local hookTooltipFunctions = { + + -- These are needed to capture items being displayed in the tooltip. + -- Some guidance taken from aux's [tooltip.lua](https://github.com/shirsig/aux-addon-vanilla/blob/master/core/tooltip.lua). + -- and pfUI's [libtooltip.lua](https://github.com/shagu/pfUI/blob/master/libs/libtooltip.lua) + + SetBagItem = function(self, bagNum, slotNum) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetContainerItemLink(bagNum, slotNum)) + return self.bagshuiData.hooked.SetBagItem(self, bagNum, slotNum) + end, + + SetAuctionItem = function(self, type, index) + self.bagshuiData.displayNextAbove = true -- Don't obscure auction listing row. + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetAuctionItemLink(type, index)) + return self.bagshuiData.hooked.SetAuctionItem(self, type, index) + end, + + SetAuctionSellItem = function(self) + local name, texture = _G.GetAuctionSellItemInfo() + self.bagshuiData.lastItemString = BsCatalog:FindItemByNameAndTexture(name, texture, "itemString") + return self.bagshuiData.hooked.SetAuctionSellItem(self) + end, + + SetCraftItem = function(self, skill, slot) + return hookTooltip_SetProfessionItem(self, "Craft", skill, slot) + end, + + SetCraftSpell = function(self, slot) + return hookTooltip_SetProfessionItem(self, "Craft", slot, nil, "Spell") + end, + + SetHyperlink = function(self, link) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(link) + -- https://github.com/veechs/Bagshui/issues/52 + -- Need to trap errors here because pfQuest adds "quest:" links to the game. + -- This in and of itself isn't a problem, but if the person clicking the + -- chat link isn't running pfQuest but IS using Bagshui, there will be an + -- error thrown that looks like it's a Bagshui issue. In reality, it's + -- simply that Vanilla WoW doesn't know what to do with quest links unless + -- an addon steps in and adds hooks to intercept them. + local ret, error = pcall(self.bagshuiData.hooked.SetHyperlink, self, link) + if error then + -- Display the error without the [Bagshui] prefix so it looks like a + -- standard game message (which it technically is). + Bagshui:ShowErrorMessage(error, nil, nil, nil, nil, nil, nil, nil, true) + end + return ret + end, + + SetInboxItem = function(self, id, attachIndex) + local name, texture = _G.GetInboxItem(id) + self.bagshuiData.lastItemString = BsCatalog:FindItemByNameAndTexture(name, texture, "itemString") + return self.bagshuiData.hooked.SetInboxItem(self, id, attachIndex) + end, + + SetInventoryItem = function(self, unit, slotNum, nameOnly) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetInventoryItemLink(unit, slotNum)) + return self.bagshuiData.hooked.SetInventoryItem(self, unit, slotNum, nameOnly) + end, + + SetLootItem = function(self, lootSlot) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetLootSlotLink(lootSlot)) + return self.bagshuiData.hooked.SetLootItem(self, lootSlot) + end, + + SetLootRollItem = function(self, id) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetLootRollItemLink(id)) + return self.bagshuiData.hooked.SetLootRollItem(self, id) + end, + + SetMerchantItem = function(self, id) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetMerchantItemLink(id)) + return self.bagshuiData.hooked.SetMerchantItem(self, id) + end, + + SetQuestItem = function(self, type, slot) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetQuestItemLink(type, slot)) + return self.bagshuiData.hooked.SetQuestItem(self, type, slot) + end, + + SetQuestLogItem = function(self, type, slot) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetQuestLogItemLink(type, slot)) + return self.bagshuiData.hooked.SetQuestLogItem(self, type, slot) + end, + + SetSendMailItem = function(self) + -- Vanilla doesn't have GetSendMailItemLink() so we need to figure it out. + local name, texture = _G.GetSendMailItem() + self.bagshuiData.lastItemString = BsCatalog:FindItemByNameAndTexture(name, texture, "itemString") + return self.bagshuiData.hooked.SetSendMailItem(self) + end, + + SetTradePlayerItem = function(self, index) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetTradePlayerItemLink(index)) + return self.bagshuiData.hooked.SetTradePlayerItem(self, index) + end, + + SetTradeSkillItem = function(self, skill, slot) + return hookTooltip_SetProfessionItem(self, "TradeSkill", skill, slot) + end, + + SetTradeTargetItem = function(self, index) + self.bagshuiData.lastItemString = BsItemInfo:ParseItemLink(_G.GetTradeTargetItemLink(index)) + return self.bagshuiData.hooked.SetTradeTargetItem(self, index) + end, + + -- Reset all tracking when the tooltip owner changes. + SetOwner = function(self, owner, anchor, xOffset, yOffset) + self.bagshuiData.lastOwner = owner + self.bagshuiData.lastItemString = nil + self.bagshuiData.lastAltKeyState = -1 + self.bagshuiData.lastControlKeyState = -1 + self.bagshuiData.lastShiftKeyState = -1 + self.bagshuiData.showInfoTooltipWithoutAlt = nil + self.bagshuiData.displayNextAbove = nil + self.bagshuiData.originalPoint = nil + self.bagshuiData.originalAnchorToFrame = nil + self.bagshuiData.originalAnchorToPoint = nil + self.bagshuiData.originalXOffset = nil + self.bagshuiData.originalYOffset = nil + + -- aux is almost impenetrable and does absolutely wild things with tooltips + -- where it caches the text instead of calling SetHyperlink(), but we *can* + -- luckily discover the item during the SetOwner() call. Refer to `methods.OnIconEnter` + -- in aux's gui\auction_listing.lua file to see why this works. + if _G.IsAddOnLoaded("aux-addon") then + local parent = owner:GetParent() + self.bagshuiData.lastItemString = + BsItemInfo:ParseItemLink((parent and parent.row and parent.row.record and parent.row.record.link)) + self.bagshuiData.displayNextAbove = (self.bagshuiData.lastItemString ~= nil) -- Don't obscure auction listing row. + end + + -- Clearing this only when the tooltip isn't visible keeps us from + -- incorrectly overriding the value set by our ItemButton code. + if not self:IsVisible() then + self.bagshuiData.infoTooltipManagedElsewhere = false + end + return self.bagshuiData.hooked.SetOwner(self, owner, anchor, xOffset, yOffset) + end, + } + + -- Frame scripts that need to be hooked for each tooltip in order to show or hide the info tooltip. + local hookTooltipScripts = { + + OnUpdate = function() + Bagshui:ManageInfoTooltip(_G.this.bagshuiData.tooltip) + end, + + OnShow = function() + -- Next-frame delay is necessary to prevent flickering tooltips with pfQuest's `/db scan`. + Bagshui:QueueClassCallback(Bagshui, Bagshui.ManageInfoTooltip, nil, nil, _G.this.bagshuiData.tooltip) + end, + + OnHide = function() + -- Do a check on the next frame to see if the info tooltip should be hidden. + -- Delay is necessary due to Blizzard's bag slot buttons constantly hiding and + -- re-showing GameTooltip OnUpdate. + Bagshui:QueueClassCallback(Bagshui, Bagshui.ManageInfoTooltip, nil, nil, _G.this.bagshuiData.tooltip) + end, + } + + --- Do the necessary work to insert all tooltip hooks. + --- Not using the Hooks class for this because it doesn't currently support hooking + --- functions outside the global namespace. + ---@param tooltip table Tooltip to hook. + ---@param createInfoTooltip boolean? Create a new info tooltip to work with this tooltip. + ---@param showAbove boolean? Place the info tooltip above the hooked tooltip by default. + function Bagshui:HookTooltip(tooltip, createInfoTooltip, showAbove) + if not tooltip then + return + end + + if not tooltip.bagshuiData then + tooltip.bagshuiData = {} + end + + local tooltipName = (tooltip:GetName() or tostring(tooltip)) + + local infoTooltip = BsInfoTooltip + + if createInfoTooltip then + infoTooltip = self:CreateTooltip(tooltipName .. "Info", nil, true) + end + + tooltip.bagshuiData.infoTooltip = infoTooltip + -- Reusable table for `ItemInfo:Get()` results that are displayed when Control+Alt are held. + tooltip.bagshuiData.lastItemInfo = {} + tooltip.bagshuiData.showInfoTooltipAbove = showAbove + tooltip.bagshuiData.hooked = {} + + -- Hook functions. + for functionName, hookFunction in pairs(hookTooltipFunctions) do + tooltip.bagshuiData.hooked[functionName] = tooltip[functionName] + tooltip[functionName] = hookFunction + end + + -- Hook scripts. + -- Handled by creating a child frame instead of hooking the existing frame's scripts + -- because it seems to behave MUCH better this way. + tooltip.bagshuiData.hookFrame = _G.CreateFrame("Frame", tooltipName .. "BagshuiHookFrame", tooltip) + tooltip.bagshuiData.hookFrame.bagshuiData = { + tooltip = tooltip, + } + for scriptName, hookScript in pairs(hookTooltipScripts) do + tooltip.bagshuiData.hookFrame:SetScript(scriptName, hookScript) + end + end + + -- Initialization. + -- Doing this here instead of Bagshui:Init() or Bagshui:AddonLoaded() + -- because we depend on Ui:CreateElementName(). + Bagshui:InitTooltips() + Bagshui:AddInfoTooltipHooks() +end) diff --git a/Components/Bagshui.Windows.lua b/Components/Bagshui.Windows.lua new file mode 100644 index 0000000..7e61a5f --- /dev/null +++ b/Components/Bagshui.Windows.lua @@ -0,0 +1,114 @@ +-- Bagshui Core: Windows +-- Window management. + +Bagshui:LoadComponent(function() + --- When the escape key is pressed, close all registered frames. + ---@param wowApiFunctionName string Hooked WoW API function that triggered this call. + ---@param arg1 any + ---@param arg2 any + ---@param arg3 any + ---@return boolean windowsOpen + function Bagshui:CloseAllWindows(wowApiFunctionName, arg1, arg2, arg3) + -- Need to know whether any other windows were closed since CloseAllWindows() is expected + -- to return true if windows were closed to prevent the Game Menu from being opened. + local otherWindowsVisible = self.hooks:OriginalHook(wowApiFunctionName, arg1, arg2, arg3) + + -- Close any of our windows there were open. + local bagshuiWindowsVisible = false + local dialogsClosed = false + + -- First loop looks for dialogs and closes them. + for frame, _ in pairs(self.framesToCloseOnEscape) do + if frame.isDialog and frame:IsVisible() then + bagshuiWindowsVisible = true + dialogsClosed = true + frame:Hide() + end + end + + -- Now we can close anything that isn't a dialog. + if not dialogsClosed then + for frame, _ in pairs(self.framesToCloseOnEscape) do + if frame:IsVisible() then + bagshuiWindowsVisible = true + end + + -- Hide the frame if it's clean. + local dirty = false + local hasCursorItem = false + if frame.bagshuiData then + hasCursorItem = frame.bagshuiData.hasCursorItem + dirty = frame.bagshuiData.dirty or frame.bagshuiData.hasModalDialog or hasCursorItem + end + if not dirty then + frame:Hide() + end + + -- Frame is dirty and has an onDirty function. + if dirty and frame.bagshuiData.onDirty and not frame.bagshuiData.hasModalDialog then + frame.bagshuiData.onDirty() + end + + -- Frame has a cursor item. + if hasCursorItem then + -- This is a quick hack to get Edit Mode cursor clearing on Escape press + -- to work. When something is picked up in Edit Mode, Inventory:ShowEditModeCursor() + -- sets the frame's hasCursorItem property to itself (the class instance). + -- Then we can detect the class instance and call ClearEditModeCursor(). + if type(hasCursorItem) == "table" and hasCursorItem.ClearEditModeCursor then + hasCursorItem:ClearEditModeCursor() + else + _G.ClearCursor() + end + end + end + end + + -- Return true if any window was closed so WoW knows not to open the Game Menu. + return otherWindowsVisible or bagshuiWindowsVisible + end + + --- Add new frame to be closed when CloseAllWindows() fires. + ---@param frame table + function Bagshui:RegisterFrameForCloseOnEscape(frame) + self.framesToCloseOnEscape[frame] = true + end + + --- When at least one of the frames registered through `Bagshui:RegisterFrameAsChildWindow() is visible, return `true`. + ---@return boolean + function Bagshui:ChildWindowsVisible() + for frame, _ in pairs(self.childWindowFrames) do + if frame:IsVisible() then + return true + end + end + return false + end + + --- Register frame as a "Child Window" (see `Bagshui:ChildWindowsVisible()`). + ---@param frame table + function Bagshui:RegisterFrameAsChildWindow(frame) + self.childWindowFrames[frame] = true + end + + local boundaryFramePositions = { "TOP", "RIGHT", "BOTTOM", "LEFT" } + + --- These "boundary frames" are used to detect when one of the Bagshui inventory + --- windows gets moved outside the screen area. See `Inventory:RescueWindow()`. + function Bagshui:ManageBoundaryFrames() + if not self.boundaryFrames then + self.boundaryFrames = {} + end + + for _, position in ipairs(boundaryFramePositions) do + if not self.boundaryFrames[position] then + self.boundaryFrames[position] = _G.CreateFrame("Frame", "BagshuiBoundary" .. position, _G.UIParent) + self.boundaryFrames[position]:SetHeight(1) + self.boundaryFrames[position]:SetWidth(1) + self.boundaryFrames[position]:SetPoint(position, _G.UIParent, position) + end + + self.boundaryFrames[position]:SetScale(BsSettings.windowScale) + end + end +end) diff --git a/Components/Bank.lua b/Components/Bank.lua new file mode 100644 index 0000000..3af5478 --- /dev/null +++ b/Components/Bank.lua @@ -0,0 +1,267 @@ +-- Bagshui Bank Inventory Class Instance +-- Exposes: Bagshui.components.Bags [via Inventory:New()] + +Bagshui:AddComponent(function() + -- Create class instance. + local Bank = Bagshui.prototypes.Inventory:New(BS_INVENTORY_TYPE.BANK) + + --- After normal Init() we need to replace the Blizzard bank frame. + function Bank:Init() + self._super.Init(self) + + -- Store a reference to the Blizzard bank frame so it can be restored. + if not self.blizzBankFrame then + self.blizzBankFrame = _G.BankFrame + end + + -- Take over bank frame duties if allowed. + self:ReplaceBlizzardBank(self.settings.replaceBank) + + -- Bank should be offline by default. + self.online = false + -- Used to track whether the player is physically at the bank. + self.atBank = false + end + + --- Take over bank frame duties (or undo the takeover). + ---@param replace boolean + function Bank:ReplaceBlizzardBank(replace) + -- Intentionally comparing to false here so that nil will still trigger the takeover. + if replace ~= false then + -- For the Blizzard bank frame to stop opening, it has to stop receiving events. + self.blizzBankFrame:UnregisterEvent("BANKFRAME_OPENED") + self.blizzBankFrame:UnregisterEvent("BANKFRAME_CLOSED") + + -- Swap with the Blizzard bank frame. + if self.blizzBankFrame:IsVisible() then + self.blizzBankFrame:Hide() + end + _G.setglobal("BankFrame", self.uiFrame) + else + -- Can't restore if we haven't done the takeover. + if not self.blizzBankFrame then + return + end + + -- Restore the Blizzard bank frame events. + -- We don't need to unregister the BANKFRAME_* events for the Bank class + -- because `Bank:Open()/Close()` filter based on the `replaceBank` setting. + self.blizzBankFrame:RegisterEvent("BANKFRAME_OPENED") + self.blizzBankFrame:RegisterEvent("BANKFRAME_CLOSED") + + -- Make the switch. + self:Close() + _G.setglobal("BankFrame", self.blizzBankFrame) + end + end + + --- Minor override to `Inventory:UpdateOnlineStatus()` so that online status will + --- match atBank status if the player arrives at or leaves the bank while + --- the window is open. + function Bank:UpdateOnlineStatus() + self._super.UpdateOnlineStatus(self) + if self.online and not self.atBank then + self.online = false + end + end + + --- Build the bank slot purchase flow into the slot buttons themselves instead + -- of having a separate Purchase button. + ---@param bagSlotButton table Bag slot button object. + function Bank:BagSlotButton_Init(bagSlotButton) + bagSlotButton.bagshuiData.nextPurchasable = false + bagSlotButton.bagshuiData.purchased = false + + -- OnEnter, if the slot is the next purchasable one, add the cost to the + -- tooltip and change to the buy cursor. + local oldOnEnter = bagSlotButton:GetScript("OnEnter") + bagSlotButton:SetScript("OnEnter", function() + local this = _G.this + if oldOnEnter then + oldOnEnter() + end + + -- Can't buy bank slots offline. + if not self.online then + return + end + + -- Ensure nextPurchasable is current for OnUpdate and OnClick. + self:UpdateBagSlotPurchaseStatus(this) + + if self.containers[this.bagshuiData.bagNum].nextPurchasable then + local nextSlotCost = _G.GetBankSlotCost(this.bagshuiData.bagSlotNum) + _G.SetTooltipMoney(_G.GameTooltip, nextSlotCost) + _G.GameTooltip:Show() + _G.SetCursor("BUY_CURSOR") + end + end) + + -- OnClick, make the purchase if it's the next purchasable slot. + local oldOnClick = bagSlotButton:GetScript("OnClick") + bagSlotButton:SetScript("OnClick", function() + local this = _G.this + -- Right-click or Ctrl+Click hides/shows this bag's items (works offline too, but not on a bag slot that is still for sale). + if not self.containers[this.bagshuiData.bagNum].nextPurchasable and self:ToggleBagHiddenOnClick(this) then + return + end + if not self.online then + return + end + if self.containers[this.bagshuiData.bagNum].nextPurchasable then + _G.PlaySound("igMainMenuOption") + -- The CONFIRM_BUY_BANK_SLOT dialog looks at the nextSlotCost property of + -- BankFrame to get the cost. Since we're replacing BankFrame with our frame, + -- we need to set that property. + self.uiFrame.nextSlotCost = _G.GetBankSlotCost(this.bagshuiData.bagSlotNum) + _G.StaticPopup_Show("CONFIRM_BUY_BANK_SLOT") + elseif not self.containers[this.bagshuiData.bagNum].purchased then + return + elseif not _G.IsAltKeyDown() and not _G.CursorHasItem() then + -- Hack to get a call to PickupBagFromSlot(). + -- Without this, Bank bags can only be picked up by dragging. + -- Another possibility is using `_G.BankFrameItemButtonBag_OnShiftClick()`. + this:GetScript("OnDragStart")() + else + oldOnClick() + end + end) + + -- Pass on to parent to finish initialization. + self._super.BagSlotButton_Init(self, bagSlotButton) + end + + --- Update the Bagshui purchased and nextPurchasable properties for a bag slot. + --- numSlotsPurchased and allSlotsPurchased are the return values from `GetNumBankSlots()`. + --- (These parameters can be passed so that `GetNumBankSlots()` doesn't have to be called + --- repeatedly during the `Bank:UpdateBagBar()` loop). + ---@param bagSlotButton table Bag slot button object. + ---@param numSlotsPurchased number? How many bank slots have been purchased so far. + ---@param allSlotsPurchased boolean? Whether all bank slots have been purchased. + function Bank:UpdateBagSlotPurchaseStatus(bagSlotButton, numSlotsPurchased, allSlotsPurchased) + if not self.online then + return + end + if numSlotsPurchased == nil then + numSlotsPurchased, allSlotsPurchased = _G.GetNumBankSlots() + end + -- Apparently GetNumBankSlots() can return nil sometimes. If that happens, + -- don't try to update anything and stick with the previous purchase status. + -- https://github.com/veechs/Bagshui/issues/177 + if numSlotsPurchased == nil then + return + end + -- `