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
+ -- `.bagshuiData.bagSlotNum` is the sequential bag slot number as set in InventoryUi:CreateBagSlotButtons().
+ self.containers[bagSlotButton.bagshuiData.bagNum].purchased = (
+ allSlotsPurchased or bagSlotButton.bagshuiData.bagSlotNum <= numSlotsPurchased
+ )
+ self.containers[bagSlotButton.bagshuiData.bagNum].nextPurchasable = (
+ not allSlotsPurchased and bagSlotButton.bagshuiData.bagSlotNum == numSlotsPurchased + 1
+ )
+ -- `usable` is consumed by the "Equip Bag" item menu entry to determine when
+ -- a slot should be disabled.
+ self.containers[bagSlotButton.bagshuiData.bagNum].usable =
+ self.containers[bagSlotButton.bagshuiData.bagNum].purchased
+ end
+
+ --- Add special bag bar handling for bank slots because they're purchasable.
+ function Bank:UpdateBagBar()
+ local tooltipText, textureColor
+ local numSlotsPurchased, allSlotsPurchased = _G.GetNumBankSlots()
+
+ for _, bagSlotButton in ipairs(self.ui.buttons.bagSlots) do
+ -- The primary bank slot is never purchasable.
+ if not bagSlotButton.bagshuiData.primary then
+ self:UpdateBagSlotPurchaseStatus(bagSlotButton, numSlotsPurchased, allSlotsPurchased)
+
+ if self.containers[bagSlotButton.bagshuiData.bagNum].purchased then
+ -- Slot has been purchased.
+ tooltipText = _G.BANK_BAG
+ textureColor = BS_COLOR.WHITE
+ elseif self.containers[bagSlotButton.bagshuiData.bagNum].nextPurchasable and self.online then
+ -- Slot is next up for purchase.
+ tooltipText = _G.BANKSLOTPURCHASE .. " " .. _G.BANK_BAG
+ textureColor = BS_COLOR.DARK_GREEN
+ else
+ -- Slot is not yet purchasable.
+ tooltipText = _G.BANK_BAG_PURCHASE
+ textureColor = BS_COLOR.DARK_RED
+ end
+
+ -- This will be picked up by `Inventory:ShowBagSlotTooltip()`.
+ bagSlotButton.tooltipText = tooltipText
+
+ -- Set the slot button texture color.
+ _G.SetItemButtonTextureVertexColor(bagSlotButton, textureColor[1], textureColor[2], textureColor[3])
+
+ -- Set the background texture color.
+ if bagSlotButton.bagshuiData.buttonComponents.background then
+ bagSlotButton.bagshuiData.buttonComponents.background:SetVertexColor(
+ textureColor[1],
+ textureColor[2],
+ textureColor[3]
+ )
+ end
+
+ -- Use cached bag texture offline.
+ if not self.online and self.containers[bagSlotButton.bagshuiData.bagNum].texture then
+ _G.SetItemButtonTexture(bagSlotButton, self.containers[bagSlotButton.bagshuiData.bagNum].texture)
+ end
+ end
+ end
+
+ self._super.UpdateBagBar(self)
+ end
+
+ --- Bank needs special logic for open.
+ function Bank:Open()
+ -- Determine whether we're at the bank.
+ -- `self.online` will be updated in `Bank:Update()`.
+ self.atBank = (
+ _G.event == "BANKFRAME_OPENED"
+ or (self.settings.replaceBank == false and self.ui:IsFrameVisible(self.blizzBankFrame))
+ )
+ self.windowUpdateNeeded = true
+ self:Update()
+
+ -- Don't respond to open event when we're not hooking the Bank,
+ -- but do update online state via `Bank:Update()`.
+ if _G.event == "BANKFRAME_OPENED" and self.settings.replaceBank == false then
+ return
+ end
+
+ self._super.Open(self)
+ end
+
+ --- Bank needs special logic for close.
+ function Bank:Close()
+ -- Determine whether we're still at the bank.
+ -- Closing the bank can never mean we're at the bank, so only
+ -- change `atBank` if it's currently true.
+ -- `self.online` will be updated in `Bank:Update()`.
+ if self.atBank then
+ self.atBank = (_G.event ~= "BANKFRAME_CLOSED")
+ end
+ self.windowUpdateNeeded = true
+ self:Update()
+
+ -- Don't respond to close event when we're not hooking the Bank.
+ if _G.event == "BANKFRAME_CLOSED" and self.settings.replaceBank == false then
+ return
+ end
+
+ self._super.Close(self)
+ end
+
+ --- Need to overload the OnHide script so that we can ensure the BANKFRAME_OPENED
+ --- event will always fire as expected. Unless `CloseBankFrame()` is called,
+ --- the game will think we never left the bank and it becomes impossible to open
+ --- the bank again until the UI is reloaded.
+ function Bank:UiFrame_OnHide()
+ self._super.UiFrame_OnHide(self)
+ if self.settings.replaceBank then
+ _G.CloseBankFrame()
+ end
+ self.atBank = false
+ end
+end)
diff --git a/Components/Catalog.lua b/Components/Catalog.lua
new file mode 100644
index 0000000..b936364
--- /dev/null
+++ b/Components/Catalog.lua
@@ -0,0 +1,1387 @@
+-- Bagshui Catalog
+-- Exposes: BsCatalog (and Bagshui.components.Catalog)
+--
+-- Account-wide index of all characters' inventories.
+-- Everything is calculated at runtime from what's in SavedVariables.
+--
+-- Init() takes care of calculations for character other than the current one,
+-- while Update() handles the current character and grand totals.
+
+Bagshui:AddComponent(function()
+ Bagshui:AddConstants({
+ BS_CATALOG_LOCATIONS = {
+ EQUIPPED = "Equipped", -- This needs to match the Abbrev_[value] localization.
+ MONEY = "$$$",
+ },
+ })
+
+ -- Table key for totals and prefix for subtotals.
+ local TOTAL_KEY = "==Total"
+
+ -- Per-list lookup of itemStrings already present, used by `Catalog:AddToItemTable()`.
+ -- Weak keys so lists that go away don't leak their lookups.
+ local uniqueItemLookups = setmetatable({}, { __mode = "k" })
+ -- Stand-in table key for items that have no itemString (tables can't use nil as a key).
+ local NIL_ITEM_STRING_KEY = "==NoItemString"
+
+ -- Money storage.
+ local MONEY_ITEM = {
+ itemString = BS_CATALOG_LOCATIONS.MONEY,
+ count = 0,
+ }
+
+ -- Cross-account sharing.
+ -- Addons can't read another account's SavedVariables, but Nampower provides
+ -- `WriteCustomFile()`/`ReadCustomFile()` for a `CustomData` folder that every client launched
+ -- from this installation shares. Each character writes one file with the parts of its data the
+ -- Catalog needs, and every character reads the others' files at startup.
+ local SHARED_FILE_PREFIX = "Bagshui_"
+ local SHARED_ROSTER_FILE = "Bagshui_roster.txt"
+ -- Minimum seconds between writes of the current character's file (a final write always happens at logout).
+ local SHARED_WRITE_MIN_INTERVAL = 60
+ -- Separates the compact Catalog data from the full inventory data in a character's file.
+ -- The full inventory is only parsed when someone actually views that character's bags.
+ -- (No newlines in the marker in case the file gets line-ending conversion.)
+ local SHARED_INVENTORY_MARKER = "--[[BAGSHUI_INVENTORY_SECTION]]"
+ -- `info` properties that are shared.
+ local SHARED_INFO_KEYS = {
+ "name", "realm", "class", "faction", "level", "race",
+ "localizedClass", "localizedFaction", "localizedRace", "money",
+ }
+
+ --- Are Nampower's file functions available?
+ ---@return boolean
+ local function sharedFilesAvailable()
+ return type(_G.WriteCustomFile) == "function" and type(_G.ReadCustomFile) == "function"
+ end
+
+ --- File name for a character's shared data.
+ ---@param characterId string
+ ---@return string
+ local function sharedFileName(characterId)
+ return SHARED_FILE_PREFIX .. (string.gsub(characterId, "[^%w]", "_")) .. ".txt"
+ end
+
+ --- Reduce an item to the few properties the Catalog uses.
+ ---@param item table?
+ ---@return table?
+ local function compactItem(item)
+ if type(item) ~= "table" or item.emptySlot == 1 or not item.itemString or (item.count or 0) < 1 then
+ return nil
+ end
+ return {
+ id = item.id,
+ itemString = item.itemString,
+ itemLink = item.itemLink,
+ count = item.count,
+ name = item.name,
+ texture = item.texture,
+ quality = item.quality,
+ }
+ end
+
+ --- Escape a string for use between double quotes in Lua source.
+ --- Unlike `BsUtil.Serialize()` this also handles newlines (item tooltips contain them)
+ --- and other control characters, which would otherwise make the shared file unreadable.
+ ---@param str string
+ ---@return string
+ local function escapeSharedString(str)
+ str = string.gsub(str, "\\", "\\\\")
+ str = string.gsub(str, '"', '\\"')
+ str = string.gsub(str, "\n", "\\n")
+ str = string.gsub(str, "\r", "\\r")
+ str = string.gsub(str, "%c", function(c)
+ return string.format("\\%03d", string.byte(c))
+ end)
+ return str
+ end
+
+ --- Append the Lua source for a table to `parts`. Only strings, numbers, booleans and
+ --- tables are written; anything else (and nan/infinite numbers) is skipped.
+ ---@param tbl table
+ ---@param parts string[]
+ ---@param depth number
+ local function serializeSharedTable(tbl, parts, depth)
+ table.insert(parts, "{")
+ if depth < 20 then
+ for key, value in pairs(tbl) do
+ local keyType = type(key)
+ local valueType = type(value)
+ if
+ (keyType == "string" or keyType == "number")
+ and (valueType == "string" or valueType == "number" or valueType == "boolean" or valueType == "table")
+ and not (valueType == "number" and (value ~= value or value == 1 / 0 or value == -1 / 0))
+ and not (keyType == "number" and (key ~= key or key == 1 / 0 or key == -1 / 0))
+ then
+ if keyType == "string" then
+ table.insert(parts, '["' .. escapeSharedString(key) .. '"]=')
+ else
+ table.insert(parts, "[" .. tostring(key) .. "]=")
+ end
+ if valueType == "table" then
+ serializeSharedTable(value, parts, depth + 1)
+ elseif valueType == "string" then
+ table.insert(parts, '"' .. escapeSharedString(value) .. '"')
+ else
+ table.insert(parts, tostring(value))
+ end
+ table.insert(parts, ",")
+ end
+ end
+ end
+ table.insert(parts, "}")
+ end
+
+ --- Serialize a table to Lua source that `BsUtil.Deserialize()` can read back.
+ ---@param tbl table
+ ---@return string
+ local function serializeShared(tbl)
+ local parts = {}
+ serializeSharedTable(tbl, parts, 0)
+ return table.concat(parts)
+ end
+
+ --- Lua source for a table key (`["name"]=` or `[1]=`), or `nil` if the key can't be shared.
+ ---@param key string|number
+ ---@return string?
+ local function sharedKeyText(key)
+ if type(key) == "string" then
+ return '["' .. escapeSharedString(key) .. '"]='
+ elseif type(key) == "number" and key == key and key ~= 1 / 0 and key ~= -1 / 0 then
+ return "[" .. tostring(key) .. "]="
+ end
+ return nil
+ end
+
+ --- Lua source for a single value, or `nil` if the value can't be shared.
+ ---@param value any
+ ---@return string?
+ local function sharedValueText(value)
+ local valueType = type(value)
+ if valueType == "table" then
+ return serializeShared(value)
+ elseif valueType == "string" then
+ return '"' .. escapeSharedString(value) .. '"'
+ elseif valueType == "boolean" then
+ return tostring(value)
+ elseif valueType == "number" and value == value and value ~= 1 / 0 and value ~= -1 / 0 then
+ return tostring(value)
+ end
+ return nil
+ end
+
+ -- Key suffixes for subtotals.
+ local SUBTOTAL_TYPE = {
+ Current = "Current",
+ Other = "Other",
+ }
+
+ -- How much to indent each tooltip line.
+ local TOOLTIP_INDENT = BS_INDENT
+
+ local Catalog = {
+ -- Array of the item info tables of all items on the account, sorted by name.
+ items = {},
+
+ -- Calculation results as produced by `BuildIndex()` and `Update()`.
+ -- Structure:
+ -- ```
+ -- {
+ -- -- Overall account-wide totals.
+ -- ["==Total"] = { => },
+ -- -- Current character totals (will be the same as the realm-level total, obviously).
+ -- ["==TotalCurrent"] = { => },
+ -- -- Other character totals.
+ -- ["==TotalOther"] = { => },
+ -- -- Each realm will have an entry.
+ -- ["realm1"] = {
+ -- -- Overall realm-wide totals.
+ -- ["==Total"] = { => },
+ -- -- Current character realm-level totals.
+ -- ["==TotalCurrent"] = { => },
+ -- -- Other character realm-level totals.
+ -- ["==TotalOther"] = { => },
+ -- -- Class-colored character name with faction appended.
+ -- ["_formattedCharacterNames"] = { => },
+ -- -- Characters on this realm, sorted alphabetically.
+ -- ["_sortedCharacterList"] = { "characterName1", "characterName2", ... },
+ -- -- Each character will have an entry.
+ -- ["characterName1"] = { => },
+ -- },
+ -- }
+ -- ```
+ totals = {},
+
+ -- Arrays of item tables that can later be iterated during calculations of totals.
+ -- There isn't an `all` entry here because that's `self.items`.
+ itemListTables = {
+ currentCharacter = {},
+ otherCharacter = {},
+ },
+
+ -- Array of locations where items are stored (BS_INVENTORY_TYPE_UI_ORDER followed by EQUIPPED).
+ -- Iterated to build per-character tooltips.
+ itemLocations = {},
+
+ -- List of realms, sorted alphabetically.
+ sortedRealmList = {},
+
+ -- Reusable table for calculating per-location totals for each character.
+ -- ```
+ -- {
+ -- = { => }
+ -- }
+ -- ```
+ tempItemCounts = {},
+
+ -- Tooltips for items from other characters need to be reloaded during `Update()`.
+ tooltipUpdateNeeded = true,
+
+ -- Has `Init()` finished?
+ initialized = false,
+ }
+ Bagshui.environment.BsCatalog = Catalog
+ Bagshui.components.Catalog = Catalog
+
+ --- Event processing.
+ ---@param event string WoW API event
+ ---@param arg1 any First event argument.
+ function Catalog:OnEvent(event, arg1)
+ -- Bagshui:PrintDebug("Catalog event " .. event)
+
+ -- Initial processing at startup. Delayed since it's not needed immediately.
+ -- Other update events are allowed to delay initial processing even further.
+ if event == "PLAYER_ENTERING_WORLD" or not self.initialized then
+ -- This check is duplicated in Catalog:Init() but let's be extra safe.
+ if not self.initialized then
+ Bagshui:QueueClassCallback(self, self.Init, 3)
+ end
+ return
+ end
+
+ if event == "BAGSHUI_CHARACTER_UPDATE" then
+ self.sharedDirty = true
+ -- Refresh the current character's name as we may now have more data.
+ self:StoreFormattedCharacterName(Bagshui.currentCharacterId, Bagshui.currentCharacterData.info)
+ return
+ end
+
+ -- Anything that can change what other accounts see means the shared file needs rewriting.
+ if
+ event == "BAGSHUI_EQUIPPED_UPDATE"
+ or event == "BAGSHUI_INVENTORY_CACHE_UPDATE"
+ or event == "BAGSHUI_MONEY_UPDATE"
+ then
+ self.sharedDirty = true
+ end
+
+ if event == "BAGSHUI_CHARACTER_LEARNED_RECIPE" then
+ -- Learning a new recipe requires updating tooltips for items from other characters.
+ self.tooltipUpdateNeeded = true
+ end
+
+ if event == "BAGSHUI_CATALOG_SHOULD_UPDATE" then
+ -- We've received the delayed event to trigger the update.
+ self:Update()
+ return
+ end
+
+ -- Any other event requires an update after a delay.
+ Bagshui:QueueEvent("BAGSHUI_CATALOG_SHOULD_UPDATE", 0.75)
+ end
+
+ --- Initialize the Catalog class.
+ --- Also calculates item totals for all characters other than the current one.
+ function Catalog:Init()
+ -- Bagshui:PrintDebug("Catalog Init()")
+
+ -- Safeguard just in case. Without this, if Init() is called multiple times
+ -- then the list of characters will be duplicated and tooltips will look weird.
+ if self.initialized then
+ return
+ end
+
+ -- Initialize storage tables.
+
+ for _, inventoryType in ipairs(BS_INVENTORY_TYPE_UI_ORDER) do
+ table.insert(self.itemLocations, inventoryType)
+ end
+ for _, itemLocation in pairs(BS_CATALOG_LOCATIONS) do
+ table.insert(self.itemLocations, itemLocation)
+ end
+
+ for _, inventoryType in ipairs(self.itemLocations) do
+ self.tempItemCounts[inventoryType] = {}
+ end
+
+ self:CreateTotalKeys(self.totals)
+
+ -- Process one character: register it in the realm lists and, if it isn't the
+ -- current character, calculate its totals.
+ local function processCharacter(characterId, data)
+ -- Make sure it's a fully formed character.
+ if data.info and data.info.name and data.info.realm then
+ local realm = data.info.realm
+
+ -- Initialize realm storage table.
+ if not self.totals[realm] then
+ table.insert(self.sortedRealmList, realm)
+ self.totals[realm] = {
+ _sortedCharacterList = {},
+ _formattedCharacterNames = {},
+ }
+ self:CreateTotalKeys(self.totals[realm])
+ end
+
+ -- Store data about this character that will be needed later.
+ table.insert(self.totals[realm]._sortedCharacterList, data.info.name)
+ self:StoreFormattedCharacterName(characterId, data.info)
+
+ -- Update() will take calculations for the current character, so Init() only needs to handle others.
+ if characterId ~= Bagshui.currentCharacterId then
+ self:CalculatePerCharacterTotal(characterId, data, self.itemListTables.otherCharacter)
+ end
+ end
+ end
+
+ -- Read characters from other accounts (if Nampower's file functions are available).
+ self:LoadSharedCharacters()
+
+ -- Process all characters in SavedVariables.
+ for characterId, data in pairs(Bagshui.characters) do
+ processCharacter(characterId, data)
+ end
+
+ -- Process characters from other accounts on this computer (see `Bagshui.sharedCharacters`).
+ -- A pcall keeps one malformed shared character from breaking the whole Catalog.
+ for characterId, data in pairs(Bagshui.sharedCharacters) do
+ if not getmetatable(data) and BsCharacterData and BsCharacterData.objectMetatable then
+ setmetatable(data, BsCharacterData.objectMetatable)
+ end
+ local ok, err = pcall(processCharacter, characterId, data)
+ if not ok then
+ Bagshui:PrintDebug("Skipping shared character " .. tostring(characterId) .. ": " .. tostring(err))
+ end
+ end
+
+ -- Ensure all items are known so tooltips will load immediately.
+ -- This used to run inside the character loop above, which re-loaded every previously
+ -- processed character's items again for each additional character (a hidden tooltip
+ -- load per item), so the cost grew with characters x items.
+ for _, item in ipairs(self.itemListTables.otherCharacter) do
+ BsItemInfo:LoadItemIntoLocalGameCache(item.itemString)
+ end
+
+ -- Produce subtotals for all characters other than the current one.
+ self:CalculateSubtotals(self.itemListTables.otherCharacter, SUBTOTAL_TYPE.Other)
+
+ -- Character and realm lists only need to be sorted once because they can't change without logging out.
+ table.sort(self.sortedRealmList)
+ for _, realm in ipairs(self.sortedRealmList) do
+ table.sort(self.totals[realm]._sortedCharacterList)
+ end
+
+ -- Get current character data and calculate totals.
+ self:Update()
+
+ -- Register slash command.
+ BsSlash:AddOpenCloseHandler("Catalog", self)
+
+ -- Allow events to be processed normally.
+ self.initialized = true
+ end
+
+ --- Add this character to the shared roster, once per session.
+ --- Lua can't list a directory, so the roster is how other characters find our file.
+ function Catalog:JoinSharedRoster()
+ if self.joinedSharedRoster then
+ return
+ end
+ self.joinedSharedRoster = true
+
+ local rosterLine = "R~" .. Bagshui.currentCharacterId
+ local ok, roster = pcall(_G.ReadCustomFile, SHARED_ROSTER_FILE)
+ if ok and type(roster) == "string" then
+ for line in string.gfind(roster, "[^\n]+") do
+ if BsUtil.Trim(line) == rosterLine then
+ return
+ end
+ end
+ end
+ -- Appended (not rewritten) because several clients may start at the same moment.
+ pcall(_G.WriteCustomFile, SHARED_ROSTER_FILE, rosterLine .. "\n", "a")
+ end
+
+ --- Write the current character's Catalog data where characters on other accounts can read it.
+ --- Rate limited; `force` skips the limit (used at logout).
+ ---@param force boolean?
+ function Catalog:WriteSharedCharacter(force)
+ if not sharedFilesAvailable() then
+ return
+ end
+
+ -- Wait until the character's basic info has been filled in.
+ local currentInfo = Bagshui.currentCharacterData.info
+ if not currentInfo or not currentInfo.name or currentInfo.name == "" or not currentInfo.realm or currentInfo.realm == "" then
+ return
+ end
+
+ -- Nothing has changed since the last successful write: nothing to do.
+ -- (`sharedDirty` is nil until the first write, which counts as dirty.)
+ if self.sharedDirty == false and self.lastSharedBody then
+ return
+ end
+
+ local now = _G.time()
+ if not force and self.lastSharedWrite and (now - self.lastSharedWrite) < SHARED_WRITE_MIN_INTERVAL then
+ self.sharedWritePending = true
+ return
+ end
+
+ -- The work is split into small steps that run one per frame, so building and serializing a big
+ -- bank doesn't freeze the game for a moment. (At logout everything runs at once.)
+ if self.sharedWriteJob then
+ if not force then
+ return
+ end
+ self.sharedWriteJob = nil
+ end
+
+ self.lastSharedWrite = now
+ self.sharedWritePending = false
+ -- Cleared now so a change that happens while the steps run triggers another write.
+ self.sharedDirty = false
+
+ local data = Bagshui.currentCharacterData
+ local record = {
+ version = BS_DATA_VERSION,
+ id = Bagshui.currentCharacterId,
+ info = {},
+ }
+ local catalogBody
+ -- pieces[storageKey][field] = array of serialized "[key]=value," strings (or a single string for non-tables).
+ local pieces = {}
+ local steps = {}
+
+ -- Basic info and equipped items.
+ table.insert(steps, function()
+ local info = data.info or {}
+ for _, key in ipairs(SHARED_INFO_KEYS) do
+ record.info[key] = info[key]
+ end
+ record.info.equipped = {}
+ if type(info.equipped) == "table" then
+ for slot, item in pairs(info.equipped) do
+ local compacted = compactItem(item)
+ if compacted then
+ record.info.equipped[slot] = compacted
+ end
+ end
+ end
+ end)
+
+ -- Bags, bank, etc. for the Catalog. Empty slots are dropped, so each bag becomes a dense array.
+ for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
+ local storageKey = BsUtil.LowercaseFirstLetter(inventoryType)
+ table.insert(steps, function()
+ if data[storageKey] and data[storageKey].inventory then
+ local bagsOut = {}
+ for bagNum, contents in pairs(data[storageKey].inventory) do
+ local list = {}
+ for _, item in ipairs(contents) do
+ local compacted = compactItem(item)
+ if compacted then
+ table.insert(list, compacted)
+ end
+ end
+ bagsOut[bagNum] = list
+ end
+ record[storageKey] = { inventory = bagsOut }
+ end
+ end)
+ end
+
+ table.insert(steps, function()
+ catalogBody = serializeShared(record)
+ end)
+
+ -- Full inventory (every slot plus bag info) so other accounts can view this character's bags.
+ -- Serialized one bag at a time; it reads the live saved tables, nothing is copied.
+ for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
+ local storageKey = BsUtil.LowercaseFirstLetter(inventoryType)
+ local stored = data[storageKey]
+ if type(stored) == "table" then
+ pieces[storageKey] = {}
+ for _, fieldName in ipairs({ "inventory", "containers", "neverOnline" }) do
+ -- Lua 5.0 doesn't give closures their own copy of `for` loop variables, so copy
+ -- everything the delayed steps use into locals declared inside the loop body.
+ local field = fieldName
+ local value = stored[field]
+ if type(value) == "table" then
+ pieces[storageKey][field] = {}
+ for loopKey in pairs(value) do
+ local subKey = loopKey
+ table.insert(steps, function()
+ local current = data[storageKey] and data[storageKey][field]
+ local subValue = type(current) == "table" and current[subKey] or nil
+ local keyText = sharedKeyText(subKey)
+ local valueText = subValue ~= nil and sharedValueText(subValue) or nil
+ if keyText and valueText then
+ table.insert(pieces[storageKey][field], keyText .. valueText .. ",")
+ end
+ end)
+ end
+ elseif value ~= nil then
+ pieces[storageKey][field] = sharedValueText(value)
+ end
+ end
+ end
+ end
+
+ -- Put it all together and write it.
+ table.insert(steps, function()
+ local inventoryParts = { "{" }
+ for storageKey, fields in pairs(pieces) do
+ table.insert(inventoryParts, sharedKeyText(storageKey) .. "{")
+ for field, content in pairs(fields) do
+ local valueText
+ if type(content) == "table" then
+ valueText = "{" .. table.concat(content) .. "}"
+ else
+ valueText = content
+ end
+ if valueText then
+ table.insert(inventoryParts, sharedKeyText(field) .. valueText .. ",")
+ end
+ end
+ table.insert(inventoryParts, "},")
+ end
+ table.insert(inventoryParts, "}")
+
+ local body = catalogBody .. "\n" .. SHARED_INVENTORY_MARKER .. "\n" .. table.concat(inventoryParts)
+
+ -- Skip the write if nothing changed.
+ if body == self.lastSharedBody then
+ return
+ end
+
+ self:JoinSharedRoster()
+ local written = pcall(_G.WriteCustomFile, sharedFileName(Bagshui.currentCharacterId), "-- " .. now .. "\n" .. body, "w")
+ if written then
+ self.lastSharedBody = body
+ else
+ self.sharedDirty = true
+ end
+ end)
+
+ local job = { steps = steps, index = 1 }
+ local function runStep()
+ local step = job.steps[job.index]
+ job.index = job.index + 1
+ local ok, err = pcall(step)
+ if not ok then
+ Bagshui:PrintDebug("Could not write shared character data: " .. tostring(err))
+ self.sharedDirty = true
+ job.index = getn(job.steps) + 1
+ end
+ end
+
+ if force then
+ while job.index <= getn(job.steps) do
+ runStep()
+ end
+ return
+ end
+
+ self.sharedWriteJob = job
+ if not self.sharedWriteFrame then
+ self.sharedWriteFrame = _G.CreateFrame("Frame")
+ end
+ self.sharedWriteFrame:SetScript("OnUpdate", function()
+ if self.sharedWriteJob ~= job then
+ -- Replaced by a forced write at logout.
+ this:SetScript("OnUpdate", nil)
+ return
+ end
+ if job.index <= getn(job.steps) then
+ runStep()
+ end
+ if job.index > getn(job.steps) then
+ self.sharedWriteJob = nil
+ this:SetScript("OnUpdate", nil)
+ end
+ end)
+ end
+
+ --- Read the characters other accounts have shared and put them in `Bagshui.sharedCharacters`.
+ --- Characters in this account's own SavedVariables always take priority.
+ function Catalog:LoadSharedCharacters()
+ BsUtil.TableClear(Bagshui.sharedCharacters)
+ if not sharedFilesAvailable() then
+ return
+ end
+
+ local ok, roster = pcall(_G.ReadCustomFile, SHARED_ROSTER_FILE)
+ if not ok or type(roster) ~= "string" then
+ return
+ end
+
+ for line in string.gfind(roster, "[^\n]+") do
+ local _, _, characterId = string.find(BsUtil.Trim(line), "^R~(.+)$")
+ if
+ characterId
+ and characterId ~= Bagshui.currentCharacterId
+ and not Bagshui.characters[characterId]
+ and not Bagshui.sharedCharacters[characterId]
+ then
+ local readOk, text = pcall(_G.ReadCustomFile, sharedFileName(characterId))
+ if readOk and type(text) == "string" and text ~= "" then
+ -- Only the compact Catalog section is parsed here; see `Catalog:LoadSharedInventory()`.
+ local catalogText = text
+ local markerStart = string.find(text, SHARED_INVENTORY_MARKER, 1, true)
+ if markerStart then
+ catalogText = string.sub(text, 1, markerStart - 1)
+ end
+ local parseOk, record = pcall(BsUtil.Deserialize, catalogText)
+ if
+ parseOk
+ and type(record) == "table"
+ and record.version == BS_DATA_VERSION
+ and type(record.info) == "table"
+ and type(record.info.name) == "string"
+ and type(record.info.realm) == "string"
+ then
+ -- Fill in any properties the file doesn't carry, the same way Bagshui does for its own saved items.
+ for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
+ local storageKey = BsUtil.LowercaseFirstLetter(inventoryType)
+ if type(record[storageKey]) == "table" and type(record[storageKey].inventory) == "table" then
+ for _, contents in pairs(record[storageKey].inventory) do
+ for _, item in ipairs(contents) do
+ BsItemInfo:InitializeItem(item, false, true)
+ end
+ end
+ end
+ end
+ if type(record.info.equipped) == "table" then
+ for _, item in pairs(record.info.equipped) do
+ BsItemInfo:InitializeItem(item, false, true)
+ end
+ end
+ -- Last time the character's file was written (shown in the Character Data manager).
+ local _, _, writtenAt = string.find(text, "^%-%- (%d+)")
+ record.lastLogout = tonumber(writtenAt)
+ Bagshui.sharedCharacters[characterId] = record
+ else
+ Bagshui:PrintDebug("Ignoring shared character file for " .. characterId)
+ end
+ end
+ end
+ end
+
+ -- Let the Character Data manager list them too.
+ if BsCharacterData and BsCharacterData.RefreshList then
+ BsCharacterData:RefreshList()
+ BsCharacterData:Update()
+ end
+ end
+
+ --- Load a shared character's full inventory (all slots and bag info) from its file so it
+ --- can be viewed in a Bagshui window. Replaces the compact Catalog data in the character's record.
+ ---@param characterId string
+ ---@return boolean loaded
+ ---@return string? reason Why loading failed.
+ function Catalog:LoadSharedInventory(characterId)
+ local record = Bagshui.sharedCharacters[characterId]
+ if not record or not sharedFilesAvailable() then
+ return false, "not a shared character, or Nampower's file functions are missing"
+ end
+
+ -- Docked windows ask for the same character back to back, so don't read the file twice.
+ if record.inventoryLoadedAt and (_G.GetTime() - record.inventoryLoadedAt) < 5 then
+ return true
+ end
+
+ local readOk, text = pcall(_G.ReadCustomFile, sharedFileName(characterId))
+ if not readOk or type(text) ~= "string" or text == "" then
+ return false, "its shared file could not be read"
+ end
+ local _, markerEnd = string.find(text, SHARED_INVENTORY_MARKER, 1, true)
+ if not markerEnd then
+ return false, "its inventory hasn't been shared yet. Log in on that character (on its own account) with this version of Bagshui once"
+ end
+ local parseOk, inventoryRecord = pcall(BsUtil.Deserialize, string.sub(text, markerEnd + 1))
+ if not parseOk or type(inventoryRecord) ~= "table" then
+ return false, "its shared inventory couldn't be read (it was probably saved by an older version). Log in on that character once with this version of Bagshui"
+ end
+
+ for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
+ local storageKey = BsUtil.LowercaseFirstLetter(inventoryType)
+ local stored = inventoryRecord[storageKey]
+ if type(stored) == "table" then
+ stored.inventory = type(stored.inventory) == "table" and stored.inventory or {}
+ stored.containers = type(stored.containers) == "table" and stored.containers or {}
+ -- Empty tables aren't serialized, so make sure every bag has an inventory table.
+ for bagNum, _ in pairs(stored.containers) do
+ if type(stored.inventory[bagNum]) ~= "table" then
+ stored.inventory[bagNum] = {}
+ end
+ end
+ for _, contents in pairs(stored.inventory) do
+ for _, item in ipairs(contents) do
+ BsItemInfo:InitializeItem(item, false, true)
+ end
+ end
+ record[storageKey] = stored
+ end
+ end
+
+ record.inventoryLoadedAt = _G.GetTime()
+ return true
+ end
+
+ --- Stop sharing a character: blank its file and remove it from the roster.
+ --- Called when a character is deleted from the Character Data manager.
+ ---@param characterId string
+ function Catalog:ForgetSharedCharacter(characterId)
+ if not sharedFilesAvailable() then
+ return
+ end
+
+ pcall(_G.WriteCustomFile, sharedFileName(characterId), "", "w")
+
+ local ok, roster = pcall(_G.ReadCustomFile, SHARED_ROSTER_FILE)
+ if ok and type(roster) == "string" then
+ local rosterLine = "R~" .. characterId
+ local remaining = ""
+ for line in string.gfind(roster, "[^\n]+") do
+ line = BsUtil.Trim(line)
+ if line ~= rosterLine then
+ remaining = remaining .. line .. "\n"
+ end
+ end
+ pcall(_G.WriteCustomFile, SHARED_ROSTER_FILE, remaining, "w")
+ end
+ end
+
+ --- Sort an array of item tables alphabetically by name.
+ ---@param a table
+ ---@param b table
+ ---@return boolean
+ local function sortItemList(a, b)
+ return (a.name or "") < (b.name or "")
+ end
+
+ --- Refresh item counts and totals for the current character, then refresh overall totals.
+ function Catalog:Update()
+ -- Bagshui:PrintDebug("Catalog:Update() - tooltips? " .. tostring(self.tooltipUpdateNeeded))
+
+ -- Reset existing grand totals and current character subtotals.
+ BsUtil.TableClear(self.totals[TOTAL_KEY])
+ for _, realm in ipairs(self.sortedRealmList) do
+ BsUtil.TableClear(self.totals[realm][TOTAL_KEY])
+ end
+ BsUtil.TableClear(self.totals[self:TotalKey(SUBTOTAL_TYPE.Current)])
+ for _, realm in ipairs(self.sortedRealmList) do
+ BsUtil.TableClear(self.totals[realm][self:TotalKey(SUBTOTAL_TYPE.Current)])
+ end
+
+ -- Wipe the item tracking table so `AddToItemTable()` will work.
+ BsUtil.TableClear(self.itemListTables.currentCharacter)
+
+ -- Get the item counts for the current character.
+ self:CalculatePerCharacterTotal(
+ Bagshui.currentCharacterId,
+ Bagshui.currentCharacterData,
+ self.itemListTables.currentCharacter
+ )
+
+ -- Calculate subtotals for current character.
+ self:CalculateSubtotals(self.itemListTables.currentCharacter, SUBTOTAL_TYPE.Current)
+
+ -- Build unique, sorted list of all items on the account.
+ BsUtil.TableClear(self.items)
+ for _, itemTable in pairs(self.itemListTables) do
+ for _, item in ipairs(itemTable) do
+ if type(item.id) == "number" and item.id > 0 then
+ -- Refresh the tooltip for items belonging to other characters so
+ -- the Catalog will accurately indicate whether the current
+ -- character can use the item.
+ if self.tooltipUpdateNeeded and itemTable ~= self.itemListTables.currentCharacter then
+ -- 3nd parameter: Force tooltip loading to use itemString
+ -- instead of bagNum/slotNum, since those are for other
+ -- characters in this context and will lead to pulling
+ -- the wrong tooltip.
+ BsItemInfo:GetTooltip(item, nil, true)
+ end
+ self:AddToItemTable(item, self.items)
+ end
+ end
+ end
+ table.sort(self.items, sortItemList)
+
+ -- Combine totals from current character and other characters.
+ self:CalculateGrandTotals()
+
+ -- Refresh the account-wide search if it's visible.
+ if self.objectManager and self.objectManager.uiFrame:IsVisible() then
+ self.objectManager:UpdateList()
+ end
+
+ -- Reset flag.
+ self.tooltipUpdateNeeded = false
+
+ -- Share the current character with other accounts.
+ self:WriteSharedCharacter()
+ end
+
+ --- Iterate a character's item storage locations and collect sums of each item.
+ --- Totals are calculated at the item, character, and realm level.
+ ---@param characterId string Unique ID of the character being analyzed. This is generated by Bagshui on startup and housed in the `Bagshui.currentCharacterId` property. It is also the key for each character in the SavedVariables `characters` table.
+ ---@param characterData table Character's table from the SavedVariables `characters` table.
+ ---@param itemTable table Array that will be filled with the unique list of all items belonging to this character.
+ function Catalog:CalculatePerCharacterTotal(characterId, characterData, itemTable)
+ assert(type(characterData) == "table", "Catalog:CalculatePerCharacterTotal() - characterData must be a table")
+ assert(
+ type(characterData.realm) == "string",
+ "Catalog:CalculatePerCharacterTotal() - characterData.realm is required"
+ )
+
+ local storage = self.totals[characterData.info.realm]
+ local storageSuffix = (characterId == Bagshui.currentCharacterId and SUBTOTAL_TYPE.Current or SUBTOTAL_TYPE.Other)
+
+ BsUtil.TableClear(storage[self:TotalKey(characterData.info.name)]) -- Reset the storage for this character.
+ for _, inventoryType in ipairs(self.itemLocations) do
+ BsUtil.TableClear(self.tempItemCounts[inventoryType]) -- Used by AddToItemTotal().
+ end
+
+ -- Normal inventory locations (Bags, Bank, etc.).
+ for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
+ local inventoryTypeStorageKey = BsUtil.LowercaseFirstLetter(inventoryType)
+ if characterData[inventoryTypeStorageKey] and characterData[inventoryTypeStorageKey].inventory then
+ for bagNum, contents in pairs(characterData[inventoryTypeStorageKey].inventory) do
+ for slotNum, item in ipairs(contents) do
+ if item.emptySlot ~= 1 then
+ self:AddItemToSubtotals(
+ item,
+ storage,
+ storageSuffix,
+ characterId,
+ characterData,
+ itemTable,
+ inventoryType
+ )
+ end
+ end
+ end
+ end
+ end
+
+ -- Equipped items.
+ if type(characterData.info.equipped) == "table" then
+ for slot, item in pairs(characterData.info.equipped) do
+ self:AddItemToSubtotals(
+ item,
+ storage,
+ storageSuffix,
+ characterId,
+ characterData,
+ itemTable,
+ BS_CATALOG_LOCATIONS.EQUIPPED
+ )
+ end
+ end
+
+ -- Money.
+ if type(characterData.info.money) == "number" then
+ MONEY_ITEM.count = characterData.info.money
+ self:AddItemToSubtotals(
+ MONEY_ITEM,
+ storage,
+ storageSuffix,
+ characterId,
+ characterData,
+ itemTable,
+ BS_CATALOG_LOCATIONS.MONEY
+ )
+ end
+
+ -- Instead of building out tooltip strings when the tooltip is requested,
+ -- we're just going to pre-build them.
+ -- The goal is to come out with something like "Bags: 3" or "Bags: 2 + Bank 5 = 7".
+
+ local tooltipString, multipleLocations, lastCount
+
+ for itemString, totalCount in pairs(storage[self:TotalKey(characterData.info.name)]) do
+ tooltipString = ""
+ multipleLocations = false
+
+ -- self.itemLocations provides the correct order for the inventory locations.
+ for _, inventoryType in ipairs(self.itemLocations) do
+ -- tempItemCounts was populated by `AddItemToSubtotals()` during the loops above.
+ if self.tempItemCounts[inventoryType][itemString] then
+ -- Add a separator if this item is found in more than one location.
+ if string.len(tooltipString) > 0 then
+ tooltipString = tooltipString .. GRAY_FONT_COLOR_CODE .. " + " .. FONT_COLOR_CODE_CLOSE
+ multipleLocations = true
+ end
+
+ -- When there are not multiple locations for an item, this information will be used to recolor the number
+ -- so that "Bags: 3" has a white 3 just like "Bags: 2 + Bank 5 = 7" will have a white 7.
+ lastCount = self.tempItemCounts[inventoryType][itemString]
+
+ if inventoryType == BS_CATALOG_LOCATIONS.MONEY and itemString == BS_CATALOG_LOCATIONS.MONEY then
+ -- Money is a little weird in that it's shoehorned into the catalog in a special location that
+ -- only contains the money itemString, so when we see it, we know that this is not a tooltip
+ -- that needs to be built up from multiple locations; it's just a money string.
+ tooltipString = BsUtil.FormatMoneyString(lastCount)
+ break
+ else
+ tooltipString = tooltipString
+ .. string.format(L.Symbol_Colon, (L_nil["Abbrev_" .. inventoryType] or L[inventoryType]))
+ .. " "
+ .. lastCount
+ end
+ end
+ end
+
+ -- Final touches -- either add the sum at the end or recolor the number.
+ if multipleLocations then
+ tooltipString = tooltipString
+ .. GRAY_FONT_COLOR_CODE
+ .. " = "
+ .. FONT_COLOR_CODE_CLOSE
+ .. HIGHLIGHT_FONT_COLOR_CODE
+ .. totalCount
+ .. FONT_COLOR_CODE_CLOSE
+ else
+ tooltipString =
+ string.gsub(tooltipString, lastCount .. "$", HIGHLIGHT_FONT_COLOR_CODE .. lastCount .. FONT_COLOR_CODE_CLOSE)
+ end
+
+ -- Overwrite the existing total count with the tooltip string since we have no other use for the per-character total.
+ storage[self:TotalKey(characterData.info.name)][itemString] = tooltipString
+ end
+ end
+
+ --- Add up per-realm subtotals of a given type into an account-wide subtotal.
+ ---@param uniqueItemList table Deduplicated list of items.
+ ---@param subtotalType string `SUBTOTAL_TYPE` value.
+ function Catalog:CalculateSubtotals(uniqueItemList, subtotalType)
+ assert(subtotalType, "Catalog:CalculateSubtotals() - subtotalType is required")
+ for _, item in ipairs(uniqueItemList) do
+ for _, realm in ipairs(self.sortedRealmList) do
+ self:AddToTotal(
+ self.totals,
+ subtotalType,
+ item.itemString,
+ self:GetTotal(self.totals[realm], subtotalType, item.itemString)
+ )
+ end
+ end
+ end
+
+ --- Combine "current" and "other" character totals into final tallies.
+ function Catalog:CalculateGrandTotals()
+ for _, subtotalType in pairs(SUBTOTAL_TYPE) do
+ for _, realm in ipairs(self.sortedRealmList) do
+ for itemString, count in pairs(self.totals[realm][self:TotalKey(subtotalType)]) do
+ -- Per-realm grand total.
+ self:AddToTotal(self.totals[realm], nil, itemString, count)
+ -- Account-wide grand total.
+ self:AddToTotal(self.totals, nil, itemString, count)
+ end
+ end
+ end
+ end
+
+ --- Primary function called for each item in a character's inventory that is responsible
+ --- for adding it to all the various subtotal tables. This is a helper for `CalculatePerCharacterTotal()`
+ --- and isn't meant to be called independently because it relies on that function doing
+ --- the necessary prep work of clearing tables.
+ ---@param item table Bagshui ItemInfo table.
+ ---@param storage table Place to store the calculation results.
+ ---@param storageSuffix string One of the SUBTOTAL_TYPE values. Controls whether the subtotals are stored in the "Current" or "Other" character bucket.
+ ---@param characterId string Unique ID of the character.
+ ---@param characterData table Character's table from the SavedVariables `characters` table.
+ ---@param uniqueItemList table Array where the deduplicated list of items will be stored (see `AddToItemTable()`).
+ ---@param inventoryType string Which location in the `self.tempItemCounts` table this item belongs to.
+ function Catalog:AddItemToSubtotals(
+ item,
+ storage,
+ storageSuffix,
+ characterId,
+ characterData,
+ uniqueItemList,
+ inventoryType
+ )
+ if
+ type(item) == "table"
+ or ((item.id or 0) > 0 or item.itemString == MONEY_ITEM.itemString) and (item.count or 0) > 0
+ then
+ -- Add to list of known items if not already present.
+ -- 3rd parameter: Make a copy of the item table if this item comes from another character's
+ -- cached inventory so we can manipulate it (mainly updating tooltips to get usable status)
+ -- without messing up their data.
+ self:AddToItemTable(item, uniqueItemList, characterId ~= Bagshui.currentCharacterId)
+ -- Character total.
+ self:AddToTotal(storage, characterData.info.name, item.itemString, item.count)
+ -- Inventory-type total.
+ self:AddToItemTotal(self.tempItemCounts[inventoryType], item.itemString, item.count)
+ -- Overall subtotal.
+ self:AddToTotal(storage, storageSuffix, item.itemString, item.count)
+ end
+ end
+
+ --- Add the given item's itemString to the provided item list table, but only
+ --- if it hasn't already been inserted.
+ ---@param item table Bagshui ItemInfo table.
+ ---@param uniqueItemList any
+ function Catalog:AddToItemTable(item, uniqueItemList, copy)
+ -- Don't duplicate.
+ -- A lookup table of itemStrings per list replaces scanning the whole list for every
+ -- insert (which made rebuilding the account-wide list quadratic). The lookup is
+ -- rebuilt from the list whenever its recorded size doesn't match, which also covers
+ -- lists that were cleared with `BsUtil.TableClear()` or modified elsewhere.
+ local lookup = uniqueItemLookups[uniqueItemList]
+ local listSize = table.getn(uniqueItemList)
+ if not lookup or lookup.size ~= listSize then
+ lookup = { size = listSize, itemStrings = {} }
+ for _, existingItem in ipairs(uniqueItemList) do
+ lookup.itemStrings[existingItem.itemString or NIL_ITEM_STRING_KEY] = true
+ end
+ uniqueItemLookups[uniqueItemList] = lookup
+ end
+
+ local itemStringKey = item.itemString or NIL_ITEM_STRING_KEY
+ if lookup.itemStrings[itemStringKey] then
+ return
+ end
+
+ local itemToInsert = copy and BsUtil.TableCopy(item) or item
+
+ table.insert(uniqueItemList, itemToInsert)
+ lookup.itemStrings[itemStringKey] = true
+ lookup.size = listSize + 1
+ end
+
+ --- Prepare the storage location, then add up the item total.
+ ---@param storage table Place to store the calculation results.
+ ---@param subtotalType string? `SUBTOTAL_TYPE` value, or `nil` if this is a grand total.
+ ---@param itemString string Unique item identifier.
+ ---@param count number Amount by which to increase this item's total.
+ function Catalog:AddToTotal(storage, subtotalType, itemString, count)
+ if type(itemString) ~= "string" or type(count) ~= "number" then
+ return
+ end
+
+ -- Ensure the storage location exists.
+ local keyString = self:TotalKey(subtotalType)
+ if not storage[keyString] then
+ storage[keyString] = {}
+ end
+
+ self:AddToItemTotal(storage[keyString], itemString, count)
+ end
+
+ --- Add the given count to the given item's total.
+ ---@param storage table Place to store the calculation results. Must be an `{ => }` table.
+ ---@param itemString string Unique item identifier.
+ ---@param count number Amount by which to increase this item's total.
+ function Catalog:AddToItemTotal(storage, itemString, count)
+ if type(itemString) ~= "string" or type(count) ~= "number" then
+ return
+ end
+ storage[itemString] = (storage[itemString] and storage[itemString] + count or count)
+ end
+
+ -- Reusable variable for `GetTotal()`'s key checking.
+ local getTotal_key
+
+ --- Pull the calculated total for the given `itemString` from the specified storage location.
+ ---@param storage table Calculation results to query.
+ ---@param subtotalType string? `SUBTOTAL_TYPE` value, or `nil` to get the grand total.
+ ---@param itemString string Unique item identifier.
+ ---@return integer count
+ function Catalog:GetTotal(storage, subtotalType, itemString)
+ getTotal_key = self:TotalKey(subtotalType)
+ return storage[getTotal_key] and storage[getTotal_key][itemString] or 0
+ end
+
+ --- Prepare the given storage table with keys that are known to be required.
+ ---@param storage table Place to create keys.
+ function Catalog:CreateTotalKeys(storage)
+ if not storage[TOTAL_KEY] then
+ storage[TOTAL_KEY] = {}
+ end
+ for _, suffix in pairs(SUBTOTAL_TYPE) do
+ local keyString = self:TotalKey(suffix)
+ if not storage[keyString] then
+ storage[keyString] = {}
+ end
+ end
+ end
+
+ --- Get the key for the calculation results table of the specified type.
+ ---@param subtotalType string? `SUBTOTAL_TYPE` value, or `nil` if this is a grand total.
+ ---@return string key
+ function Catalog:TotalKey(subtotalType)
+ return TOTAL_KEY .. (subtotalType or "")
+ end
+
+ --- Build a ` [ 0 then
+ realmsWithItems = realmsWithItems + 1
+ end
+
+ -- Now add all the other realms.
+ for _, realm in ipairs(self.sortedRealmList) do
+ if realm ~= Bagshui.currentRealm then
+ if self:GetPerRealmTooltipInfo(itemString, realm) > 0 then
+ realmsWithItems = realmsWithItems + 1
+ end
+ end
+ end
+
+ -- Populate the tooltip.
+ if table.getn(addTooltipInfo_LeftStrings) > 0 then
+ if tooltip:NumLines() > 0 then
+ tooltip:AddLine(" ")
+ end
+
+ -- Grand total for multi-realm.
+ if realmsWithItems > 1 then
+ tooltip:AddDoubleLine(
+ HIGHLIGHT_FONT_COLOR_CODE .. L.Total .. FONT_COLOR_CODE_CLOSE,
+ HIGHLIGHT_FONT_COLOR_CODE .. self:GetTotal(self.totals, nil, itemString) .. FONT_COLOR_CODE_CLOSE
+ )
+ end
+
+ -- Add tooltip lines, indenting the left side in multi-realm mode.
+ for lineNum, left in ipairs(addTooltipInfo_LeftStrings) do
+ tooltip:AddDoubleLine(
+ (realmsWithItems > 1 and TOOLTIP_INDENT or HIGHLIGHT_FONT_COLOR_CODE) .. left .. FONT_COLOR_CODE_CLOSE,
+ addTooltipInfo_RightStrings[lineNum]
+ )
+ end
+
+ return true
+ else
+ tooltip:AddDoubleLine(
+ GRAY_FONT_COLOR_CODE .. L.Total .. FONT_COLOR_CODE_CLOSE,
+ GRAY_FONT_COLOR_CODE .. 0 .. FONT_COLOR_CODE_CLOSE
+ )
+ end
+
+ return false
+ end
+
+ --- When at least 1 of the given item is held by one or more characters on the specified realm,
+ --- add all the tooltip data for that realm to the reusable strings tables.
+ ---@param itemString string Item to gather counts for.
+ ---@param realm string Realm to get counts for.
+ ---@return integer realmCount Total count of the given item found on the specified realm.
+ function Catalog:GetPerRealmTooltipInfo(itemString, realm)
+ -- This realm has no data.
+ if not self.totals[realm] then
+ return 0
+ end
+
+ local overallTotal = self:GetTotal(self.totals[realm], nil, itemString)
+
+ if (overallTotal or 0) > 0 or itemString == BS_CATALOG_LOCATIONS.MONEY then
+ -- Realm-wide total goes first.
+ self:AddTooltipInfoLine(
+ realm,
+ HIGHLIGHT_FONT_COLOR_CODE
+ .. (itemString == BS_CATALOG_LOCATIONS.MONEY and BsUtil.FormatMoneyString(overallTotal) or overallTotal)
+ .. FONT_COLOR_CODE_CLOSE
+ )
+
+ -- Add the total held by each character (if more than 0)
+ local characterTotals
+ for _, characterName in ipairs(self.totals[realm]._sortedCharacterList) do
+ characterTotals = self.totals[realm][self:TotalKey(characterName)][itemString]
+ if characterTotals then
+ self:AddTooltipInfoLine(
+ TOOLTIP_INDENT .. self.totals[realm]._formattedCharacterNames[characterName],
+ characterTotals
+ )
+ end
+ end
+ end
+
+ return overallTotal
+ end
+
+ --- Add the given strings to the reusable tooltip storage tables, to be consumed
+ --- by `AddTooltipInfo()`.
+ ---@param left string?
+ ---@param right string?
+ function Catalog:AddTooltipInfoLine(left, right)
+ table.insert(addTooltipInfo_LeftStrings, left or "")
+ table.insert(addTooltipInfo_RightStrings, right or "")
+ end
+
+ --- Get an item from the catalog by matching name and (optionally) texture.
+ --- Useful when item ID isn't known (like for some hooked tooltip functions).
+ --- This doesn't guarantee a 100% match since there are a few items that have
+ --- the same name and texture but different IDs (Recruit's Boots/Pants/Shirt).
+ ---@param name string Item name.
+ ---@param texture string? Item texture (recommended for a guaranteed accurate match).
+ ---@param property string? Extract a single item property instead of returning the entire item table.
+ ---@return table|string? # Item table or item property.
+ function Catalog:FindItemByNameAndTexture(name, texture, property)
+ for _, item in ipairs(self.items) do
+ if item.name == name and (not texture or item.texture == texture) then
+ if property then
+ return item[property]
+ else
+ return item
+ end
+ break
+ end
+ end
+ end
+
+ --- Build the account-wide search interface.
+ function Catalog:InitUi()
+ -- Don't initialize multiple times.
+ if self.objectManager then
+ return
+ end
+
+ -- Upvalue for use inside ObjectManager class functions.
+ local catalog = self
+
+ self.objectManager = Bagshui.prototypes.ObjectManager:New({
+ objectType = "Catalog",
+ listType = BS_UI_SCROLLABLE_LIST_TYPE.ITEM,
+ managerMultiSelect = false,
+ disableObjectCreation = true,
+ disableObjectDeletion = true,
+ disableObjectEditing = true,
+ disableObjectSharing = true,
+ managerHeight = 700,
+ managerWidth = 400,
+ searchPlaceholderText = L.CatalogManager_SearchBoxPlaceholder,
+ wikiPage = BS_WIKI_PAGES.Search,
+ })
+
+ function self.objectManager:InitUi()
+ self._super.InitUi(self)
+ self.ui.searchBox:SetWidth(self.dimensions.width * 0.6)
+
+ -- Add CharacterData manager button to toolbar.
+ local characterButton = self.ui:CreateIconButton({
+ name = "Character",
+ texture = "CharacterRobed",
+ parentFrame = self.toolbar,
+ anchorPoint = "RIGHT",
+ anchorToPoint = "RIGHT",
+ xOffset = -2,
+ noTooltipDelay = true,
+ noTooltipTextDelay = true,
+ tooltipAnchor = "ANCHOR_PRESERVE",
+ tooltipAnchorPoint = "TOPRIGHT",
+ tooltipAnchorToPoint = "BOTTOMRIGHT",
+ tooltipXOffset = 4,
+ tooltipYOffset = -4,
+ tooltipFunction = function(button, tooltip)
+ tooltip:AddLine(
+ HIGHLIGHT_FONT_COLOR_CODE .. string.format(L.Prefix_Manage, L.CharacterData) .. FONT_COLOR_CODE_CLOSE
+ )
+ tooltip:AddLine(BS_NEWLINE .. L.CatalogManager_KnownCharacters)
+ tooltip:AddLine("- " .. table.concat(BsCharacterData.characterNameList, BS_NEWLINE .. "- "))
+ end,
+ onClick = function()
+ BsCharacterData:Open()
+ end,
+ })
+ end
+
+ function self.objectManager:GetObjectList()
+ return catalog.items
+ end
+ end
+
+ --- Open the account-wide search interface, optionally with a pre-populated search string.
+ ---@param searchString string? Search string to pre-populate.
+ function Catalog:Open(searchString)
+ self:Init()
+ self:InitUi()
+ self.objectManager.Open(self.objectManager)
+ if type(searchString) == "string" then
+ self.objectManager.ui.listFrame.bagshuiData.searchBox:SetText(searchString)
+ end
+ end
+
+ --- Hide the catalog window.
+ function Catalog:Close()
+ self.objectManager:Close()
+ end
+
+ --- Open the Catalog if it's closed and vice-versa.
+ function Catalog:Toggle()
+ if self:Visible() then
+ self.objectManager:Close()
+ else
+ self:Open()
+ end
+ end
+
+ --- Determine whether the Catalog window is open.
+ function Catalog:Visible()
+ return self.objectManager and self.objectManager.uiFrame and self.objectManager.uiFrame:IsVisible()
+ end
+
+ --- Open and search the Catalog, setting focus on the search box.
+ ---@param searchString string? Search string to pre-populate.
+ function Catalog:Search(searchString)
+ self:Open(searchString)
+ self.objectManager.ui.listFrame.bagshuiData.searchBox:SetFocus()
+ end
+
+ -- Class event registration.
+ -- This is done at the end because RegisterEvent expects the class to have an OnEvent function.
+ Bagshui:RegisterEvent("PLAYER_ENTERING_WORLD", Catalog)
+ Bagshui:RegisterEvent("BAGSHUI_CHARACTER_UPDATE", Catalog)
+ Bagshui:RegisterEvent("BAGSHUI_CHARACTER_LEARNED_RECIPE", Catalog)
+ Bagshui:RegisterEvent("BAGSHUI_EQUIPPED_UPDATE", Catalog)
+ Bagshui:RegisterEvent("BAGSHUI_INVENTORY_CACHE_UPDATE", Catalog)
+ Bagshui:RegisterEvent("BAGSHUI_MONEY_UPDATE", Catalog)
+ Bagshui:RegisterEvent("BAGSHUI_CATALOG_SHOULD_UPDATE", Catalog)
+end)
diff --git a/Components/Categories.Ui.lua b/Components/Categories.Ui.lua
new file mode 100644
index 0000000..d0d193c
--- /dev/null
+++ b/Components/Categories.Ui.lua
@@ -0,0 +1,895 @@
+-- Bagshui Category Management UI
+
+Bagshui:AddComponent(function()
+ local Categories = BsCategories
+
+ -- Pull the list of valid player class category fields from the Category object skeleton.
+ local CLASS_CATEGORY_FIELDS = {}
+ for fieldName, _ in pairs(BS_CATEGORY_SKELETON.classes[BsGameInfo.characterClasses]) do
+ table.insert(CLASS_CATEGORY_FIELDS, fieldName)
+ end
+
+ --- Subclass override for InitUI() to handle class-specific details.
+ function Categories:InitUi()
+ -- ObjectList:InitUi() has the same check, but let's avoid doing any unnecessary work
+ -- since the superclass function isn't called immediately.
+ if self.objectManager then
+ return
+ end
+
+ local categories = self
+
+ -- Custom menus.
+ self.menus = Bagshui.prototypes.Menus:New()
+
+ -- New Category menu.
+ self.menus:AddMenu(
+ "NewCategory",
+ -- Level 1 contains entries for a blank category and creating from a template.
+ {
+ {
+ text = L.Category,
+ func = function()
+ self.objectManager:NewObject()
+ end,
+ notCheckable = true,
+ },
+ {
+ text = L.ClassCategory,
+ func = function()
+ self.objectManager:NewObject(BS_NEW_CLASS_CATEGORY_TEMPLATE)
+ end,
+ notCheckable = true,
+ },
+ },
+ -- Nothing for level 2/3.
+ nil,
+ nil
+ )
+
+ -- Character Class dropdown menu.
+
+ --- Helper function for player class dropdown to prevent UIDropDownMenu_Refresh from adding a check mark.
+ ---@param dropDown table Dropdown menu WoW UI object.
+ local function clearDropDownCheckMarks(dropDown)
+ dropDown.selectedID = nil
+ dropDown.selectedValue = nil
+ dropDown.selectedName = nil
+ -- Removing any existing check marks.
+ for i = 1, _G.UIDROPDOWNMENU_MAXBUTTONS do
+ _G["DropDownList1Button" .. i]:UnlockHighlight()
+ _G["DropDownList1Button" .. i .. "Check"]:Hide()
+ end
+ end
+
+ --- Helper function for player class dropdown to update the selection when an item is clicked.
+ ---@param dropDown table Dropdown menu WoW UI object.
+ ---@param value any Value of the item that was clicked.
+ local function dropDownMenuSetSelectedValue(dropDown, value)
+ dropDown.bagshuiData.selectedValue = value
+ _G.UIDropDownMenu_SetSelectedValue(dropDown, value)
+ clearDropDownCheckMarks(dropDown)
+ end
+
+ --- Callback function for clicking an item in the player class menu.
+ ---@param arg1 table Will always be classMenuArg1 as defined below.
+ local function classMenuFunc(arg1)
+ dropDownMenuSetSelectedValue(arg1.dropDown, _G.this.value)
+ arg1.editor:SetClass(_G.this.value)
+ end
+
+ local classMenu = BsUtil.TableCopy(BsGameInfo.characterClassMenu)
+ -- Reusable table for passing values through to the callback function.
+ local classMenuArg1 = {}
+
+ -- Generate the menu items and add the menu.
+ for _, menuItem in ipairs(classMenu) do
+ menuItem.arg1 = classMenuArg1
+ menuItem.notCheckable = true
+ menuItem.func = classMenuFunc
+ end
+
+ self.menus:AddMenu(
+ "Classes",
+ classMenu,
+ -- Nothing for level 2/3.
+ nil,
+ nil,
+ -- OpenMenu callback.
+ function(menu, dropDown, editor)
+ clearDropDownCheckMarks(dropDown)
+ classMenuArg1.dropDown = dropDown
+ classMenuArg1.editor = editor
+ end
+ )
+
+ -- Prepare the list of available bags.
+ -- It would be nice to do this in Config\RuleFunctionTemplates.lua,
+ -- but that's not happening without a lot of refactoring.
+ BsUtil.TableClear(BsRules.ruleFunctionTemplates.Bag)
+ for _, inventoryType in ipairs(BS_INVENTORY_TYPE_UI_ORDER) do
+ local inventory = Bagshui.components[inventoryType]
+ local sequentialBagSlotNum = 1
+ for _, containerId in ipairs(inventory.containerIds) do
+ local ruleFunctionCode = string.format("Bag(%s)", containerId)
+ table.insert(BsRules.ruleFunctionTemplates.Bag, {
+ -- Example: "Bank: Bag(10)"
+ text = string.format(
+ "%s%s:%s %s",
+ NORMAL_FONT_COLOR_CODE,
+ inventory.inventoryTypeLocalized,
+ FONT_COLOR_CODE_CLOSE,
+ ruleFunctionCode
+ ),
+ code = ruleFunctionCode,
+ description = string.format(
+ L.RuleFunction_Bag_ExampleDescription,
+ containerId,
+ sequentialBagSlotNum,
+ inventory.inventoryTypeLocalized
+ ),
+ })
+ sequentialBagSlotNum = sequentialBagSlotNum + 1
+ end
+ end
+ -- Need to pick up the multi-bag example and add that too.
+ if L_nil["RuleFunction_Bag_ExampleMulti"] then
+ table.insert(BsRules.ruleFunctionTemplates.Bag, {
+ code = L_nil["RuleFunction_Bag_ExampleMulti"],
+ description = L_nil["RuleFunction_Bag_ExampleDescriptionMulti"],
+ })
+ end
+
+ -- Construct the Rule Function menu for the category editor.
+ -- There's some dumb hackery going on here because I didn't feel like refactoring the auto-split menu code.
+
+ --- Callback function for the Rule Function menu.
+ --- arg1 and arg2 change depending on whether it's called from a normal menu item or an auto-split menu.
+ --- ```none
+ --- Normal:
+ --- arg1 = editBox
+ --- arg2 = code to insert
+ --- Auto-split:
+ --- arg1 = {
+ --- templateList = list of templates where arg2 can be found
+ --- editBox = editBox
+ --- }
+ --- arg2 = template Id
+ --- ```
+ ---@param arg1 any See above.
+ ---@param arg2 any See above.
+ local function insertRuleFunctionTemplate(arg1, arg2)
+ local codeToInsert, editBox
+ if arg1.templateList then
+ -- Extra item hack.
+ if type(arg2) == "table" and arg2.extra then
+ codeToInsert = arg2.code
+ else
+ codeToInsert = arg1.code or arg1.templateList[arg2].code
+ end
+ editBox = arg1.editBox
+ else
+ codeToInsert = arg2
+ editBox = arg1
+ end
+ if type(editBox) ~= "table" or not codeToInsert then
+ return
+ end
+ if not editBox.GetText then
+ Bagshui:PrintError("Couldn't find edit box (this shouldn't happen!)")
+ return
+ end
+ local editBoxContent = (editBox:GetText() or "")
+ -- Separate functions with " or " unless there's already an and/or present.
+ if string.len(editBoxContent) > 0 then
+ if not (string.find(editBoxContent, "%s+or%s*$") or string.find(editBoxContent, "%s+and%s*$")) then
+ editBoxContent = editBoxContent .. " or"
+ end
+ if not string.find(editBoxContent, "%s$") then
+ editBoxContent = editBoxContent .. " "
+ end
+ end
+
+ editBoxContent = editBoxContent .. codeToInsert
+ editBox:SetText(editBoxContent)
+ end
+
+ --- Using the given list of Rule Function templates, create a menu table.
+ --- This is broken out into a function because there used to be separate
+ --- buttons for builtin and 3rd party rule functions. That was changed because
+ --- it didn't seem like a necessary distinction. It wasn't worth removing this
+ --- function though.
+ ---@param sortedRuleFunctionTemplateNames string[] Ordered array of rule function template names.
+ ---@param ruleFunctionTemplates table List of `{ = { { template 1 }, { template N } }`.
+ ---@param ruleFunctionTemplateExtraEntries table? List of `{ = { { template 1 }, { template N } }` that will be added to the bottom of the template menu.
+ ---@param ruleFunctionTemplateGenericDescriptions table List of `{ = "Description"] }` Tooltip for the rule function's parent menu item.
+ local function buildRuleFunctionMenu(
+ ruleFunctionMenus,
+ sortedRuleFunctionTemplateNames,
+ ruleFunctionTemplates,
+ ruleFunctionTemplateExtraEntries,
+ ruleFunctionTemplateGenericDescriptions
+ )
+ BsUtil.TableClear(ruleFunctionMenus)
+ local ruleFunctionMenu
+
+ for _, functionName in ipairs(sortedRuleFunctionTemplateNames) do
+ local templates = ruleFunctionTemplates[functionName]
+ if templates then
+ -- Whenever the menu gets filled up, roll over to a new one.
+ if not ruleFunctionMenu or table.getn(ruleFunctionMenu) >= _G.UIDROPDOWNMENU_MAXBUTTONS then
+ table.insert(ruleFunctionMenus, {})
+ ruleFunctionMenu = ruleFunctionMenus[table.getn(ruleFunctionMenus)]
+ end
+
+ if table.getn(templates) == 1 then
+ -- When there's only one template, no submenu is needed.
+ table.insert(ruleFunctionMenu, {
+ text = templates[1].text or templates[1].code,
+ tooltipTitle = templates[1].tooltipTitle or templates[1].text or templates[1].code,
+ tooltipText = templates[1].description,
+ func = insertRuleFunctionTemplate,
+ notCheckable = true,
+ arg2 = templates[1].code,
+ })
+ else
+ -- Create a bespoke auto-split menu for each rule function's templates.
+ -- With some auto-split menu refactoring this could be cleaner, but it's not a priority.
+
+ -- Prepare the "extra" examples (usually multi-parameter) as extra menu items
+ -- that will be added to the bottom of the menu. When there are enough templates
+ -- to force the creation of multiple menus, these will appear at the bottom of each.
+ local extraMenuItems = {}
+ if type(ruleFunctionTemplateExtraEntries) == "table" and ruleFunctionTemplateExtraEntries[functionName] then
+ for i, template in ipairs(ruleFunctionTemplateExtraEntries[functionName]) do
+ table.insert(extraMenuItems, {
+ text = template.text or template.code,
+ tooltipTitle = template.tooltipTitle or template.text or template.code,
+ tooltipText = template.description,
+ notCheckable = true,
+ value = {
+ func = insertRuleFunctionTemplate,
+ objectId = {
+ editBox = nil,
+ },
+ },
+ -- Stupid hack to get the data we need across to `insertRuleFunctionTemplate()`.
+ arg2 = {
+ extra = true,
+ code = template.code,
+ },
+ })
+ end
+ end
+ -- Need the extra count so we know how much to limit the maximum
+ -- entries per menu by.
+ local extraMenuItemCount = table.getn(extraMenuItems)
+
+ local templateNum = 0
+ local listNum = 0
+ -- We're going to keep looping until we've created enough menus
+ -- to account for all the templates. This is usually only necessary
+ -- for PeriodicTable when AtlasLoot is loaded.
+ repeat
+ -- Need to re-check the menu on every loop.
+ if table.getn(ruleFunctionMenu) >= _G.UIDROPDOWNMENU_MAXBUTTONS then
+ Bagshui:PrintError("Too many rule functions for editor menu")
+ break
+ end
+
+ listNum = listNum + 1
+ local autoSplitMenuType = "RuleFunction:" .. functionName .. listNum
+ local templateList = templates -- Upvalue for nameFunc.
+ local ruleFunctionName = functionName -- Upvalue for parentMenuNameTrimFunc.
+ local autoSplitMenuIdList = {}
+
+ -- Populate this menu up to the maximum number of entries we can fit.
+ for i = 1, ((_G.UIDROPDOWNMENU_MAXBUTTONS - extraMenuItemCount) * _G.UIDROPDOWNMENU_MAXBUTTONS) do
+ templateNum = templateNum + 1
+ if not templates[templateNum] then
+ break
+ end
+ table.insert(autoSplitMenuIdList, templateNum)
+ end
+
+ -- Auto-split submenu.
+ Bagshui.prototypes.Menus:AddAutoSplitMenu(autoSplitMenuType, {
+ defaultIdList = autoSplitMenuIdList,
+ parentMenuNameTrimFunc = function(menuName)
+ -- Remove FunctionName( from the beginning of the string.
+ return string.gsub(menuName, ruleFunctionName .. "%([\"']", "")
+ end,
+ nameFunc = function(id)
+ return templateList[id].text or templateList[id].code
+ end,
+ tooltipTitleFunc = function(id)
+ return templateList[id].tooltipTitle or templateList[id].text or templateList[id].code
+ end,
+ tooltipTextFunc = function(id)
+ return templateList[id].description
+ end,
+ extraItems = extraMenuItems,
+ })
+
+ -- Parent menu item.
+ table.insert(ruleFunctionMenu, {
+ text = functionName .. "(...)",
+ tooltipTitle = functionName .. "(...)",
+ tooltipText = (ruleFunctionTemplateGenericDescriptions[functionName] or "")
+ .. (templates.aliasTooltipAddendum or ""),
+ hasArrow = true,
+ notCheckable = true,
+ value = {
+ autoSplitMenuType = autoSplitMenuType,
+ func = insertRuleFunctionTemplate,
+ notCheckable = true,
+ objectId = {
+ templateList = templateList,
+ editBox = nil,
+ },
+ },
+ })
+
+ until not templates[templateNum]
+ end
+ end
+ end
+ end
+
+ -- Create the Rule Function menus.
+
+ local ruleFunctionMenus = {}
+
+ buildRuleFunctionMenu(
+ ruleFunctionMenus,
+ BsRules.sortedRuleFunctionTemplateNames,
+ BsRules.ruleFunctionTemplates,
+ BsRules.ruleFunctionTemplatesExtra,
+ BsRules.ruleFunctionTemplateGenericDescriptions
+ )
+
+ for i, ruleFunctionMenu in ipairs(ruleFunctionMenus) do
+ self.menus:AddMenu(
+ "RuleFunctionTemplates" .. i,
+ ruleFunctionMenu,
+ nil,
+ nil,
+ -- The pre-open callback handles getting information across to the func callback.
+ function(menu, editBox, _)
+ for _, menuItem in pairs(menu.levels[1]) do
+ if type(menuItem.value) == "table" then
+ -- Auto-split menus pass arg1 via value.objectId.
+ menuItem.value.objectId.editBox = editBox
+ else
+ -- Normal menus pass editBox through to func as the first argument.
+ menuItem.arg1 = editBox
+ end
+ end
+ end
+ )
+ end
+
+ -- Custom object editor for Categories.
+ local categoryEditor = {}
+
+ -- Calls ObjectList:InitUi().
+ self._super.InitUi(
+ self,
+ nil, -- No custom Object Manager needed.
+ categoryEditor, -- This is our custom Object Editor.
+ -- All the overrides.
+ {
+ -- Basic Object Manager settings.
+
+ managerWidth = 475,
+ managerHeight = 600,
+
+ managerColumns = {
+ {
+ field = "name",
+ title = L.ObjectManager_Column_Name,
+ widthPercent = "65",
+ currentSortOrder = "ASC",
+ lastSortOrder = "ASC",
+ },
+ {
+ field = "sequence",
+ title = L.ObjectManager_Column_Sequence,
+ align = "RIGHT",
+ widthPercent = "10",
+ lastSortOrder = "ASC",
+ },
+ {
+ field = "builtin",
+ title = L.ObjectManager_Column_Source,
+ widthPercent = "16",
+ lastSortOrder = "ASC",
+ },
+ {
+ field = "inUse",
+ title = L.ObjectManager_Column_InUse,
+ widthPercent = "9",
+ lastSortOrder = "ASC",
+ },
+ },
+
+ -- Add error indicator to category name.
+ managerColumnTextFunc = function(fieldName, categoryId, category, preliminaryDisplayValue)
+ if fieldName == "name" and categories.errors[categoryId] then
+ return preliminaryDisplayValue .. BS_FONT_COLOR.UI_ORANGE .. " " .. FONT_COLOR_CODE_CLOSE
+ end
+ return preliminaryDisplayValue
+ end,
+
+ -- Category Manager Add button shows a menu instead of just creating a new category.
+ managerAddButtonOnClick = function()
+ self.menus:OpenMenu("NewCategory", _G.this, nil, _G.this, 0, 0, "TOPLEFT", "BOTTOMLEFT")
+ end,
+
+ -- Basic Object Editor settings.
+
+ editorWidth = 550,
+ editorHeight = 565,
+
+ -- Order of fields in editor UI.
+ editorFields = {
+ "name",
+ "nameSort",
+ "sequence",
+ "rule",
+ "list",
+ },
+
+ -- Configuration for editor fields.
+ editorFieldProperties = {
+ name = {
+ required = true,
+ },
+ sequence = {
+ required = true,
+ },
+ rule = {
+ widgetType = "ScrollableEditBox",
+ widgetHeight = 150,
+ monospace = true,
+ },
+ list = {
+ widgetType = "ItemList",
+ widgetWidth = 260,
+ widgetHeight = 250,
+ multiSelect = true,
+ },
+ },
+ }
+ )
+
+ ---comment Override NewEditor so we can modify the interface.
+ ---@return table objectEditorInstance
+ function self.objectManager:NewEditor()
+ -- Create the ObjectEditor instance.
+ local editor = self._super.NewEditor(self)
+ local ui = editor.ui
+
+ -- Assume it's not a class category.
+ editor.isClassCategory = false
+
+ -- Trigger rule validation when the EditBox loses focus
+ editor.editBoxes.rule:SetScript("OnEditFocusLost", function()
+ editor:UpdateState(true)
+ end)
+
+ -- Place the Rule Function template menu buttons under the Rule label, to the left of the code box.
+ local nextAnchorToFrame = editor.labelWidgetPairs.rule.bagshuiData.labelFrame
+ local nextButtonSpacing = -BsSkin.toolbarSpacing
+ local i = 0
+ local menuName
+ repeat
+ i = i + 1
+ menuName = "RuleFunctionTemplates" .. i
+ if categories.menus.menuList[menuName] and table.getn(categories.menus.menuList[menuName].levels[1]) > 0 then
+ local menuToOpen = menuName -- Upvalue for onClick.
+ editor.buttons["ruleFunction" .. i] = ui:CreateIconButton({
+ name = editor.nameAndNumber .. "AddRuleFunction" .. i,
+ parentFrame = editor.content,
+ texture = i == 1 and "RuleFunction" or "Ellipsis",
+ anchorPoint = "TOPRIGHT",
+ anchorToFrame = nextAnchorToFrame,
+ anchorToPoint = "BOTTOMRIGHT",
+ xOffset = 0,
+ yOffset = nextButtonSpacing,
+ tooltipAnchor = "ANCHOR_PRESERVE",
+ tooltipAnchorPoint = "TOPRIGHT",
+ tooltipAnchorToPoint = "TOPLEFT",
+ tooltipTitle = L.CategoryEditor_AddRuleFunction,
+
+ onClick = function()
+ categories.menus:OpenMenu(
+ menuToOpen, -- Menu name.
+ editor.labelWidgetPairs.rule.bagshuiData.widget.bagshuiData.editBox, -- callback arg1.
+ nil, -- callback arg2.
+ _G.this, -- anchorFrame.
+ 0,
+ 0, -- offsets.
+ "TOPRIGHT",
+ "BOTTOMLEFT" -- anchor points.
+ )
+ end,
+ })
+ nextAnchorToFrame = editor.buttons["ruleFunction" .. i]
+ nextButtonSpacing = -BsSkin.toolbarTightSpacing
+ end
+ until not categories.menus.menuList[menuName]
+
+ -- Help button goes below the Rule Function buttons.
+ ui:CreateIconButton({
+ name = editor.nameAndNumber .. "Help",
+ parentFrame = editor.content,
+ texture = "Question",
+ anchorPoint = "TOPRIGHT",
+ anchorToFrame = nextAnchorToFrame,
+ anchorToPoint = "BOTTOMRIGHT",
+ xOffset = 0,
+ yOffset = -BsSkin.toolbarGroupSpacing,
+ tooltipAnchor = "ANCHOR_PRESERVE",
+ tooltipAnchorPoint = "TOPRIGHT",
+ tooltipAnchorToPoint = "TOPLEFT",
+ tooltipTitle = L["CategoryEditor_RuleFunctionWiki"],
+
+ onClick = function()
+ ui:ShowUrl(BS_WIKI_URL .. BS_WIKI_PAGES.Rules)
+ end,
+ })
+
+ -- Validation button / status indicator will be positioned at the bottom left of the code box.
+ editor.buttons.ruleValidation = ui:CreateIconButton({
+ name = editor.nameAndNumber .. "RuleValidation",
+ parentFrame = editor.content,
+ texture = "Compile",
+ anchorPoint = "RIGHT",
+ anchorToFrame = editor.labelWidgetPairs.rule.bagshuiData.label,
+ anchorToPoint = "RIGHT",
+ xOffset = 0,
+ yOffset = 0,
+ tooltipTitle = L.CategoryEditor_RuleValidation_Validate,
+ tooltipAnchor = "ANCHOR_PRESERVE",
+ tooltipAnchorPoint = "BOTTOMRIGHT",
+ tooltipAnchorToPoint = "BOTTOMLEFT",
+ tooltipXOffset = -5,
+ tooltipYOffset = -6,
+ noTooltipDelay = true,
+ noTooltipTextDelay = true,
+
+ onClick = function()
+ if _G.arg1 == "LeftButton" then
+ if editor.ruleInvalid then
+ BsLogWindow:Open()
+ else
+ editor:UpdateState(true)
+ _G.this:GetScript("OnEnter")()
+ end
+ elseif _G.arg1 == "RightButton" then
+ BsLogWindow:Open()
+ end
+ end,
+ })
+ editor.buttons.ruleValidation:RegisterForClicks("LeftButtonDown", "RightButtonDown")
+ editor.buttons.ruleValidation:Disable()
+ -- Set the second anchor point for the rule validation button so that it ends up at the bottom left of the edit box
+ editor.buttons.ruleValidation:SetPoint(
+ "BOTTOM",
+ editor.labelWidgetPairs.rule.bagshuiData.widget,
+ "BOTTOM",
+ 0,
+ BsSkin.toolbarTightSpacing
+ )
+
+ -- Character class selector for class categories (hidden by default).
+ local classSelectorWidth = 85
+ local classSelector = ui:CreateDropDownMenuButton(
+ editor.nameAndNumber .. "ClassSelector",
+ editor.content,
+ classSelectorWidth,
+ categories.menus,
+ "Classes",
+ editor -- Pass through to the OpenMenu callback as arg2, which will pass through to func as arg2, which will call editor:SetClass().
+ )
+ editor.labelWidgetPairs.class = ui:CreateLabeledWidget(
+ editor.content, -- Parent.
+ string.format(L.Symbol_Colon, L.CategoryEditor_Field_class), -- Label text.
+ editor.labelWidth, -- This property is added by InitUi().
+ classSelector, -- Widget.
+ classSelectorWidth, -- Widget width.
+ editor.manager.editorDimensions.widgetHeight,
+ "TOPLEFT",
+ editor.labelWidgetPairs.sequence, -- Anchor to frame.
+ "BOTTOMLEFT" -- Anchor to point.
+ )
+ editor.labelWidgetPairs.class:Hide()
+
+ -- Add ID to bottom.
+
+ local idEditBox = ui:CreateEditBox(
+ "objectId",
+ editor.content,
+ nil,
+ "NumberFontNormalSmallGray",
+ false, -- multiline.
+ true, -- noBackgroundOrBorder.
+ true -- selectAllOnFocus.
+ )
+ -- Ensure text is visible when highlighted.
+ local oldOnFocusGained = idEditBox:GetScript("OnEditFocusGained")
+ idEditBox:SetScript("OnEditFocusGained", function()
+ oldOnFocusGained()
+ _G.this:SetTextColor(1, 1, 1)
+ end)
+ -- Clear highlight on focus lost.
+ local oldOnFocusLost = idEditBox:GetScript("OnEditFocusLost")
+ idEditBox:SetScript("OnEditFocusLost", function()
+ oldOnFocusLost()
+ _G.this:HighlightText(0, 0)
+ _G.this:SetTextColor(0.35, 0.35, 0.35)
+ end)
+
+ editor.idDisplay = ui:CreateLabeledWidget(
+ editor.content, -- Parent.
+ string.format(L.Symbol_Colon, L.ID), -- Label text.
+ 20, -- Label width.
+ idEditBox, -- Widget.
+ self.editorDimensions.widgetWidth, -- Widget width.
+ self.editorDimensions.widgetHeight, -- Widget height.
+ "BOTTOMLEFT", -- Anchor point.
+ editor.content, -- Anchor to frame.
+ "BOTTOMLEFT", -- Anchor to point.
+ nil, -- X offset.
+ nil, -- Y offset.
+ L.CategoryEditor_ID_TooltipTitle,
+ L.CategoryEditor_ID_TooltipText
+ )
+ editor.idDisplay.bagshuiData.labelFrame:SetWidth(editor.idDisplay.bagshuiData.label:GetStringWidth())
+ editor.idDisplay.bagshuiData.widget:ClearAllPoints()
+ editor.ui:SetPoint(
+ editor.idDisplay.bagshuiData.widget,
+ "LEFT",
+ editor.idDisplay.bagshuiData.labelFrame,
+ "RIGHT",
+ 0,
+ 0
+ )
+ editor.idDisplay.bagshuiData.label:SetFontObject("GameFontDisableSmall")
+ local font = (editor.idDisplay.bagshuiData.label:GetFont())
+ editor.idDisplay.bagshuiData.label:SetTextColor(0.35, 0.35, 0.35)
+ editor.idDisplay.bagshuiData.label:SetFont(font, 10)
+ font = (editor.idDisplay.bagshuiData.widget:GetFont())
+ editor.idDisplay.bagshuiData.widget:SetTextColor(0.35, 0.35, 0.35)
+ editor.idDisplay.bagshuiData.widget:SetFont(font, 10)
+
+ -- Store the editor frame height so it can be adjusted up and down based on whether it's a class category
+ editor.uiFrame.bagshuiData.originalHeight = editor.uiFrame:GetHeight()
+
+ return editor
+ end
+
+ --- Change the Editor interface based on whether it's a class category.
+ function categoryEditor:UpdateUiForClassCategories()
+ -- Only class categories have a classes property.
+ self.isClassCategory = self.referenceObject.classes ~= nil
+
+ local editorHeight = self.uiFrame.bagshuiData.originalHeight
+
+ -- Show/hide the player class selector.
+ if self.isClassCategory then
+ self.labelWidgetPairs.class:Show()
+ self.labelWidgetPairs.class.bagshuiData.widget:Show() -- Widgets aren't parented to the holding frame so we need to show/hide them separately.
+ self.labelWidgetPairs.rule:SetPoint("TOPLEFT", self.labelWidgetPairs.class, "BOTTOMLEFT", 0, -10)
+ editorHeight = editorHeight + self.labelWidgetPairs.class:GetHeight() + 10
+ else
+ self.labelWidgetPairs.class:Hide()
+ self.labelWidgetPairs.class.bagshuiData.widget:Hide() -- Widgets aren't parented to the holding frame so we need to show/hide them separately.
+ self.labelWidgetPairs.rule:SetPoint("TOPLEFT", self.labelWidgetPairs.sequence, "BOTTOMLEFT", 0, -10)
+ end
+
+ self.uiFrame:SetHeight(editorHeight)
+ end
+
+ --- For class categories, Update the editor field storage redirect table to point
+ --- to the storage for the selected player class.
+ ---@param class string Player class
+ function categoryEditor:SetClass(class)
+ if not self.isClassCategory or not class then
+ return
+ end
+
+ if not self.updatedObject.classes[class] then
+ self.updatedObject.classes[class] = {}
+ end
+
+ -- Redirect all changes to point to this player class storage.
+ for _, field in ipairs(CLASS_CATEGORY_FIELDS) do
+ self.fieldStorageRedirect[field] = self.updatedObject.classes[class]
+ self:PopulateField(field, self.updatedObject.classes[class])
+ end
+ end
+
+ --- Category-specific work for loading an object into the editor.
+ ---@param objectId string|number? See ObjectEditor:Load() definition.
+ ---@param duplicate boolean? See ObjectEditor:Load() definition.
+ ---@param template table? See ObjectEditor:Load() definition.
+ ---@param refresh boolean? See ObjectEditor:Load() definition.
+ ---@param onFirstSave function? See ObjectEditor:Load() definition.
+ ---@return string|number # See ObjectEditor:Load() definition.
+ function categoryEditor:Load(objectId, duplicate, template, refresh, onFirstSave)
+ -- When loading a new category, reset isClassCategory first because the loading
+ -- process ends up calling UpdateState(), which needs to know whether it's actually a class category
+ if not refresh then
+ self.isClassCategory = false
+ end
+
+ local newObjectId = self._super.Load(self, objectId, duplicate, template, refresh, onFirstSave)
+
+ local classDropDown = self.labelWidgetPairs.class.bagshuiData.widget
+
+ if refresh then
+ -- On refresh, need to update field redirects because tables can change on save
+ self:SetClass(classDropDown.bagshuiData.selectedValue)
+ else
+ -- Do initial adjustments for standard/Class categories
+ self:UpdateUiForClassCategories()
+
+ -- Set the initially selected class to that of the current character
+ if self.isClassCategory then
+ dropDownMenuSetSelectedValue(classDropDown, Bagshui.currentCharacterInfo.class)
+ self:SetClass(Bagshui.currentCharacterInfo.class)
+ end
+ end
+
+ -- Update ID.
+ self.ui:SetEditBoxTextReadOnly(
+ self.idDisplay.bagshuiData.widget,
+ type(self.objectId) == "string" and string.format('"%s"', self.objectId) or self.objectId
+ )
+
+ return newObjectId
+ end
+
+ --- Special handling is needed for the Category editor UI state because we
+ --- have to keep track of whether the rule function code validates.
+ ---@param forceRuleValidation boolean Run the rule function code through validation even if it hasn't changed.
+ function categoryEditor:UpdateState(forceRuleValidation)
+ self._super.UpdateState(self)
+
+ -- Set supplemental button state based whether the object is read-only.
+ local buttonNum = 0
+ for i = 1, 10 do
+ local button = self.buttons["ruleFunction" .. i]
+ if not button then
+ break
+ end
+ button[(self.originalObject.readOnly and "Disable" or "Enable")](button)
+ end
+
+ local ruleEditBox = self.editBoxes.rule
+ local ruleValidationButton = self.buttons.ruleValidation
+
+ -- Prepare some defaults.
+ local rule = BsUtil.Trim(ruleEditBox:GetText() or "")
+ local ruleValidationButtonEnabled = string.len(rule) > 0 -- Rule can't be validated unless there's code.
+ local ruleValidationButtonTexture = "Compile"
+ local ruleValidationButtonColor = BS_COLOR.YELLOW
+ local ruleValidationButtonTooltipTitle = L.CategoryEditor_RuleValidation_Validate
+ local ruleValidationButtonTooltipText
+
+ -- Rule validation button should only be enabled when there's rule text.
+ ruleValidationButton[(ruleValidationButtonEnabled and "Enable" or "Disable")](ruleValidationButton)
+
+ local ruleValid, errorMessage
+
+ -- Determine whether the rule code is valid.
+ if
+ -- Automatically validate the rule if it's not being edited.
+ (ruleValidationButtonEnabled and not ruleEditBox.bagshuiData.hasFocus)
+ -- Also need to validate if the button was clicked.
+ or forceRuleValidation
+ then
+ ruleValid, errorMessage = BsRules:Validate(rule)
+ elseif ruleEditBox.bagshuiData.hasFocus and not self.dirtyFields.rule then
+ -- Revert to last saved validation state if the rule hasn't been edited.
+ errorMessage = categories.errors[self.originalObject.categoryId]
+ ruleValid = not errorMessage
+ end
+
+ -- Only change the validation icon if validation state is known.
+ self.ruleInvalid = false
+ if ruleValidationButtonEnabled and ruleValid ~= nil then
+ if ruleValid then
+ ruleValidationButtonTexture = "Check"
+ ruleValidationButtonColor = BS_COLOR.UI_GREEN
+ ruleValidationButtonTooltipTitle = L.CategoryEditor_RuleValidation_Valid
+ else
+ self.ruleInvalid = true
+ ruleValidationButtonTexture = "Exclamation"
+ ruleValidationButtonColor = BS_COLOR.UI_ORANGE
+ ruleValidationButtonTooltipTitle = L.CategoryEditor_RuleValidation_Invalid
+ ruleValidationButtonTooltipText = errorMessage
+ Bagshui:Log(
+ Categories:FormatErrorMessage(errorMessage, self.objectId, self.updatedObject.name),
+ L.CategoryEditor,
+ BS_LOG_MESSAGE_TYPE.ERROR
+ )
+ end
+ end
+
+ -- Update the validation button.
+ self.ui:SetIconButtonTexture(
+ ruleValidationButton,
+ "Icons\\" .. ruleValidationButtonTexture,
+ ruleValidationButtonColor
+ )
+ ruleValidationButton.bagshuiData.tooltipTitle = ruleValidationButtonTooltipTitle
+ ruleValidationButton.bagshuiData.tooltipText = ruleValidationButtonTooltipText
+ end
+
+ --- Helper function for categoryEditor:IsDirty() to determine when a class
+ --- category doesn't contain any player class-specific data.
+ ---@param classObject table Category object to check.
+ ---@return boolean isPlayerClassDataEmpty
+ local function classCategoryObjectIsEmpty(classObject)
+ -- A player class object being nil is the same thing as it existing and having empty rule and list properties.
+ return (classObject == nil) or (string.len(classObject.rule or "") == 0 and table.getn(classObject.list) == 0)
+ end
+
+ --- In addition to the normal dirty checks, class categories require additional checks.
+ ---@return boolean
+ function categoryEditor:IsDirty()
+ local dirty = self._super.IsDirty(self)
+
+ -- Additional checks for class categories.
+ if self.isClassCategory then
+ for class, _ in pairs(BsGameInfo.characterClasses) do
+ local originalClassObject = self.originalObject.classes and self.originalObject.classes[class]
+ local updatedClassObject = self.updatedObject.classes[class]
+
+ if originalClassObject or updatedClassObject then
+ -- When something has changed, we need to know whether it's significant.
+ if self:IsObjectDirty(originalClassObject, updatedClassObject) then
+ -- Changes can only be meaningful if both the player class object
+ -- for both original and updated objects isn't empty.
+ if
+ not (classCategoryObjectIsEmpty(originalClassObject) and classCategoryObjectIsEmpty(updatedClassObject))
+ then
+ -- Do deep inspection of each class category field to see if there
+ -- are real differences. This is basically the same thing that happens
+ -- at the top level, but we need to only do the comparison if the other
+ -- checks have been satisfied to avoid false positives.
+ for _, fieldName in ipairs(CLASS_CATEGORY_FIELDS) do
+ if
+ self:IsObjectDirty(
+ (originalClassObject and originalClassObject[fieldName] or nil),
+ (updatedClassObject and updatedClassObject[fieldName] or nil)
+ )
+ then
+ dirty = true
+ self.dirtyFields[fieldName] = _G.GetTime()
+ else
+ self.dirtyFields[fieldName] = nil
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ return dirty
+ end
+
+ -- Override for `ObjectEditor:CloseMenusAndClearFocuses()` to deal with the ID display.
+ function categoryEditor:CloseMenusAndClearFocuses(menus, editBoxes, listSelections)
+ self._super.CloseMenusAndClearFocuses(self, menus, editBoxes, listSelections)
+
+ if editBoxes ~= false then
+ self.idDisplay.bagshuiData.widget:ClearFocus()
+ end
+ end
+ end
+end)
diff --git a/Components/Categories.lua b/Components/Categories.lua
new file mode 100644
index 0000000..8b59905
--- /dev/null
+++ b/Components/Categories.lua
@@ -0,0 +1,796 @@
+-- Bagshui Categories
+-- Exposes: BsCategories (and Bagshui.components.Categories)
+-- Raises: BAGSHUI_CATEGORY_UPDATE
+--
+-- The high-level flow of categorization and rule evaluation is as follows:
+-- 1. During inventory layout updates, Categories:Categorize() is called for each item.
+-- 2. Categories:Categorize() loops through all active categories [any category assigned to a group], in category sequence order.
+-- 3. For each category, Categories:MatchCategory() is called.
+-- 4. Categories:MatchCategory() calls Rules:Match() with the finalized rule.
+-- 5. Rules:Match() executes the rule it within the protected rule environment and returns true if it matches.
+-- 6. Once a match is found, Categories:Categorize() updates the bagshuiGroupId, bagshuiCategoryId, and uncategorized properties.
+
+Bagshui:AddComponent(function()
+ Bagshui:AddConstants({
+
+ -- Allowed properties.
+ BS_CATEGORY_SKELETON = {
+ -- Category name.
+ name = "",
+ -- Alternate name to be used in sorting.
+ nameSort = "",
+
+ -- Global evaluation ordering.
+ sequence = 0,
+
+ -- Array of item IDs (the 0 in the array is so that TableCopy will know what type of value is allowed).
+ list = { 0 },
+ -- Rule expression.
+ rule = "",
+
+ classes = {
+ -- Class categories can have any valid player class (DRUID, HUNTER, etc.) as the key.
+ -- That is expressed here by putting the GameInfo.characterClasses table as the key within
+ -- the template, which Util.TableClear() knows to interpret as a list of valid keys.
+ -- IMPORTANT: There is code in Categories.Ui.lua that depends on this table.
+ [BsGameInfo.characterClasses] = {
+ list = { 0 }, -- List of item IDs.
+ rule = "", -- Rule expression.
+ },
+ },
+ },
+
+ -- Template for creating a new standard category.
+ BS_NEW_CATEGORY_TEMPLATE = {
+ name = "",
+ nameSort = "",
+ sequence = BS_DEFAULT_CATEGORY_SEQUENCE,
+ list = {},
+ rule = "",
+ },
+
+ -- Template for creating a new class category.
+ BS_NEW_CLASS_CATEGORY_TEMPLATE = {
+ name = "",
+ nameSort = "",
+ sequence = BS_DEFAULT_CATEGORY_SEQUENCE,
+ classes = {},
+ },
+
+ -- Which template properties can be used in sort orders?
+ BS_CATEGORY_PROPERTIES_ALLOWED_IN_SORT_ORDERS = {
+ "name",
+ },
+ })
+
+ -- Key in _G.BagshuiData where categories will be stored.
+ local CATEGORIES_DATA_STORAGE_KEY = "categories"
+
+ -- Categories class is built on the ObjectList prototype.
+ local Categories = Bagshui.prototypes.ObjectList:New({
+
+ dataStorageKey = CATEGORIES_DATA_STORAGE_KEY,
+ objectVersion = Bagshui.config.Categories.version,
+ objectMigrationFunction = Bagshui.config.Categories.migrate,
+ objectName = "Category",
+ objectNamePlural = "Categories",
+ objectSkeleton = BS_CATEGORY_SKELETON,
+ objectTemplate = BS_NEW_CATEGORY_TEMPLATE,
+ defaults = Bagshui.config.Categories.defaults,
+ defaultObjectValues = {
+ -- Put built-in rules without a specified sequence higher than the user default.
+ sequence = BS_DEFAULT_BUILTIN_CATEGORY_SEQUENCE,
+ },
+ wikiPage = BS_WIKI_PAGES.Categories,
+
+ -- Default category ID when Categories:Categorize() can't find a match.
+ defaultCategory = BS_DEFAULT_CATEGORY_ID,
+
+ -- "Compiled" rules i.e. rule expressions run through `loadstring()`.
+ finalizedCategoryRules = {},
+
+ -- Error tracking.
+ errors = {}, -- Category ID to error message mapping for quick retrieval.
+ errorTimestamps = {}, -- Track when errors occurred so we know whether re-testing is needed.
+ recursedCategories = {}, -- Any category that contains a MatchCategory() rule function call.
+
+ -- There's some initialization that needs to happen on `PLAYER_ENTERING_WORLD`
+ -- (see `Categories:OnEvent()`), but that event fires when zoning and we
+ -- only want to do the stuff once.
+ finalInitComplete = false,
+
+ -- Debug.
+ debugResetOnLoad = false and BS_DEBUG,
+ })
+
+ --- Initialize categories list.
+ function Categories:Init()
+ -- Add sorting by sequence (doing this before superclass init so that it will get sorted automatically).
+ self.sortedIdLists.sequence = {}
+ self.idListSortFunctions.sequence = function(idA, idB)
+ return (self._idListSortFunctions_objectList[idA].sequence or BS_DEFAULT_CATEGORY_SEQUENCE)
+ < (self._idListSortFunctions_objectList[idB].sequence or BS_DEFAULT_CATEGORY_SEQUENCE)
+ end
+
+ -- Calls ObjectList:Init().
+ self._super.Init(self, true)
+
+ -- Add auto-split Categories menu.
+ Bagshui.prototypes.Menus:AddAutoSplitMenu(BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES, {
+ defaultIdList = self.sortedIdLists.name,
+ sortFunc = function(categoryIds)
+ self:SortIdList(categoryIds, nil, "name")
+ end,
+ nameFunc = function(id)
+ return self:GetName(id) or tostring(id)
+ end,
+ })
+
+ -- Register events.
+ Bagshui:RegisterEvent("PLAYER_ENTERING_WORLD", self)
+ Bagshui:RegisterEvent("BAGSHUI_CATEGORIES_CACHE_LOAD", self)
+ Bagshui:RegisterEvent("BAGSHUI_CATEGORY_UPDATE", self)
+
+ self.initialized = true
+ end
+
+ --- Event handling.
+ ---@param event string Event name.
+ ---@param arg1 any Event argument.
+ function Categories:OnEvent(event, arg1)
+ --Bagshui:PrintDebug("Categories OnEvent " .. event .. " (prototype: " .. tostring(self == Categories) .. ")")
+
+ -- Check to see if the ObjectList prototype is handling this event
+ if self._super.OnEvent(self, event, arg1) then
+ return
+ end
+
+ -- After PLAYER_ENTERING_WORLD, wait a bit and finish loading.
+ if event == "PLAYER_ENTERING_WORLD" and not self.finalInitComplete then
+ -- Need to wait to finalize rules so 3rd party integrations can register first.
+ Bagshui:QueueClassCallback(self, self.FinalizeAllRules, 1)
+ -- Don't make the client too busy with server queries immediately.
+ Bagshui:QueueEvent("BAGSHUI_CATEGORIES_CACHE_LOAD", 3)
+ -- Make sure this only happens once.
+ self.finalInitComplete = true
+ return
+ end
+
+ -- Queued event has fired, so perform the item cache load.
+ if event == "BAGSHUI_CATEGORIES_CACHE_LOAD" then
+ self:LoadListItemsIntoGameCache()
+ return
+ end
+
+ -- Re-validate categories in error state if the error was due to a MatchCategory() rule function dependency.
+ if event == "BAGSHUI_CATEGORY_UPDATE" then
+ local prevErrorMessages = self:GetErrors()
+
+ -- For all categories that have a MatchCategory() rule function other
+ -- than the one that was just saved and triggered this event:
+ -- 1. Clear their errors.
+ -- 2. Re-test.
+ -- This needs to be done in two loops because we don't track the
+ -- dependency relationships.
+ --
+ -- The `Categories.recursedCategories` table is updated in
+ -- `Categories:FinalizeRule()` based on whether a MatchCategory() rule
+ -- function was used (could be other things in the future if needed).
+
+ -- Loop 1: Clear errors.
+ for categoryId, categoryInfo in pairs(self.list) do
+ if self.recursedCategories[categoryId] and categoryId ~= self.lastUpdatedCategoryId then
+ self.errors[categoryId] = nil
+ end
+ end
+
+ -- Loop 2: Re-test everybody.
+ for categoryId, categoryInfo in pairs(self.list) do
+ if self.recursedCategories[categoryId] and categoryId ~= self.lastUpdatedCategoryId then
+ self:FinalizeRule(categoryId, categoryInfo, nil, true)
+ end
+ end
+
+ -- Refresh the category list if visible.
+ if
+ self:GetErrors() ~= prevErrorMessages
+ and self.objectManager
+ and self.objectManager.uiFrame
+ and self.objectManager.uiFrame:IsVisible()
+ then
+ self.objectManager:UpdateList()
+ end
+ return
+ end
+ end
+
+ --- Ensure all items referenced in category item lists are in the local cache so that GetItemInfo() works.
+ --- (There's too much of a delay to try and do this in real-time while editing a category).
+ function Categories:LoadListItemsIntoGameCache()
+ if self.finalInitComplete then
+ return
+ end
+ for _, category in pairs(self.list) do
+ if category.classes then
+ for _, classCategory in pairs(category.classes) do
+ self:ItemInfoCacheLoad(classCategory.list)
+ end
+ else
+ self:ItemInfoCacheLoad(category.list)
+ end
+ end
+ end
+
+ --- Helper for LoadListItemsIntoGameCache() to share code between standard and class categories.
+ function Categories:ItemInfoCacheLoad(itemList)
+ if type(itemList) == "table" and table.getn(itemList) > 0 then
+ for _, itemId in pairs(itemList) do
+ BsItemInfo:LoadItemIntoLocalGameCache(itemId)
+ end
+ end
+ end
+
+ --- See `ObjectList:GetUses()`.
+ ---@param objectId string|number
+ ---@param objectMetadata table Metadata about the object.
+ ---@return boolean inUse Whether the object is being used.
+ function Categories:GetUses(objectId, objectMetadata)
+ return BsProfiles:GetProfilesUsingObject(objectId, objectMetadata, BsCategories)
+ end
+
+ --- Things that need to happen before ObjectList:Save() actually saves the category.
+ ---@param categoryId string|number ID of the new category.
+ ---@param category table New category information.
+ function Categories:DoPreSaveOperations(categoryId, category)
+ -- Reset revalidation tracking (used in `Categories:OnEvent()` for `BAGSHUI_CATEGORY_UPDATE`).
+ self.lastUpdatedCategoryId = nil
+
+ -- Don't allow empty rules or lists
+ if category.rule and string.len(tostring(category.rule)) == 0 then
+ category.rule = nil
+ end
+ if type(category.list) ~= "table" or (type(category.list) == "table" and table.getn(category.list) == 0) then
+ category.list = nil
+ end
+
+ -- Default sequence number / type check
+ if not category.sequence then
+ category.sequence = BS_DEFAULT_CATEGORY_SEQUENCE
+ end
+ if type(category.sequence) ~= "number" then
+ category.sequence = tonumber(category.sequence)
+ end
+ end
+
+ --- Things that need to happen after ObjectList:Save() actually saves the category.
+ ---@param categoryId string|number ID of the new category.
+ ---@param category table New category information.
+ function Categories:DoPostSaveOperations(categoryId, category)
+ -- Don't revalidate this category because it was just saved
+ -- (used in `Categories:OnEvent()` for `BAGSHUI_CATEGORY_UPDATE`).
+ self.lastUpdatedCategoryId = categoryId
+
+ -- Sort list entries so they look good in the editor.
+ if type(category.list) == "table" and table.getn(category.list) > 0 then
+ table.sort(category.list, function(itmA, itmB)
+ local itmAName = _G.GetItemInfo(itmA)
+ local itmBName = _G.GetItemInfo(itmB)
+ return string.lower(itmAName or "") < string.lower(itmBName or "")
+ end)
+ end
+
+ -- Compile the rule functions.
+ -- Blocking this at startup because FinalizeAllRules() will take care of the first compile.
+ if self.initialized then
+ self:FinalizeCategoryRules(categoryId, true)
+ end
+ end
+
+ --- Loop through all categories and pre-compile them.
+ function Categories:FinalizeAllRules()
+ if type(self.list) ~= "table" then
+ return
+ end
+ for categoryId, _ in pairs(self.list) do
+ self:FinalizeCategoryRules(categoryId, true)
+ end
+ end
+
+ --- "Compile" a category's rule expression(s) by passing to `Categories:FinalizeRule()`.
+ --- This wrapper is needed because class categories can contain one rule per class.
+ ---@param categoryId string|number ID of the category.
+ ---@param recompile boolean true to force recompilation.
+ function Categories:FinalizeCategoryRules(categoryId, recompile)
+ assert(categoryId, "categoryId must be specified")
+
+ local category = self:Get(categoryId)
+
+ if category.classes then
+ for class, classCategory in pairs(category.classes) do
+ self:FinalizeRule(categoryId, classCategory, class, recompile)
+ end
+ else
+ self:FinalizeRule(categoryId, category, nil, recompile)
+ end
+ end
+
+ --- "Compile" a category's rule expression(s) by passing to `Rules:Compile()`.
+ --- Ultimately this will run it through loadstring() to produce a function.
+ --- Store the result in `Categories.finalizedCategoryRules` so we don't have
+ --- to do this over and over while categorizing items.
+ ---@param categoryId string|number ID of the category.
+ ---@param category table Category object.
+ ---@param ruleIdSuffix string? Suffix to uniquely identify the compiled rule.
+ ---@param recompile boolean? true to force recompilation.
+ ---@param recursed boolean? This call is coming from a `MatchCategory()` rule function,
+ ---@return function|string compiledRule
+ function Categories:FinalizeRule(categoryId, category, ruleIdSuffix, recompile, recursed)
+ assert(categoryId, "categoryId must be specified")
+ assert(category, "category must be specified")
+
+ -- When a rule is in the process of being edited, short-circuit and return
+ -- the edited version. Without this, MatchCategory() loops wouldn't be detected
+ -- until the category is saved.
+ if self.objectManager and self.objectManager.editors then
+ for _, editor in ipairs(self.objectManager.editors) do
+ if editor.objectId == categoryId and editor.uiFrame:IsVisible() and editor:IsDirty() then
+ return editor.updatedObject.rule
+ end
+ end
+ end
+
+ --Bagshui:PrintDebug("FinalizeRule: "..categoryId)
+ local finalRuleId = self:BuildFinalizedRuleId(categoryId, ruleIdSuffix)
+
+ -- When a recompile isn't being forced and the finalized rule already exists, just return it.
+ -- This optimization is leveraged by MatchCategory() to avoid constantly calling loadstring().
+ if self.finalizedCategoryRules[finalRuleId] and not recompile then
+ return self.finalizedCategoryRules[finalRuleId]
+ end
+
+ -- Otherwise, proceed with compiling/recompiling the rule.
+ local finalRule = ""
+ self.errors[categoryId] = nil
+
+ -- Remove previously compiled rule if it exists.
+ -- This does mean that when a rule is changed and the recompile fails, the rule will become invalid.
+ self.finalizedCategoryRules[finalRuleId] = nil
+
+ -- The rule expression is the category.rule property, so long as it's not the magic
+ -- match-anything "*".
+ if type(category.rule) == "string" and string.len(category.rule) > 0 and category.rule ~= "*" then
+ finalRule = category.rule
+ end
+
+ -- During a MatchCategory() loop, we *must* stop here or we'll overflow the call stack.
+ -- To prevent that, just immediately return the rule without trying to compile it.
+ if recursed then
+ return finalRule
+ end
+
+ -- Handling item lists in MatchCategory() instead of here to improve efficiency;
+ -- it just loops through the list looking for an ID match instead of invoking
+ -- the overhead of the rules engine.
+ -- if type(category.list) == "table" and table.getn(category.list) > 0 then
+ -- if string.len(finalRule) > 0 then
+ -- finalRule = "(" .. finalRule .. ") or "
+ -- end
+ -- finalRule = finalRule .. "id(" .. table.concat(category.list, ",") .. ")"
+
+ -- Make sure there's a rule to compile.
+ if string.len(finalRule) > 0 then
+ -- Validate before compiling and store status.
+ local valid, errorMessage = BsRules:Validate(category.rule, categoryId)
+ if valid then
+ -- Compile the rule once we know it's good.
+ self.finalizedCategoryRules[finalRuleId] = BsRules:Compile(finalRule)
+ else
+ self:ReportError(categoryId, errorMessage)
+ end
+
+ -- Identify this as a category that needs to be re-tested every time there's
+ -- a category edit of any kind (see `Categories:OnEvent()` for `BAGSHUI_CATEGORY_UPDATE`).
+ -- [MatchCategory() sets the _matchCategory_originalCaller property on the Rules class for us.]
+ self.recursedCategories[categoryId] = categoryId == BsRules._matchCategory_originalCaller or nil
+ end
+
+ return self.finalizedCategoryRules[finalRuleId]
+ end
+
+ --- Create a unique ID for a rule based on the category ID and optional suffix.
+ ---@param categoryId string|number ID of the category.
+ ---@param ruleIdSuffix string? Suffix to uniquely identify the compiled rule.
+ ---@return string ruleId `":[]"`
+ function Categories:BuildFinalizedRuleId(categoryId, ruleIdSuffix)
+ return categoryId .. (ruleIdSuffix and (":" .. tostring(ruleIdSuffix)) or "")
+ end
+
+ --- Given an item, a list of sequenced category IDs, a table mapping categories to group IDs,
+ --- and a default category ID, determine the category to which that item should be assigned.
+ ---@param item table An item from the Bagshui inventory cache (should be based on `BS_ITEM_SKELETON`).
+ ---@param character table? Character information table (uses `Bagshui.currentCharacterInfo` if not provided).
+ ---@param sortedSequenceNumbers number[] An array of numbers that correspond to indexes of the `categoriesToGroups` table. This is what dictates priority of category evaluation.
+ ---@param categoriesToGroups table Table of `{ = { categoryId1 = groupId1, categoryId2 = groupId2 } }`. (The `categoryIdsGroupedBySequence` table produced by `Inventory:UpdateLayoutLookupTables()`).
+ ---@param defaultCategoryId string|number? If no matching category is found, assign the item to this category. (When not specified, use `Categories.defaultCategoryId`).
+ function Categories:Categorize(item, character, sortedSequenceNumbers, categoriesToGroups, defaultCategoryId)
+ if not item then
+ return
+ end
+
+ character = character or Bagshui.currentCharacterInfo
+ defaultCategoryId = defaultCategoryId or self.defaultCategory
+
+ -- Start a rule engine session so that we can call BsRules:Match() without needing to constantly check/update Rules.item and Rules.character.
+ local ruleSession = BsRules:SetItemAndCharacter(item, character)
+
+ -- Process categories in sequence order.
+ for _, sequenceNum in ipairs(sortedSequenceNumbers) do
+ --Bagshui:PrintDebug("processing categories with sequence #"..sequenceNum)
+ for categoryId, groupId in pairs(categoriesToGroups[sequenceNum]) do
+ --Bagshui:PrintDebug("testing category ID " .. categoryId)
+ -- Try to match the category.
+ if
+ groupId
+ and string.len(groupId or "") > 0
+ and self:MatchCategory(categoryId, item, character, ruleSession)
+ then
+ --Bagshui:PrintDebug("Category " .. categoryId .. " and group " .. groupId .. " assigned to " .. item.name)
+ item.bagshuiGroupId = groupId
+ item.bagshuiCategoryId = categoryId
+ item.uncategorized = (categoryId == defaultCategoryId) and 1 or 0
+ return
+ end
+ end
+ end
+
+ -- If we make it this far, assign the default category.
+ Bagshui:PrintDebug(tostring(item.Name) .. " is uncategorized")
+ item.bagshuiGroupId = categoriesToGroups[defaultCategoryId]
+ item.bagshuiCategoryId = defaultCategoryId
+ item.uncategorized = 1
+ end
+
+ -- Reusable variables for MatchCategory() to reduce garbage collector load.
+ local category, ruleIdSuffix, finalRule, status, retVal, errorMessage
+
+ --- Return true/false based on whether the given item matches the given category
+ ---@param categoryId string|number ID of the category.
+ ---@param item table An item from the Bagshui inventory cache.
+ ---@param character table? Character information table (uses `Bagshui.currentCharacterInfo` if not provided).
+ ---@param ruleSession number? Rule session ID from `Rules:SetItemAndCharacter()`.
+ ---@param recursed boolean? This call is coming from a `MatchCategory()` rule function,
+ ---@return boolean
+ function Categories:MatchCategory(categoryId, item, character, ruleSession, recursed)
+ category = self:Get(categoryId)
+ if not category then
+ return false
+ end
+
+ -- MatchCategory() rule function help: Add to call stack.
+ if BsRules._matchCategory_callStack then
+ table.insert(BsRules._matchCategory_callStack, categoryId)
+ end
+
+ -- Very fast processing for catchall category.
+ if category.rule == "*" then
+ return true
+ end
+
+ ruleIdSuffix = nil
+ character = character or Bagshui.currentCharacterInfo
+
+ -- Class-specific categories.
+ if category.classes then
+ -- Redefine the category reference to be the class-specific category.
+ category = category.classes[character.class]
+ -- There's nothing to try and match when there's not an entry for the current class.
+ if not category then
+ return false
+ end
+ -- When this class-specific rule is compiled, make it unique by adding the class name as the ID suffix.
+ ruleIdSuffix = character.class
+ end
+
+ -- Try to match item lists first.
+ if type(category.list) == "table" and table.getn(category.list) > 0 then
+ for _, itemId in ipairs(category.list) do
+ if itemId == item.id then
+ return true
+ end
+ end
+ end
+
+ -- If the rule is in error status and hasn't been changed since the error occurred,
+ -- just fail (checking this here makes sense because item lists can't have errors)
+ if self.errors[categoryId] and (self.errorTimestamps[categoryId] or 0) >= category.dateModified then
+ self:ReportError(categoryId, self.errors[categoryId])
+ return false
+ end
+
+ -- Quick fail if no rule.
+ if not category.rule or string.len(tostring(category.rule)) == 0 then
+ return false
+ end
+
+ -- Get the compiled rule to be evaluated.
+ finalRule = self:FinalizeRule(categoryId, category, ruleIdSuffix, false, recursed)
+
+ -- Final check to ensure there's a rule to execute.
+ if
+ not finalRule
+ or (
+ not recursed -- Don't stop here or MatchCategory() won't work.
+ and type(finalRule) ~= "function"
+ )
+ then
+ return false
+ end
+
+ -- Do the test.
+ retVal, errorMessage = BsRules:Match(finalRule, item, character, ruleSession, nil, recursed)
+
+ if errorMessage == nil then
+ -- Successful rule evaluation, return rule function value.
+ return retVal
+ else
+ self:ReportError(categoryId, errorMessage)
+ return false
+ end
+ end
+
+ -- Reusable variables for ReportError() to reduce garbage collector load.
+ local formattedMessage
+
+ --- Display an error when so long as it hasn't yet been shown.
+ ---@param categoryId any
+ ---@param errorMessage any
+ function Categories:ReportError(categoryId, errorMessage)
+ assert(categoryId, "categoryId must be specified")
+ if not self.errors[categoryId] then
+ formattedMessage = self:FormatErrorMessage(errorMessage, categoryId)
+ self.errors[categoryId] = formattedMessage
+ self.errorTimestamps[categoryId] = _G.time()
+ -- Don't spam chat with errors at startup.
+ if self.initialized then
+ Bagshui:PrintError(formattedMessage, L.Category)
+ end
+ end
+ end
+
+ --- Given information about an error, fill out the error message template.
+ ---@param errorMessage string The error.
+ ---@param categoryId string|number ID of the category that caused the error.
+ ---@param categoryName string? Category name. If not provided, the category ID will be used to look it up. Or if nothing is found, this is assumed to be a new category.
+ ---@return string errorMessage Display-ready error message.
+ function Categories:FormatErrorMessage(errorMessage, categoryId, categoryName)
+ return string.format(
+ L.Error_CategoryEvaluation,
+ tostring(
+ categoryName
+ or (self.list[categoryId] and self.list[categoryId].name)
+ or string.format(L.Prefix_New, L.Category)
+ ),
+ tostring(errorMessage)
+ )
+ end
+
+ --- Get all category errors as a single newline-separated string.
+ ---@param filter table? Category IDs to check for errors.
+ ---@return string errors
+ function Categories:GetErrors(filter)
+ if BsUtil.TrueTableSize(self.errors) == 0 then
+ return ""
+ end
+ local errors = ""
+ for _, categoryId in ipairs(self.sortedIdLists.name) do
+ if self.errors[categoryId] and (type(filter) ~= "table" or filter[categoryId]) then
+ errors = errors .. (string.len(errors) > 0 and BS_NEWLINE or "") .. self.errors[categoryId]
+ end
+ end
+ return errors
+ end
+
+ --- Obtain the list of categories to which a given item ID has been manually assigned.
+ ---@param itemId number Item ID.
+ ---@param categoryIdListOutput any[] Array to fill with category IDs (will be cleared).
+ ---@param limitToCategoryIds (string|number)[]? When provided, only allow category IDs within this array to be matched.
+ function Categories:GetDirectCategoryAssignmentsForItem(itemId, categoryIdListOutput, limitToCategoryIds)
+ assert(
+ type(categoryIdListOutput) == "table",
+ "Categories:GetDirectCategoryAssignmentsForItem(): categoryIdListOutput is required and must be the table to fill."
+ )
+ BsUtil.TableClear(categoryIdListOutput)
+ for categoryId, categoryInfo in pairs(self.list) do
+ if categoryInfo.classes ~= nil then
+ for class, classInfo in pairs(categoryInfo.classes) do
+ self:ItemInDirectAssignmentList(classInfo.list, categoryId, itemId, categoryIdListOutput, limitToCategoryIds)
+ end
+ else
+ self:ItemInDirectAssignmentList(categoryInfo.list, categoryId, itemId, categoryIdListOutput, limitToCategoryIds)
+ end
+ end
+ end
+
+ --- Test whether the given Category contains the specified item ID in its Direct Assignment list.
+ --- Two modes:
+ --- 1. Helper for `Categories:GetDirectCategoryAssignmentsForItem()` to fill `categoryIdListOutput`.
+ --- 2. Return `true` if the Category has the item. Used for Direct Assignment Class menus in Edit Mode.
+ ---@param list number[]? Direct Assignment list.
+ ---@param itemId number Item ID.
+ ---@param categoryId number|string Unique Category identifier.
+ ---@param categoryIdListOutput? any[] See `Categories:GetDirectCategoryAssignmentsForItem()`
+ ---@param limitToCategoryIds (string|number)[]? See `Categories:GetDirectCategoryAssignmentsForItem()`
+ ---@param class string? Required if `list` is not provided and `categoryId` represents a Class Category.
+ ---@return boolean?
+ function Categories:ItemInDirectAssignmentList(
+ list,
+ categoryId,
+ itemId,
+ categoryIdListOutput,
+ limitToCategoryIds,
+ class
+ )
+ if not list then
+ local categoryInfo = self.list[categoryId]
+ if not categoryInfo then
+ return
+ end
+ if categoryInfo.classes then
+ if not class or not categoryInfo.classes[class] then
+ return
+ end
+
+ list = categoryInfo.classes[class].list
+ else
+ list = categoryInfo.list
+ end
+ end
+
+ if type(list) == "table" then
+ for i, categoryItemId in ipairs(list) do
+ if
+ categoryItemId == itemId
+ and (
+ (limitToCategoryIds and BsUtil.TableContainsValue(limitToCategoryIds, itemId) ~= nil)
+ or not limitToCategoryIds
+ )
+ then
+ if categoryIdListOutput then
+ table.insert(categoryIdListOutput, categoryId)
+ end
+ return true
+ end
+ end
+ end
+ end
+
+ --- Directly assign an item to a category.
+ ---@param categoryId string|number
+ ---@param itemId string|number
+ ---@param class string? Required for class categories.
+ function Categories:AssignItemToCategory(categoryId, itemId, class)
+ assert(itemId, "Categories:AssignItemToCategory(): itemID is required.")
+
+ local categoryInfo = self.list[categoryId]
+ if not categoryInfo then
+ return
+ end
+
+ local list
+
+ if categoryInfo.classes ~= nil then
+ assert(class, "Categories:AssignItemToCategory(): class is required for Class Categories.")
+
+ if not categoryInfo.classes[class] then
+ categoryInfo.classes[class] = {}
+ end
+ if not categoryInfo.classes[class].list then
+ categoryInfo.classes[class].list = {}
+ end
+
+ list = categoryInfo.classes[class].list
+ else
+ if not categoryInfo.list then
+ categoryInfo.list = {}
+ end
+ list = categoryInfo.list
+ end
+
+ if not list then
+ return
+ end
+
+ if BsUtil.TableContainsValue(list, itemId) == nil then
+ table.insert(list, itemId)
+ end
+ self:Save(categoryId, categoryInfo)
+ end
+
+ --- Remove the direct assignment of an item to a category.
+ ---@param categoryId string|number
+ ---@param itemId string|number
+ ---@param class string? Required for class categories.
+ function Categories:RemoveItemFromCategory(categoryId, itemId, class)
+ assert(itemId, "Categories:RemoveItemFromCategory(): itemID is required.")
+
+ local categoryInfo = self.list[categoryId]
+ if not categoryInfo then
+ return
+ end
+
+ local list = categoryInfo.list
+
+ if categoryInfo.classes ~= nil then
+ assert(class, "Categories:RemoveItemFromCategory(): class is required for Class Categories.")
+
+ if not categoryInfo.classes[class] then
+ return
+ end
+
+ list = categoryInfo.classes[class].list
+ end
+
+ if not list then
+ return
+ end
+
+ BsUtil.TableRemoveArrayItem(list, itemId)
+ self:Save(categoryId, categoryInfo)
+ end
+
+ --- Assign/unassign and item to a category, depending on its current status.
+ ---@param categoryId string|number
+ ---@param itemId string|number
+ ---@param class string? Required for class categories.
+ function Categories:ToggleItemCategoryAssignment(categoryId, itemId, class)
+ assert(itemId, "Categories:ToggleItemCategoryAssignment(): itemID is required.")
+
+ local categoryInfo = self.list[categoryId]
+ if not categoryInfo then
+ return
+ end
+
+ local list = categoryInfo.list
+
+ if categoryInfo.classes ~= nil then
+ assert(class, "Categories:ToggleItemCategoryAssignment(): class is required for Class Categories.")
+
+ if categoryInfo.classes[class] and categoryInfo.classes[class].list then
+ list = categoryInfo.classes[class].list
+ end
+ end
+
+ if list and BsUtil.TableContainsValue(list, itemId) ~= nil then
+ self:RemoveItemFromCategory(categoryId, itemId, class)
+ else
+ self:AssignItemToCategory(categoryId, itemId, class)
+ end
+ end
+
+ --- Obtain the list of all categories that match an item, whether via rules or direct assignment.
+ ---@param item table Bagshui item cache entry.
+ ---@param character table? Character to use for rule matching.
+ ---@param categoryIdListOutput any[] Array to fill with category IDs (will be cleared).
+ function Categories:GetAllMatchingCategoriesForItem(item, character, categoryIdListOutput)
+ assert(
+ type(categoryIdListOutput) == "table",
+ "Categories:GetDirectCategoryAssignmentsForItem(): categoryIdListOutput is required and must be the table to fill."
+ )
+ if type(categoryIdListOutput) ~= "table" then
+ categoryIdListOutput = {}
+ else
+ BsUtil.TableClear(categoryIdListOutput)
+ end
+ local ruleSession = BsRules:SetItemAndCharacter(item)
+ for categoryId, _ in pairs(self.list) do
+ if self:MatchCategory(categoryId, item, character, ruleSession) then
+ table.insert(categoryIdListOutput, categoryId)
+ end
+ end
+ end
+
+ -- Exports and initialization
+ Bagshui.environment.BsCategories = Categories
+ Bagshui.components.Categories = Categories
+ Bagshui:RegisterEvent("BAGSHUI_ADDON_LOADED", Categories)
+end)
diff --git a/Components/Character.lua b/Components/Character.lua
new file mode 100644
index 0000000..3ff82ad
--- /dev/null
+++ b/Components/Character.lua
@@ -0,0 +1,682 @@
+-- Bagshui Character Attribute Tracking
+-- Exposes: BsCharacter (and Bagshui.components.Character)
+-- Raises:
+-- - BAGSHUI_CHARACTER_LEARNED_RECIPE
+-- BAGSHUI_CHARACTER_UPDATE
+-- BAGSHUI_EQUIPPED_UPDATE
+-- BAGSHUI_EQUIPPED_HISTORY_UPDATE
+-- BAGSHUI_MONEY_UPDATE
+-- BAGSHUI_PROFESSION_ITEM_UPDATE
+--
+-- Builds lists of skills, spells, and profession crafts and reagents.
+-- Profession limitations:
+-- - Item lists can only be constructed when the profession window is opened -
+-- that's why we register for TRADE_SKILL_SHOW and CRAFT_SHOW.
+-- - Enchanting uses the "Craft" API instead of TradeSkill, which means that the
+-- items returned from GetCraftItemLink are actually spells. It appears that
+-- a list of Enchanting crafts can't be autogenerated and a database would need
+-- to be constructed.
+
+Bagshui:AddComponent(function()
+ -- Events that will trigger `Character:OnEvent()`.
+ local CHARACTER_EVENTS = {
+ BAGSHUI_INITIAL_CHARACTER_UPDATE = true, -- Delayed processing at startup.
+ CHAT_MSG_SKILL = true, -- New skills learned or leveled up.
+ CHAT_MSG_SYSTEM = true, -- Messages to parse for important events.
+ CRAFT_SHOW = true, -- Enchanting profession window is opened.
+ PLAYER_ENTERING_WORLD = true, -- Trigger initial processing at startup.
+ PLAYER_LEVEL_UP = true, -- Level up.
+ SKILL_LINES_CHANGED = true, -- Need to update skills (needed to catch some weird skills like Fist Weapons).
+ SPELLS_CHANGED = true, -- Need to update spells.
+ TRADE_SKILL_SHOW = true, -- Profession window is opened (other than Enchanting).
+ UNIT_INVENTORY_CHANGED = true, -- Equipped gear has changed -- requires arg1 == "player" check.
+ }
+
+ -- Events that should trigger a money update.
+ -- Making a table for these because there are a bunch.
+ -- Using key-value so it's easy to test in `Character:OnEvent()`.
+ local MONEY_EVENTS = {
+ PLAYER_MONEY = true,
+ PLAYER_TRADE_MONEY = true,
+ SEND_MAIL_MONEY_CHANGED = true,
+ SEND_MAIL_COD_CHANGED = true,
+ TRADE_MONEY_CHANGED = true, -- Might not need this one but might as well do it just to be safe.
+ }
+
+ -- The comments below refer to `currentCharacterInfo`, which is a pointer to
+ -- `Bagshui.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO]`.
+
+ -- Array of property names for `currentCharacterInfo` whose values are tables.
+ local CHARACTER_INFO_TABLES = {
+ "skills",
+ "spellNamesToIds",
+ "spells",
+ "professionCrafts",
+ "professionReagents",
+ "equippedHistory",
+ "equipped",
+ }
+
+ -- Array of important property names and dummy values for `currentCharacterInfo`.
+ -- See comment above the initial call to `Character:UpdateInfo()` at the end of
+ -- this file for more information.
+ local CHARACTER_INFO_MINIMUM_PROPERTIES = {
+ class = "",
+ faction = "",
+ name = "",
+ realm = "",
+ level = 1,
+ localizedClass = "",
+ localizedFaction = "",
+ localizedRace = "",
+ money = 0,
+ race = "",
+ }
+
+ -- The game only gives skill categories as localized strings, so this is a way
+ -- of making internal references locale-independent.
+ local LOCALIZED_TO_EN_SKILL_ID = {
+ [L["Professions"]] = "professions",
+ [L["Secondary Skills"]] = "secondarySkills",
+ [L["Weapon Skills"]] = "weaponSkills",
+ [L["Armor Proficiencies"]] = "armorProficiencies",
+ }
+
+ -- We have no use for these skills, so don't store them.
+ local IGNORE_SKILL_CATEGORY = {
+ [L["Class Skills"]] = true,
+ [L["Languages"]] = true,
+ }
+
+ -- `Bagshui.currentCharacterData` table is guaranteed to be initialized by
+ -- `Bagshui:AddonLoaded()` so we don't need to check it.
+ local currentCharacterInfo = Bagshui.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO]
+
+ -- Ensure key tables exist in `Bagshui.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO]`.
+ for _, tableName in ipairs(CHARACTER_INFO_TABLES) do
+ if not currentCharacterInfo[tableName] then
+ currentCharacterInfo[tableName] = {}
+ end
+ end
+
+ -- Ensure skill tables exist.
+ -- 1.12 doesn't give any sort of IDs for skill types, only localized names, but
+ -- we're dealing with that via `LOCALIZED_TO_EN_SKILL_ID`.
+ for _, skillName in pairs(LOCALIZED_TO_EN_SKILL_ID) do
+ if not currentCharacterInfo.skills[skillName] then
+ currentCharacterInfo.skills[skillName] = {}
+ end
+ end
+
+ -- Build Character class.
+ local Character = {
+ -- `Bagshui.currentCharacterData[BS_CONFIG_KEY.CHARACTER_INFO]`.
+ info = currentCharacterInfo,
+ -- List of all inventory slots, including bags, and their localized names.
+ ---@type table
+ inventorySlots = {},
+ -- Bag slot IDs so `Character:UpdateGear()` can differentiate.
+ ---@type table
+ bagSlots = {},
+ -- When character info is nil, we'll retry a few times.
+ startupRetries = 0,
+ initialized = false,
+ }
+ -- Add skills, spells, etc.
+ for _, tableName in ipairs(CHARACTER_INFO_TABLES) do
+ Character[tableName] = currentCharacterInfo[tableName]
+ end
+ -- Add professions, secondarySkills, etc.
+ for _, skillName in pairs(LOCALIZED_TO_EN_SKILL_ID) do
+ Character[skillName] = currentCharacterInfo.skills[skillName]
+ end
+ -- Add inventory slots.
+ for slot, localized in pairs(BS_INVENTORY_SLOT_NAMES) do
+ Character.inventorySlots[slot] = localized
+ end
+ for inventoryType, config in pairs(Bagshui.config.Inventory) do
+ for bagNum, slotName in ipairs(config.inventorySlots) do
+ Character.inventorySlots[slotName] = inventoryType .. "Container" .. bagNum
+ Character.bagSlots[slotName] = true
+ end
+ end
+ Bagshui.environment.BsCharacter = Character
+ Bagshui.components.Character = Character
+
+ --- Event processing.
+ ---@param event string WoW API event
+ ---@param arg1 any First event argument.
+ function Character:OnEvent(event, arg1)
+ --Bagshui:PrintDebug("Character event " .. event .. " // " .. tostring(arg1))
+
+ -- Initial processing at startup. The delayed event is necessary to ensure
+ -- we can actually get the data we need, which isn't available instantly.
+ -- See comment above the initial call to Character:UpdateInfo() at the end of
+ -- this file for more information.
+ if event == "PLAYER_ENTERING_WORLD" then
+ if not self.initialized then
+ Bagshui:QueueEvent("BAGSHUI_INITIAL_CHARACTER_UPDATE", 1.5)
+ end
+ return
+ end
+
+ -- Set Character things up at login.
+ if event == "BAGSHUI_INITIAL_CHARACTER_UPDATE" then
+ self:UpdateSkillsAndSpells()
+ self:UpdateGear()
+ self:UpdateMoney()
+ -- Need to set this true before calling UpdateInfo() so that it will
+ -- correctly raise BAGSHUI_CHARACTER_UPDATE.
+ self.initialized = true
+ self:UpdateInfo()
+ return
+ end
+
+ -- Some important events are only conveyed through chat messages that need
+ -- string matching to determine whether they're what we want.
+ if event == "CHAT_MSG_SYSTEM" then
+ -- This seems to be the best way to know when a character learns a new recipe.
+ -- Handling it centrally so other classes just have to register for BAGSHUI_CHARACTER_LEARNED_RECIPE.
+ if string.find(arg1, L.ChatMsgIdentifier_LearnedRecipe) then
+ BsItemInfo:InvalidateUsableCache()
+ Bagshui:RaiseEvent("BAGSHUI_CHARACTER_LEARNED_RECIPE")
+ end
+ return
+ end
+
+ -- Nothing else can happen until the class is initialized.
+ if not self.initialized then
+ return
+ end
+
+ -- Equipped gear changed ("inventory" in this case is what's equipped, not what's in bags).
+ -- [This used to also fire on UPDATE_INVENTORY_ALERTS, but that is about equipped gear *durability*.]
+ if event == "UPDATE_INVENTORY_ALERTS" then
+ -- This fires for all characters in a party/raid, and we only care about the player.
+ if arg1 == "player" then
+ --self:UpdateGear()
+ Bagshui:QueueClassCallback(self, self.UpdateGear, 1)
+ end
+ return
+ end
+
+ -- Refresh spells/skills and remove any items from the profession tables that shouldn't be there anymore.
+ -- This is also where the initial update after PLAYER_ENTERING_WORLD occurs.
+ if event == "SPELLS_CHANGED" or event == "CHAT_MSG_SKILL" or event == "SKILL_LINES_CHANGED" then
+ Bagshui:QueueClassCallback(self, self.UpdateSkillsAndSpells, 1)
+ return
+ end
+
+ -- Player level changed.
+ if event == "PLAYER_LEVEL_UP" then
+ -- self:UpdateInfo(arg1)
+ Bagshui:QueueClassCallback(self, self.UpdateInfo, 1, false, arg1)
+ return
+ end
+
+ -- Money changed.
+ if MONEY_EVENTS[event] then
+ self:UpdateMoney()
+ return
+ end
+
+ -- Do a profession update.
+ -- This can only happen when the appropriate window is opened!
+ if event == "CRAFT_SHOW" or event == "TRADE_SKILL_SHOW" then
+ -- Wait a smidgen before attempting the update so that game functions will return values.
+ Bagshui:QueueClassCallback(self, self.UpdateProfessionItems, 1, false, event)
+ return
+ end
+ end
+
+ -- Determine whether there are actual changes in `Character:UpdateInfo()`.
+ local _updateInfo_oldValues = {}
+
+ --- Refresh basic character information.
+ ---@param newLevel number? `arg1` from the `PLAYER_LEVEL_UP` event, needed because `UnitLevel()` can be wrong just after gaining a level.
+ function Character:UpdateInfo(newLevel)
+ -- Record old values.
+ for key, value in pairs(self.info) do
+ _updateInfo_oldValues[key] = value
+ end
+
+ self.info.name = _G.UnitName("player")
+ self.info.realm = BsUtil.Trim(_G.GetCVar("realmName"))
+ self.info.localizedClass, self.info.class = _G.UnitClass("player")
+ self.info.faction, self.info.localizedFaction = _G.UnitFactionGroup("player")
+ self.info.localizedRace, self.info.race = _G.UnitRace("player")
+
+ -- Level should never be 0. UnitLevel() returns 0 for a short time after login,
+ -- and we want to ignore that. The processing of CHARACTER_INFO_MINIMUM_PROPERTIES
+ -- at the end of this file will fill it with 1 for a brief time at the very first login.
+ -- After that, it'll be cached in SavedVariables.
+ newLevel = newLevel or _G.UnitLevel("player")
+ if newLevel ~= 0 then
+ self.info.level = newLevel
+ end
+
+ -- Ensure the standard Class entry is uppercase, just to be safe. That's how the game
+ -- typically returns it, so there are other parts of Bagshui that assume it'll be in all caps.
+ self.info.class = string.upper(self.info.class)
+
+ -- Retry at startup (when no `newLevel` parameter is passed) if needed due to empty values.
+ if not newLevel and not self.faction and self.infoRetries < 5 then
+ self.infoRetries = self.infoRetries + 1
+ Bagshui:QueueEvent("BAGSHUI_INITIAL_CHARACTER_UPDATE", 1)
+ return
+ end
+
+ -- Raise an event if there were changes.
+ if self.initialized then
+ for key, value in pairs(self.info) do
+ if self.info[key] ~= _updateInfo_oldValues[key] then
+ Bagshui:RaiseEvent("BAGSHUI_CHARACTER_UPDATE")
+ BsItemInfo:InvalidateUsableCache()
+ break
+ end
+ end
+ end
+ end
+
+ --- Keep money current.
+ function Character:UpdateMoney()
+ local newMoney = _G.GetMoney()
+ if not newMoney then
+ return
+ end
+ if self.info.money ~= newMoney then
+ Bagshui:RaiseEvent("BAGSHUI_MONEY_UPDATE")
+ end
+ self.info.money = newMoney
+ end
+
+ -- Reusable tables for `UpdateSkillsAndSpells()`.
+
+ local update_tempSpells = {}
+ local update_tempSkills = {}
+
+ --- Refresh the list of known skills and spells.
+ function Character:UpdateSkillsAndSpells()
+ -- Backup copy to compare against so we know whether changes actually happened.
+ BsUtil.TableCopy(self.spells, update_tempSpells)
+ BsUtil.TableCopy(self.skills, update_tempSkills)
+
+ -- Rebuild the lists fresh each time.
+ BsUtil.TableClear(self.spells)
+ BsUtil.TableClear(self.spellNamesToIds)
+ for _, skillTypeList in pairs(self.skills) do
+ BsUtil.TableClear(skillTypeList)
+ end
+
+ -- To get the full list of spells, we have to just keep asking GetSpellName
+ -- for spells, starting at 1, until it stops answering.
+ local spellNum = 1
+ while true do
+ local spellName, spellRank = _G.GetSpellName(spellNum, _G.BOOKTYPE_SPELL)
+ if not spellName then
+ break
+ end
+ self.spells[spellName] = spellRank
+ self.spellNamesToIds[spellName] = spellNum
+ spellNum = spellNum + 1
+ end
+
+ -- Iterate skills and place them in the appropriate category.
+ local skillCategory
+ local skip -- Will be either nil or true depending on whether the skill is in `IGNORE_SKILL_CATEGORY`.
+ for skillIndex = 1, _G.GetNumSkillLines() do
+ local skillName, isHeader, _, skillRank = _G.GetSkillLineInfo(skillIndex)
+ if isHeader then
+ skip = IGNORE_SKILL_CATEGORY[skillName]
+ if not skip then
+ skillCategory = LOCALIZED_TO_EN_SKILL_ID[skillName] or skillName
+ if not self.skills[skillCategory] then
+ self.skills[skillCategory] = {}
+ end
+ end
+ elseif not skip and skillCategory and skillName then
+ self.skills[skillCategory][skillName] = skillRank
+ end
+ end
+
+ -- Cleanup.
+ self:PruneProfessionItems()
+
+ -- Is anything different?
+ if
+ self.initialized
+ and (
+ not BsUtil.ObjectsEqual(self.spells, update_tempSpells)
+ or not BsUtil.ObjectsEqual(self.skills, update_tempSkills)
+ )
+ then
+ Bagshui:RaiseEvent("BAGSHUI_CHARACTER_UPDATE")
+ BsItemInfo:InvalidateUsableCache()
+ end
+
+ -- Don't need to keep contents (but do keep the tables).
+ BsUtil.TableClear(update_tempSpells)
+ BsUtil.TableClear(update_tempSkills)
+ end
+
+ --- Refresh the list of equipped armor and weapons.
+ function Character:UpdateGear()
+ -- Bs:PrintDebug("Character:UpdateGear()")
+ local historyChanged = false
+ local equippedChanged = false
+ local itemString, prevItemString
+
+ for slot, localized in pairs(self.inventorySlots) do
+ -- Track whether there's been a change.
+ prevItemString = self.equipped[slot] and self.equipped[slot].itemString or ""
+
+ -- Get the current item in this slot.
+ itemString = BsItemInfo:ParseItemLink(_G.GetInventoryItemLink("player", _G.GetInventorySlotInfo(slot)))
+
+ -- Ensure the slot has an item storage table available.
+ if not self.equipped[slot] then
+ self.equipped[slot] = {}
+ end
+
+ if itemString then
+ -- Fill slot information.
+ if not BsItemInfo:Get(itemString, self.equipped[slot], true, true) then
+ Bagshui:PrintDebug("Character:UpdateGear() GetItemInfo failed")
+ end
+ -- Set `count` property to at least 1 so that the Catalog will know that this item should be added to the totals.
+ self.equipped[slot].count = math.max(self.equipped[slot].count, 1)
+
+ -- Add to history (skip bags).
+ if not self.equippedHistory[itemString] and not self.bagSlots[slot] then
+ self.equippedHistory[itemString] = localized
+ historyChanged = true
+ end
+ else
+ -- Wipe the slot.
+ BsItemInfo:InitializeItem(self.equipped[slot])
+ end
+
+ -- Check for changes.
+ if itemString ~= prevItemString then
+ equippedChanged = true
+ end
+ end
+
+ if equippedChanged then
+ Bagshui:RaiseEvent("BAGSHUI_EQUIPPED_UPDATE")
+ end
+ if historyChanged then
+ Bagshui:RaiseEvent("BAGSHUI_EQUIPPED_HISTORY_UPDATE")
+ end
+ end
+
+ --- Remove the given item from the list of worn gear.
+ ---@param itemLink string Item to remove.
+ function Character:RemoveFromEquippedGear(itemLink)
+ if self.equippedHistory[itemLink] then
+ self.equippedHistory[itemLink] = nil
+ Bagshui:RaiseEvent("BAGSHUI_EQUIPPED_HISTORY_UPDATE")
+ end
+ end
+
+ -- Remove any profession crafts/reagents that are no longer known.
+ function Character:PruneProfessionItems()
+ if self:PruneItemList(self.professionCrafts) or self:PruneItemList(self.professionReagents) then
+ Bagshui:RaiseEvent("BAGSHUI_PROFESSION_ITEM_UPDATE")
+ BsItemInfo:InvalidateUsableCache()
+ end
+ end
+
+ -- Clean up the given list of crafts or reagents.
+ ---@param list table Crafts or reagents table to process.
+ ---@return boolean # true if any items were removed from the list.
+ function Character:PruneItemList(list)
+ local changesMade = false
+ for item, skills in pairs(list) do
+ -- Clear out any skills associated with this item that this character no longer has.
+ for skillName, _ in pairs(skills) do
+ if not self.professions[skillName] or self.secondarySkills[skillName] then
+ list[item][skillName] = nil
+ changesMade = true
+ end
+ end
+ -- If there are no skills left, remove the item.
+ if BsUtil.TrueTableSize(skills) == 0 then
+ list[item] = nil
+ changesMade = true
+ end
+ end
+ return changesMade
+ end
+
+ -- Perform a profession update.
+ function Character:UpdateProfessionItems(event)
+ local changesMade = false
+
+ -- Functions for professions other than Enchanting.
+ local getSkillLine = _G.GetTradeSkillLine
+ local getNumSkills = _G.GetNumTradeSkills
+ local getSkillItemLink = _G.GetTradeSkillItemLink
+ local getSkillNumReagents = _G.GetTradeSkillNumReagents
+ local getSkillReagentItemLink = _G.GetTradeSkillReagentItemLink
+
+ -- Enchanting has its own set of functions, and it's the only profession
+ -- referred to as a "Craft" instead of a "TradeSkill".
+ if event == "CRAFT_SHOW" then
+ getSkillLine = _G.GetCraftDisplaySkillLine
+ getNumSkills = _G.GetNumCrafts
+ getSkillItemLink = _G.GetCraftItemLink
+ getSkillNumReagents = _G.GetCraftNumReagents
+ getSkillReagentItemLink = _G.GetCraftReagentItemLink
+ end
+
+ local craftName, craftType, craftItemLink, reagentItemLink
+
+ -- Find out which profession window was opened.
+ local professionName = getSkillLine()
+
+ -- Hunter pet training window shows up as a craft with a nil professionName.
+ if professionName then
+ -- Process each available skill.
+ local numSkills = getNumSkills()
+ if numSkills > 0 then
+ for skillNum = 1, numSkills do
+ -- Get the name and type of item crafted by this skill (need type to make sure it's not a header).
+ craftName, craftType = self:GetSkillInfo(event, skillNum)
+ if craftName and craftType ~= "header" then
+ -- Get the information about what this skill creates and add to list of crafts.
+ craftItemLink = getSkillItemLink(skillNum)
+ if self:AddProfessionItemToList(professionName, craftItemLink, self.professionCrafts) then
+ changesMade = true
+ end
+
+ -- Process each reagent for this skill
+ local numReagents = getSkillNumReagents(skillNum)
+ if numReagents > 0 then
+ for reagentNum = 1, numReagents do
+ -- Add to list of reagents
+ reagentItemLink = getSkillReagentItemLink(skillNum, reagentNum)
+ if self:AddProfessionItemToList(professionName, reagentItemLink, self.professionReagents) then
+ changesMade = true
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- Notify of changes.
+ if changesMade then
+ Bagshui:RaiseEvent("BAGSHUI_PROFESSION_ITEM_UPDATE")
+ BsItemInfo:InvalidateUsableCache()
+ end
+ end
+
+ --- Add an item's ID to the given list.
+ ---@param professionName string Name of the associated profession.
+ ---@param itemLink string Link of item produced by the skill.
+ ---@param list table List to add the item to.
+ ---@return boolean? # True if changes made to `list`, false if no changes made, or nil if the itemLink wasn't able to be processed.
+ function Character:AddProfessionItemToList(professionName, itemLink, list)
+ if type(itemLink) ~= "string" then
+ return
+ end
+ local _, itemId = BsItemInfo:ParseItemLink(itemLink)
+ -- itemLinks from skills aren't always actual items (for example, Enchants
+ -- return an "enchant:####" link instead of "item:####:####:####:####").
+ -- When ParseItemLink doesn't get a normal item: link, it will return nil.
+ if not itemId then
+ return
+ end
+ -- Initialize item table if needed and store relevant profession.
+ if not list[itemId] then
+ list[itemId] = {}
+ end
+ if list[itemId][professionName] == true then
+ -- Already linked to the profession.
+ return false
+ end
+ list[itemId][professionName] = true
+ -- A change was made.
+ return true
+ end
+
+ --- GetTradeSkillInfo and GetCraftInfo return slightly different values,
+ --- so we can't just assign their functions directly to variables like everything
+ --- else in UpdateProfessionItems(). This wrapper resolves the differences.
+ ---@param event string WoW event that triggered this update.
+ ---@param skillNum number Current skill line number.
+ ---@return string craftName Name of the item.
+ ---@return string craftType "header" or difficulty of crafting.
+ function Character:GetSkillInfo(event, skillNum)
+ local craftName, craftType
+ if event == "CRAFT_SHOW" then
+ craftName, _, craftType = _G.GetCraftInfo(skillNum)
+ else
+ craftName, craftType = _G.GetTradeSkillInfo(skillNum)
+ end
+ return craftName, craftType
+ end
+
+ --- Is the given item ID in the provided profession item list? Designed to be used from a rule function.
+ --- itemTable must be provided in order to allow testing against arbitrary characters, not just the current one
+ --- Example: BsCharacter:TestItem(rules.item.id, rules.character.professionCrafts, ruleArguments)
+ ---@param itemId number Item ID to be checked.
+ ---@param professionTable table Profession crafts or reagent table produced by this class.
+ ---@param argumentList string[]? If present, array of one or more profession names that will be used to additionally filter the results.
+ ---@return boolean # Whether the item was found.
+ function Character:TestItem(itemId, professionTable, argumentList)
+ if type(professionTable) ~= "table" then
+ return false
+ end
+
+ -- The item needs to be in the given item table.
+ if not professionTable[itemId] then
+ return false
+ end
+
+ -- No arguments -- we already know this item is in the table, so return true.
+ if not argumentList or type(argumentList) ~= "table" or (argumentList and table.getn(argumentList) == 0) then
+ return true
+ end
+
+ -- Arguments are a list of keys (professions) for the tables within professionTable -- does this item belong to one of them?
+ local allArgumentsEmptyStrings = true
+ for _, argument in ipairs(argumentList) do
+ argument = argument and BsUtil.Trim(tostring(argument)) or ""
+ if string.len(argument) > 0 then
+ allArgumentsEmptyStrings = false
+ if professionTable[itemId][argument] or professionTable[itemId][L[argument]] then
+ return true
+ end
+ end
+ end
+
+ -- We weren't actually given any keys to check, so this item matches.
+ if allArgumentsEmptyStrings then
+ return true
+ end
+
+ -- Default to false.
+ return false
+ end
+
+ --- Given a skill name, return the character's current level for that skill.
+ ---@param skillName string Skill to search for.
+ ---@param character table? Character to check.
+ ---@return number skillLevel Skill level, or -1 if the character doesn't have the skill.
+ function Character:GetSkillLevel(skillName, character)
+ character = character or self.info
+ for _, skills in pairs(character.skills) do
+ for skill, level in pairs(skills) do
+ if skill == skillName then
+ return level
+ end
+ end
+ end
+ return -1
+ end
+
+ --- Build a ` [ 0 and string.sub(characterInfo.faction, 1, 1) or "?")
+ .. "]"
+ -- End faction color.
+ .. FONT_COLOR_CODE_CLOSE
+ -- Realm.
+ .. (includeRealm and GRAY_FONT_COLOR_CODE .. " • " .. characterInfo.realm .. FONT_COLOR_CODE_CLOSE or "")
+ end
+
+ -- Class event registration.
+ -- This is done at the end because RegisterEvent expects the class to have an OnEvent function.
+ for event, enabled in pairs(CHARACTER_EVENTS) do
+ Bagshui:RegisterEvent(event, Character)
+ end
+ -- Money money money.
+ for event, enabled in pairs(MONEY_EVENTS) do
+ if enabled then
+ Bagshui:RegisterEvent(event, Character)
+ end
+ end
+
+ -- Make a first pass at grabbing character information. Only some data is typically
+ -- available right away, with UnitFactionGroup() returning nil and UnitLevel()
+ -- returning 0 until shortly after PLAYER_ENTERING_WORLD, so we also make a fill-in
+ -- pass with dummy values. The second call to UpdateInfo that occurs shortly after
+ -- PLAYER_ENTERING_WORLD should rectify the missing properties.
+ -- One important reason to do this is to avoid errors during initial rule validation,
+ -- which gets upset if BsCharacter.info.localizedClass is nil.
+ Character:UpdateInfo()
+ for property, initialValue in pairs(CHARACTER_INFO_MINIMUM_PROPERTIES) do
+ if not currentCharacterInfo[property] then
+ currentCharacterInfo[property] = initialValue
+ end
+ end
+end)
diff --git a/Components/CharacterData.lua b/Components/CharacterData.lua
new file mode 100644
index 0000000..b553cff
--- /dev/null
+++ b/Components/CharacterData.lua
@@ -0,0 +1,265 @@
+-- Bagshui Character Data Management UI
+-- Exposes: BsCharacterData (and Bagshui.components.CharacterData)
+-- Raises: BAGSHUI_CHARACTERDATA_UPDATE
+-- Consumes: BAGSHUI_CHARACTERDATA_UPDATE
+
+Bagshui:AddComponent(function()
+ -- CharacterData class is built on the ObjectList prototype, even though it
+ -- only exists to provide a UI for deleting old alts.
+ local CharacterData = Bagshui.prototypes.ObjectList:New({
+
+ dataStorageKey = "characters",
+ objectName = "CharacterData",
+ objectNamePlural = "CharacterData",
+ wikiPage = BS_WIKI_PAGES.CharacterData,
+
+ -- Disable all table content filters.
+ objectSkeleton = BS_OBJECT_LIST_TEMPLATE_TABLE_WILDCARD,
+ objectTemplate = BS_OBJECT_LIST_TEMPLATE_TABLE_WILDCARD,
+
+ managerMultiSelect = false,
+
+ disableObjectCreation = true,
+ disableObjectEditing = true,
+ disableObjectSharing = true,
+
+ disableObjectDeletionFunc = function(characterId)
+ -- Loop for scrollable list entries.
+ if type(characterId) == "table" then
+ for charId, _ in pairs(characterId) do
+ if charId == Bagshui.currentCharacterId or Bagshui.sharedCharacters[charId] then
+ return true
+ end
+ end
+ end
+ -- Single object ID.
+ -- Characters from other accounts have to be deleted from the account they belong to.
+ return characterId == Bagshui.currentCharacterId or Bagshui.sharedCharacters[characterId] ~= nil
+ end,
+
+ -- This metatable will be assigned to each character in SavedVariables BagshuiData.characters.
+ -- It's basically a hack that quickly makes the character data structures compatible
+ -- with ObjectList's expectations, since ObjectList wasn't originally designed
+ -- to handle an object that stores its primary data in a sub-table. (Adding this metatable
+ -- is safe because there is no arbitrary access to keys in the character table.)
+ objectMetatable = {
+ __index = function(tbl, key)
+ if rawget(tbl, key) then
+ return rawget(tbl, key)
+ end
+ if rawget(tbl, "info") then
+ return rawget(tbl, "info")[key]
+ end
+ return rawget(tbl, key)
+ end,
+ },
+
+ ---@type string[]
+ -- List of cached characters that will be used for the Character menu.
+ -- Managed by `CharacterData:Update()`.
+ characterIdList = {},
+
+ ---@type string[]
+ -- Same as `characterIdList`, but formatted names.
+ characterNameList = {},
+ })
+
+ Bagshui.environment.BsCharacterData = CharacterData
+ Bagshui.components.CharacterData = CharacterData
+
+ --- Initialize the CharacterData class.
+ function CharacterData:Init()
+ -- Add sorting functions.
+ self.sortedIdLists.realm = {}
+ self.idListSortFunctions.realm = function(idA, idB)
+ return (self._idListSortFunctions_objectList[idA].realm or "")
+ < (self._idListSortFunctions_objectList[idB].realm or "")
+ end
+ self.sortedIdLists.lastLogout = {}
+ self.idListSortFunctions.lastLogout = function(idA, idB)
+ return (self._idListSortFunctions_objectList[idA].lastLogout or 0)
+ < (self._idListSortFunctions_objectList[idB].lastLogout or 0)
+ end
+
+ -- Calls ObjectList:Init().
+ self._super.Init(self)
+
+ -- ObjectList points `self.list` at the saved characters table (`Bagshui.characters`).
+ -- Replace it with a merged view that also contains characters shared by other accounts,
+ -- which are not saved. See `CharacterData:RefreshList()` and `CharacterData:Delete()`.
+ self.list = {}
+ self:RefreshList()
+
+ -- Generate the Character auto-split menu's initial list.
+ CharacterData:Update()
+
+ -- Add auto-split menu that lists all characters.
+ Bagshui.prototypes.Menus:AddAutoSplitMenu(BS_AUTO_SPLIT_MENU_TYPE.CHARACTERS, {
+ defaultIdList = self.characterIdList,
+ nameFunc = function(id)
+ return BsCharacter:FormatCharacterName(id, true) or id
+ end,
+ -- Manage...
+ extraItems = {
+ {
+ text = string.format(L.Symbol_Ellipsis, L.Manage),
+ tooltipTitle = L.Manage,
+ tooltipText = string.format(L.Prefix_Manage, L.CharacterData),
+ checked = false,
+ value = {
+ func = function()
+ Bagshui:CloseMenus()
+ self:Open()
+ end,
+ },
+ },
+ },
+ })
+ end
+
+ --- Event handling.
+ ---@param event string Event name.
+ ---@param arg1 any Event argument.
+ function CharacterData:OnEvent(event, arg1)
+ -- Check to see if the ObjectList prototype is handling this event
+ if self._super.OnEvent(self, event, arg1) then
+ return
+ end
+
+ -- Refresh the Character auto-split menu.
+ if event == "BAGSHUI_CHARACTERDATA_UPDATE" then
+ self:Update()
+ return
+ end
+ end
+
+ --- Subclass override for InitUI() to handle class-specific details.
+ function CharacterData:InitUi()
+ -- Calls ObjectList:InitUi().
+ self._super.InitUi(
+ self,
+ nil, -- No custom Object Manager needed.
+ nil, -- No custom Object Editor needed.
+ -- Not much to configure since there's no editing.
+ {
+ managerHeight = 400,
+ managerWidth = 450,
+ managerColumns = {
+ {
+ field = "name",
+ title = L.ObjectManager_Column_Name,
+ widthPercent = "45",
+ currentSortOrder = "ASC",
+ lastSortOrder = "ASC",
+ },
+ {
+ field = "realm",
+ title = L.ObjectManager_Column_Realm,
+ widthPercent = "25",
+ lastSortOrder = "ASC",
+ },
+ {
+ field = "lastLogout",
+ title = L.ObjectManager_Column_LastInventoryUpdate,
+ widthPercent = "25",
+ lastSortOrder = "ASC",
+ },
+ },
+
+ -- Show correctly formatted data.
+ managerColumnTextFunc = function(fieldName, id, obj, preliminaryDisplayValue)
+ if fieldName == "name" then
+ local formattedName = BsCharacter:FormatCharacterName(id) or preliminaryDisplayValue
+ if Bagshui.sharedCharacters[id] and type(formattedName) == "string" then
+ formattedName = formattedName .. " " .. GRAY_FONT_COLOR_CODE .. "(other account)" .. FONT_COLOR_CODE_CLOSE
+ end
+ return formattedName
+ elseif fieldName == "lastLogout" then
+ return _G.date(
+ "%Y-%b-%d",
+ type(preliminaryDisplayValue) == "number" and preliminaryDisplayValue or _G.time()
+ )
+ end
+ return preliminaryDisplayValue
+ end,
+
+ deletePrompt = L.ObjectManager_DeleteForPrompt,
+ deletePromptExtraInfo = L.CharacterDataManager_DeleteInfo,
+ }
+ )
+ end
+
+ --- Rebuild the list shown in the manager: this account's saved characters plus
+ --- characters shared by other accounts (which are never saved here).
+ function CharacterData:RefreshList()
+ BsUtil.TableClear(self.list)
+ for id, character in pairs(Bagshui.characters) do
+ self.list[id] = character
+ end
+ for id, character in pairs(Bagshui.sharedCharacters) do
+ if not self.list[id] then
+ self.list[id] = character
+ end
+ end
+ self:UpdateTrackingLists()
+ end
+
+ --- Delete a character from this account's saved data. (Subclass override.)
+ --- Because `self.list` is a merged view, the base implementation can't be used.
+ --- The character's shared file is also cleared so other accounts stop seeing it.
+ ---@param characterId string
+ ---@return boolean success
+ function CharacterData:Delete(characterId)
+ if
+ characterId == Bagshui.currentCharacterId
+ or Bagshui.sharedCharacters[characterId]
+ or not Bagshui.characters[characterId]
+ then
+ Bagshui:PrintWarning(string.format(L.ObjectList_ActionNotAllowed, L.Deletion, L.CharacterData))
+ return false
+ end
+
+ Bagshui.characters[characterId] = nil
+ if BsCatalog and BsCatalog.ForgetSharedCharacter then
+ BsCatalog:ForgetSharedCharacter(characterId)
+ end
+ self:RefreshList()
+ Bagshui:RaiseEvent(self.objectChangeEvent, false, characterId)
+ return true
+ end
+
+ -- Keep the Character auto-split menu current when old alts are deleted.
+ function CharacterData:Update()
+ BsUtil.TableClear(self.characterIdList)
+
+ -- Sort the list of characters alphabetically but sort by realm first.
+ for character, _ in pairs(Bagshui.characters) do
+ if character ~= Bagshui.currentCharacterId then
+ table.insert(self.characterIdList, character)
+ end
+ end
+ -- Characters shared by other accounts can be viewed too.
+ for character, _ in pairs(Bagshui.sharedCharacters) do
+ table.insert(self.characterIdList, character)
+ end
+ local function sortKey(characterId)
+ local characterData = Bagshui.characters[characterId] or Bagshui.sharedCharacters[characterId]
+ return tostring(characterData.info.realm or "") .. " " .. tostring(characterData.info.name or "")
+ end
+ table.sort(self.characterIdList, function(a, b)
+ return sortKey(a) < sortKey(b)
+ end)
+ -- Place the current character at the top.
+ table.insert(self.characterIdList, 1, Bagshui.currentCharacterId)
+
+ -- Refresh formatted name list.
+ BsUtil.TableClear(self.characterNameList)
+ for _, id in ipairs(self.characterIdList) do
+ table.insert(self.characterNameList, BsCharacter:FormatCharacterName(id, true) or id)
+ end
+ end
+
+ -- Class event registration.
+ -- This is done at the end because RegisterEvent expects the class to have an OnEvent function.
+ Bagshui:RegisterEvent("BAGSHUI_CHARACTERDATA_UPDATE", CharacterData)
+end)
diff --git a/Components/Compat.lua b/Components/Compat.lua
new file mode 100644
index 0000000..b14ee3c
--- /dev/null
+++ b/Components/Compat.lua
@@ -0,0 +1,221 @@
+-- Bagshui Compatibility
+-- Warnings for for compatibility with other addons.
+
+Bagshui:AddComponent(function()
+ ---@alias DISABLE_ADDON string
+ local DISABLE_ADDON = "!!!!!DISABLE!!!!!"
+
+ --- Execute compatibility checks that will help avoid issues or unexpected behaviors.
+ function Bagshui:CheckCompat()
+ -- The pfUI Bags module also hooks the Blizzard Bank events, so both
+ -- Bagshui and pfUI display simultaneously. When in this state, show an
+ -- actionable dialog so it's easy to avoid the problem.
+ if pfUI and pfUI.env and pfUI.env.C and pfUI.env.C.disabled then
+ -- Don't do anything until pfUI firstrun is done.
+ if pfUI.firstrun and pfUI.firstrun.steps then
+ for _, step in pairs(pfUI.firstrun.steps) do
+ if not _G.pfUI_init[step.name] then
+ self:QueueClassCallback(self, self.CheckCompat, 0.5)
+ return
+ end
+ end
+ end
+
+ self:CheckOtherAddonSetting("pfUIBags", true, pfUI.env.C.disabled, "bags", "1", "replaceBank")
+ end
+
+ -- tDF All-In-One-Bag is an embedded version of SUCC-Bag and it will override
+ -- our Backpack hook. Show a recommendation to disable it so key bindings
+ -- work as expected.
+ self:CheckOtherAddonSetting(
+ "tDFAllInOneBags",
+ (_G.IsAddOnLoaded("tDF") and _G.ShaguTweaks and _G.ShaguTweaks.T and _G.ShaguTweaks_config),
+ _G.ShaguTweaks_config,
+ (_G.ShaguTweaks and _G.ShaguTweaks.T and _G.ShaguTweaks.T["All-In-One-Bag"]),
+ 0,
+ "hookBag0"
+ )
+
+ -- Swapper interferes with Bagshui's bag swapping implementation.
+ self:CheckOtherAddonSetting("Swapper", DISABLE_ADDON, nil, nil, nil, nil, true)
+ end
+
+ -- Callback data for compatibility dialog.
+ local addonPromptData = {
+
+ --- Perform the disable action and prompt for UI reload.
+ --- @param data any
+ disableFunc = function(data)
+ -- Change the addon setting.
+ if data.disable then
+ _G.DisableAddOn(data.bagshuiCompatSettingId)
+ else
+ data.addonSettingTable[data.addonSettingKey] = data.settingDisabledValue
+ end
+
+ local dialogName = "BAGSHUI_COMPAT_RELOAD_UI"
+
+ if not _G.StaticPopupDialogs[dialogName] then
+ _G.StaticPopupDialogs[dialogName] = {
+ text = "",
+ button1 = L.Reload,
+ button2 = L.NotNow,
+ timeout = 0,
+ whileDead = true,
+ OnAccept = function()
+ _G.ReloadUI()
+ end,
+ }
+ end
+
+ _G.StaticPopupDialogs[dialogName].text = data.infoMessage .. BS_NEWLINE .. BS_NEWLINE .. L.Compat_ReloadUIPrompt
+ _G.StaticPopup_Show(dialogName)
+ end,
+
+ --- Show dialog with instructions for managing the offending setting.
+ ---@param data any
+ ignoreFunc = function(data)
+ -- Shouldn't be able to reach this if Ignore button is hidden, but just in case...
+ if data.noIgnore then
+ Bagshui:CheckOtherAddonSetting(
+ data.bagshuiCompatSettingId,
+ data.addonInstalledCondition,
+ data.addonSettingTable,
+ data.addonSettingKey,
+ data.settingDisabledValue,
+ data.bagshuiSettingToCheck,
+ data.noIgnore
+ )
+ end
+
+ -- Record the choice to ignore.
+ BsSettings[data.ignoreSetting] = true
+
+ local dialogName = "BAGSHUI_COMPAT_INFO"
+
+ if not _G.StaticPopupDialogs[dialogName] then
+ _G.StaticPopupDialogs[dialogName] = {
+ text = "",
+ button1 = _G.OKAY,
+ timeout = 10,
+ whileDead = true,
+ }
+ end
+
+ _G.StaticPopupDialogs[dialogName].text = data.infoMessage
+ _G.StaticPopup_Show(dialogName)
+ end,
+ }
+
+ --- Inspect the value of a specific setting belonging to another addon that can
+ --- conflict with Bagshui and prompt to disable it.
+ --- Some things need to be defined in Bagshui configs for this to work.
+ --- See the exiting pfUIBags ones as an example.
+ --- Settings:
+ --- - `compat_LastSetting`
+ --- - `compat_Ignored`
+ --- Localization:
+ --- - `Compat_`
+ --- - `Compat_Info`
+ ---
+ --- Building addon disabling into this was an afterthought so it's kind of a hack.
+ --- To use that, pass the addon name as the first parameter and `DISABLE_ADDON` as the second.
+ ---@param bagshuiCompatSettingId string Used for the compat settings and localization (see function description).
+ ---@param addonInstalledCondition boolean|DISABLE_ADDON Must be `true` for the check to proceed.
+ ---@param addonSettingTable table? Storage for the other addon's setting.
+ ---@param addonSettingKey any? Key within `addonSettingTable` where the offending setting is found.
+ ---@param settingDisabledValue any? Value for `addonSettingKey` that indicates the setting is in a **good** state.
+ ---@param bagshuiSettingToCheck string? Bagshui setting that must be enabled for the check to proceed. **Must be a character or account-scoped setting!**
+ ---@param noIgnore boolean? Hide the Ignore button in the compatibility prompt.
+ function Bagshui:CheckOtherAddonSetting(
+ bagshuiCompatSettingId,
+ addonInstalledCondition,
+ addonSettingTable,
+ addonSettingKey,
+ settingDisabledValue,
+ bagshuiSettingToCheck,
+ noIgnore
+ )
+ -- Can't proceed if addon isn't installed or Bagshui setting is disabled.
+ if not addonInstalledCondition or (bagshuiSettingToCheck and not BsSettings[bagshuiSettingToCheck]) then
+ return
+ end
+
+ local lastSetting = "compat_" .. bagshuiCompatSettingId .. "LastSetting"
+ local ignored = "compat_" .. bagshuiCompatSettingId .. "Ignored"
+
+ -- Reset compatibility warning when addon setting is changed.
+ if type(addonSettingTable) == "table" and BsSettings[lastSetting] ~= addonSettingTable[addonSettingKey] then
+ BsSettings[ignored] = false
+ end
+
+ -- Check actual setting.
+ if
+ (addonInstalledCondition == DISABLE_ADDON and _G.IsAddOnLoaded(bagshuiCompatSettingId))
+ or (
+ addonInstalledCondition ~= DISABLE_ADDON
+ and type(addonSettingTable) == "table"
+ and addonSettingTable[addonSettingKey] ~= settingDisabledValue
+ and not BsSettings[ignored]
+ )
+ then
+ local dialogName = "BAGSHUI_COMPAT_DISABLE"
+
+ if not _G.StaticPopupDialogs[dialogName] then
+ _G.StaticPopupDialogs[dialogName] = {
+ text = "",
+ button1 = L.Disable,
+ button2 = L.Ignore,
+ timeout = 0,
+ whileDead = true,
+ cancels = dialogName,
+
+ --- Disable was clicked, so do it.
+ ---@param data table Reference to `self.renameGroup_Data`, passed through via the dialog's `data` property.
+ OnAccept = function(data)
+ data.disableFunc(data)
+ end,
+
+ --- Ignore was clicked.
+ ---@param data table Reference to `self.renameGroup_Data`, passed through via the dialog's `data` property.
+ OnCancel = function(data, reason)
+ if reason == "override" then
+ return
+ end
+ data.ignoreFunc(data)
+ end,
+ }
+ end
+
+ _G.StaticPopupDialogs[dialogName].button2 = (not noIgnore) and L.Ignore or nil
+ _G.StaticPopupDialogs[dialogName].text = BS_FONT_COLOR.BAGSHUI
+ .. "Bagshui"
+ .. FONT_COLOR_CODE_CLOSE
+ .. BS_NEWLINE
+ .. L["Compat_" .. bagshuiCompatSettingId]
+
+ self:CloseMenus()
+
+ local dialog = _G.StaticPopup_Show(dialogName)
+
+ if dialog then
+ addonPromptData.disable = (addonInstalledCondition == DISABLE_ADDON)
+ addonPromptData.bagshuiCompatSettingId = bagshuiCompatSettingId
+ addonPromptData.addonInstalledCondition = addonInstalledCondition
+ addonPromptData.addonSettingTable = addonSettingTable
+ addonPromptData.addonSettingKey = addonSettingKey
+ addonPromptData.settingDisabledValue = settingDisabledValue
+ addonPromptData.bagshuiSettingToCheck = bagshuiSettingToCheck
+ addonPromptData.noIgnore = noIgnore
+ addonPromptData.ignoreSetting = ignored
+ addonPromptData.infoMessage = L["Compat_" .. bagshuiCompatSettingId .. "Info"]
+ dialog.data = addonPromptData
+ end
+ end
+
+ -- Record last setting value so we know when to reset our warning.
+ if type(addonSettingTable) == "table" then
+ BsSettings[lastSetting] = addonSettingTable[addonSettingKey] or nil
+ end
+ end
+end)
diff --git a/Components/GameInfo.lua b/Components/GameInfo.lua
new file mode 100644
index 0000000..db57597
--- /dev/null
+++ b/Components/GameInfo.lua
@@ -0,0 +1,386 @@
+-- Bagshui WoW Game Information Loader
+-- Exposes: BsGameInfo (and Bagshui.components.GameInfo)
+-- Raises: BAGSHUI_GAME_UPDATE
+
+Bagshui:AddComponent(function()
+ -- Information constructed here is used for localization and generation of default categories.
+
+ Bagshui:AddConstants({
+ BS_INVENTORY_SLOT_NAMES = {
+ HeadSlot = _G.INVTYPE_HEAD,
+ NeckSlot = _G.INVTYPE_NECK,
+ ShoulderSlot = _G.INVTYPE_SHOULDER,
+ BackSlot = _G.INVTYPE_CLOAK,
+ ChestSlot = _G.INVTYPE_CHEST,
+ ShirtSlot = _G.INVTYPE_SHIELD,
+ TabardSlot = _G.INVTYPE_TABARD,
+ WristSlot = _G.INVTYPE_WRIST,
+ HandsSlot = _G.INVTYPE_HAND,
+ WaistSlot = _G.INVTYPE_WAIST,
+ LegsSlot = _G.INVTYPE_LEGS,
+ FeetSlot = _G.INVTYPE_FEET,
+ Finger0Slot = _G.INVTYPE_FINGER,
+ Finger1Slot = _G.INVTYPE_FINGER,
+ Trinket0Slot = _G.INVTYPE_TRINKET,
+ Trinket1Slot = _G.INVTYPE_TRINKET,
+ MainHandSlot = _G.INVTYPE_WEAPONMAINHAND,
+ SecondaryHandSlot = _G.INVTYPE_WEAPONOFFHAND,
+ RangedSlot = _G.INVTYPE_RANGED,
+ },
+
+ -- Map item subclasses to the skills they require.
+ -- Only needed when the subclass doesn't match the skill name.
+ BS_ITEM_SUBCLASS_TO_SKILL = {
+ ["Fishing Pole"] = "Fishing",
+ ["One-Handed Axes"] = "Axes",
+ ["One-Handed Maces"] = "Maces",
+ ["One-Handed Swords"] = "Swords",
+ ["Plate"] = "Plate Mail",
+ ["Shields"] = "Shield",
+ },
+ })
+
+ -- Prepare storage of game information.
+ local GameInfo = {
+
+ -- Character classes.
+ -- Keys: English UPPERCASE names
+ -- Values: English Proper Noun Case names
+ characterClasses = {
+ ["DRUID"] = "Druid",
+ ["HUNTER"] = "Hunter",
+ ["MAGE"] = "Mage",
+ ["PALADIN"] = "Paladin",
+ ["PRIEST"] = "Priest",
+ ["ROGUE"] = "Rogue",
+ ["SHAMAN"] = "Shaman",
+ ["WARLOCK"] = "Warlock",
+ ["WARRIOR"] = "Warrior",
+ },
+
+ -- Item classes added here are not covered by GetAuctionItemClasses and
+ -- need to be localized manually in the locale file.
+ -- Key case must match the case of the item class as returned by the game.
+ itemClasses = {
+ ["Key"] = "Key",
+ ["Miscellaneous"] = "Miscellaneous",
+ ["Quest"] = "Quest",
+ ["Trade Goods"] = "Trade Goods",
+ },
+
+ -- Item subclasses added here are not covered by GetAuctionItemSubClasses.
+ itemSubclasses = {},
+
+ -- Need these mappings to call GetAuctionItemSubClasses.
+ itemSubClassIds = {
+ Weapon = 1,
+ Armor = 2,
+ Container = 3,
+ Projectile = 6,
+ Quiver = 7,
+ Recipe = 8,
+ },
+
+ -- List of inventory slot locations, used to generate rule function templates.
+ inventorySlots = {},
+
+ -- Class translation helper tables.
+ reverseTranslatedCharacterClasses = {},
+ lowercaseReverseTranslatedCharacterClasses = {},
+ lowercaseToNormalCaseReverseTranslatedCharacterClasses = {},
+ lowercaseLocalizedCharacterClasses = {},
+ lowercaseToNormalCaseLocalizedCharacterClasses = {},
+
+ -- Game state tracking.
+ currentZone = "",
+ currentRealZone = "",
+ currentSubZone = "",
+ currentMinimapZone = "",
+ playerGroupType = BS_GAME_PLAYER_GROUP_TYPE.SOLO,
+ isInInstance = nil, -- Game returns 1/nil so we're just going with it here.
+ instanceType = "none",
+ lootMethod = "freeforall",
+ masterLooterPartyId = nil,
+ }
+ Bagshui.environment.BsGameInfo = GameInfo
+ Bagshui.components.GameInfo = GameInfo
+
+ -- Populate item classes and subclasses.
+
+ GameInfo.itemClasses["Weapon"], GameInfo.itemClasses["Armor"], GameInfo.itemClasses["Container"], GameInfo.itemClasses["Consumable"], GameInfo.itemClasses["Trade Goods"], GameInfo.itemClasses["Projectile"], GameInfo.itemClasses["Quiver"], GameInfo.itemClasses["Recipe"], GameInfo.itemClasses["Reagent"], GameInfo.itemClasses["Miscellaneous"] =
+ _G.GetAuctionItemClasses()
+
+ for itemClass, localizedItemClass in pairs(GameInfo.itemClasses) do
+ GameInfo.itemSubclasses[itemClass] = {}
+ end
+
+ GameInfo.itemSubclasses["Weapon"]["One-Handed Axes"], GameInfo.itemSubclasses["Weapon"]["Two-Handed Axes"], GameInfo.itemSubclasses["Weapon"]["Bows"], GameInfo.itemSubclasses["Weapon"]["Guns"], GameInfo.itemSubclasses["Weapon"]["One-Handed Maces"], GameInfo.itemSubclasses["Weapon"]["Two-Handed Maces"], GameInfo.itemSubclasses["Weapon"]["Polearms"], GameInfo.itemSubclasses["Weapon"]["One-Handed Swords"], GameInfo.itemSubclasses["Weapon"]["Two-Handed Swords"], GameInfo.itemSubclasses["Weapon"]["Staves"], GameInfo.itemSubclasses["Weapon"]["Fist Weapons"], GameInfo.itemSubclasses["Weapon"]["Miscellaneous"], GameInfo.itemSubclasses["Weapon"]["Daggers"], GameInfo.itemSubclasses["Weapon"]["Thrown"], GameInfo.itemSubclasses["Weapon"]["Crossbows"], GameInfo.itemSubclasses["Weapon"]["Wands"], GameInfo.itemSubclasses["Weapon"]["Fishing Pole"] =
+ _G.GetAuctionItemSubClasses(GameInfo.itemSubClassIds.Weapon)
+
+ GameInfo.itemSubclasses["Armor"]["Miscellaneous"], GameInfo.itemSubclasses["Armor"]["Cloth"], GameInfo.itemSubclasses["Armor"]["Leather"], GameInfo.itemSubclasses["Armor"]["Mail"], GameInfo.itemSubclasses["Armor"]["Plate"], GameInfo.itemSubclasses["Armor"]["Shields"], GameInfo.itemSubclasses["Armor"]["Librams"], GameInfo.itemSubclasses["Armor"]["Idols"], GameInfo.itemSubclasses["Armor"]["Totems"] =
+ _G.GetAuctionItemSubClasses(GameInfo.itemSubClassIds.Armor)
+
+ GameInfo.itemSubclasses["Container"]["Bag"], GameInfo.itemSubclasses["Container"]["Soul Bag"], GameInfo.itemSubclasses["Container"]["Herb Bag"], GameInfo.itemSubclasses["Container"]["Enchanting Bag"] =
+ _G.GetAuctionItemSubClasses(GameInfo.itemSubClassIds.Container)
+
+ GameInfo.itemSubclasses["Projectile"]["Arrow"], GameInfo.itemSubclasses["Projectile"]["Bullet"] =
+ _G.GetAuctionItemSubClasses(GameInfo.itemSubClassIds.Projectile)
+
+ GameInfo.itemSubclasses["Quiver"]["Quiver"], GameInfo.itemSubclasses["Quiver"]["Ammo Pouch"] =
+ _G.GetAuctionItemSubClasses(GameInfo.itemSubClassIds.Quiver)
+
+ GameInfo.itemSubclasses["Recipe"]["Book"], GameInfo.itemSubclasses["Recipe"]["Leatherworking"], GameInfo.itemSubclasses["Recipe"]["Tailoring"], GameInfo.itemSubclasses["Recipe"]["Engineering"], GameInfo.itemSubclasses["Recipe"]["Blacksmithing"], GameInfo.itemSubclasses["Recipe"]["Cooking"], GameInfo.itemSubclasses["Recipe"]["Alchemy"], GameInfo.itemSubclasses["Recipe"]["First Aid"], GameInfo.itemSubclasses["Recipe"]["Enchanting"], GameInfo.itemSubclasses["Recipe"]["Fishing"] =
+ _G.GetAuctionItemSubClasses(GameInfo.itemSubClassIds.Recipe)
+
+ -- Item subclasses not provided by GetAuctionItemClasses which need to be localized manually in the locale file.
+ GameInfo.itemSubclasses["Key"]["Key"] = "Key"
+ GameInfo.itemSubclasses["Miscellaneous"]["Junk"] = "Junk"
+ GameInfo.itemSubclasses["Quest"]["Quest"] = "Quest"
+ GameInfo.itemSubclasses["Trade Goods"]["Devices"] = "Devices"
+ GameInfo.itemSubclasses["Trade Goods"]["Explosives"] = "Explosives"
+ GameInfo.itemSubclasses["Trade Goods"]["Parts"] = "Parts"
+ GameInfo.itemSubclasses["Trade Goods"]["Trade Goods"] = "Trade Goods"
+
+ -- Populate Inventory Slots.
+ -- Automatically build the English -> localized translations of inventory slot names.
+ -- (INVTYPE_X -> localized doesn't need to be stored because it's always available in global variables).
+ for globalVariable, englishName in pairs({
+ -- Armor.
+ INVTYPE_BODY = "Shirt",
+ INVTYPE_CHEST = "Chest",
+ INVTYPE_CLOAK = "Back",
+ INVTYPE_FEET = "Feet",
+ INVTYPE_FINGER = "Finger",
+ INVTYPE_HAND = "Hands",
+ INVTYPE_HEAD = "Head",
+ INVTYPE_LEGS = "Legs",
+ INVTYPE_NECK = "Neck",
+ INVTYPE_ROBE = "Chest",
+ INVTYPE_SHOULDER = "Shoulder",
+ INVTYPE_WAIST = "Waist",
+ INVTYPE_WRIST = "Wrist",
+
+ -- Weapons.
+ INVTYPE_RANGED = "Ranged",
+ INVTYPE_WEAPON = "One-Hand",
+ INVTYPE_2HWEAPON = "Two-Hand",
+ INVTYPE_WEAPONMAINHAND = "Main Hand",
+ INVTYPE_WEAPONOFFHAND = "Off Hand",
+ INVTYPE_SHIELD = "Off Hand",
+ INVTYPE_HOLDABLE = "IHeld In Off-hand",
+ INVTYPE_RELIC = "Relic",
+
+ -- Other.
+ INVTYPE_BAG = "Bag",
+ INVTYPE_TABARD = "Tabard",
+ INVTYPE_TRINKET = "Trinket",
+ }) do
+ GameInfo.inventorySlots[englishName] = _G[globalVariable]
+ end
+
+ --- Some game information can't be built until the Bagshui localization has been loaded.
+ function GameInfo:PopulatePostLocalizationInfo()
+ -- Professions to profession bags.
+ self.professionsToBags = {
+ [L.Enchanting] = L["Enchanting Bag"],
+ [L.Herbalism] = L["Herb Bag"],
+ }
+
+ -- Alphabetically sorted list of player classes.
+ self.sortedCharacterClasses = {}
+ for uppercaseClass, nounCaseClass in pairs(self.characterClasses) do
+ self.reverseTranslatedCharacterClasses[L[nounCaseClass]] = nounCaseClass
+ table.insert(self.sortedCharacterClasses, uppercaseClass)
+ end
+ table.sort(self.sortedCharacterClasses, function(a, b)
+ return L[self.characterClasses[a]] < L[self.characterClasses[b]]
+ end)
+
+ -- Provide a menu template for player classes.
+ -- Used by Category Editor and Inventory Edit Mode Direct Assignment menu.
+ self.characterClassMenu = {}
+ for _, class in ipairs(BsGameInfo.sortedCharacterClasses) do
+ table.insert(self.characterClassMenu, {
+ text = L[BsGameInfo.characterClasses[class]],
+ value = class,
+ })
+ end
+
+ -- Used by RequiresClass.
+ for _, nounCaseClass in pairs(self.characterClasses) do
+ self.lowercaseReverseTranslatedCharacterClasses[string.lower(L[nounCaseClass])] = string.lower(nounCaseClass)
+ self.lowercaseToNormalCaseReverseTranslatedCharacterClasses[string.lower(L[nounCaseClass])] = nounCaseClass
+ end
+ for _, nounCaseClass in pairs(self.characterClasses) do
+ self.lowercaseLocalizedCharacterClasses[string.lower(nounCaseClass)] = string.lower(L[nounCaseClass])
+ self.lowercaseToNormalCaseLocalizedCharacterClasses[string.lower(nounCaseClass)] = L[nounCaseClass]
+ end
+ end
+
+ --- Storage helper for `GameInfo:UpdateLocation()`.
+ --- Returns nil if the string is empty.
+ ---@param str any
+ ---@return any
+ local function nilForEmptyString(str)
+ return string.len(str or "") > 0 and str or nil
+ end
+
+ -- Tracking variables used to determine whether UpdateLocation() should raise BAGSHUI_GAME_UPDATE.
+
+ local oldZone, oldRealZone, oldSubZone, oldMinimapZone, oldInInstance, oldInstanceType
+
+ --- Store current location data.
+ function GameInfo:UpdateLocation()
+ oldZone = self.currentZone
+ oldRealZone = self.currentRealZone
+ oldSubZone = self.currentSubZone
+ oldMinimapZone = self.currentMinimapZone
+ oldInInstance = self.isInInstance
+ oldInstanceType = self.instanceType
+
+ self.currentZone = nilForEmptyString(_G.GetZoneText())
+ self.currentRealZone = nilForEmptyString(_G.GetRealZoneText())
+ self.currentSubZone = nilForEmptyString(_G.GetSubZoneText())
+ self.currentMinimapZone = nilForEmptyString(_G.GetMinimapZoneText())
+ self.isInInstance, self.instanceType = _G.IsInInstance()
+
+ if
+ oldZone ~= self.currentZone
+ or oldRealZone ~= self.currentRealZone
+ or oldSubZone ~= self.currentSubZone
+ or oldMinimapZone ~= self.currentMinimapZone
+ or oldInInstance ~= self.isInInstance
+ or oldInstanceType ~= self.instanceType
+ then
+ Bagshui:RaiseEvent("BAGSHUI_GAME_UPDATE")
+ end
+ end
+
+ -- Tracking variables used to determine whether UpdateLocation() should raise BAGSHUI_GAME_UPDATE.
+
+ local oldPlayerGroupType, oldLootMethod, oldMasterLooter
+
+ -- Reusable variable for `GetLootMethod()` return value.
+ local masterLooterPartyId
+
+ --- Store current group (as in party/raid) data.
+ function GameInfo:UpdateGroup()
+ oldPlayerGroupType = self.playerGroupType
+ oldLootMethod = self.lootMethod
+ oldMasterLooter = self.isMasterLooter
+
+ -- There's no GetGroupType() but we can infer it.=
+ self.playerGroupType = (
+ (_G.GetNumRaidMembers() or 0) > 0 and BS_GAME_PLAYER_GROUP_TYPE.RAID
+ or (_G.GetNumPartyMembers() or 0) > 0 and BS_GAME_PLAYER_GROUP_TYPE.PARTY
+ or BS_GAME_PLAYER_GROUP_TYPE.SOLO
+ )
+
+ -- Don't need the third return value from GetLootMethod() because
+ -- the second is 0 if the current player is the master looter.
+ self.lootMethod, masterLooterPartyId = _G.GetLootMethod()
+ self.isMasterLooter = (self.lootMethod == "master" and masterLooterPartyId == 0)
+
+ if
+ oldPlayerGroupType ~= self.playerGroupType
+ or oldLootMethod ~= self.lootMethod
+ or oldMasterLooter ~= self.isMasterLooter
+ then
+ Bagshui:RaiseEvent("BAGSHUI_GAME_UPDATE")
+ end
+ end
+
+ --- Zone output helper for the GameInfo slash handler.
+ --- Only prints both zones if they're different and non-nil.
+ ---@param z1 string? First zone.
+ ---@param z2 string? Second zone.
+ local function printNonNilZones(z1, z2)
+ local mainZone = z1 or z2
+ if not mainZone then
+ return
+ end
+ Bs:PrintBare(BS_INDENT .. mainZone)
+ if mainZone == z2 or not z2 then
+ return
+ end
+ Bs:PrintBare(BS_INDENT .. z2)
+ end
+
+ --- Event handling.
+ ---@param event string Event identifier.
+ ---@param arg1 any? Argument 1 from the event.
+ function GameInfo:OnEvent(event, arg1)
+ if event == "PLAYER_ENTERING_WORLD" then
+ self:UpdateLocation()
+ self:UpdateGroup()
+ return
+ end
+
+ if string.find(event, "ZONE") then
+ self:UpdateLocation()
+ return
+ end
+
+ if string.find(event, "PARTY") or string.find(event, "RAID") then
+ self:UpdateGroup()
+ return
+ end
+
+ if event == "BAGSHUI_LOCALIZATION_LOADED" then
+ self:PopulatePostLocalizationInfo()
+ return
+ end
+ end
+
+ --- Slash command helper called from `ItemInfo`'s slash handler.
+ --- This lets us have `/Bagshui Info Location` etc.
+ ---@param tokens table Tokenized input from slash command.
+ ---@return boolean handled `true` if ItemInfo shouldn't continue processing.
+ function GameInfo:HandleInfoSlash(tokens)
+ if
+ BsUtil.MatchLocalizedOrNon(tokens[2], "location")
+ or BsUtil.MatchLocalizedOrNon(tokens[2], "instance")
+ or BsUtil.MatchLocalizedOrNon(tokens[2], "zone")
+ then
+ Bs:Print(string.format(L.Symbol_Colon, L.Location))
+
+ Bs:PrintBare(string.format(L.Symbol_Colon, L.Zone))
+ printNonNilZones(self.currentZone, self.currentSubZone)
+ Bs:PrintBare(string.format(L.Symbol_Colon, L.Subzone))
+ printNonNilZones(self.currentSubZone, self.currentMinimapZone)
+
+ Bs:PrintBare(string.format(L.Symbol_Colon, L.Instance))
+ if not self.isInInstance or self.instanceType == "none" then
+ Bs:PrintBare(BS_INDENT .. L.NoneParenthesis)
+ else
+ Bs:PrintBare(BS_INDENT .. self.instanceType)
+ end
+ return true
+ elseif
+ BsUtil.MatchLocalizedOrNon(tokens[2], "group")
+ or BsUtil.MatchLocalizedOrNon(tokens[2], "party")
+ or BsUtil.MatchLocalizedOrNon(tokens[2], "loot")
+ or BsUtil.MatchLocalizedOrNon(tokens[2], "raid")
+ then
+ Bs:Print(string.format(L.Symbol_Colon, _G.GROUP))
+ Bs:PrintBare(BS_INDENT .. self.playerGroupType)
+
+ Bs:PrintBare(string.format(L.Symbol_Colon, _G.LOOT))
+ Bs:PrintBare(BS_INDENT .. string.format(L.Symbol_Colon, _G.LOOT_METHOD) .. " " .. self.lootMethod)
+ Bs:PrintBare(BS_INDENT .. "IsLootMaster? " .. tostring(self.isMasterLooter))
+ return true
+ end
+ end
+
+ Bagshui:RegisterEvent("BAGSHUI_LOCALIZATION_LOADED", GameInfo)
+ -- Location events.
+ Bagshui:RegisterEvent("PLAYER_ENTERING_WORLD", GameInfo)
+ Bagshui:RegisterEvent("MINIMAP_ZONE_CHANGED", GameInfo)
+ Bagshui:RegisterEvent("ZONE_CHANGED", GameInfo)
+ Bagshui:RegisterEvent("ZONE_CHANGED_NEW_AREA", GameInfo)
+ Bagshui:RegisterEvent("ZONE_CHANGED_INDOORS", GameInfo)
+ -- Group events.
+ Bagshui:RegisterEvent("PARTY_LOOT_METHOD_CHANGED", GameInfo)
+ Bagshui:RegisterEvent("PARTY_MEMBERS_CHANGED", GameInfo)
+ Bagshui:RegisterEvent("RAID_ROSTER_UPDATE", GameInfo)
+end)
diff --git a/Components/GameReport.lua b/Components/GameReport.lua
new file mode 100644
index 0000000..c386607
--- /dev/null
+++ b/Components/GameReport.lua
@@ -0,0 +1,68 @@
+-- Bagshui Game Report
+-- Exposes: BsGameReport (and Bagshui.components.GameReport)
+--
+-- Display information about the current WoW environment that can be useful for troubleshooting.
+
+Bagshui:AddComponent(function()
+ -- Initialize component.
+ local GameReport = Bagshui.prototypes.ScrollableTextWindow:New({
+ name = "GameReport",
+ readOnly = true,
+ selectAllOnFocus = true,
+ title = L.GameReport,
+ width = 400,
+ height = 600,
+
+ -- Used in OnEvent when receiving BAGSHUI_LOG_UPDATE events.
+ allowUpdates = true,
+ })
+ Bagshui.components.GameReport = GameReport
+ Bagshui.environment.BsGameReport = GameReport
+
+ --- Open the Bagshui game report window.
+ function GameReport:Open()
+ local lines = {}
+ local enabledAddons = {}
+ local disabledAddons = {}
+
+ local function addLine(text, tbl)
+ table.insert(tbl or lines, text)
+ end
+
+ addLine("Bagshui Version: " .. BS_VERSION)
+ addLine("Game Version: " .. table.concat({ _G.GetBuildInfo() }, " ") .. BS_NEWLINE)
+ addLine("Addons " .. BS_NEWLINE)
+ addLine("SuperWoW: " .. (_G.SUPERWOW_VERSION or "No") .. BS_NEWLINE)
+ for i = 1, _G.GetNumAddOns() do
+ local name, title, _, enabled, _, disabled = _G.GetAddOnInfo(i)
+ local version = _G.GetAddOnMetadata(name, "Version") or ""
+ addLine("* " .. title .. " " .. version, ((enabled and not disabled) and enabledAddons or disabledAddons))
+ end
+ addLine("**Enabled:**")
+ addLine(table.concat(enabledAddons, BS_NEWLINE))
+ addLine(BS_NEWLINE .. "Disabled: " .. BS_NEWLINE)
+ addLine(table.concat(disabledAddons, BS_NEWLINE))
+ addLine(BS_NEWLINE .. " ")
+
+ self._super.Open(self, table.concat(lines, BS_NEWLINE))
+
+ self.editBox:SetFocus()
+ end
+
+ --- Add customizations to the ScrollableTextWindow.
+ function GameReport:InitUi()
+ if self.uiFrame then
+ return
+ end
+ self._super.InitUi(self)
+ local instructions = self.uiFrame:CreateFontString(nil, nil, "GameFontHighlightSmall")
+ instructions:SetPoint("TOPLEFT", self.uiFrame.bagshuiData.header, "BOTTOMLEFT", 0, -2)
+ self.uiFrame.bagshuiData.scrollFrame:SetPoint("TOPLEFT", instructions, "BOTTOMLEFT", 0, -8)
+ instructions:SetText(L.GameReport_Instructions)
+ end
+
+ -- Register slash handler.
+ BsSlash:AddHandler("Report", function()
+ GameReport:Open()
+ end, true)
+end)
diff --git a/Components/Hooks.lua b/Components/Hooks.lua
new file mode 100644
index 0000000..d8d3c75
--- /dev/null
+++ b/Components/Hooks.lua
@@ -0,0 +1,192 @@
+-- Bagshui Hooks Class Prototype
+-- Exposes: Bagshui.prototypes.Hooks
+--
+-- WoW API functions can be hooked by creating an instance of the Hooks class and/or
+-- calling SetHooks()/SetHook(). The functions being called for hooks are expected to take
+-- the name of the WoW API function as their first parameter. They can then call
+-- `:OriginalHook()` to trigger the original
+-- API function. The name of the function is passed instead of a direct function
+-- reference so that if multiple API functions are hooked with the same Bagshui
+-- function, the Bagshui function has a way to tell what the original function name was.
+
+Bagshui:LoadComponent(function()
+ local Hooks = {}
+ Bagshui.prototypes.Hooks = Hooks
+
+ --- Create a new instance of the Hooks class.
+ ---@param hooksToRegister table? `hooks` parameter for SetHooks().
+ ---@param classObject table? Parameter for SetHook().
+ ---@return table hooksClassInstance
+ function Hooks:New(hooksToRegister, classObject)
+ local hooks = {
+ _super = Hooks,
+
+ originalHookFunctions = {},
+ }
+
+ setmetatable(hooks, self)
+ self.__index = self
+
+ hooks:SetHooks(hooksToRegister, BS_HOOK_ACTION.REGISTER, classObject)
+
+ return hooks
+ end
+
+ --- Bulk hook registration.
+ ---@param hooks table { wowApiFunctionName = newFunction } parameters for SetHook().
+ ---@param registrationAction BS_HOOK_ACTION Parameter for SetHook().
+ ---@param classObject table? Parameter for SetHook().
+ function Hooks:SetHooks(hooks, registrationAction, classObject)
+ if not hooks then
+ return
+ end
+ for wowApiFunctionName, newFunction in pairs(hooks) do
+ self:SetHook(wowApiFunctionName, newFunction, registrationAction, classObject)
+ end
+ end
+
+ --- WoW API hook management (register / unregister / check status).
+ --- Hooked functions are expected to handle all the parameters and return values as the originals,
+ --- but this can't really be checked, so it's up to the developer to do the right thing.
+ ---@param wowApiFunctionName string Name of the original WoW API function being hooked.
+ ---@param newFunction function|string Function that will handle the hook *or* the name of a class function, if classObject is provided.
+ ---@param registrationAction BS_HOOK_ACTION? Should the hook be registered, unregistered, or checked? (Registers if not provided.)
+ ---@param classObject table? The class that will handle this hook. When provided, `newFunction` *must* be a string instead of a function.
+ function Hooks:SetHook(wowApiFunctionName, newFunction, registrationAction, classObject)
+ local originalFunction, hookFunction, classSelf
+ registrationAction = registrationAction or BS_HOOK_ACTION.REGISTER
+
+ if registrationAction == BS_HOOK_ACTION.REGISTER then
+ -- Make sure the function isn't already hooked.
+ if not self.originalHookFunctions[wowApiFunctionName] then
+ -- Capture the original function reference.
+ originalFunction = _G[wowApiFunctionName]
+
+ -- Start by using self:OnHook, which will print a message that no hook action was taken.
+ classSelf = self
+ hookFunction = self.OnHook
+
+ -- When given a function as newFunction, use that as the hook function.
+ if type(newFunction) == "function" then
+ classSelf = nil
+ hookFunction = newFunction
+ end
+
+ -- When a class is provided, always use it as the self parameter for the hook call.
+ -- Also, if newFunction was a string, grab the class function.
+ if classObject then
+ classSelf = classObject
+ if type(newFunction) == "string" and classObject[newFunction] then
+ hookFunction = classObject[newFunction]
+ else
+ hookFunction = nil
+ end
+ end
+
+ -- Make sure the hookFunction is valid.
+ if type(hookFunction) ~= "function" then
+ Bagshui:PrintWarning(
+ "Could not install hook function for '"
+ .. wowApiFunctionName
+ .. "' -- newFunction parameter is not a valid function."
+ )
+ return
+ end
+
+ -- Store the original function reference.
+ self.originalHookFunctions[wowApiFunctionName] = originalFunction
+
+ -- Install hook.
+ -- Not using variadic arguments (...) - see https://github.com/shagu/pfUI/commit/e7dd8776f142a708e4677c1299ff89f1bcbe2baf
+ _G.setglobal(wowApiFunctionName, function(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
+ if classSelf then
+ return hookFunction(
+ classSelf,
+ wowApiFunctionName,
+ arg1,
+ arg2,
+ arg3,
+ arg4,
+ arg5,
+ arg6,
+ arg7,
+ arg8,
+ arg9,
+ arg10
+ )
+ end
+ return hookFunction(wowApiFunctionName, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
+ end)
+ else
+ Bagshui:PrintDebug("Hook function for '" .. wowApiFunctionName .. "' is already installed")
+ end
+ elseif registrationAction == BS_HOOK_ACTION.UNREGISTER then
+ originalFunction = self.originalHookFunctions[wowApiFunctionName]
+
+ if originalFunction ~= _G[wowApiFunctionName] then
+ _G.setglobal(wowApiFunctionName, originalFunction)
+ self.originalHookFunctions[wowApiFunctionName] = nil
+
+ Bagshui:PrintDebug("Hook function for '" .. wowApiFunctionName .. "' removed.")
+ else
+ Bagshui:PrintDebug("Hook function for '" .. wowApiFunctionName .. "' was not installed")
+ end
+ elseif registrationAction == BS_HOOK_ACTION.CHECK then
+ Bagshui:Print("Hooks:")
+ if
+ self.originalHookFunctions[wowApiFunctionName]
+ and _G[wowApiFunctionName] ~= self.originalHookFunctions[wowApiFunctionName]
+ then
+ Bagshui:Print("> " .. wowApiFunctionName .. ": hooked")
+ else
+ Bagshui:Print("> " .. wowApiFunctionName .. ": NOT hooked")
+ end
+ end
+ end
+
+ --- Call the original hook function if it exists.
+ --- Not using variadic arguments (...) - see https://github.com/shagu/pfUI/commit/e7dd8776f142a708e4677c1299ff89f1bcbe2baf
+ ---@param wowApiFunctionName string Name of the original WoW API function to call.
+ ---@param arg1 any?
+ ---@param arg2 any?
+ ---@param arg3 any?
+ ---@param arg4 any?
+ ---@param arg5 any?
+ ---@param arg6 any?
+ ---@param arg7 any?
+ ---@param arg8 any?
+ ---@param arg9 any?
+ ---@param arg10 any?
+ ---@return any? # Return value from the original WoW API function.
+ function Hooks:OriginalHook(wowApiFunctionName, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
+ if wowApiFunctionName == nil then
+ return
+ end
+ if self.originalHookFunctions[wowApiFunctionName] then
+ return self.originalHookFunctions[wowApiFunctionName](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
+ end
+ end
+
+ -- Hook handling (skeleton) / warning message.
+ -- This won't get called unless a hook isn't properly set up.
+ -- Not using variadic arguments (...) - see https://github.com/shagu/pfUI/commit/e7dd8776f142a708e4677c1299ff89f1bcbe2baf
+ ---@param wowApiFunctionName string Name of the original WoW API function to call.
+ ---@param arg1 any?
+ ---@param arg2 any?
+ ---@param arg3 any?
+ ---@param arg4 any?
+ ---@param arg5 any?
+ ---@param arg6 any?
+ ---@param arg7 any?
+ ---@param arg8 any?
+ ---@param arg9 any?
+ ---@param arg10 any?
+ function Hooks:OnHook(wowApiFunctionName, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
+ Bagshui:PrintWarning(
+ "Hook function "
+ .. tostring(wowApiFunctionName)
+ .. " triggered. Calling original API function (this probably shouldn't happen)."
+ )
+ self:OriginalHook(wowApiFunctionName, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
+ end
+end)
diff --git a/Components/Inventory.Actions.lua b/Components/Inventory.Actions.lua
new file mode 100644
index 0000000..308546a
--- /dev/null
+++ b/Components/Inventory.Actions.lua
@@ -0,0 +1,798 @@
+-- Bagshui Inventory Prototype: Actions
+-- Moving, equipping, making stuff happen.
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ --- Clear all stock change indicators.
+ function Inventory:ResetStockState()
+ for _, container in pairs(self.inventory) do
+ for _, item in pairs(container) do
+ item.bagshuiDate = 0
+ item.bagshuiStockState = BS_ITEM_STOCK_STATE.NO_CHANGE
+ end
+ end
+ -- Update the window, cascade to docked inventory, and force cache update.
+ -- (Inventory:UpdateCache() is where hasChanges is updated).
+ self:ForceUpdateWindow(true, true)
+ end
+
+ --- Return `true` if an item is protected from sale by the current settings.
+ ---@param item table Bagshui inventory cache entry.
+ ---@return string? protectionType Localized reason the item was protected ("Soulbound", "Quality", etc.), if any.
+ function Inventory:GetItemSellProtectionReason(item)
+ -- Protection is completely disabled.
+ if not self.settings.sellProtectionEnabled then
+ return
+ end
+
+ -- Active Quest.
+ if self.settings.sellProtectionActiveQuest and Bagshui.activeQuestItems[item.name] then
+ return L.ItemPropFriendly_activeQuest
+ end
+
+ -- Soulbound.
+ if self.settings.sellProtectionSoulbound and BsRules:Match("Soulbound()", item) then
+ return L.ItemPropFriendly_soulbound
+ end
+
+ -- Equipped Gear.
+ if self.settings.sellProtectionEquipped and BsCharacter.equippedHistory[item.itemString] then
+ return L.EquippedGear
+ end
+
+ -- Quality at or above threshold.
+ if (item.quality or 1) >= self.settings.sellProtectionQualityThreshold then
+ return L.Quality
+ end
+ end
+
+ --- Reset the pending sale of an item so it's no longer highlighted.
+ ---@param itemButton table? If provided, call OnEnter for this item button to update the tooltip.
+ ---@param noUpdate boolean? Don't call `Inventory:UpdateItemSlotColors()`.
+ function Inventory:ClearItemPendingSale(itemButton, noUpdate)
+ if self.itemPendingSale then
+ self.itemPendingSale = nil
+ self.highlightItemsInContainerId = nil
+ self.highlightItemsContainerSlot = nil
+ if itemButton then
+ self:ItemButton_OnEnter(itemButton)
+ end
+ if not noUpdate then
+ self:UpdateItemSlotColors()
+ end
+ end
+ end
+
+ --- Sort and organize inventory while the window is open.
+ --- Used from toolbars, menus, and key bindings.
+ function Inventory:Resort()
+ self.forceResort = true
+ -- Do NOT use QueueUpdate() here.
+ self:Update(true)
+ end
+
+ --- Initiate the restacking process.
+ --- This doesn't do the actual work; it just sets up a queue of restack operations,
+ --- which is necessary because there needs to be a slight delay between each operation.
+ --- That process is handled by ProcessMoveQueue().
+ function Inventory:Restack()
+ -- Ensure inventory cache is up to date.
+ self.cacheUpdateNeeded = true
+ self:UpdateCache()
+
+ -- This function can be called when a docked inventory has partial stacks
+ -- but this one doesn't. The case where this one has partials and the docked
+ -- one may or may not is handled once Inventory:ProcessMoveQueue() is done.
+ if not self.multiplePartialStacks then
+ if self.dockedInventory then
+ self.dockedInventory:Restack()
+ end
+ return
+ end
+
+ -- Prepare the helper function for sorting partial stacks.
+ if not self._PartialStackSortHelper then
+ self._PartialStackSortHelper = function(itmA, itmB)
+ return itmA.count > itmB.count
+ end
+ end
+
+ -- Reset restack queues.
+ self:ClearMoveQueue()
+
+ -- Reset restack retry count (used by ProcessMoveQueue() to decide when to give up if items are locked).
+ self.moveRetryCount = 0
+
+ -- Find all the partial stacks.
+ -- This will build out a table:
+ -- ```
+ -- {
+ -- [itemId1] = {
+ -- itemCacheReference1,
+ -- itemCacheReference2,
+ -- itemCacheReference3,
+ -- }
+ -- [itemId2] = {
+ -- itemCacheReference4,
+ -- itemCacheReference5,
+ -- }
+ -- }
+ -- ```
+ local allPartialStacks = {}
+ for _, bagContents in pairs(self.inventory) do
+ for _, item in ipairs(bagContents) do
+ if (self.partialStacks[item.id] or 0) > 1 and item.count < item.maxStackCount then
+ if allPartialStacks[item.id] == nil then
+ allPartialStacks[item.id] = {}
+ end
+ table.insert(allPartialStacks[item.id], item)
+ end
+ end
+ end
+
+ -- Sort partial stack lists for each item from largest to smallest.
+ for _, partialStacks in pairs(allPartialStacks) do
+ table.sort(partialStacks, self._PartialStackSortHelper)
+ end
+
+ local target, source, oldTargetCount, sourceLoopStart
+
+ -- Process each item's set of partial stacks in turn to create the restack queue.
+ for _, partialStacks in pairs(allPartialStacks) do
+ --self:PrintDebug("Restacking item ID " .. _)
+
+ -- Sources are obtained starting from the end of the list since it's
+ -- sorted largest to smallest and we're trying to move stacks with the
+ -- lowest counts onto those with the highest counts.
+ sourceLoopStart = table.getn(partialStacks)
+
+ -- Likewise, targets are obtained from the beginning of the list.
+ for targetIndex = 1, table.getn(partialStacks) - 1 do
+ target = partialStacks[targetIndex]
+ --self:PrintDebug("> starting target count: " .. target.count .. " (of max " .. target.maxStackCount .. " )")
+
+ if target.count < target.maxStackCount then
+ -- Work backwards through the list of partial stacks, potentially
+ -- up to the next-to-first (which is the initial target, so we
+ -- know we can never use it as a source).
+ for sourceIndex = sourceLoopStart, 2, -1 do
+ source = partialStacks[sourceIndex]
+ --self:PrintDebug(">> source count: " .. source.count)
+
+ if source ~= target and source.count > 0 and target.count < target.maxStackCount and target.count > 0 then
+ -- Queue move of source stacks onto target.
+ self:QueueMove(source, target)
+ --self:PrintDebug(string.format(">>> queued %s:%s (%s) to %s:%s (%s)", source.bagNum, source.slotNum, source.count, target.bagNum, target.slotNum, target.count))
+
+ -- Calculate the changes.
+ oldTargetCount = target.count
+ target.count = math.min(target.count + source.count, target.maxStackCount)
+ source.count = source.count - (target.count - oldTargetCount)
+ --self:PrintDebug(">> NEW source count: " .. source.count)
+ --self:PrintDebug(">> NEW target count: " .. target.count)
+
+ -- We're done with this source and can move on to the next one.
+ if source.count == 0 then
+ sourceLoopStart = sourceLoopStart - 1
+ end
+
+ -- Stop the inner loop if current target is maxed out.
+ if target.count == target.maxStackCount then
+ --self:PrintDebug(">> target maxed; breaking inner loop")
+ break
+ end
+ end
+ end
+ end
+ end
+ end
+ --self:PrintDebug(table.getn(self.queuedMoveSources) .. " moves queued")
+
+ -- Reusable function for restacking docked Inventory class.
+ if self.dockedInventory and not self.restack_callback then
+ self.restack_callback = function()
+ self.dockedInventory:Restack()
+ end
+ end
+
+ -- Make the actual moves.
+ self:MoveItems(self.restack_callback)
+ end
+
+ function Inventory:ClearMoveQueue()
+ BsUtil.TableClear(self.queuedMoveSources)
+ BsUtil.TableClear(self.queuedMoveTargets)
+ end
+
+ function Inventory:QueueMove(source, target)
+ table.insert(self.queuedMoveSources, source)
+ table.insert(self.queuedMoveTargets, target)
+ end
+
+ --- Perform queued item move operations.
+ ---
+ --- 🚦 Before calling, the `Inventory.queuedMoveSources/Targets` arrays must be
+ --- filled with the corresponding items that should be moved. (There aren't any
+ --- functions to do this; just use `table.insert()`).
+ ---
+ --- `Inventory:Restack()` is a good example of how to do this.
+ ---@param onComplete function? Will receive one boolean parameter indicating the success or failure of the move operations.
+ ---@param onProgress function? Will receive one number parameter indicating the current item in the list of moves that just completed.
+ ---@param resortAfterEachMove boolean? Organize the inventory after every action.
+ function Inventory:MoveItems(onComplete, onProgress, resortAfterEachMove)
+ -- Tracks the overall success of all operations in this session and is passed to `moveQueue_onComplete`.
+ self.moveQueue_success = true
+ -- Invoked when the move queue is empty and passed the success/failure status.
+ self.moveQueue_onComplete = onComplete
+ -- Used to calculate the current step for `moveQueue_onProgress()` since
+ self.moveQueue_totalItems = table.getn(self.queuedMoveSources)
+ -- Invoked after each move and passed the number of the move.
+ self.moveQueue_onProgress = onProgress
+ -- Organize inventory after every move.
+ self.moveQueue_resort = resortAfterEachMove
+ -- Reset tracking.
+ self.moveQueue_LastSource = nil
+ self:ProcessMoveQueue()
+ end
+
+ --- # 🛑 Do not call directly; use `Inventory:MoveItems()` 🛑.
+ --- Recursive item mover with delayed retry on fail.
+ --- Utilizes the class properties that are set by `Inventory:MoveItems()`.
+ function Inventory:ProcessMoveQueue()
+ --self:PrintDebug("Starting ProcessMoveQueue()")
+
+ -- Are we done?
+ if table.getn(self.queuedMoveSources) <= 0 then
+ --self:PrintDebug("Nothing left to do")
+ self:QueueUpdate(0.1)
+ -- Trigger callback.
+ if type(self.moveQueue_onComplete) == "function" then
+ self.moveQueue_onComplete(self.moveQueue_success)
+ end
+ return
+ end
+
+ --self:PrintDebug("starting move # " .. table.getn(self.queuedMoveSources))
+
+ -- Defaults.
+ local queueDelay = 0.15
+ local thisMoveSucceeded = false
+
+ -- Get info about the current operation.
+ local source = self.queuedMoveSources[1]
+ local target = self.queuedMoveTargets[1]
+
+ -- Reset retry count.
+ if source ~= self.moveQueue_LastSource then
+ self.moveRetryCount = 0
+ end
+
+ -- Make sure neither item is locked
+ -- ClassicAPI's C_Container returns nil for empty slots, otherwise a table with `isLocked`.
+ local sourceInfo = _G.C_Container.GetContainerItemInfo(source.bagNum, source.slotNum)
+ local targetInfo = _G.C_Container.GetContainerItemInfo(target.bagNum, target.slotNum)
+ local sourceLocked = sourceInfo and sourceInfo.isLocked
+ local targetLocked = targetInfo and targetInfo.isLocked
+
+ -- Only attempt the move if everything is unlocked
+ if not sourceLocked and not targetLocked then
+ -- Record stock states so we can revise them.
+ local oldSourceStockState = source.bagshuiStockState
+ local oldSourceDate = source.bagshuiDate or 0
+ local oldTargetStockState = target.bagshuiStockState
+ local oldTargetDate = target.bagshuiDate or 0
+
+ -- Set resorting for this inventory and any supplemental storage.
+ if self.moveQueue_resort then
+ self.forceResort = self.forceResort or self.moveQueue_resort
+ if
+ type(self.bagSwappingSupplementalStorage) == "table"
+ and table.getn(self.bagSwappingSupplementalStorage) > 0
+ then
+ for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
+ if Bagshui.components[inventoryClassName] and Bagshui.components[inventoryClassName].online then
+ Bagshui.components[inventoryClassName].forceResort = Bagshui.components[inventoryClassName].forceResort
+ or self.moveQueue_resort
+ end
+ end
+ end
+ end
+
+ -- Do the actual move.
+ self:MoveItem(source, target)
+
+ if not _G.CursorHasItem() then
+ -- Try to make stock states and dates make sense.
+ -- Always copy source stock state over target if the source was new/increased,
+ -- but only copy a decreased stock state if the target didn't have a state.
+ if
+ oldSourceStockState
+ and oldSourceStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE
+ and (
+ oldSourceStockState ~= BS_ITEM_STOCK_STATE.DOWN
+ or (
+ oldSourceStockState == BS_ITEM_STOCK_STATE.DOWN
+ and (not oldTargetStockState or oldTargetStockState == BS_ITEM_STOCK_STATE.NO_CHANGE)
+ )
+ )
+ then
+ target.bagshuiStockState = oldSourceStockState
+ end
+ target.bagshuiDate = math.max(oldSourceDate, oldTargetDate)
+
+ -- These can come out of the queue now.
+ thisMoveSucceeded = true
+
+ -- Reset retry count.
+ self.moveRetryCount = 0
+ end
+ end
+
+ -- Move attempt failed.
+ if not thisMoveSucceeded then
+ -- Show error message and retry if we haven't reached the limit.
+ local baseError = string.format(L.Error_RestackFailed, source.name)
+
+ if (self.moveRetryCount or 0) < 5 then
+ -- Longer delay until retry if either item was locked.
+ queueDelay = 0.5
+
+ if self.moveRetryCount > 1 then
+ Bagshui:ShowErrorMessage(string.format(L.Error_Suffix_Retrying, baseError), self.inventoryType, 1.0, 0.578, 0)
+ queueDelay = 1.0
+ end
+
+ self.moveRetryCount = self.moveRetryCount + 1
+ else
+ -- Once we've tried several times, give up on this operation and move to the next one.
+ Bagshui:ShowAndLogErrorMessage(baseError)
+ self.moveQueue_success = false
+ self.moveRetryCount = 0
+ thisMoveSucceeded = true
+ end
+ end
+
+ -- Once we're done with this set, take them out of the queue.
+ if thisMoveSucceeded then
+ table.remove(self.queuedMoveSources, 1)
+ table.remove(self.queuedMoveTargets, 1)
+ -- Update progress.
+ if type(self.moveQueue_onProgress) == "function" then
+ self.moveQueue_onProgress(self.moveQueue_totalItems - table.getn(self.queuedMoveSources))
+ end
+ end
+
+ -- Queue either the next restack operation or retry the current one.
+ -- The check at the beginning of this function will stop things when we're done.
+ --self:PrintDebug("Remaining: " .. table.getn(self.queuedMoveSources))
+ Bagshui:QueueClassCallback(self, self.ProcessMoveQueue, queueDelay, nil)
+ end
+
+ --- Move an item from one place to another, ensuring no modifier keys get in the way.
+ ---@param source table Inventory cache entry.
+ ---@param target table|number Inventory cache entry *or* bag number, if equipping a bag.
+ ---@param onComplete function? Called once the operation is complete with a boolean parameter indicating whether the cursor still has an item.
+ function Inventory:MoveItem(source, target, onComplete)
+ local doCallback = true
+
+ -- Empty the cursor just in case, since we're technically picking up and putting down items.
+ _G.ClearCursor()
+
+ -- Intentionally calling the game's `PickupContainerItem()` instead of `Bagshui:PickupItem()`
+ -- because it's immediately picked up and put down, and we don't need (or want) to invoke
+ -- `ContainerFrameItemButton_OnClick()` either. As an extra safety measure, force all
+ -- modifier keys to return false for the duration of the move since some addons
+ -- hook `PickupContainerItem()` and change its behavior when a modifier is pressed.
+
+ local oldIsAltKeyDown = _G.IsAltKeyDown
+ local oldIsControlKeyDown = _G.IsControlKeyDown
+ local oldIsShiftKeyDown = _G.IsShiftKeyDown
+ _G.IsAltKeyDown = BsUtil.ReturnFalse
+ _G.IsControlKeyDown = BsUtil.ReturnFalse
+ _G.IsShiftKeyDown = BsUtil.ReturnFalse
+ _G.PickupContainerItem(source.bagNum, source.slotNum)
+ if type(target) == "table" then
+ _G.PickupContainerItem(target.bagNum, target.slotNum)
+ elseif type(target) == "number" then
+ -- This is an item being equipped to a slot.
+ _G.EquipCursorItem(_G.ContainerIDToInventoryID(target))
+ -- Might be BOE, so pass callback responsibilities over.
+ -- (Could check for BOE but there's really no reason to do so when
+ -- WaitForStaticPopupClose() will just invoke the callback immediately
+ -- if the BOE dialog doesn't open.)
+ BsUtil.WaitForStaticPopupClose(
+ "EQUIP_BIND",
+ 300, -- Five minute wait.
+ function(waitSuccess)
+ if type(onComplete) == "function" then
+ if waitSuccess == false then
+ onComplete(false)
+ else
+ onComplete((not _G.CursorHasItem()))
+ end
+ end
+ end
+ )
+ -- Callback is handled by WaitForStaticPopupClose().
+ doCallback = false
+ end
+ _G.IsAltKeyDown = oldIsAltKeyDown
+ _G.IsControlKeyDown = oldIsControlKeyDown
+ _G.IsShiftKeyDown = oldIsShiftKeyDown
+
+ if doCallback and type(onComplete) == "function" then
+ onComplete((not _G.CursorHasItem()))
+ end
+ end
+
+ -- When the bag being swapped in is inside the bag it's replacing, we need
+ -- to know where it gets moved to so we can actually equip it.
+ local swapBag_sourceItemTracker = {}
+
+ --- Vanilla doesn't allow non-empty bags to be swapped, and manually emptying and
+ --- refilling is a pain. Let's make that better.
+ --- ### Note about profession bags and swapping.
+ --- There doesn't seem to be a way to ask whether an item can go in a container
+ --- other than calling `PutItemInBag()` and seeing whether it errors. For this
+ --- reason, profession bags are being excluded as valid temporary storage during
+ --- bag swapping. (Profession bags themselves can still be swapped.)
+ ---@param sourceItem table New bag (entry from inventory cache).
+ ---@param bagNum number Bag slot to swap.
+ ---@param phase number? Used internally by the swapping process to trigger each phase (empty / swap / fill).
+ function Inventory:SwapBag(sourceItem, bagNum, phase, force)
+ if type(sourceItem) ~= "table" or sourceItem.emptySlot == 1 or not BsItemInfo:IsContainer(sourceItem) then
+ -- If this is the initial call and the item isn't a bag, error out.
+ -- After that, jump straight to refilling the original bag.
+ if (phase or 1) == 1 then
+ Bagshui:ShowErrorMessage(_G.BAG_NOT_EQUIPPABLE, nil, nil, nil, nil, nil, nil, false, true)
+ return
+ else
+ phase = 3
+ end
+ end
+
+ if (phase or 1) == 1 then
+ -- PHASE 1: Empty the old bag.
+
+ -- Total steps is:
+ -- Number of items in the bag being emptied
+ -- + 1 (Equip the new bag)
+ -- + Number of items to put back in the new bag
+ self.swapBag_totalSteps = self.containers[bagNum].slotsFilled * 2 + 1
+ -- Consumed by `Inventory:UpdateSwapBagProgress()`.
+ self.swapBag_currentStep = 0
+
+ -- In case the bag was inside the bag we're swapping, we'll need to know its
+ -- new location so we can actually equip it.
+ swapBag_sourceItemTracker[1] = sourceItem
+
+ self:EmptyBag(
+ bagNum,
+ swapBag_sourceItemTracker,
+ -- Completion callback.
+ function(success)
+ if success == false or type(success) == "string" then
+ Bagshui:ShowErrorMessage(type(success) == "string" and success or L.Error_BagSwap_EmptyOldFailed)
+ self:UpdateSwapBagProgress(bagNum)
+ -- Try to put things back.
+ self:SwapBag(sourceItem, bagNum, 3)
+ return
+ end
+
+ -- Need a slight delay here so the cache has time to catch up.
+ Bagshui:QueueClassCallback(self, self.SwapBag, 0.15, nil, swapBag_sourceItemTracker[1], bagNum, 2)
+ end,
+ -- Progress callback.
+ function()
+ self:UpdateSwapBagProgress(bagNum)
+ end
+ )
+ elseif phase == 2 then
+ -- PHASE 2: Equip the new bag.
+
+ self:EquipBag(
+ sourceItem,
+ bagNum,
+ -- Completion callback.
+ function(success)
+ if success == false or type(success) == "string" then
+ Bagshui:ShowErrorMessage(type(success) == "string" and success or L.Error_BagSwap_EquipNewFailed)
+ end
+ self:UpdateSwapBagProgress(bagNum)
+ -- Always put things back even if the equip attempt failed.
+ self:SwapBag(sourceItem, bagNum, 3)
+ end
+ )
+ elseif phase == 3 then
+ -- PHASE 3: Fill the new bag.
+
+ Bagshui:QueueClassCallback(
+ self,
+ self.FillBag,
+ 0.15,
+ nil,
+ bagNum,
+ self.tempMoveTargets,
+ -- Completion callback.
+ function()
+ BsUtil.TableClear(self.tempMoveTargets)
+ self:UpdateSwapBagProgress(bagNum, true)
+ self.forceResort = true
+ -- Need to use QueueUpdate() here to ensure forceResort is consumed properly
+ -- with the other window changes that are going to be happening at the same time.
+ self:QueueUpdate()
+ end,
+ -- Progress callback.
+ function()
+ self:UpdateSwapBagProgress(bagNum)
+ end
+ )
+ end
+ end
+
+ --- Increment the progress indicator to show how far along the bag swap is.
+ --- Always call with `done = true` at the end of the swap to ensure the progress indicator is hidden.
+ ---@param bagNum number Bag slot being swapped.
+ ---@param done boolean? Swap is complete.
+ function Inventory:UpdateSwapBagProgress(bagNum, done)
+ if done then
+ self.temporarilyShowBagBar = false
+ self.ui.buttons.bagSlots[self.myContainerIds[bagNum]].bagshuiData.progressPercent = 100
+ else
+ -- We're using the bag bar and cooldown to display progress.
+ if not self.temporarilyShowBagBar then
+ self.temporarilyShowBagBar = true
+ self:UpdateWindow()
+ end
+ self.swapBag_currentStep = self.swapBag_currentStep + 1
+ self.ui.buttons.bagSlots[self.myContainerIds[bagNum]].bagshuiData.progressPercent = self.swapBag_currentStep
+ / self.swapBag_totalSteps
+ * 100
+ end
+ end
+
+ -- Used to determine whether there are enough free slots to empty a bag.
+ -- (Reusable table for `Inventory:EmptyBag()`.)
+ local emptyBag_potentialAvailableSlots = {}
+
+ --- Remove all items from a bag.
+ --- Automatically backs up the new locations to `Inventory.tempMoveTargets`.
+ ---@param bagNum number Container to empty.
+ ---@param itemsToTrack table? Array of cache items to find the future position of if they move.
+ ---@param onComplete function? Completion callback (will be called from `Inventory:MoveItems()`).
+ ---@param onProgress function? Per-move callback (will be called from `Inventory:MoveItems()`).
+ function Inventory:EmptyBag(bagNum, itemsToTrack, onComplete, onProgress)
+ if not self.containers[bagNum] then
+ return
+ end
+
+ -- We're going to make things simpler by just working with the list of all empty
+ -- slots and skipping the ones that belong to the bag being emptied in the
+ -- loop where we fill `queuedMoveSources/Targets`.
+ BsUtil.TableCopyFlat(self.emptyGenericContainerSlots, emptyBag_potentialAvailableSlots)
+
+ -- Find out how much free space there is, excluding the bag being emptied.
+ local availableEmptySlotCount = self:GetAdjustedEmptySlotCount(bagNum)
+
+ -- Extra space is needed. See if we can find any.
+ if (self.containers[bagNum].slotsFilled or 0) > availableEmptySlotCount then
+ availableEmptySlotCount = availableEmptySlotCount
+ + self:GetSupplementalEmptySlotCount(emptyBag_potentialAvailableSlots)
+ end
+
+ -- Before going any further, make sure there's enough space.
+ -- This is a variable instead of being in the `if` just below because it
+ -- needs to be updated if we run out of slots in `emptyBag_potentialAvailableSlots`.
+ local readyToEmpty = self.containers[bagNum].slotsFilled <= availableEmptySlotCount
+
+ if readyToEmpty then
+ -- Reset queues used by `Inventory:ProcessMoveQueue()`
+ self:ClearMoveQueue()
+
+ -- We'll need to iterate through `emptyGenericContainerSlots` to find the
+ -- next one that doesn't belong to this bag.
+ local nextTargetSlotIndex = 0
+ local nextTargetSlot
+
+ -- Find a corresponding empty slot for every item in this bag.
+ for _, item in ipairs(self.inventory[bagNum]) do
+ if item.emptySlot ~= 1 then
+ repeat
+ nextTargetSlotIndex = nextTargetSlotIndex + 1
+ nextTargetSlot = emptyBag_potentialAvailableSlots[nextTargetSlotIndex]
+ until not nextTargetSlot or nextTargetSlot.bagNum ~= bagNum
+ if not nextTargetSlot then
+ -- This hopefully shouldn't be hit but just in case.
+ readyToEmpty = false
+ break
+ end
+ -- Update tracking.
+ if type(itemsToTrack) == "table" then
+ for i = 1, table.getn(itemsToTrack) do
+ if itemsToTrack[i] == item then
+ itemsToTrack[i] = nextTargetSlot
+ end
+ end
+ end
+ -- Instructions for MoveItems().
+ self:QueueMove(item, nextTargetSlot)
+ end
+ end
+ end
+
+ -- Are we good to go?
+ if readyToEmpty then
+ -- Make items available for refilling.
+ BsUtil.TableCopyFlat(self.queuedMoveTargets, self.tempMoveTargets)
+
+ -- Move! Those! Items!
+ self:MoveItems(onComplete, onProgress, true)
+
+ -- No need to hold onto this.
+ BsUtil.TableClear(emptyBag_potentialAvailableSlots)
+ else
+ -- Failure state.
+ self:ClearMoveQueue()
+
+ local errorMessage = (self:GetBagSlotSwappableText(bagNum, true) or L.Error_BagSwap_EmptyOldInsufficientSpace)
+
+ if type(onComplete) == "function" then
+ onComplete(errorMessage)
+ else
+ Bagshui:ShowErrorMessage(errorMessage)
+ end
+ end
+ end
+
+ --- Move the given list of items into a different bag.
+ ---@param bagNum number Bag into which the items should be placed.
+ ---@param items table[] Array of inventory cache entries.
+ ---@param onComplete function? Completion callback (will be called from `Inventory:MoveItems()`).
+ ---@param onProgress function? Per-move callback (will be called from `Inventory:MoveItems()`).
+ function Inventory:FillBag(bagNum, items, onComplete, onProgress)
+ if not self.inventory[bagNum] then
+ return
+ end
+
+ BsUtil.TableCopyFlat(items, self.queuedMoveSources)
+ BsUtil.TableCopyFlat(self.inventory[bagNum], self.queuedMoveTargets)
+
+ -- Prune source table if there aren't enough slots in the destination.
+ if table.getn(self.queuedMoveSources) > table.getn(self.queuedMoveTargets) then
+ for i = table.getn(self.queuedMoveSources), table.getn(self.queuedMoveTargets) + 1, -1 do
+ table.remove(self.queuedMoveSources, i)
+ end
+ end
+
+ self:MoveItems(onComplete, onProgress, true)
+ end
+
+ --- Equip a bag.
+ ---@param item table Inventory cache entry of the bag to be equipped.
+ ---@param bagNum number Bag slot into which the bag will be placed.
+ ---@param onComplete function? Success/failure callback.
+ function Inventory:EquipBag(item, bagNum, onComplete)
+ self:MoveItem(item, bagNum, onComplete)
+ end
+
+ --- Find the count of free bag slots, less those belonging to `excludeBagNum` and
+ --- any profession bags.
+ ---@param excludeBagNum number? Bag to omit.
+ ---@return integer emptySlots
+ function Inventory:GetAdjustedEmptySlotCount(excludeBagNum)
+ local availableEmptySlotCount = 0
+ for containerNum, container in pairs(self.containers) do
+ if
+ containerNum ~= excludeBagNum
+ and container.numSlots > 0
+ and container.slotsFilled < container.numSlots
+ -- Generic bags only (no profession bags). It's too hard to know what
+ -- is allowed in a container without trying it, so we're just going
+ -- to ignore profession bags as swap targets.
+ and container.genericType == BsGameInfo.itemSubclasses["Container"]["Bag"]
+ then
+ availableEmptySlotCount = availableEmptySlotCount + (container.numSlots - container.slotsFilled)
+ end
+ end
+ return availableEmptySlotCount
+ end
+
+ -- Reusable table for `Inventory:GetSupplementalEmptySlotCount()`.
+ local getSupplementalEmptySlotCount_inventoriesChecked = {}
+
+ --- Determine how many slots are available in other inventories to help with bag swapping.
+ ---@param availableSlotTable table? Array to which additional slots should be added.
+ ---@return integer availableEmptySlotCount
+ ---@return table inventoriesChecked Other inventory classes that were checked for available slots.
+ function Inventory:GetSupplementalEmptySlotCount(availableSlotTable)
+ local availableEmptySlotCount = 0
+ BsUtil.TableClear(getSupplementalEmptySlotCount_inventoriesChecked)
+ if
+ -- Other inventory classes that can be used during swapping are configured in this property.
+ type(self.bagSwappingSupplementalStorage) == "table" and table.getn(self.bagSwappingSupplementalStorage) > 0
+ then
+ for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
+ local supplemental = Bagshui.components[inventoryClassName]
+ if supplemental and supplemental.online then
+ if table.getn(supplemental.emptyGenericContainerSlots) > 0 then
+ availableEmptySlotCount = availableEmptySlotCount + table.getn(supplemental.emptyGenericContainerSlots)
+ if availableSlotTable then
+ for _, slot in ipairs(supplemental.emptyGenericContainerSlots) do
+ table.insert(emptyBag_potentialAvailableSlots, slot)
+ end
+ end
+ end
+ getSupplementalEmptySlotCount_inventoriesChecked[inventoryClassName] = true
+ end
+ end
+ end
+ return availableEmptySlotCount, getSupplementalEmptySlotCount_inventoriesChecked
+ end
+
+ --- Calculate how many more slots are required before a swap of the specified bag can be performed.
+ ---@param bagNum number Container to check.
+ ---@return integer slotsNeeded
+ ---@return table? inventoriesChecked Second return value from `Inventory:GetSupplementalEmptySlotCount()`.
+ function Inventory:GetAdditionalSlotsNeededToSwapBag(bagNum)
+ local filledSlots = (self.containers[bagNum].slotsFilled or 0)
+
+ if filledSlots == 0 then
+ return 0
+ end
+
+ local availableEmptySlots = self:GetAdjustedEmptySlotCount(bagNum)
+
+ if availableEmptySlots >= filledSlots then
+ return 0
+ end
+
+ local supplementalEmptySlots, inventoriesChecked = self:GetSupplementalEmptySlotCount()
+
+ if availableEmptySlots + supplementalEmptySlots >= filledSlots then
+ return 0, inventoriesChecked
+ end
+
+ return (filledSlots - availableEmptySlots - supplementalEmptySlots), inventoriesChecked
+ end
+
+ --- Error-triggered bag swapping (as opposed to native bag swapping via a Bagshui
+ --- bag slot button's OnClick). This allows Bagshui to perform bag swaps even
+ --- if the equip action is invoked via the main action bar or any other method.
+ ---
+ --- Methodology based on Swapper by Gabriele Cimolino
+ --- https://github.com/cubenicke/Swapper
+ ---@param wowApiFunctionName any
+ ---@param event any
+ ---@param message any
+ function Inventory:UIErrorsFrame_OnEvent(wowApiFunctionName, event, message)
+ if event == "UI_ERROR_MESSAGE" and message == _G.TEXT(_G.ERR_DESTROY_NONEMPTY_BAG) then
+ local newBag, bagSlot
+
+ -- Find the bag that needs to be equipped.
+ for _, bagContents in pairs(self.inventory) do
+ for __, item in ipairs(bagContents) do
+ if item.locked == 1 then
+ newBag = item
+ end
+ end
+ end
+
+ -- Find the slot into which it should be put.
+ for _, containerId in ipairs(self.containerIds) do
+ if _G.IsInventoryItemLocked(_G.ContainerIDToInventoryID(containerId)) then
+ bagSlot = containerId
+ end
+ end
+
+ -- Suppress the error and do the swap.
+ if newBag and bagSlot then
+ self:SwapBag(newBag, bagSlot)
+ -- Can't just return here or it seems to break things, so
+ -- we'll blank the message instead.
+ message = ""
+ end
+ end
+ self.hooks:OriginalHook(wowApiFunctionName, event, message)
+ end
+end)
diff --git a/Components/Inventory.Cache.lua b/Components/Inventory.Cache.lua
new file mode 100644
index 0000000..4733783
--- /dev/null
+++ b/Components/Inventory.Cache.lua
@@ -0,0 +1,598 @@
+-- Bagshui Inventory Prototype: Cache Management
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ --- Primary cache management function that copies inventory contents into `self.inventory[bag][slot]`.
+ ---
+ --- Also responsible for deciding:
+ --- * Whether resort/window update is required.
+ --- * Item stock states (including saving/restoring when an items/bags are moved around).
+ --- * If each empty slot is allowed to stack.
+ --- * When toolbar icons that act on the inventory should be enabled (toolbar updates are performed in Inventory.layout.lua).
+ function Inventory:UpdateCache()
+ -- self:PrintDebug("UpdateCache()")
+
+ -- Don't even try any updates until they're permitted.
+ if not self.inventoryUpdateAllowed or not self.online then
+ return
+ end
+
+ -- forceFullCacheUpdate should also satisfy any forceCacheUpdate condition.
+ self.forceCacheUpdate = self.forceCacheUpdate or self.forceFullCacheUpdate
+
+ -- If an update wasn't actually required, skip it.
+ if not self.cacheUpdateNeeded and not self.forceCacheUpdate then
+ return
+ end
+
+ -- If this was triggered by a bag change, wait for the next update.
+ if self.lastEvent == "ITEM_LOCK_CHANGED" and BsUtil.TrueTableSize(self.lastUpdateLockedContainers) > 0 then
+ BsUtil.TableClear(self.lastUpdateLockedContainers)
+ -- Queuing another update here instead of completely ignoring to ensure that an update
+ -- does happen eventually (and it should typically happen sooner than the delay here).
+ self:QueueUpdate(0.1)
+ return
+ end
+
+ self:PrintDebug("Updating inventory cache")
+
+ -- We've passed the initial checks and can start preparing to update the cache.
+
+ -- Reference to self.inventory[bagNum][slotNum].
+ local item
+
+ -- The `*Changes` variables are used to figure out what needs to occur be once this function has finished.
+
+ -- New items added, items removed, counts changed, etc. Most commonly true of the three `*Changes` variables.
+ local majorChanges = false
+ -- Usually just means that an item has locked or unlocked.
+ local cosmeticChanges = false
+ -- Not currently used; used to be called `resort_suggested` in EngInventory. Leaving in case it comes in handy in the future.
+ local minorChanges = false
+
+ -- Track whether there are multiple partial stacks of the same item to decide whether the
+ -- Restack button should be enabled.
+ self.multiplePartialStacks = false
+
+ -- Update this on every cache pass, since those happen fairly frequently.
+ -- Used to determine whether change highlighting should be enabled.
+ self.hasChanges = false
+
+ -- Whether any item in the cache has the `_bagshuiPreventEmptySlotStack` property set to `true`
+ -- Used by `ManageDryRun()` as a factor to determine whether `enableResortIcon` should be true.
+ self.hasSlotsWithStackingPrevented = false
+
+ -- Bag (outer loop) variables.
+ local bagName, bagNumSlots, bagTexture, bagType, bagSlotLink, bagItemCode, bagInfo
+
+ -- Slot (inner loop) variables.
+ local itemReadable
+ -- Used to compare current cache item and actual item now present in the slot
+ local preCharges, preCount, preItemLink, preLocked, preItemString, preTooltip, nowCount, nowItemLink, nowLocked, nowCharges
+ -- These two variables are used to maintain and restore from the stock state cache.
+ -- (See Bagshui:PickupInventoryItem() for reasoning.)
+ local shadowId, prevShadowId
+
+ -- Reset tracking tables.
+ BsUtil.TableClear(self.partialStacks)
+ BsUtil.TableClear(self.emptyGenericContainerSlots)
+
+ -- Identify any container changes triggered by moving bags between slots.
+ if Bagshui.pickedUpBagSlotNum and Bagshui.putDownBagSlotNum then
+ -- Container changes are only applicable if it's one of our containers.
+ local pickedUpContainerId = self.inventoryIdsToContainerIds[Bagshui.pickedUpBagSlotNum]
+ local putDownContainerId = self.inventoryIdsToContainerIds[Bagshui.putDownBagSlotNum]
+ if pickedUpContainerId and putDownContainerId then
+ self.pendingContainerChanges[pickedUpContainerId] = putDownContainerId
+ self.pendingContainerChanges[putDownContainerId] = pickedUpContainerId
+ end
+ end
+ local hasPendingContainerChanges = BsUtil.TrueTableSize(self.pendingContainerChanges) > 0
+
+ -- Loop through bags.
+ -- Using _bagIndex instead of _ as the throwaway variable for this loop because
+ -- _ is used heavily within, and overwriting the loop variable can cause issues.
+ for _bagIndex, bagNum in ipairs(self.containerIds) do
+ -- Initialize bag cache if needed.
+ if self.inventory[bagNum] == nil then
+ self.inventory[bagNum] = {}
+ end
+
+ -- Get bag info.
+ bagName = nil
+ bagNumSlots = self:GetContainerNumSlots(bagNum) -- self:GetContainerNumSlots() intelligently handles this for for all container types.
+ bagTexture = nil
+ bagType = nil
+ bagSlotLink = nil
+ bagItemCode = nil
+ if self.primaryContainer.id == bagNum then
+ -- Can't pull primary container information from the API.
+ bagName = self.primaryContainer.name
+ bagTexture = self.primaryContainer.texture
+ bagType = self.primaryContainer.name
+ else
+ bagName = _G.GetBagName(bagNum)
+ bagSlotLink = _G.GetInventoryItemLink("player", _G.ContainerIDToInventoryID(bagNum))
+ if bagSlotLink ~= nil then
+ _, _, bagItemCode = string.find(bagSlotLink, "(%d+):")
+ _, _, _, _, _, bagType, _, _, bagTexture = _G.GetItemInfo(bagItemCode)
+ end
+ end
+
+ -- Keep track of whether this bag got locked to help coordinate bag change updates after it's unlocked.
+ self.lastUpdateLockedContainers[bagNum] = _G.IsInventoryItemLocked(_G.ContainerIDToInventoryID(bagNum))
+
+ -- No reason to store full texture paths since they're always in Interface\Icons.
+ bagTexture = bagTexture
+
+ -- self.containers[bagNum] always already exists in SavedVariables or has been
+ -- initialized to {} during Inventory:Init().
+ bagInfo = self.containers[bagNum]
+
+ bagInfo.bagNum = bagNum
+
+ -- Bag has changed or isn't initialized.
+ if
+ self.pendingContainerChanges[bagNum]
+ or bagInfo.name ~= bagName
+ or bagInfo.numSlots ~= bagNumSlots
+ or bagInfo.texture ~= bagTexture
+ or bagInfo.type ~= bagType
+ or (
+ -- Need to check for numSlots > 0 to avoid constantly rebuilding container info for empty bag slots
+bagInfo.numSlots > 0 and (not bagInfo.name or not bagInfo.type)
+ )
+ or self.initialInventoryUpdateNeeded
+ then
+ -- When the bag changes after startup, wipe its cache.
+ if (bagInfo.numSlots or 0) > 0 and not self.initialInventoryUpdateNeeded then
+ BsUtil.TableClear(self.inventory[bagNum])
+ end
+ bagInfo.name = bagName
+ bagInfo.numSlots = bagNumSlots
+ bagInfo.texture = bagTexture
+ bagInfo.type = bagType
+
+ -- isProfessionBag and genericType are used primarily for empty slot stacking.
+ -- Anything that isn't a normal bag class and isn't the primary container
+ -- is going to be considered a profession bag.
+ bagInfo.isProfessionBag = (
+ bagType ~= BsGameInfo.itemSubclasses["Container"]["Bag"] and bagType ~= self.primaryContainer.name
+ )
+
+ -- A bag's "generic type" is the the item class returned by GetItemInfo for profession
+ -- bags or the localized version of "Bag" for any other bag and the primary container.
+ bagInfo.genericType = bagInfo.isProfessionBag and bagInfo.type or BsGameInfo.itemSubclasses["Container"]["Bag"]
+
+ -- Bag changes require a resort even if the window is visible.
+ self.forceResort = true
+ end
+
+ -- Reset tracking of filled slots.
+ self.containers[bagNum].slotsFilled = 0
+
+ -- Make sure this bag has slots to process.
+ if bagNumSlots > 0 then
+ -- Prepare to stack empty slots (actual stacking is handled during UpdateWindow()).
+ self:InitializeEmptySlotStackTracking(bagInfo)
+
+ -- Time to go through all the bag slots and look at each item.
+ for slotNum = 1, bagNumSlots do
+ shadowId = nil
+ prevShadowId = nil
+
+ -- Initialize this slot in the cache when it doesn't exist yet.
+ -- Using table.insert here to ensure table.getn() works correctly.
+ if table.getn(self.inventory[bagNum]) < slotNum then
+ table.insert(self.inventory[bagNum], {})
+ BsItemInfo:InitializeItem(self.inventory[bagNum][slotNum])
+ end
+
+ item = self.inventory[bagNum][slotNum]
+
+ -- Store previous information so we can determine whether changes occurred.
+ preItemLink = item.itemLink
+ preItemString = item.itemString
+ preCharges = item.charges or 0
+ preCount = item.count or 0
+ preLocked = item.locked
+ preTooltip = item.tooltip
+
+ -- Get current item information.
+ nowItemLink = _G.GetContainerItemLink(bagNum, slotNum)
+ -- Ignoring quality (return value 4) from GetContainerItemInfo because
+ -- it sometimes returns -1, which according to https://warcraft.wiki.gg/index.php?title=API_GetContainerItemInfo&oldid=2497880
+ -- means it's somehow special (stackable, unique or a quest item).
+ -- We'll obtain it from GetItemInfo() instead. (We also don't need
+ -- texture since that will be obtained during ItemInfo:Get()).
+ _, nowCount, nowLocked, _, itemReadable = _G.GetContainerItemInfo(bagNum, slotNum)
+
+ -- Update filled slot count for this container.
+ if nowItemLink ~= nil then
+ self.containers[bagNum].slotsFilled = self.containers[bagNum].slotsFilled + 1
+ end
+
+ -- If count was nil, set it to 0 so that the cache isn't constantly getting
+ -- re-processed (otherwise preCount won't equal nowCount for empty slots).
+ nowCount = nowCount or 0
+
+ -- Assume there are no charges. This property will be filled either by Bagshui's
+ -- native tooltip charges parsing in ItemInfo:GetTooltip() or just below
+ -- if SuperWoW is loaded. Making item.charges not equal preCharges is also the
+ -- trigger for native charges parsing when the item doesn't change.
+ item.charges = 0
+ nowCharges = 0
+
+ -- Item charges from SuperWoW: move negative item counts, which indicate charges, into the charges field.
+ if nowCount < 0 then
+ nowCharges = math.abs(nowCount)
+ item.charges = nowCharges
+ nowCount = 1
+ end
+
+ -- ClassicAPI reports charges directly, so Bagshui doesn't have to depend on
+ -- SuperWoW's negative item counts or tooltip parsing. Only ask for filled slots,
+ -- and don't override charges already obtained above.
+ -- (Note that C_Container.GetContainerItemInfo() reports a count of 1 for charged
+ -- items, which is why the count above still comes from GetContainerItemInfo().)
+ if nowCharges == 0 and nowItemLink ~= nil then
+ local apiCharges = _G.C_Container.GetContainerItemCharges(bagNum, slotNum)
+ apiCharges = apiCharges and tonumber(apiCharges)
+ if apiCharges and apiCharges > 0 then
+ nowCharges = apiCharges
+ item.charges = apiCharges
+ end
+ end
+
+ -- Process item if there are changes.
+ if
+ preItemLink ~= nowItemLink -- Item has changed.
+ or item.bagNum == nil -- AddItemBagInfo() hasn't been called on this slot yet.
+ or preLocked ~= nowLocked -- Locked/unlocked.
+ or preCount ~= nowCount -- Count has changed (changes to charges are checked separately).
+ or preTooltip ~= item.tooltip
+ or self.initialInventoryUpdateNeeded
+ or self.pendingContainerChanges[bagNum]
+ or item._getItemInfoFailed -- Item was flagged as requiring a refresh.
+ or self.forceFullCacheUpdate -- Update even items that may not appear to need it.
+ then
+ -- Clear the failure flag (see explanation just below the call to ItemInfo:Get()).
+ item._getItemInfoFailed = nil
+
+ -- Bag information is needed regardless of whether the slot is empty (bagNum, slotNum, bagType).
+ -- This must happen before calling ItemInfo:Get() so that bagNum/slotNum are available.
+ self:AddItemBagInfo(item, bagInfo)
+ item.slotNum = slotNum
+
+ -- Populate basic item information.
+ if
+ not BsItemInfo:Get(
+ nowItemLink, -- itemIdentifier
+ item, -- itemInfoTable
+ false, -- initialize
+ (preItemLink ~= nowItemLink), -- reinitialize
+ false, -- forceIntoLocalGameCache
+ self -- Inventory class instance
+ )
+ then
+ -- If GetItemInfo() returns nil, ItemInfo:Get() will return false. When this occurs,
+ -- bail and rely on cached inventory data until the next update attempt. This should only
+ -- happen during the first second or two after login, so we do delay the initial inventory
+ -- cache update to attempt to account for this. However, it's still safest to keep the
+ -- check in place.
+
+ -- Add a flag to this item's cache entry so we know that it absolutely has to be refreshed
+ -- next time around.
+ item._getItemInfoFailed = true
+ return
+ end
+
+ -- Need to store different data for filled vs. empty slots.
+ if item.itemString ~= nil then
+ -- Slot contains an item.
+ item.count = nowCount
+ item.locked = nowLocked or BS_ITEM_SKELETON.locked
+ item.readable = itemReadable or BS_ITEM_SKELETON.readable
+ -- We got charges from GetContainerItemInfo() and need to restore
+ -- because ItemInfo:Get() might have wiped them.
+ if nowCharges > 0 then
+ item.charges = nowCharges
+ end
+ else
+ -- Slot is empty.
+ BsItemInfo:InitializeEmptySlotItem(item)
+ if self.containers[bagNum].isProfessionBag then
+ item.name = item.bagType .. " " .. item.name
+ end
+
+ -- Slot has just become empty - don't allow it to collapse back
+ -- into the stack until re-sorting occurs.
+ if preItemLink ~= nowItemLink then
+ item._bagshuiPreventEmptySlotStack = true
+ end
+
+ -- Add to empty slot tracking table.
+ -- See `Inventory:SwapBag()` comments regarding the exclusion of profession bags.
+ if bagInfo.genericType == BsGameInfo.itemSubclasses["Container"]["Bag"] then
+ table.insert(self.emptyGenericContainerSlots, item)
+ end
+ end
+
+ -- Check for stock state changes.
+ if
+ item.itemString ~= nil
+ and not self.freshCache
+ -- Nothing has actually changed if the item was just picked up and put back down.
+ and Bagshui.lastCursorItemUniqueId ~= BsItemInfo:GetUniqueItemId(item)
+ then
+ if item.itemString ~= preItemString and item.bagshuiDate == -1 then
+ -- Item itself has changed, so flag it as new.
+ item._proposedStockState = BS_ITEM_STOCK_STATE.NEW
+ item._proposedDate = _G.time()
+
+ -- Enable resorting because an item DID change.
+ -- Even if the stock state change isn't approved, resort may be needed.
+ majorChanges = true
+ elseif item.itemString == preItemString and item.count ~= preCount and preCount ~= -1 then
+ -- Item has not changed, but count did, so stock state needs to be updated.
+ if item.count > preCount then
+ item._proposedStockState = BS_ITEM_STOCK_STATE.UP
+ else
+ item._proposedStockState = BS_ITEM_STOCK_STATE.DOWN
+ end
+ item._proposedDate = _G.time()
+
+ -- Also enable resorting since count is usually a sort property.
+ -- This isn't dependent on the stock change being approved since the item
+ -- slot count DID change, which can require a resort.
+ majorChanges = true
+ elseif
+ self.initialInventoryUpdateNeeded
+ and Bagshui.currentCharacterData.lastLogout
+ and (item.bagshuiDate or 0) > 0
+ then
+ -- Just logged in, so move item dates forward by the amount of time since this
+ -- character last logged out. This makes it so that only in-game time is counted.
+ item.bagshuiDate = item.bagshuiDate + (_G.time() - Bagshui.currentCharacterData.lastLogout)
+ end
+ end
+ elseif
+ not BS_SUPER_WOW_LOADED
+ and preCharges ~= item.charges
+ -- Tooltip gets loaded earlier when a full cache update happens.
+ and not self.forceFullCacheUpdate
+ then
+ -- None of the "big" changes happened to trigger a call to ItemInfo:Get(),
+ -- but the item had some number of charges that we parsed from the tooltip
+ -- previously, so we need to refresh it.
+ -- (Normally the call to GetTooltip() happens automatically within ItemInfo:Get().)
+ BsItemInfo:GetTooltip(item, self)
+ end -- Item changes check.
+
+ -- When charges has changed, a re-sort is probably needed.
+ -- This check is here so that it works for both SuperWoW and native charges parsing.
+ if item.charges ~= preCharges then
+ majorChanges = true
+ end
+
+ -- Build "shadow" IDs for non-empty slots. (See comment above the declaration of shadowId for full explanation.)
+ if item.emptySlot ~= 1 then
+ shadowId = BsItemInfo:GetUniqueItemId(item)
+
+ -- Determine whether a restoration of stock state is required.
+ if self.pendingContainerChanges[bagNum] then
+ -- Bag swap.
+ prevShadowId = BsItemInfo:GetUniqueItemId(item, self.pendingContainerChanges[bagNum])
+ elseif
+ Bagshui.pickedUpItemBagNum
+ and Bagshui.pickedUpItemSlotNum
+ and self.myContainerIds[Bagshui.pickedUpItemBagNum]
+ and Bagshui.putDownItemBagNum
+ and Bagshui.putDownItemSlotNum
+ and self.myContainerIds[Bagshui.putDownItemBagNum]
+ and (
+ (item.bagNum == Bagshui.pickedUpItemBagNum and item.slotNum == Bagshui.pickedUpItemSlotNum)
+ or (item.bagNum == Bagshui.putDownItemBagNum and item.slotNum == Bagshui.putDownItemSlotNum)
+ )
+ then
+ -- Item moved.
+ if item.bagNum == Bagshui.pickedUpItemBagNum and item.slotNum == Bagshui.pickedUpItemSlotNum then
+ prevShadowId = BsItemInfo:GetUniqueItemId(item, Bagshui.putDownItemBagNum, Bagshui.putDownItemSlotNum)
+ else
+ prevShadowId = BsItemInfo:GetUniqueItemId(item, Bagshui.pickedUpItemBagNum, Bagshui.pickedUpItemSlotNum)
+ end
+ end
+ end
+
+ -- Reasons to restore or clear the item stock state.
+ if prevShadowId and self.shadowStockState[prevShadowId] then
+ -- Restore stock state from the item's most recent location after a container move.
+ -- (See Bagshui:PickupInventoryItem() for reasoning).
+ item.bagshuiDate = (self.shadowBagshuiDate[prevShadowId] or 0)
+ item.bagshuiStockState = self.shadowStockState[prevShadowId]
+ elseif item.bagshuiDate == -1 then
+ -- -1 means to clear the stock state.
+ -- Among other things, this can be triggered by Inventory:ItemButton_OnClick().
+ item.bagshuiDate = 0
+ item.bagshuiStockState = BS_ITEM_STOCK_STATE.NO_CHANGE
+ end
+
+ -- Update partial stack tracking.
+ if item.count < (tonumber(item.maxStackCount) or 0) then
+ if self.partialStacks[item.id] == nil then
+ self.partialStacks[item.id] = 1
+ else
+ self.partialStacks[item.id] = self.partialStacks[item.id] + 1
+ self.multiplePartialStacks = true
+ end
+ end
+
+ -- Item isn't assigned to a group, so resort is required.
+ if item.bagshuiGroupId == "" then
+ majorChanges = true
+ end
+
+ -- Locked status has changed, so an update is a good idea.
+ if item.locked ~= preLocked then
+ cosmeticChanges = true
+ end
+
+ -- Clean up any vestigial properties at startup.
+ if self.initialInventoryUpdateNeeded then
+ for key, _ in pairs(item) do
+ if BS_ITEM_SKELETON[key] == nil then
+ item[key] = nil
+ end
+ end
+ end
+
+ -- Update the "shadow" stock state cache, but only if all pending
+ -- changes are cleared so that we don't overwrite anything too soon.
+ -- (See Bagshui:PickupInventoryItem() for reasoning.)
+ if shadowId and not hasPendingContainerChanges then
+ self.shadowBagshuiDate[shadowId] = item.bagshuiDate
+ self.shadowStockState[shadowId] = item.bagshuiStockState
+ end
+
+ -- Update item counts for use in stock state rectification.
+ if item.itemString then
+ if not self.postUpdateItemCounts[item.itemString] then
+ self.postUpdateItemCounts[item.itemString] = 0
+ end
+ self.postUpdateItemCounts[item.itemString] = self.postUpdateItemCounts[item.itemString] + item.count
+ end
+ end -- Item slot loop within each bag.
+ else
+ -- There are no slots, so wipe the cache for this bag.
+ -- self:PrintDebug("no slots in bag "..bagNum.." wiping cache")
+ if table.getn(self.inventory[bagNum]) ~= 0 then
+ majorChanges = true
+ end
+ BsUtil.TableClear(self.inventory[bagNum])
+ end
+ end -- Bag [container] loop.
+
+ -- Post-update stock state rectification.
+ -- Only allow changes to stock state when they make sense at an inventory level,
+ -- not just because an individual slot changed.
+ for _, container in pairs(self.inventory) do
+ for _, item in pairs(container) do
+ if
+ item._proposedStockState
+ and (
+ (
+ -- Down: New count must be less than old count.
+item._proposedStockState == BS_ITEM_STOCK_STATE.DOWN
+ and self.postUpdateItemCounts[item.itemString] < (self.preUpdateItemCounts[item.itemString] or 0)
+ )
+ or (
+ -- New/Up: New count must be greater than old count.
+item._proposedStockState ~= BS_ITEM_STOCK_STATE.DOWN
+ and self.postUpdateItemCounts[item.itemString] > (self.preUpdateItemCounts[item.itemString] or 0)
+ )
+ )
+ then
+ -- self:PrintDebug(item.name .. " proposed " .. tostring(item._proposedStockState) .. " " .. tostring(self.preUpdateItemCounts[item.itemString]) .. " -> " .. tostring(self.postUpdateItemCounts[item.itemString]) .. " approved")
+ item.bagshuiStockState = item._proposedStockState
+ item.bagshuiDate = item._proposedDate
+ -- elseif item._proposedStockState then
+ -- self:PrintDebug(item.name .. " proposed " .. tostring(item._proposedStockState) .. " " .. tostring(self.preUpdateItemCounts[item.itemString]) .. " -> " .. tostring(self.postUpdateItemCounts[item.itemString]) .. " DENIED")
+ end
+ item._proposedStockState = nil
+ item._proposedDate = nil
+ item._allowStockStateChange = nil
+
+ -- Update tracking of whether there are highlight-able items.
+ if item.bagshuiStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE then
+ self.hasChanges = true
+ end
+
+ -- Update tracking of empty slots peeled off the stack.
+ if item._bagshuiPreventEmptySlotStack then
+ self.hasSlotsWithStackingPrevented = true
+ end
+ end
+ end
+ -- This update cycle's post-update counts become next cycle's pre-update counts.
+ BsUtil.TableCopy(self.postUpdateItemCounts, self.preUpdateItemCounts)
+ BsUtil.TableClear(self.postUpdateItemCounts)
+
+ -- Store resort/update status for use by Update() and friends in Inventory.Layout.lua.
+ if majorChanges then
+ self.resortNeeded = true
+ self.windowUpdateNeeded = true
+ elseif minorChanges then
+ self.resortNeeded = self.resortNeeded or false
+ self.windowUpdateNeeded = true
+ elseif cosmeticChanges then
+ self.windowUpdateNeeded = true
+ end
+ self.cacheChanged = (majorChanges or cosmeticChanges or minorChanges)
+
+ -- Make sure we don't do a full cache rebuild again.
+ self.initialInventoryUpdateNeeded = false
+
+ -- Item stock states can now be tracked.
+ self.freshCache = false
+
+ -- Reset cache flags.
+ self.cacheUpdateNeeded = false
+ self.forceCacheUpdate = false
+ self.forceFullCacheUpdate = false
+
+ -- Reset triggers for container-change based stock state restoration.
+ -- (See Bagshui:PickupInventoryItem() for reasoning.)
+ if hasPendingContainerChanges and Bagshui.pickedUpBagSlotNum and Bagshui.putDownBagSlotNum then
+ Bagshui.pickedUpBagSlotNum = nil
+ Bagshui.putDownBagSlotNum = nil
+ BsUtil.TableClear(self.pendingContainerChanges)
+ end
+
+ -- Raise change event.
+ if majorChanges or minorChanges or cosmeticChanges then
+ Bagshui:RaiseEvent("BAGSHUI_INVENTORY_CACHE_UPDATE")
+ end
+ end
+
+ --- Smart version of GetContainerNumSlots that will switch functions if it's
+ --- a special container that requires a different function.
+ ---@param bagNum integer Container number for which to get the slot count.
+ ---@return integer bagSlotCount
+ function Inventory:GetContainerNumSlots(bagNum)
+ local bagNumSlotsFunction = _G.GetContainerNumSlots
+ if self.primaryContainer.numSlotsFunction and self.primaryContainer.id == bagNum then
+ bagNumSlotsFunction = self.primaryContainer.numSlotsFunction
+ end
+ return bagNumSlotsFunction(bagNum)
+ end
+
+ --- Add bag information to the given cache item.
+ ---@param item table self.inventory cache entry.
+ ---@param bagInfo table self.containers entry.
+ ---@param isEmptySlotStack boolean? true if this slot
+ function Inventory:AddItemBagInfo(item, bagInfo, isEmptySlotStack)
+ item.bagNum = bagInfo.bagNum
+ -- Empty slot stack proxy items expected to have their "generic type" as their bagType
+ -- instead of the value returned by GetItemInfo() for the bag. This is needed
+ -- so that profession bag slots can be named properly in InitializeEmptySlotItem().
+ item.bagType = isEmptySlotStack and bagInfo.genericType or bagInfo.type
+ end
+
+ --- Prepare an entry in the emptySlotStacks table for to track the empty slot count for a given bag.
+ ---@param bagInfo table self.containers entry.
+ function Inventory:InitializeEmptySlotStackTracking(bagInfo)
+ if not bagInfo.genericType then
+ return
+ end
+
+ if not self.emptySlotStacks[bagInfo.genericType] then
+ self.emptySlotStacks[bagInfo.genericType] = {}
+ end
+
+ -- Always re-initialize when called to ensure bag changes don't result in incorrect empty slot textures.
+ BsItemInfo:InitializeItem(self.emptySlotStacks[bagInfo.genericType])
+ self:AddItemBagInfo(self.emptySlotStacks[bagInfo.genericType], bagInfo, true)
+ BsItemInfo:InitializeEmptySlotItem(self.emptySlotStacks[bagInfo.genericType], true)
+ end
+end)
diff --git a/Components/Inventory.EditMode.lua b/Components/Inventory.EditMode.lua
new file mode 100644
index 0000000..50f4d98
--- /dev/null
+++ b/Components/Inventory.EditMode.lua
@@ -0,0 +1,668 @@
+-- Bagshui Inventory Prototype: Edit Mode
+-- Most Edit Mode related things live here, although there are a few bits in
+-- OnClick/OnEnter/OnLeave events for item buttons and groups.
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ --- Enter or exit Edit Mode.
+ ---@param enable boolean
+ function Inventory:SetEditMode(enable)
+ self:ClearEditModeCursor()
+ self:ManagePendingGroupAssignments(nil, false) -- Discard any pending group assignments.
+ self.highlightItemsInContainerId = nil
+ self.highlightItemsInContainerLocked = nil
+ self.editMode = (enable == true)
+ self:ClearSearch()
+ self:UpdateEditModeUiStateBasedOnCursor()
+ self.windowUpdateNeeded = true
+ self.lookupTablesStale = true
+ self.forceResort = true
+ self:Update()
+ end
+
+ --- Turn Edit Mode on if it's off and vice versa.
+ function Inventory:ToggleEditMode()
+ self:SetEditMode(not self.editMode)
+ end
+
+ --- Trigger window update only if Edit Mode is enabled.
+ ---@param forceResort boolean? Trigger a full re-sort.
+ ---@param itemSlotColorsOnly boolean? High performance update mode.
+ ---@param calledFromEvent boolean? This is coming from a BAGSHUI_PROFILE_UPDATE event raised by another inventory class.
+ function Inventory:EditModeWindowUpdate(forceResort, itemSlotColorsOnly, calledFromEvent)
+ if not (self.editMode or calledFromEvent) then
+ return
+ end
+
+ if itemSlotColorsOnly and forceResort ~= true then
+ -- High-performance window redraw to update category highlighting.
+ -- Using normal Update() is way too slow to keep pace with mouse movements.
+ self:UpdateItemSlotColors()
+ else
+ -- Normal Edit Mode update.
+ self.cacheUpdateNeeded = false
+ self.windowUpdateNeeded = true
+ if forceResort then
+ self.lookupTablesStale = true
+ end
+ self.forceResort = (forceResort == true)
+ self:Update()
+ -- Don't cause a stack overflow with recursive events.
+ if not calledFromEvent then
+ Bagshui:RaiseEvent("BAGSHUI_INVENTORY_EDIT_MODE_UPDATE", nil, self, forceResort)
+ end
+ end
+ end
+
+ --- Since we're faking cursor attachment via a tooltip, we need a way to determine
+ --- whether something has been picked up.
+ ---@return boolean # true if there's an item on the cursor in Edit Mode.
+ function Inventory:EditModeCursorHasItem()
+ return (self.editMode and self.editState.cursorItem ~= nil)
+ end
+
+ -- Intelligently display the Edit Mode cursor if there's an item to be held.
+ function Inventory:ShowEditModeCursor()
+ if self.editMode and self.editState.cursorItem ~= nil and self.editState.cursorTooltipTitle ~= nil then
+ -- This is a quick hack to enable Edit Mode cursor clearing on Escape press.
+ -- See Bagshui:CloseAllWindows() for details.
+ self.uiFrame.bagshuiData.hasCursorItem = self
+
+ local cursorTooltip = BsCursorTooltip
+ cursorTooltip:SetOwner(self.uiFrame, "ANCHOR_CURSOR")
+ cursorTooltip:ClearLines()
+
+ -- Add text.
+ cursorTooltip:AddLine(self.editState.cursorTooltipTitle)
+ if self.editState.cursorTooltipText then
+ cursorTooltip:AddLine(self.editState.cursorTooltipText)
+ end
+
+ -- Display correct icon.
+ local cursorTooltipIcon = "Icons\\Question"
+ if self.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.GROUP then
+ cursorTooltipIcon = "Icons\\Group"
+ elseif self.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.CATEGORY then
+ cursorTooltipIcon = "Icons\\Category"
+ elseif self.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.ITEM then
+ if self.editState.cursorItem.emptySlot == 1 then
+ cursorTooltipIcon = self:GetEmptySlotTexture(self.editState.cursorItem)
+ else
+ cursorTooltipIcon = self.editState.cursorItem.texture
+ end
+ end
+ cursorTooltip.bagshuiData.texture1:SetTexture(BsUtil.GetFullTexturePath(cursorTooltipIcon))
+
+ -- Display the tooltip so it will calculate its initial width based on the text.
+ cursorTooltip:Show()
+
+ -- Adjust width to account for icon.
+ local currentWidth = cursorTooltip:GetWidth()
+ local firstLineWidthWithIcon = cursorTooltip.bagshuiData.textLeft1:GetStringWidth() + 36
+ if firstLineWidthWithIcon > currentWidth then
+ cursorTooltip:SetWidth(firstLineWidthWithIcon)
+ end
+
+ -- Make the cursor glow.
+ _G.SetCursor("CAST_CURSOR")
+
+ -- Make sure tooltips aren't fighting.
+ BsInfoTooltip:Hide()
+
+ self:UpdateEditModeUiStateBasedOnCursor()
+ end
+ end
+
+ --- Hide the Edit Mode cursor tooltip without clearing it (used when the cursor
+ --- leaves the inventory window).
+ function Inventory:HideEditModeCursor()
+ BsCursorTooltip:Hide()
+ _G.SetCursor(nil)
+ end
+
+ --- Drop whatever the Edit Mode cursor is carrying without doing anything with it.
+ function Inventory:ClearEditModeCursor()
+ if self.editState.cursorItem ~= nil then
+ -- This is part of a quick hack to enable Edit Mode cursor clearing on Escape press.
+ -- See Bagshui:CloseAllWindows() for details.
+ self.uiFrame.bagshuiData.hasCursorItem = nil
+
+ -- Reset all editState variables.
+ self.editState.cursorItem = nil
+ self.editState.cursorItemType = nil
+ self.editState.cursorTooltipTitle = nil
+ self.editState.cursorTooltipText = nil
+
+ self.editState.highlightCategory = nil
+ self.editState.highlightItem = nil
+
+ self:HideEditModeCursor()
+ self:UpdateEditModeUiStateBasedOnCursor()
+ self:EditModeWindowUpdate()
+ end
+ end
+
+ --- Make changes to UI based on picking up/putting down elements.
+ --- - Set the correct text on group move targets based on what's on the cursor.
+ --- - Enable/disable group/item slot interactivity.
+ function Inventory:UpdateEditModeUiStateBasedOnCursor()
+ local cursorHasItem = self:EditModeCursorHasItem()
+ local cursorItemIsGroup = (cursorHasItem and self.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.GROUP)
+
+ -- Update group move target text and colors.
+ local groupMoveTargetText = cursorItemIsGroup and "•" or "+"
+ for _, groupMoveTarget in ipairs(self.ui.frames.groupMoveTargets) do
+ groupMoveTarget.bagshuiData.text:SetText(groupMoveTargetText)
+ self.ui:SetGroupColors(groupMoveTarget.bagshuiData.button, false)
+ end
+
+ -- Enable/disable group interactivity.
+ for _, group in ipairs(self.ui.frames.groups) do
+ group:Enable()
+ end
+
+ -- Item slots should NOT get mouse input when there's something on the cursor,
+ -- since we always want it directed to the group.
+ for _, itemSlotButton in pairs(self.ui.buttons.itemSlots) do
+ itemSlotButton:EnableMouse(not cursorHasItem)
+ end
+ end
+
+ --- Build the correct text for the Edit Mode cursor tooltip.
+ ---@param objectName string
+ ---@param objectType string
+ ---@return string tooltipTitle
+ function Inventory:FormatEditModeCursorTooltipTitle(objectName, objectType)
+ return BS_FONT_COLOR.BAGSHUI
+ .. string.format(L.Symbol_Colon, objectType)
+ .. FONT_COLOR_CODE_CLOSE
+ .. " "
+ .. HIGHLIGHT_FONT_COLOR_CODE
+ .. objectName
+ .. FONT_COLOR_CODE_CLOSE
+ end
+
+ --- "Pick up" an item and place it on the cursor.
+ ---@param item table Inventory item cache entry.
+ function Inventory:EditModePickUpItem(item)
+ assert(item, "Inventory<" .. tostring(self.inventoryType) .. ">:EditModePickUpItem() - item is required")
+ if item.id == 0 then
+ return
+ end
+
+ self.editState.cursorItem = item
+ self.editState.cursorItemType = BS_INVENTORY_OBJECT_TYPE.ITEM
+ self.editState.cursorTooltipTitle =
+ self:FormatEditModeCursorTooltipTitle(BsItemInfo:GetQualityColoredName(item), L.Item)
+ self.editState.cursorTooltipText = string.format(L.EditMode_Tooltip_SelectNew, string.lower(L.Location))
+ self.editState.highlightItem = item.id
+
+ _G.PlaySound("INTERFACESOUND_CURSORGRABOBJECT")
+ self:ShowEditModeCursor()
+ end
+
+ --- When a group is clicked with an item on the cursor in Edit Mode, display the
+ --- menu so the item can be assigned to a category within the group.
+ ---@param item table Inventory item cache entry.
+ ---@param groupWidget table Group UI frame.
+ function Inventory:ShowItemCategoryAssignmentMenuForGroup(item, groupWidget)
+ self:ClearEditModeCursor()
+ local groupId = groupWidget.bagshuiData.groupId
+
+ -- Already in the correct place; nothing to do.
+ if groupId == item.bagshuiGroupId then
+ return
+ end
+
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ -- Show menu at cursor if mouse is over the group widget,
+ -- or force it to be at the widget otherwise.
+ self.menus:OpenMenu("Item", item, groupId, groupWidget.mouseIsOver and "cursor" or groupWidget)
+ end
+
+ --- "Pick up" a category and place it on the cursor.
+ --- Named EditModePickUpCategory to fit with EditModePickUpItem.
+ ---@param categoryId string|number
+ function Inventory:EditModePickUpCategory(categoryId)
+ if not categoryId then
+ return
+ end
+
+ local categoryName = BsCategories:GetName(categoryId)
+ if categoryName then
+ Bagshui:CloseMenus()
+
+ -- Grab category info.
+ self.editState.cursorItem = categoryId
+ self.editState.cursorItemType = BS_INVENTORY_OBJECT_TYPE.CATEGORY
+ self.editState.cursorTooltipTitle = self:FormatEditModeCursorTooltipTitle(categoryName, L.Category)
+ self.editState.cursorTooltipText = string.format(L.EditMode_Tooltip_SelectNew, string.lower(L.Group))
+ self.editState.highlightCategory = categoryId
+
+ -- Attach the category to the cursor.
+ self:ShowEditModeCursor()
+ _G.PlaySound("INTERFACESOUND_CURSORGRABOBJECT")
+ end
+ end
+
+ -- Assign a category to a group (move from previous group or assign for the first time).
+ ---@param categoryId string|number
+ ---@param groupId string Group identifier in `"row:column"` format.
+ ---@param noHighlight boolean? When true, do not highlight `categoryId` when the window update occurs.
+ function Inventory:AssignCategoryToGroup(categoryId, groupId, noHighlight)
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ -- Parse the destination location.
+ local destRow, destColumn = unpack(BsUtil.Split(groupId, ":"))
+ destRow = tonumber(destRow)
+ destColumn = tonumber(destColumn)
+
+ -- Remove from previous location, if present.
+ self:RemoveCategoryFromGroup(categoryId, self.categoriesToGroups[categoryId], true)
+
+ -- Assign to the destination location.
+ table.insert(self.layout[destRow][destColumn].categories, categoryId)
+
+ self:ClearEditModeCursor()
+
+ if not noHighlight then
+ self.editState.highlightCategory = categoryId
+ end
+
+ -- Update window to show new organization
+ self:EditModeWindowUpdate(true)
+ end
+
+ --- Unassign a category from a group.
+ ---@param categoryId string|number
+ ---@param groupId string Group identifier in `"row:column"` format.
+ ---@param noWindowUpdate boolean? When true, don't call `EditModeWindowUpdate()` after making changes.
+ function Inventory:RemoveCategoryFromGroup(categoryId, groupId, noWindowUpdate)
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ -- Parse the group location.
+ local sourceRow, sourceColumn = unpack(BsUtil.Split(groupId, ":"))
+ sourceRow = tonumber(sourceRow)
+ sourceColumn = tonumber(sourceColumn)
+
+ -- Find the category in the categories array and remove it.
+ for index, groupCategoryId in ipairs(self.layout[sourceRow][sourceColumn].categories) do
+ if groupCategoryId == categoryId then
+ table.remove(self.layout[sourceRow][sourceColumn].categories, index)
+ break
+ end
+ end
+
+ -- Update window to show new organization.
+ if not noWindowUpdate then
+ self:EditModeWindowUpdate(true)
+ end
+ end
+
+ --- Assign/unassign a group to/from a category.
+ ---@param categoryId any
+ ---@param groupId string Group identifier in `"row:column"` format.
+ ---@param noHighlight boolean? When true, do not highlight `categoryId` when the window update occurs.
+ function Inventory:ToggleCategoryGroupAssignment(categoryId, groupId, noHighlight)
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ if self.categoriesToGroups[categoryId] == groupId then
+ self:RemoveCategoryFromGroup(categoryId, groupId)
+ else
+ self:AssignCategoryToGroup(categoryId, groupId, noHighlight)
+ end
+ end
+
+ --- "Pick up" a group so it can be moved to a new location.
+ --- Named EditModePickUpGroup to fit with EditModePickUpItem.
+ ---@param groupId string Group identifier in `"row:column"` format.
+ function Inventory:EditModePickUpGroup(groupId)
+ assert(groupId, "Inventory<" .. tostring(self.inventoryType) .. ">:EditModePickUpGroup() - groupId is required")
+
+ self.editState.cursorItem = groupId
+ self.editState.cursorItemType = BS_INVENTORY_OBJECT_TYPE.GROUP
+ self.editState.cursorTooltipTitle = self:FormatEditModeCursorTooltipTitle(
+ ((string.len(self.groups[groupId].name or "") > 0) and self.groups[groupId].name or L.UnnamedGroup),
+ L.Group
+ )
+ self.editState.cursorTooltipText = string.format(L.EditMode_Tooltip_SelectNew, string.lower(L.Location))
+
+ -- Attach the group to the cursor
+ _G.PlaySound("INTERFACESOUND_CURSORGRABOBJECT")
+ self:ShowEditModeCursor()
+ end
+
+ --- Assign a group to the current layout. This includes:
+ --- - Moving a group from its current location to a new one.
+ --- - Creating a new group when a category or item is on the cursor and a group move target is clicked.
+ ---@param objectId string|number
+ ---@param objectType BS_INVENTORY_OBJECT_TYPE
+ ---@param insertType BS_INVENTORY_LAYOUT_DIRECTION
+ ---@param destRow number Row to place the group in.
+ ---@param destColumn number Column to place the group in.
+ function Inventory:AssignGroupToLayout(objectId, objectType, insertType, destRow, destColumn)
+ self:ClearEditModeCursor()
+
+ local group = nil
+ local groupIsNew = false
+
+ if objectType == BS_INVENTORY_OBJECT_TYPE.GROUP then
+ -- This is a group that is already assigned to the layout in another spot.
+
+ -- Get info about the group we need to move.
+ local sourceRow, sourceColumn = unpack(BsUtil.Split(objectId, ":"))
+ sourceRow = tonumber(sourceRow)
+ sourceColumn = tonumber(sourceColumn)
+
+ -- Can't move a group onto itself or just next ot itself.
+ if
+ insertType == BS_INVENTORY_LAYOUT_DIRECTION.COLUMN
+ and sourceRow == destRow
+ and (destColumn == sourceColumn or destColumn == sourceColumn + 1)
+ then
+ return
+ end
+
+ -- Remove group from old position.
+ group, destRow, destColumn = self:RemoveGroupFromLayout(sourceRow, sourceColumn, destRow, destColumn, true)
+ else
+ -- When a category or item is placed on a group move target, a new group needs to be created.
+ if objectType == BS_INVENTORY_OBJECT_TYPE.CATEGORY or objectType == BS_INVENTORY_OBJECT_TYPE.ITEM then
+ -- Set variables that will eventually be consumed by ManagePendingGroupAssignments().
+ self.pendingGroupAssignmentObject = objectId
+ self.pendingGroupAssignmentObjectType = objectType
+ end
+
+ -- Create a new empty group. ManagePendingGroupAssignments() will take care of filling it if needed.
+ group = {
+ categories = {},
+ name = "",
+ }
+ groupIsNew = true
+ end
+
+ -- Add group to new position.
+ if insertType == BS_INVENTORY_LAYOUT_DIRECTION.ROW then
+ table.insert(self.layout, destRow, { group })
+ else
+ table.insert(self.layout[destRow], destColumn, group)
+ end
+
+ self:EditModeWindowUpdate(true)
+
+ -- Prompt to name new groups.
+ if groupIsNew then
+ self:RenameGroup(destRow .. ":" .. destColumn, true)
+ end
+ end
+
+ --- Apply or clear the pending group assignments from `self.pendingGroupAssignmentObject`/`pendingGroupAssignmentObjectType`.
+ ---@param groupId string? Group to which the pending assignment should apply.
+ ---@param apply boolean? If false, discard the pending assignments instead of applying them.
+ function Inventory:ManagePendingGroupAssignments(groupId, apply)
+ if apply then
+ local objectId = self.pendingGroupAssignmentObject
+ local objectType = self.pendingGroupAssignmentObjectType
+ if objectType == BS_INVENTORY_OBJECT_TYPE.CATEGORY then
+ self:AssignCategoryToGroup(objectId, groupId, true)
+ elseif objectType == BS_INVENTORY_OBJECT_TYPE.ITEM then
+ self:ShowItemCategoryAssignmentMenuForGroup(objectId, self.groupsIdsToFrames[groupId])
+ end
+ end
+ self.pendingGroupAssignmentObject = nil
+ self.pendingGroupAssignmentObjectType = nil
+ end
+
+ --- Prompt for group name and update if changed.
+ ---@param groupId string Group identifier in `"row:column"` format.
+ ---@param groupIsNew boolean? When true, delete the group when the Undo (Cancel) button is clicked.
+ function Inventory:RenameGroup(groupId, groupIsNew)
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ local dialogName = "BAGSHUI_RENAME_GROUP"
+
+ if not _G.StaticPopupDialogs[dialogName] then
+ _G.StaticPopupDialogs[dialogName] = {
+ text = "",
+ button1 = _G.OKAY,
+ button2 = _G.CANCEL,
+ hasEditBox = true,
+ timeout = 0,
+ whileDead = true,
+ hideOnEscape = true,
+ EditBoxOnEnterPressed = BsUtil.StaticPopupDialogs_EnterClicksFirstButton,
+ -- Extra property to be used in OnShow to fill the edit box.
+ -- Blizzard doesn't provide an easy way to do this in Vanilla as the
+ -- data property isn't accessible in OnShow, and all the FrameXML code uses
+ -- global variables and functions.
+ _bagshuiDefaultEditBoxText = "",
+
+ -- Set the default value for the edit box.
+ OnShow = function()
+ _G[_G.this:GetName() .. "EditBox"]:SetText(_G.StaticPopupDialogs[dialogName]._bagshuiDefaultEditBoxText)
+ _G[_G.this:GetName() .. "EditBox"]:SetFocus()
+ end,
+
+ --- Okay was clicked, so change the group name.
+ ---@param data table Reference to `self.renameGroup_Data`, passed through via the dialog's `data` property.
+ OnAccept = function(data)
+ self.editModeGroupHighlight = nil
+ if self.groups[data.groupId] then
+ self.groups[data.groupId].name = _G[_G.this:GetParent():GetName() .. "EditBox"]:GetText()
+ end
+ self:ManagePendingGroupAssignments(data.groupId, true)
+ self:EditModeWindowUpdate()
+ end,
+
+ --- Cancel was clicked.
+ --- For new groups, this means the group is no longer wanted.
+ --- For existing groups, just remove highlighting.
+ ---@param data table Reference to `self.renameGroup_Data`, passed through via the dialog's `data` property.
+ OnCancel = function(data)
+ if data.groupIsNew then
+ local row, column = self:GroupIdToRowColumn(data.groupId)
+ if row and column then
+ self:RemoveGroupFromLayout(row, column)
+ end
+ end
+ self.editModeGroupHighlight = nil
+ self:ManagePendingGroupAssignments(data.groupId, false)
+ self:EditModeWindowUpdate()
+ end,
+
+ -- Clear text on hide so it doesn't bleed over to other dialogs.
+ OnHide = BsUtil.StaticPopupDialogs_ClearTextOnHide,
+ }
+ end
+
+ -- Current group name and text to prompt user with.
+ local currentName = self.groups[groupId].name
+ local prompt = groupIsNew and L.EditMode_Prompt_NewGroupName
+ or string.format(
+ L.EditMode_Prompt_RenameGroup,
+ (string.len(currentName or "") > 0 and currentName or L.UnnamedGroup)
+ )
+
+ -- Update dialog properties.
+ local dialogProps = _G.StaticPopupDialogs[dialogName]
+ dialogProps.text = prompt
+ dialogProps._bagshuiDefaultEditBoxText = currentName or ""
+
+ -- Lock group highlight so it's more clear what group is being renamed.
+ self.editModeGroupHighlight = groupId
+ self:EditModeWindowUpdate()
+
+ -- This seems to get removed sometimes so we'll make sure it stays.
+ if not self.renameGroup_Data then
+ self.renameGroup_Data = {}
+ end
+
+ -- Set properties we need the dialog scripts to know.
+ self.renameGroup_Data.groupId = groupId
+ self.renameGroup_Data.groupIsNew = groupIsNew
+
+ -- Change button labels based on what is happening.
+ if groupIsNew then
+ _G.StaticPopupDialogs[dialogName].button1 = L.Create
+ _G.StaticPopupDialogs[dialogName].button2 = L.Undo
+ else
+ _G.StaticPopupDialogs[dialogName].button1 = _G.OKAY
+ _G.StaticPopupDialogs[dialogName].button2 = _G.CANCEL
+ end
+
+ -- Show the dialog.
+ local dialog = _G.StaticPopup_Show(dialogName)
+
+ -- Pass stuff to dialog functions via the magic data property.
+ if dialog then
+ dialog.data = self.renameGroup_Data
+ end
+ end
+
+ --- Delete a group after confirmation.
+ ---@param groupId string Group identifier in `"row:column"` format.
+ function Inventory:DeleteGroup(groupId)
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ local dialogName = "BAGSHUI_DELETE_GROUP"
+
+ if not _G.StaticPopupDialogs[dialogName] then
+ _G.StaticPopupDialogs[dialogName] = {
+ text = L.EditMode_Prompt_DeleteGroup,
+ button1 = L.Delete,
+ button2 = L.Cancel,
+ showAlert = true,
+ timeout = 0,
+ whileDead = true,
+ hideOnEscape = true,
+
+ -- Delete the group.
+ -- The groupId parameter comes from the dialog's data property which is set below.
+ OnAccept = function(groupId)
+ local row, column = self:GroupIdToRowColumn(groupId)
+ self.editModeGroupHighlight = nil
+ self:RemoveGroupFromLayout(row, column)
+ end,
+
+ -- Remove highlighting.
+ OnCancel = function()
+ self.editModeGroupHighlight = nil
+ self:EditModeWindowUpdate()
+ end,
+ }
+ end
+
+ -- Lock group highlight so it's more clear which group is up for deletion.
+ self.editModeGroupHighlight = groupId
+ self:EditModeWindowUpdate()
+
+ -- Display the dialog and provide the object type and object name to the prompt text.
+ local dialog = _G.StaticPopup_Show(dialogName)
+
+ -- Pass groupId through to StaticPopupDialog via the magical data property.
+ if dialog then
+ dialog.data = groupId
+ end
+ end
+
+ --- Remove a group from the current layout, returning adjusted destination row/column values if provided.
+ --- These adjusted values are needed when a group is moved, because removing it from its current position
+ --- can change where it needs to be inserted. For example, moving a group from 1:1 to 1:4 will mean it
+ --- actually needs to be inserted at 1:3, since the removal shifts everything down by 1. Similarly,
+ --- 2:4 to 4:1 requires inserting at 3:1 if the group being moved was the only one on row 2.
+ ---@param row number Row of the group being deleted.
+ ---@param column number Column of the group being deleted.
+ ---@param destRow number? Desired destination row to be adjusted.
+ ---@param destColumn number? Desired destination column to be adjusted.
+ ---@param noWindowUpdate boolean? When true, don't call EditModeWindowUpdate() after performing the removal.
+ ---@return table? group # Group object.
+ ---@return number? destRow # Adjusted destination row.
+ ---@return number? destColumn # Adjusted destination column.
+ function Inventory:RemoveGroupFromLayout(row, column, destRow, destColumn, noWindowUpdate)
+ assert(row and column, "Inventory:RemoveGroupFromLayout(): row and column are both required.")
+
+ local group = table.remove(self.layout[row], column)
+
+ -- Remove empty row.
+ local rowRemoved = false
+ if table.getn(self.layout[row]) == 0 then
+ table.remove(self.layout, row)
+ rowRemoved = true
+ end
+
+ -- Destination layout adjustments.
+ if destRow ~= nil and destColumn ~= nil then
+ -- Decrement destination column number by 1 if staying on the same row since
+ -- everything is shifted down by removal (but only if the destination column
+ -- is higher than the source).
+ if destRow == row and destColumn > column then
+ destColumn = destColumn - 1
+ end
+
+ -- Decrement destination row number by 1 (same reasoning as column adjustment above).
+ if rowRemoved and destRow > row then
+ destRow = destRow - 1
+ end
+ end
+
+ -- Update window to show new organization.
+ if not noWindowUpdate then
+ self:EditModeWindowUpdate(true)
+ end
+
+ return group, destRow, destColumn
+ end
+
+ --- Parse row and column numbers from a "row:column" string.
+ ---@param groupId string Group identifier in `"row:column"` format.
+ ---@return number? row
+ ---@return number? column
+ function Inventory:GroupIdToRowColumn(groupId)
+ local row, column = unpack(BsUtil.Split(groupId, ":"))
+ row = tonumber(row)
+ column = tonumber(column)
+ return row, column
+ end
+
+ --- Assign a sort order to a group.
+ ---@param sortOrderId string|number|nil Sort order identifier, or nil to reset to default.
+ ---@param groupId string Group identifier in `"row:column"` format.
+ function Inventory:AssignSortOrderToGroup(sortOrderId, groupId)
+ if not self:GroupExists(groupId) then
+ return
+ end
+
+ self.groups[groupId].sortOrder = sortOrderId
+
+ -- Update window to show new organization.
+ self:EditModeWindowUpdate(true)
+ end
+
+ --- Test whether a group exists.
+ ---@param groupId string Group identifier in `"row:column"` format.
+ ---@return boolean
+ function Inventory:GroupExists(groupId)
+ if not groupId then
+ return false
+ end
+ if not self.groups[groupId] then
+ Bagshui:PrintError(string.format(L.Error_GroupNotFound, tostring(groupId)))
+ return false
+ end
+ return true
+ end
+end)
diff --git a/Components/Inventory.Layout.lua b/Components/Inventory.Layout.lua
new file mode 100644
index 0000000..7b061cd
--- /dev/null
+++ b/Components/Inventory.Layout.lua
@@ -0,0 +1,2619 @@
+-- Bagshui Inventory Prototype: Window Layout
+-- Categorizing, Sorting, Putting Things On The Screen, etc.
+-- Most of the functions here shouldn't be called directly as Inventory:Update() handles the coordination.
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ -- When deciding whether there are differences between a dry run and current state,
+ -- some tables need to be compared deeper than the default of 1 level. Set those here.
+ local LAYOUT_LOOKUP_TABLE_COMPARE_DEPTH = {
+ categoryIdsGroupedBySequence = 2,
+ groupItems = 2,
+ }
+
+ -- Reusable variable for use in `Inventory:Update()`.
+ local update_cascadeInventory
+
+ --- This is the main workhorse for orchestrating UI changes; it's called when almost anything happens.
+ ---@param cascade boolean? After completing the update process for this inventory, trigger it for any docked inventory.
+ function Inventory:Update(cascade)
+ -- self:PrintDebug(string.format("Update() windowUpdateBlocked=%s", tostring(self.windowUpdateBlocked)))
+
+ -- Only update if we haven't been blocked from doing so.
+ -- One of the main reasons this can happen is when the window is being dragged --
+ -- see `Inventory:SaveWindowPosition()` for details.
+ if self.windowUpdateBlocked then
+ return
+ end
+
+ -- Update is only needed if the window is visible.
+ if not self:Visible() and not self.forceCacheUpdate then
+ self.windowUpdateBlocked = false
+ return
+ end
+
+ -- Decide whether we'll need to cascade, and pass everything along.
+ update_cascadeInventory = cascade and (self.dockedInventory or self.dockedToInventory)
+ if update_cascadeInventory then
+ update_cascadeInventory.forceCacheUpdate = update_cascadeInventory.forceCacheUpdate or self.forceCacheUpdate
+ update_cascadeInventory.forceResort = update_cascadeInventory.forceResort or self.forceResort
+ update_cascadeInventory.windowUpdateNeeded = update_cascadeInventory.windowUpdateNeeded or self.windowUpdateNeeded
+ end
+
+ -- Reset per-update transient flags before running the pipeline.
+ self.cacheChanged = false
+ self.lookupTablesWereRebuilt = false
+ -- We're updating!
+ self.windowUpdateBlocked = true
+
+ -- Before doing anything else, we need to know if we're online.
+ self:UpdateOnlineStatus()
+
+ -- Always update the cache if we get this far.
+ self:UpdateCache()
+
+ -- self.forceCacheUpdate was true, but the window might not be visible.
+ if not self:Visible() then
+ -- No need to redraw everything when we're not visible.
+ self.windowUpdateBlocked = false
+ return
+ end
+
+ -- Nothing changed and nothing is waiting for a redraw -- skip the entire pipeline.
+ -- This is the primary optimization for spurious BAG_UPDATE events.
+ if not self.cacheChanged and not self.windowUpdateNeeded and not self.forceResort and not self.lookupTablesStale then
+ self.windowUpdateBlocked = false
+ if update_cascadeInventory then
+ update_cascadeInventory:Update()
+ end
+ return
+ end
+
+ -- Perform all updates in the necessary order.
+ self:ValidateLayout()
+ -- Capture resort signals before ManageDryRun(true) clears forceResort.
+ local needsCategorizeAndSort = self.forceResort or self.resortNeeded
+ self:ManageDryRun(true) -- First call decides whether we're in dry run mode (`self.dryRun`).
+ self:UpdateLayoutLookupTables()
+ if self.cacheChanged or self.lookupTablesWereRebuilt or needsCategorizeAndSort then
+ self:CategorizeAndSort()
+ end
+ self:ManageDryRun(false) -- Second call re-points lookup tables if needed and sets `self.enableResortIcon`.
+ -- If the dry run shows the layout is identical to last render, the window
+ -- update request came only from CategorizeAndSort, and no cache items actually
+ -- changed, skip the full redraw. The cache-changed guard is essential: both
+ -- proposedLayoutState and currentLayoutState hold the same live pointers into
+ -- self.inventory, so ObjectsEqual() always reports them as identical after
+ -- UpdateCache() writes new data in place -- even when item A replaced item B
+ -- in the same slot. Without the guard the texture would not update.
+ -- UpdateWindow() still runs its lightweight path (colors, border, strata).
+ if self.dryRun and not self.enableResortIcon and self.windowUpdateNeededByCategorize and not self.cacheChanged then
+ self.windowUpdateNeeded = false
+ end
+ self.windowUpdateNeededByCategorize = false
+ -- Always refresh item button visual state when the window is visible and
+ -- the full layout rebuild is being skipped (keeps search and highlights current).
+ if not self.windowUpdateNeeded and self:Visible() then
+ self:UpdateItemSlotColors()
+ end
+ self:FindSpecialItems()
+ self:UpdateWindow()
+ self:UpdateBagBar()
+ self:UpdateToolbar()
+
+ -- Reset status.
+ self.windowUpdateBlocked = false
+
+ -- Automatically cascade updates to docked inventory.
+ if update_cascadeInventory then
+ update_cascadeInventory:Update()
+ end
+ end
+
+ --- Force a window redraw with optional cache update.
+ ---@param cascade boolean? Parameter for `Inventory:Update()`.
+ ---@param updateCache boolean? `true` to trigger a cache update.
+ function Inventory:ForceUpdateWindow(cascade, updateCache)
+ self.cacheUpdateNeeded = updateCache or false
+ self.windowUpdateNeeded = true
+ self.forceResort = false
+ self:Update(cascade)
+ end
+
+ --- Refresh only item slot colors without all the overhead of calculating the window layout.
+ --- High-performance -- much better than `UpdateWindow()`/`ForceUpdateWindow()`, so it should be used for
+ --- changing highlights or opacity (searching, bag bar mouseover, Edit Mode highlighting, etc.).
+ function Inventory:UpdateItemSlotColors()
+ for _, button in ipairs(self.ui.buttons.itemSlots) do
+ if button:IsVisible() then
+ self.ui:UpdateItemButtonColorsAndBadges(button)
+ end
+ end
+ end
+
+ --- Refresh cooldown animations on all visible item slot buttons without rebuilding the window layout.
+ --- Called in response to BAG_UPDATE_COOLDOWN so cooldown timers appear immediately after item use.
+ function Inventory:UpdateItemSlotCooldowns()
+ local cooldownStart, cooldownDuration, isOnCooldown
+ for _, button in ipairs(self.ui.buttons.itemSlots) do
+ if button:IsVisible() and button.bagshuiData.item then
+ cooldownStart = 0
+ cooldownDuration = 0
+ isOnCooldown = nil
+ if button.bagshuiData.item.itemString ~= nil then
+ cooldownStart, cooldownDuration, isOnCooldown =
+ _G.GetContainerItemCooldown(button.bagshuiData.bagNum, button.bagshuiData.slotNum)
+ end
+ _G.CooldownFrame_SetTimer(
+ button.bagshuiData.buttonComponents.cooldown,
+ cooldownStart,
+ cooldownDuration,
+ isOnCooldown
+ )
+ end
+ end
+ end
+
+ --- Ensure there will be no errors when using the active layout.
+ function Inventory:ValidateLayout()
+ -- Completely empty layouts need at least a row and a group.
+ if table.getn(self.layout) == 0 then
+ table.insert(self.layout, {})
+ BsUtil.TableCopy(BS_NEW_PROFILE_TEMPLATE.structure.primary.layout, self.layout)
+ end
+
+ -- Validate categories assigned to the layout.
+ for row = 1, table.getn(self.layout) do
+ for group = 1, table.getn(self.layout[row]) do
+ local groupCategories = self.layout[row][group].categories
+ if groupCategories then
+ -- Remove invalid categories.
+ -- Need to step backwards so we can use table.remove().
+ for catNum = table.getn(groupCategories), 1, -1 do
+ local categoryId = groupCategories[catNum]
+ if not BsCategories.list[categoryId] then
+ table.remove(groupCategories, catNum)
+ end
+ end
+ else
+ -- This group needs a categories property.
+ self.layout[row][group].categories = {}
+ end
+ end
+ end
+ end
+
+ --- Control whether the current layout update is a dry run or not.
+ --- **Should not be called directly!** Use `Inventory:Update()` to coordinate the process.
+ ---
+ --- Doing a dry run means storing information in the `proposedLayoutState` tables
+ --- instead of `currentLayoutState`. The proposed state can then be compared
+ --- to current to decide whether resorting is needed.
+ ---
+ --- This must be called twice during the `Update()` process - once prior to categorizing/sorting
+ --- with a parameter of `true` and again just after with `false` (or nil) before calling `UpdateWindow()`.
+ ---@param phase1 boolean? Pass `true` the first time the function is called.
+ function Inventory:ManageDryRun(phase1)
+ if phase1 then
+ -- Determine whether this is real or if we're just simulating to decide whether there are changes.
+ self.dryRun = false
+ -- Ensure items don't shift around when the window is open under normal usage.
+ -- self.forceResort can be used to override (usually when the user clicks the Resort button).
+ if (not self.resortNeeded or self:Visible()) and not self.forceResort then
+ self.dryRun = true
+ -- self:PrintDebug("++++DRY RUN ON++++")
+ -- else
+ -- self:PrintDebug("----dry run off----")
+ end
+
+ -- Reset force resort flag.
+ self.forceResort = false
+
+ -- Point layout state tracking keys to the correct tables.
+ for key, _ in pairs(self.currentLayoutState) do
+ self[key] = self[self.dryRun and "proposedLayoutState" or "currentLayoutState"][key]
+ end
+ else
+ -- Resetting tables to current if needed.
+ -- Decide whether the resort icon needs to be enabled.
+ self.enableResortIcon = false
+ -- Special stuff for dry run mode:
+ -- - Manage the resort icon.
+ -- - Re-point layout state tables.
+ if
+ self.dryRun
+ -- Safety check to ensure pointers are always reset.
+ or self.groups ~= self.currentLayoutState.groups
+ then
+ -- We can immediately know to enable resorting if any empty slot is peeled off its stack.
+ self.enableResortIcon = (self.emptySlotStackingAllowed and self.hasSlotsWithStackingPrevented)
+
+ for key, _ in pairs(self.currentLayoutState) do
+ if
+ self.dryRun
+ and not self.enableResortIcon -- No need to keep checking once we've hit true.
+ and not BsUtil.ObjectsEqual(
+ self.proposedLayoutState[key],
+ self.currentLayoutState[key],
+ nil,
+ nil,
+ LAYOUT_LOOKUP_TABLE_COMPARE_DEPTH[key] or 1
+ )
+ then
+ self.enableResortIcon = true
+ end
+
+ -- Always swap back to the "current" layout state for UpdateWindow() instead
+ -- of the dry run one so that nothing moves around undesirably.
+ self[key] = self.currentLayoutState[key]
+ end
+ end
+ end
+ end
+
+ --- Assign inventory items to Bagshui categories and sort into the correct order for display.
+ --- **Should not be called directly!** Use `Inventory:Update()` to coordinate the process.
+ ---
+ --- Inventory cache to lookup table relationships:
+ --- - Cache is stored as `self.inventory[bagNum][slotNum]`
+ --- - Group items store references to the inventory cache items:
+ --- `self.groupItems[groupNum][position] = `
+ function Inventory:CategorizeAndSort()
+ --self:PrintDebug("CategorizeAndSort()")
+
+ -- Categorize items and assign to groups.
+ self:CategorizeItems()
+
+ -- Sort items within groups.
+ self:SortGroups()
+
+ -- Reset flags.
+ self.resortNeeded = false
+ self.windowUpdateNeeded = true
+ self.windowUpdateNeededByCategorize = true
+ end
+
+ --- Fill the lookup tables that are used when building the interface.
+ --- ***Generally* should not be called directly!** Use `Inventory:Update()` to coordinate the process.
+ ---
+ --- Lookup tables managed by this function:
+ --- - `activeGroups`: `{ = }`
+ --- - `categoryIdsGroupedBySequence` is used to determine the order of execution for category matching:
+ --- ```
+ --- {
+ --- [] = {
+ --- [],
+ --- [],
+ --- },
+ --- [] = {
+ --- [],
+ --- },
+ --- }
+ --- ```
+ --- - `sortedCategorySequenceNumbers` is just an array of the sequence numbers from `categoryIdsGroupedBySequence`
+ --- so that it can easily be sorted. This avoids having to resort the sequence numbers in `categoryIdsGroupedBySequence`
+ --- on every call to `Categories:Categorize()`.
+ --- - `categoriesToGroups` is exactly what it sounds like - the mapping of category IDs to group IDs (which are simply row:position)
+ --- and is used by `CategorizeAndSort()` when filling the groupItems table.
+ function Inventory:UpdateLayoutLookupTables()
+ --self:PrintDebug("UpdateLayoutLookupTables()")
+ -- self:PrintDebug(BsCategories.list)
+
+ -- Only rebuild when the layout structure has actually changed.
+ -- Also rebuild if the current tables are unpopulated: ManageDryRun() can switch self.groups
+ -- (and the other layout-state tables) to the proposedLayoutState copies, which start empty
+ -- and won't have been filled yet if this is the first dry-run pass since the last rebuild.
+ if not self.lookupTablesStale and next(self.groups) ~= nil then
+ return
+ end
+ self.lookupTablesWereRebuilt = true
+
+ -- This will be used at the end if defaultCategoryFound is still false.
+ local lastGroupId = ""
+ local defaultCategoryFound = false
+
+ -- Wipe all the lookup tables managed by this function.
+ BsUtil.TableClear(self.activeCategoryIds)
+ BsUtil.TableClear(self.activeGroups)
+ BsUtil.TableClear(self.categoriesToGroups)
+ BsUtil.TableClear(self.categorySequenceNumbers)
+ BsUtil.TableClear(self.groups)
+ BsUtil.TableClear(self.sortedCategorySequenceNumbers)
+ -- Don't remove unused sequence number tables - no need to upset the garbage collector.
+ -- (This could be slightly more efficient with something like Compost, but it's not a huge deal.)
+ for _, categoryIds in pairs(self.categoryIdsGroupedBySequence) do
+ BsUtil.TableClear(categoryIds)
+ end
+
+ -- Process the layout and update lookup tables.
+ for rowNum, rowGroups in ipairs(self.layout) do
+ for groupNum, groupDetails in ipairs(rowGroups) do
+ local groupId = rowNum .. ":" .. groupNum
+ lastGroupId = groupId
+
+ -- Store reference to the group for easy access later.
+ self.groups[groupId] = groupDetails
+
+ -- Store the group ID on the group so it's easily accessible for use by Update()
+ groupDetails.groupId = groupId
+
+ -- Groups aren't technically active unless they have categories assigned and at least one category is valid.
+ if groupDetails.categories and table.getn(groupDetails.categories) > 0 then
+ for _, categoryId in ipairs(groupDetails.categories) do
+ self:AddCategoryToLookupTables(categoryId, groupId)
+
+ if categoryId == BsCategories.defaultCategory then
+ defaultCategoryFound = true
+ end
+ end
+ end
+ end
+ end
+
+ -- Ensure the default category is always assigned to a group. This is a failsafe and really shouldn't ever be reached.
+ if not defaultCategoryFound then
+ self:PrintDebug("WARNING: no default category found!")
+ self:AddCategoryToLookupTables(BsCategories.defaultCategory, lastGroupId)
+ end
+
+ -- Order sortedCategorySequenceNumbers by category sequence.
+ table.sort(self.sortedCategorySequenceNumbers)
+
+ self.lookupTablesStale = false
+ end
+
+ --- Helper function to add a category to the lookup tables.
+ ---@param categoryId string ID of the category.
+ ---@param groupId string ID of the group to associate with the category.
+ function Inventory:AddCategoryToLookupTables(categoryId, groupId)
+ -- Ensure this is a valid category.
+ -- Shouldn't ever really be an issue because ValidateLayout() is called first, but let's be safe.
+ if BsCategories.list[categoryId] then
+ -- Get info about the category.
+ local categoryDetails = BsCategories.list[categoryId]
+
+ -- This is an active category (used when checking for errors).
+ self.activeCategoryIds[categoryId] = true
+
+ -- We haven't seen this sequence number yet.
+ if self.categoryIdsGroupedBySequence[categoryDetails.sequence] == nil then
+ self.categoryIdsGroupedBySequence[categoryDetails.sequence] = {}
+ end
+
+ -- Add sequence number to sortedCategorySequenceNumbers so it can be used during categorization.
+ if not self.categorySequenceNumbers[categoryDetails.sequence] then
+ self.categorySequenceNumbers[categoryDetails.sequence] = true
+ table.insert(self.sortedCategorySequenceNumbers, categoryDetails.sequence)
+ end
+
+ -- Create the category-to-group relationship in categoryIdsGroupedBySequence[][ 0 and table.getn(self.inventory[bagNum]) > 0 then
+ -- Process all bag slots.
+ local bagNumSlots = self.containers[bagNum].numSlots
+ if bagNumSlots > 0 then
+ for slotNum = 1, bagNumSlots do
+ -- Categorize each item.
+ -- This must ALWAYS be performed so new items don't show up with an unknown category in the Bagshui tooltip.
+ BsCategories:Categorize(
+ self.inventory[bagNum][slotNum],
+ nil, -- Categorization as any alternate character is not currently enabled.
+ self.sortedCategorySequenceNumbers,
+ self.categoryIdsGroupedBySequence
+ )
+
+ -- Fall back to the default group if one wasn't assigned.
+ groupId = self.inventory[bagNum][slotNum].bagshuiGroupId
+ if string.len(tostring(groupId or "")) == 0 then
+ groupId = defaultGroupId
+ end
+
+ -- Empty slots are now allowed to stack since they've been through the categorizing process.
+ -- (When an empty slot is first seen during a cache update, it gets the _bagshuiPreventEmptySlotStack
+ -- property set so that empty slots don't just "disappear" into a stack when the window is open
+ -- and the user moves an item out of a slot.)
+ if not self.dryRun and self.inventory[bagNum][slotNum].emptySlot == 1 then
+ self.inventory[bagNum][slotNum]._bagshuiPreventEmptySlotStack = nil
+ end
+
+ -- Final safety check to ensure we don't somehow try to assign to a group that doesn't exist.
+ if not self.groupItems[groupId] then
+ groupId = defaultGroupId
+ end
+
+ -- Add to group positions lookup table.
+ table.insert(self.groupItems[groupId], self.inventory[bagNum][slotNum])
+ end -- bagNumSlots loop
+ end -- bagNumSlots > 0
+ end -- Known bag check
+ end -- self.containerIds loop
+
+ -- Show the error button if there were problems.
+ self.errorText = BsCategories:GetErrors(self.activeCategoryIds)
+ end
+
+ -- Sort each group's items in the configured order.
+ -- **Should not be called directly!** Use `Inventory:Update()` to coordinate the process.
+ function Inventory:SortGroups()
+ for groupId, groupConfig in pairs(self.activeGroups) do
+ BsSortOrders:SortGroup(self.groupItems[groupId], groupConfig, self.settings.defaultSortOrder)
+ end
+ end
+
+ --- Return approximate dimensions for a row of groups using the same slot/group math as UpdateWindow().
+ ---@param rowGroups table[] Layout groups to measure.
+ ---@param maxColumns number Maximum item columns allowed in the row.
+ ---@param adjustedItemSlotSize number Scaled item slot size.
+ ---@param itemSlotMargin number Space around item slots.
+ ---@param groupPadding number Group padding.
+ ---@return number actualWidth
+ ---@return number actualHeight
+ ---@return number visibleGroupCount
+ ---@return number rowWidthInItems
+ function Inventory:GetLayoutRowDimensions(rowGroups, maxColumns, adjustedItemSlotSize, itemSlotMargin, groupPadding)
+ local numGroupsInRow = table.getn(rowGroups)
+ local totalItemsInRow = 0
+ local rowWidthInItems = 0
+ local rowHeightInItems = 1
+ local unshrinkableGroupCount = 0
+ local groupWidthsInItems = {}
+ local groupId
+
+ for columnNum = 1, numGroupsInRow do
+ groupId = rowGroups[columnNum].groupId
+ groupWidthsInItems[groupId] = self.groupItemCounts[groupId] or 0
+ totalItemsInRow = totalItemsInRow + groupWidthsInItems[groupId]
+ end
+
+ rowWidthInItems = totalItemsInRow
+
+ while rowWidthInItems > maxColumns do
+ rowHeightInItems = rowHeightInItems + 1
+ repeat
+ rowWidthInItems = 0
+ unshrinkableGroupCount = 0
+ for columnNum = 1, numGroupsInRow do
+ groupId = rowGroups[columnNum].groupId
+
+ if groupWidthsInItems[groupId] > 1 then
+ local newWidth = groupWidthsInItems[groupId] - 1
+ if self.groupItemCounts[groupId] / newWidth <= rowHeightInItems then
+ groupWidthsInItems[groupId] = newWidth
+ else
+ unshrinkableGroupCount = unshrinkableGroupCount + 1
+ end
+ else
+ unshrinkableGroupCount = unshrinkableGroupCount + 1
+ end
+ rowWidthInItems = rowWidthInItems + groupWidthsInItems[groupId]
+ end
+ until rowWidthInItems <= maxColumns or unshrinkableGroupCount >= numGroupsInRow
+
+ -- If every group is already at its minimum width, this row can't fit within
+ -- maxColumns. Stop measuring instead of looping forever.
+ if rowWidthInItems > maxColumns then
+ local canShrink = false
+ for columnNum = 1, numGroupsInRow do
+ if groupWidthsInItems[rowGroups[columnNum].groupId] > 1 then
+ canShrink = true
+ break
+ end
+ end
+ if not canShrink then
+ break
+ end
+ end
+ end
+
+ local actualWidth = 0
+ local visibleGroupCount = 0
+ for columnNum = 1, numGroupsInRow do
+ groupId = rowGroups[columnNum].groupId
+ if groupWidthsInItems[groupId] > 0 then
+ actualWidth = actualWidth
+ + (groupWidthsInItems[groupId] * (adjustedItemSlotSize + itemSlotMargin))
+ + itemSlotMargin
+ + (groupPadding * 2)
+ visibleGroupCount = visibleGroupCount + 1
+ end
+ end
+
+ if totalItemsInRow == 0 then
+ return 0, 0, visibleGroupCount, rowWidthInItems
+ end
+
+ return
+ actualWidth,
+ (rowHeightInItems * (adjustedItemSlotSize + itemSlotMargin)) + itemSlotMargin + (groupPadding * 2),
+ visibleGroupCount,
+ rowWidthInItems
+ end
+
+ --- Build a temporary layout for rendering visible groups in a smaller footprint.
+ --- Does not mutate the saved Structure profile layout.
+ ---@param maxColumns number Maximum item columns allowed by the window setting.
+ ---@param adjustedItemSlotSize number Scaled item slot size.
+ ---@param itemSlotMargin number Space around item slots.
+ ---@param groupPadding number Group padding.
+ ---@param groupXSpacing number Horizontal space between groups.
+ ---@param groupYSpacing number Vertical space between groups.
+ ---@return table renderLayout Temporary layout table.
+ function Inventory:BuildAutoCompactRenderLayout(
+ maxColumns,
+ adjustedItemSlotSize,
+ itemSlotMargin,
+ groupPadding,
+ groupXSpacing,
+ groupYSpacing
+ )
+ local visibleGroups = {}
+ local hideGroup
+ local groupId
+
+ for rowNum = 1, table.getn(self.layout) do
+ for columnNum = 1, table.getn(self.layout[rowNum]) do
+ groupId = self.layout[rowNum][columnNum].groupId
+ if groupId then
+ hideGroup = false
+ if self.groups[groupId] and self.groups[groupId].hide then
+ hideGroup = true
+ self.hasHiddenGroups = true
+ end
+
+ if not self.showHidden and hideGroup then
+ self.groupItemCounts[groupId] = self.highlightItemsInContainerLocked
+ and self:GetGroupItemCountForLayout(groupId, self.highlightItemsInContainerLocked)
+ or 0
+ else
+ self.groupItemCounts[groupId] = self:GetGroupItemCountForLayout(groupId)
+ end
+
+ if self.groupItemCounts[groupId] > 0 then
+ table.insert(visibleGroups, {
+ groupId = groupId,
+ name = self.layout[rowNum][columnNum].name,
+ sourceRowNum = rowNum,
+ sourceColumnNum = columnNum,
+ })
+ end
+ else
+ Bagshui:PrintWarning("Inventory update call should have forced resort!")
+ end
+ end
+ end
+
+ if table.getn(visibleGroups) == 0 then
+ return {}
+ end
+
+ local bestLayout
+ local bestArea
+ local bestWidth
+
+ for targetWidth = 1, maxColumns do
+ local candidateLayout = {}
+ local currentRow = {}
+
+ for groupNum = 1, table.getn(visibleGroups) do
+ local trialRow = {}
+ for currentRowGroupNum = 1, table.getn(currentRow) do
+ table.insert(trialRow, currentRow[currentRowGroupNum])
+ end
+ table.insert(trialRow, visibleGroups[groupNum])
+
+ local _, _, _, trialRowWidthInItems = self:GetLayoutRowDimensions(
+ trialRow,
+ targetWidth,
+ adjustedItemSlotSize,
+ itemSlotMargin,
+ groupPadding
+ )
+
+ if table.getn(currentRow) > 0 and trialRowWidthInItems > targetWidth then
+ table.insert(candidateLayout, currentRow)
+ currentRow = {}
+ end
+ table.insert(currentRow, visibleGroups[groupNum])
+ end
+
+ if table.getn(currentRow) > 0 then
+ table.insert(candidateLayout, currentRow)
+ end
+
+ local candidateWidth = 0
+ local candidateHeight = 0
+ local visibleRows = 0
+ for rowNum = 1, table.getn(candidateLayout) do
+ local rowWidth, rowHeight, visibleGroupCount = self:GetLayoutRowDimensions(
+ candidateLayout[rowNum],
+ maxColumns,
+ adjustedItemSlotSize,
+ itemSlotMargin,
+ groupPadding
+ )
+ if visibleGroupCount > 0 then
+ rowWidth = rowWidth + ((visibleGroupCount - 1) * groupXSpacing)
+ if rowWidth > candidateWidth then
+ candidateWidth = rowWidth
+ end
+ candidateHeight = candidateHeight + rowHeight
+ visibleRows = visibleRows + 1
+ end
+ end
+ if visibleRows > 1 then
+ candidateHeight = candidateHeight + ((visibleRows - 1) * groupYSpacing)
+ end
+
+ local candidateArea = candidateWidth * candidateHeight
+ if
+ not bestArea
+ or candidateArea < bestArea
+ or (candidateArea == bestArea and candidateWidth < bestWidth)
+ then
+ bestLayout = candidateLayout
+ bestArea = candidateArea
+ bestWidth = candidateWidth
+ end
+ end
+
+ return bestLayout
+ end
+
+ --- Actually do the work of updating the UI.
+ --- **Should not be called directly!** Use `Inventory:Update()` or `Inventory:ForceWindowUpdate()` to coordinate the process.
+ ---
+ --- This function handles:
+ --- - Sizing and placing groups so everything fits within the allowed number of columns
+ --- - Calling `AssignItemsToSlots()` to fill item slot buttons
+ --- - Stacking/unstacking empty slots
+ --- - Hiding/showing components and setting window colors based on preferences
+ --- - Sizing the window
+ function Inventory:UpdateWindow()
+ -- self:PrintDebug("UpdateWindow()")
+
+ -- Clear the "never online" message once we're online.
+ if self.online then
+ Bagshui.currentCharacterData[self.inventoryTypeSavedVars].neverOnline = nil
+ end
+
+ -- Can only highlight changes if there's something to highlight.
+ self.highlightChangesEnabled = (
+ self.hasChanges
+ or (self.dockedInventory and self.dockedInventory.hasChanges)
+ or (self.dockedToInventory and self.dockedToInventory.hasChanges)
+ ) or false
+
+ -- Safeguard: Turn off Highlight Changes if there's nothing to highlight.
+ if not self.highlightChangesEnabled then
+ self.highlightChanges = false
+ end
+
+ -- There are a few things that always need to happen even if we're not
+ -- doing a full layout update.
+
+ self.windowColor = self.settings.windowBackground
+ self.borderColor = self.settings.windowBorder
+
+ if self.settings.windowUseSkinColors and BsSkin.skinBackgroundColor then
+ self.windowColor = BsSkin.skinBackgroundColor
+ end
+ if self.settings.windowUseSkinColors and BsSkin.windowBackdrop then
+ self.windowColor = BsSkin.windowColor or { 1, 1, 1, 1 }
+ end
+
+ if not self.online then
+ self.borderColor = BS_COLOR.RED
+ elseif self.settings.windowUseSkinColors and BsSkin.skinBorderColor then
+ self.borderColor = BsSkin.skinBorderColor
+ elseif self.settings.windowUseSkinColors and BsSkin.windowBackdrop then
+ self.borderColor = BsSkin.windowBorderColor or { 1, 1, 1, 1 }
+ end
+
+ self.uiFrame:SetBackdropColor(self.windowColor[1], self.windowColor[2], self.windowColor[3], self.windowColor[4])
+ self.uiFrame:SetBackdropBorderColor(
+ self.borderColor[1],
+ self.borderColor[2],
+ self.borderColor[3],
+ self.borderColor[4]
+ )
+
+ self.uiFrame:SetFrameStrata(self.settings.windowStrata)
+
+ -- Update the utilization display setting.
+ -- Done here because it's consumed in `UpdateBagBar()` and `UpdateToolbar()`.
+ self.alwaysShowUsageSummary = (
+ self.settings.bagUsageDisplay == BS_INVENTORY_BAG_USAGE_DISPLAY.ALWAYS
+ or (self.settings.bagUsageDisplay == BS_INVENTORY_BAG_USAGE_DISPLAY.SMART and not self.settings.stackEmptySlots)
+ or false
+ )
+
+ -- Only do the intensive work of redrawing the UI if we have to.
+ if self.windowUpdateNeeded then
+ BsUtil.TableClear(self.groupsIdsToFrames)
+
+ local uiFrames = self.ui.frames
+ local groupFrames = uiFrames.groups
+ local uiButtons = self.ui.buttons
+
+ local itemSlotSize = self.settings.itemSize
+ local itemSlotScale = self.ui:GetItemButtonScale(self.ui.buttons.itemSlots[1], itemSlotSize)
+ local adjustedItemSlotSize = self.ui:GetItemButtonScaledSize(self.ui.buttons.itemSlots[1], itemSlotSize)
+ local itemSlotMargin = self.settings.itemMargin + (BsSkin.itemSlotMarginFudge or 0)
+
+ local groupMarginFudged = self.settings.groupMargin + BsSkin.groupMarginFudge
+ local groupLabelHeight = groupFrames[1].bagshuiData.text:GetHeight() + math.abs(BsSkin.groupLabelMinPadding)
+ local groupPadding = self.settings.groupPadding
+ + (BsSkin.groupPaddingFudge or 0)
+ + ((BsSkin.itemSlotMarginFudge / 2) * itemSlotScale)
+
+ local maxColumns = self.settings.windowMaxColumns
+ -- Edit Mode shows every group, including empty ones, so rows have many more groups than usual.
+ -- Squeezing them into the normal limit makes the window absurdly tall, so allow it to be wider.
+ if self.editMode and maxColumns < BS_EDIT_MODE_MIN_MAX_COLUMNS then
+ maxColumns = BS_EDIT_MODE_MIN_MAX_COLUMNS
+ end
+ local anchorLeft = self.settings.windowContentAlignment == "LEFT"
+ local showGroupLabels = (self.settings.showGroupLabels and not self.settings.hideGroupLabelsOverride)
+ or self.editMode
+
+ local firstFrame, widestRowLastFrame, topmostFrame
+
+ -- Window component visibility.
+ local showHeader = ((self.settings.showHeader and not self.dockTo) or self.temporarilyShowWindowHeader)
+ local showFooter = (
+ (self.settings.showFooter and not self.dockTo)
+ or self.temporarilyShowWindowFooter
+ or self.temporarilyShowBagBar
+ )
+
+ -- Reset hidden group and item tracking.
+ self.hasHiddenGroups = false -- Updated in this function.
+ self.hasHiddenItems = false -- Updated in GetGroupItemCountForLayout().
+ self.hasOpenables = false -- Updated in AssignItemsToSlots().
+ self.nextOpenableItemBagNum = nil -- Updated in AssignItemsToSlots().
+ self.nextOpenableItemSlotNum = nil -- Updated in AssignItemsToSlots().
+ self.nextOpenableItemSlotButton = nil -- Updated in AssignItemsToSlots().
+
+ -- Full reset of empty slot stack counts is needed so that the tracking of which
+ -- bag they represent is rebuilt.
+ self:ResetEmptySlotStackCounts(true)
+
+ -- Is empty slot stacking currently allowed?
+ self.emptySlotStackingAllowed = (
+ self.settings.stackEmptySlots -- User setting.
+ and not self.expandEmptySlotStacks -- Temporary expansion.
+ and not self.editMode -- Always expand in Edit Mode.
+ )
+
+ -- Used to track row changes so they include group move targets (the normal
+ -- rowNum variable is just an iteration over self.layout).
+ local uiRowNum = 0
+
+ -- These three rowGroup* variables control which direction we step though
+ -- the groups (left-to-right or right-to-left).
+ local rowGroupStart, rowGroupEnd, rowGroupStep
+
+ -- Exactly what it says on the tin.
+ local numGroupsInRow
+ local totalItemsInRow = 0
+ local rowWidthInItems = 0
+ local rowHeightInItems = 0
+ local finalWindowWidth = 0
+ local finalWindowHeight = 0
+ local hideGroup = false
+
+ -- Used to track which row is actually the widest after the frames are positioned.
+ local widestRowLastFrameOutsideX = 0
+ local currentRowLastFrameOutsideX
+
+ -- Used during row width calculations to determine the point when we need
+ -- to increase the row height again.
+ local unshrinkableGroupCount = 0
+
+ -- The uiGroupNum* variables are used to keep track of which group frame(s)
+ -- we should be using for each row and the overall progress as we step through the layout.
+ local uiGroupNum = 1 -- Need to have a default value here since we use uiGroupNum later to hide unused groups.
+ local uiGroupNumStart = 1
+ local uiGroupNumEnd
+
+ -- currentItemSlotButtonNum tracks the progress of assigning items to buttons.
+ local currentItemSlotButtonNum = 1
+
+ -- Groups can have some weird padding going on because of their edge textures,
+ -- so we need to adjust the group spacing so the visual outcome is correct.
+ local groupXSpacing = groupMarginFudged
+
+ -- Similarly, the initial position of everything isn't straightforward and
+ -- gets affected by Edit Mode.
+ local groupEdgeOffset = groupMarginFudged
+ -- Increase the space between the bottom edge and the first group when the footer is visible.
+ local groupEdgeYOffset = groupMarginFudged - (showFooter and BsSkin.groupMarginFudge or 0)
+
+ -- When group labels are shown, we may need more Y space.
+ local groupYSpacing = groupXSpacing
+ if showGroupLabels then
+ if groupYSpacing < groupLabelHeight then
+ groupYSpacing = groupLabelHeight
+ end
+ groupYSpacing = groupYSpacing + BsSkin.groupLabelYPadding
+ end
+
+ -- Don't need to calculate the row (horizontal) group move target width outside Edit Mode.
+ local rowGroupMoveTargetWidth = 0
+
+ -- Edit Mode spacing adjustments -- keep things as tight as possible since the window will
+ -- almost always grow due to more groups being visible.
+ if self.editMode then
+ groupXSpacing = -1
+ groupYSpacing = groupXSpacing
+ groupEdgeOffset = BsSkin.inventoryEditModeMargin
+ groupEdgeYOffset = groupEdgeOffset
+
+ -- The horizontal group move target is basically the width of a single group.
+ rowGroupMoveTargetWidth = adjustedItemSlotSize
+ + (itemSlotMargin * 2)
+ + (groupPadding * 2)
+ + ((BsSkin.groupMoveTargetThinDimension + BsSkin.groupMoveTargetAnchorOffset) * 2)
+ end
+
+ -- Reset group move target count tracking for ShowNextGroupMoveTarget().
+ self.groupMoveTargetCount = 0
+
+ -- Column number adjustment for insertion via group move targets.
+ -- This is needed so that we get the correct insertion point regardless of
+ -- whether we're going from left to right or vice versa.
+ local groupMoveTargetLeadingColumnNumAdjustment = 0
+ local groupMoveTargetTrailingColumnNumAdjustment = 0
+
+ -- Prepare to use ShowFrameInNextPosition() for groups.
+ self:InitFramePositioningTable(
+ "UpdateWindow", -- Position tracking table.
+ groupXSpacing, -- Frame X spacing.
+ groupYSpacing, -- Frame Y spacing.
+ uiFrames.main, -- Initial frame to anchor to.
+ -groupEdgeOffset,
+ groupEdgeYOffset
+ )
+
+ -- Reset tracking tables.
+ BsUtil.TableClear(self.groupItemCounts) -- How many items are in each group.
+ BsUtil.TableClear(self.groupWidthsInItems) -- Group width in items (actual count EXCEPT in Edit Mode, where 0 becomes 1 to force the group to be visible when empty).
+ BsUtil.TableClear(self.actualGroupWidths) -- Group width in screen units.
+
+ local renderLayout = self.layout
+ if self.settings.autoCompactLayout and not self.editMode then
+ renderLayout = self:BuildAutoCompactRenderLayout(
+ maxColumns,
+ adjustedItemSlotSize,
+ itemSlotMargin,
+ groupPadding,
+ groupXSpacing,
+ groupYSpacing
+ )
+ end
+
+ -- Groups are placed starting at the bottom of the main frame and working upwards.
+ -- Since we're starting at the bottom of the window, we need to start at the last row of the layout table.
+ for rowNum = table.getn(renderLayout), 1, -1 do
+ -- self:PrintDebug("ROW "..rowNum)
+
+ -- Get the number of groups in this row.
+ numGroupsInRow = table.getn(renderLayout[rowNum])
+ -- Use that information to figure out the number of the end group.
+ uiGroupNumEnd = uiGroupNumStart + numGroupsInRow - 1
+
+ -- Determine anchoring.
+ if anchorLeft then
+ rowGroupStart = 1
+ rowGroupEnd = numGroupsInRow
+ rowGroupStep = 1
+ groupMoveTargetLeadingColumnNumAdjustment = 0
+ groupMoveTargetTrailingColumnNumAdjustment = 1
+ else
+ rowGroupStart = numGroupsInRow
+ rowGroupEnd = 1
+ rowGroupStep = -1
+ groupMoveTargetLeadingColumnNumAdjustment = 1
+ groupMoveTargetTrailingColumnNumAdjustment = 0
+ end
+
+ -- Populate lists that are used to determine how tall each group needs to
+ -- be in order to fit all items within the maximum column count.
+ totalItemsInRow = 0
+ for columnNum = rowGroupStart, rowGroupEnd, rowGroupStep do
+ local groupId = renderLayout[rowNum][columnNum].groupId
+
+ if groupId then
+ hideGroup = false
+
+ -- Determine whether this group is visible and update hasHiddenGroups
+ -- to control whether the Show/Hide toolbar button is enabled.
+ if self.groups[groupId] and self.groups[groupId].hide then
+ hideGroup = true
+ self.hasHiddenGroups = true
+ end
+
+ -- Determine how many items are in each group in this row based on group visibility.
+ if not self.editMode and not self.showHidden and hideGroup then
+ -- Set the groupItemCounts for this group to 0 if it's not visible unless it contains
+ -- items from the bag that has highlight lock set.
+ self.groupItemCounts[groupId] = self.highlightItemsInContainerLocked
+ and self:GetGroupItemCountForLayout(groupId, self.highlightItemsInContainerLocked)
+ or 0
+ else
+ self.groupItemCounts[groupId] = self:GetGroupItemCountForLayout(groupId)
+ end
+
+ -- Increment total for this row.
+ totalItemsInRow = totalItemsInRow + self.groupItemCounts[groupId]
+
+ -- Starting width for all groups is the number of items in the group.
+ self.groupWidthsInItems[groupId] = self.groupItemCounts[groupId]
+
+ -- groupWidthsInItems gets fudged for Edit Mode to make all groups visible, even if they're empty.
+ if self.groupWidthsInItems[groupId] == 0 and self.editMode then
+ self.groupWidthsInItems[groupId] = 1
+ end
+ else
+ Bagshui:PrintWarning("Inventory update call should have forced resort!")
+ end
+ end
+
+ -- Starting values for row height/width.
+ rowWidthInItems = totalItemsInRow
+ rowHeightInItems = 1
+
+ -- Shrink group widths until everything fits within the configured column limit. Basically:
+ -- 1. Increment the row height by 1.
+ -- 2. Loop through all columns and decrease width of each by 1 (so long as that's possible and won't make it too tall).
+ -- 3. See if everything fits now. If not, go back to step 2 unless all groups are now too tall, in which case, go back to step 1.
+ -- (No need to do any of this unless there are too many items in the row to fit in the configured number of columns.)
+ while rowWidthInItems > maxColumns do
+ rowHeightInItems = rowHeightInItems + 1
+ repeat
+ rowWidthInItems = 0
+ unshrinkableGroupCount = 0
+ for columnNum = rowGroupStart, rowGroupEnd, rowGroupStep do
+ local groupId = renderLayout[rowNum][columnNum].groupId
+
+ -- Shrink the group by 1, if possible.
+ if self.groupWidthsInItems[groupId] > 1 then
+ local newWidth = self.groupWidthsInItems[groupId] - 1
+ -- Make sure this width would't make the group too tall for this row height.
+ if self.groupItemCounts[groupId] / newWidth <= rowHeightInItems then
+ self.groupWidthsInItems[groupId] = newWidth
+ else
+ unshrinkableGroupCount = unshrinkableGroupCount + 1
+ end
+ else
+ unshrinkableGroupCount = unshrinkableGroupCount + 1
+ end
+ rowWidthInItems = rowWidthInItems + self.groupWidthsInItems[groupId]
+ end
+ until rowWidthInItems <= maxColumns or unshrinkableGroupCount >= numGroupsInRow
+
+ -- Failsafe for impossible rows: if there are more visible groups than
+ -- allowed columns, every group bottoms out at width 1 and can't shrink
+ -- any further.
+ if rowWidthInItems > maxColumns then
+ local canShrink = false
+ for columnNum = rowGroupStart, rowGroupEnd, rowGroupStep do
+ if self.groupWidthsInItems[renderLayout[rowNum][columnNum].groupId] > 1 then
+ canShrink = true
+ break
+ end
+ end
+ if not canShrink then
+ break
+ end
+ end
+ end
+
+ -- Is there anything to display on this row?
+ if totalItemsInRow == 0 and not self.editMode then
+ -- All groups are not visible.
+ for groupNum = uiGroupNumStart, uiGroupNumEnd do
+ -- This check doesn't seem like it should be necessary, but https://github.com/veechs/Bagshui/issues/172
+ -- says otherwise.
+ if groupFrames[groupNum] then
+ groupFrames[groupNum]:Hide()
+ end
+ end
+ else
+ -- There are groups to display, so let's continue.
+ -- Calculate the actual group widths, which we need to know when we make the group frame visible.
+ local totalActualGroupWidths = 0 -- Will be used to determine how wide the window needs to be.
+ for columnNum = rowGroupStart, rowGroupEnd, rowGroupStep do
+ local group = renderLayout[rowNum][columnNum]
+
+ -- Make sure this group will be displayed before adding it to the totals.
+ if self.groupWidthsInItems[group.groupId] > 0 or self.editMode then
+ self.actualGroupWidths[group.groupId] = (
+ -- Item slot widths, margin included.
+(self.groupWidthsInItems[group.groupId] * (adjustedItemSlotSize + itemSlotMargin))
+ -- This accounts for the space on the opposite side of the last item.
+ + itemSlotMargin
+ -- Left and right group padding.
+ + (groupPadding * 2)
+ )
+ totalActualGroupWidths = totalActualGroupWidths + self.actualGroupWidths[group.groupId]
+ else
+ self.actualGroupWidths[group.groupId] = 0
+ end
+ end
+
+ -- Every group on the row gets the same height even if it's not full of items.
+ local actualGroupHeight =
+ -- Item slot heights, margin included.
+ (rowHeightInItems * (adjustedItemSlotSize + itemSlotMargin))
+ -- This accounts for the space above the last item.
+ + itemSlotMargin
+ -- Top and bottom group padding
+ + (groupPadding * 2)
+
+ -- Edit Mode accommodations
+ if self.editMode then
+ -- Group labels move inside in Edit Mode and need to be added to the height.
+ actualGroupHeight = actualGroupHeight + groupLabelHeight
+
+ -- Show the between-row group move target below this row.
+ uiRowNum = uiRowNum + 1
+ self:ShowNextGroupMoveTarget(
+ rowGroupMoveTargetWidth,
+ BsSkin.groupMoveTargetThinDimension,
+ uiRowNum,
+ rowNum + 1,
+ 1,
+ BS_INVENTORY_LAYOUT_DIRECTION.ROW
+ )
+ end
+
+ -- Put the groups in their places.
+ uiGroupNum = uiGroupNumStart
+ uiRowNum = uiRowNum + 1
+
+ for columnNum = rowGroupStart, rowGroupEnd, rowGroupStep do
+ local groupId = renderLayout[rowNum][columnNum].groupId
+
+ -- Ensure the group exists.
+ self.ui:CreateGroup(uiGroupNum)
+ local group = groupFrames[uiGroupNum]
+
+ -- Store a reference to the group widget so it's easily accessible via group ID.
+ -- Primarily used when an item needs to be assigned to a new group in ManagePendingGroupAssignments().
+ self.groupsIdsToFrames[groupId] = group
+
+ -- Show leading group move target for this group in Edit Mode.
+ if self.editMode then
+ self:ShowNextGroupMoveTarget(
+ BsSkin.groupMoveTargetThinDimension,
+ actualGroupHeight,
+ uiRowNum,
+ rowNum,
+ columnNum + groupMoveTargetLeadingColumnNumAdjustment,
+ BS_INVENTORY_LAYOUT_DIRECTION.COLUMN
+ )
+ -- In Edit Mode, the group move target should be used for height/width calculations.
+ if not firstFrame then
+ firstFrame = self.positioningTables.UpdateWindow.anchorToFrame
+ end
+ end
+
+ -- Display the group.
+ if self.groupWidthsInItems[groupId] > 0 then
+ -- Update info on the UI object.
+ group.bagshuiData.groupId = groupId
+ group.bagshuiData.rowNum = renderLayout[rowNum][columnNum].sourceRowNum or rowNum
+ group.bagshuiData.columnNum = renderLayout[rowNum][columnNum].sourceColumnNum or columnNum
+
+ -- Add the group to the window.
+ self:ShowFrameInNextPosition(
+ "UpdateWindow",
+ uiRowNum,
+ group,
+ self.actualGroupWidths[groupId],
+ actualGroupHeight
+ )
+
+ -- This is the group that should be used for height/width calculations.
+ if not firstFrame then
+ firstFrame = self.positioningTables.UpdateWindow.anchorToFrame
+ end
+
+ -- Set background and border colors.
+ self.ui:SetGroupColors(group)
+
+ -- Labels.
+ if showGroupLabels then
+ -- Move to the correct location depending on whether we're in Edit Mode.
+ group.bagshuiData.labelFrame:SetHeight(groupLabelHeight)
+ group.bagshuiData.labelFrame:ClearAllPoints()
+ if self.editMode then
+ group.bagshuiData.labelFrame:SetWidth(
+ self.actualGroupWidths[groupId] - (groupPadding * 2) - itemSlotMargin
+ )
+ group.bagshuiData.labelFrame:SetPoint(
+ "TOPLEFT",
+ group,
+ "TOPLEFT",
+ BsSkin.groupLabelEditModeXOffset,
+ BsSkin.groupLabelEditModeYOffset
+ )
+ group.bagshuiData.labelFrame:EnableMouse(false)
+ else
+ group.bagshuiData.labelFrame:SetWidth(
+ self.actualGroupWidths[groupId] + BsSkin.groupLabelRelativeWidth
+ )
+ group.bagshuiData.labelFrame:SetPoint(
+ "BOTTOMLEFT",
+ group,
+ "TOPLEFT",
+ BsSkin.groupLabelXOffset,
+ BsSkin.groupLabelYOffset
+ )
+ group.bagshuiData.labelFrame:EnableMouse(true)
+ end
+
+ -- Set text to group name, or a single space if the group name is empty.
+ -- The single space is necessary so that the height can be measured properly.
+ group.bagshuiData.text:SetText(
+ string.len(renderLayout[rowNum][columnNum].name or "") > 0 and renderLayout[rowNum][columnNum].name
+ or " "
+ )
+
+ group.bagshuiData.labelFrame:Show()
+ else
+ -- Labels are turned off.
+ group.bagshuiData.labelFrame:Hide()
+ end
+
+ -- Assign items to the group (only if there are items in the group).
+ if self.groupItemCounts[groupId] > 0 then
+ currentItemSlotButtonNum = self:AssignItemsToSlots(
+ groupId,
+ uiGroupNum,
+ currentItemSlotButtonNum,
+ self.groupWidthsInItems[groupId],
+ groupPadding,
+ itemSlotSize,
+ itemSlotMargin
+ )
+ end
+
+ -- Give groups mouse input and display group manager buttons in Edit Mode.
+ self.ui:SetGroupInteractivityEnabled(group, self.editMode)
+ else
+ -- Group should not be visible.
+ group:Hide()
+ end
+
+ uiGroupNum = uiGroupNum + 1
+ end
+
+ -- Display the trailing group move target for this row.
+ if self.editMode then
+ self:ShowNextGroupMoveTarget(
+ BsSkin.groupMoveTargetThinDimension,
+ actualGroupHeight,
+ uiRowNum,
+ rowNum,
+ rowGroupEnd + groupMoveTargetTrailingColumnNumAdjustment,
+ BS_INVENTORY_LAYOUT_DIRECTION.COLUMN
+ )
+ end
+
+ -- Update the widest row record so we can make the window the correct width.
+ currentRowLastFrameOutsideX = (
+ anchorLeft and self.positioningTables.UpdateWindow.anchorToFrame:GetRight()
+ or self.positioningTables.UpdateWindow.anchorToFrame:GetLeft()
+ )
+ if
+ widestRowLastFrameOutsideX == 0
+ or (
+ anchorLeft and widestRowLastFrameOutsideX < currentRowLastFrameOutsideX
+ or widestRowLastFrameOutsideX > currentRowLastFrameOutsideX
+ )
+ then
+ widestRowLastFrameOutsideX = currentRowLastFrameOutsideX
+ widestRowLastFrame = self.positioningTables.UpdateWindow.anchorToFrame
+ end
+ end
+
+ uiGroupNumStart = uiGroupNumStart + numGroupsInRow
+ end
+
+ -- Add the final group move target above the last row.
+ if self.editMode then
+ uiRowNum = uiRowNum + 1
+ self:ShowNextGroupMoveTarget(
+ rowGroupMoveTargetWidth,
+ BsSkin.groupMoveTargetThinDimension,
+ uiRowNum,
+ 1,
+ 1,
+ BS_INVENTORY_LAYOUT_DIRECTION.ROW
+ )
+ end
+
+ -- Hide unused item slot buttons, groups, and group move targets.
+ local itemSlotButtonCount = table.getn(uiButtons.itemSlots)
+ if currentItemSlotButtonNum <= itemSlotButtonCount then
+ for buttonNum = currentItemSlotButtonNum, itemSlotButtonCount do
+ uiButtons.itemSlots[buttonNum]:Hide()
+ end
+ end
+ for i = uiGroupNum, table.getn(uiFrames.groups) do
+ uiFrames.groups[i]:Hide()
+ end
+ for i = self.groupMoveTargetCount + 1, table.getn(uiFrames.groupMoveTargets) do
+ uiFrames.groupMoveTargets[i]:Hide()
+ end
+
+ -- The last frame to be placed is always going to be the highest one vertically,
+ -- unlike the frame used for horizontal sizing, which is dependent on which row is the widest.
+ topmostFrame = self.positioningTables.UpdateWindow.anchorToFrame
+
+ -- Show the "No inventory data" message for when the cache has never been populated.
+ if not self.editMode and BsUtil.TrueTableSize(self.inventory) == 0 then
+ self.ui.text.noData:Show()
+ else
+ self.ui.text.noData:Hide()
+ end
+
+ -- Calculate what we always know for width and height (header/footer are added next).
+ finalWindowWidth =
+ -- Inventory content.
+ ((firstFrame and widestRowLastFrame) and math.abs(firstFrame:GetRight() - widestRowLastFrame:GetLeft()) or 0)
+ -- Outer group margins.
+ + groupEdgeOffset * 2
+ -- Window padding.
+ + BsSkin.inventoryWindowPadding * 2
+
+ finalWindowHeight =
+ -- Inventory content.
+ ((topmostFrame and firstFrame) and (topmostFrame:GetTop() - firstFrame:GetBottom()) or 0)
+ -- Bottom group margin.
+ + groupEdgeYOffset
+ -- Edit Mode messes with the sizing.
+ + (
+ self.editMode
+ and BsSkin.groupMoveTargetThinDimension
+ or (
+ -- Top group margin is the standard one.
+groupEdgeOffset
+ -- Need some breathing room above the topmost labels.
+ + (showGroupLabels and (groupLabelHeight - BsSkin.groupMarginFudge) or 0)
+ )
+ )
+ -- "No data" message
+ + (self.ui.text.noData:IsShown() and self.ui.text.noData:GetHeight() or 0)
+ -- Window padding.
+ + BsSkin.inventoryWindowPadding * 2
+
+ -- Show/hide header/footer (toolbars) and add to window dimensions.
+ if showHeader then
+ -- Ensure main frame anchor is correct.
+ uiFrames.main:SetPoint("TOP", uiFrames.header, "BOTTOM", 0, 0)
+ uiFrames.header:Show()
+ -- Update window height.
+ finalWindowHeight = finalWindowHeight
+ + (uiFrames.header:GetHeight())
+ + math.abs(BsSkin.inventoryHeaderFooterYAdjustment)
+ else
+ -- When the header is hidden, the main frame needs to anchor to the UI frame instead.
+ uiFrames.main:SetPoint("TOP", self.uiFrame, "TOP", 0, BsSkin.inventoryWindowPadding)
+ uiFrames.header:Hide()
+ end
+
+ if showFooter then
+ -- Ensure main frame anchor is correct.
+ uiFrames.main:SetPoint("BOTTOM", uiFrames.footer, "TOP", 0, 0)
+
+ -- Show/hide Bag Bar and mini utilization summary.
+ if self.settings.showBagBar or self.temporarilyShowBagBar then
+ uiFrames.bagBar:Show()
+ self.ui.frames.miniSpaceSummaryBottom:Hide()
+ else
+ uiFrames.bagBar:Hide()
+ if self.alwaysShowUsageSummary then
+ self.ui.frames.miniSpaceSummaryBottom:Show()
+ end
+ end
+
+ -- Remove space summary from top toolbar.
+ self.ui.frames.miniSpaceSummaryTop:Hide()
+
+ -- Apply bag bar scaling and opacity.
+ local bagBarScale = (itemSlotSize / uiButtons.itemSlots[1].bagshuiData.originalSizeAdjusted)
+ * BsSkin.bagBarScale
+ uiFrames.bagBar:SetScale(bagBarScale)
+ -- Invert scaling for available space display so text is the normal size.
+ uiFrames.spaceSummary:SetScale(1 / bagBarScale)
+ -- Invert scaling for borders if needed.
+ -- If bag bar scaling was reworked to scale the individual buttons, Ui:SetItemButtonSize()
+ -- could be used to handle this automatically, but that refactor is not happening now.
+ if BsSkin.itemSlotBorderInverseScale then
+ for _, bagSlotButton in ipairs(self.ui.buttons.bagSlots) do
+ bagSlotButton.bagshuiData.buttonComponents.border:SetScale(1 / bagBarScale)
+ end
+ end
+
+ -- Determine footer height based on whether the bag bar is visible.
+ local footerHeight = math.max(
+ (
+ -- Bag bar visible.
+self.settings.showBagBar and (uiButtons.itemSlots[1].bagshuiData.originalSizeAdjusted * bagBarScale)
+ or 0
+ ),
+ BsSkin.inventoryHeaderFooterHeight
+ )
+
+ uiFrames.footer:SetHeight(footerHeight)
+ uiFrames.footer:Show()
+
+ -- Update window height.
+ finalWindowHeight = finalWindowHeight + footerHeight + BsSkin.inventoryHeaderFooterYAdjustment
+ else
+ -- When the footer is hidden, the main frame needs to anchor to the UI frame instead.
+ uiFrames.main:SetPoint("BOTTOM", self.uiFrame, "BOTTOM", 0, BsSkin.inventoryWindowPadding)
+ uiFrames.footer:Hide()
+ end
+
+ -- We can finally size the window frame.
+ self.uiFrame:SetWidth(math.max(finalWindowWidth, BsSkin.inventoryWindowMinWidth))
+ self.uiFrame:SetHeight(finalWindowHeight)
+
+ -- Set scale and anchor for docked frame.
+ if self.dockTo then
+ -- Scaling of docked frames is accomplished by scaling the Main frame, then changing the
+ -- dimensions of the window frame. This is done instead of just scaling self.uiFrame to keep
+ -- the window border looking the same as the frame it's docked to; it looks a little weird if
+ -- the border of the docked frame is shrunk.
+ local dockedFrameScale = BsSkin.inventoryDockedScale * Bagshui.components[self.dockTo].uiFrame:GetScale()
+ self.ui.frames.main:SetScale(dockedFrameScale)
+ self.uiFrame:SetWidth(finalWindowWidth * dockedFrameScale)
+ self.uiFrame:SetHeight(finalWindowHeight * dockedFrameScale)
+
+ -- Determine how to anchor the docked window based on how the parent window is docked.
+ self.uiFrame:ClearAllPoints()
+ local anchorY = "BOTTOM"
+ local anchorX = "RIGHT"
+ local anchorToY = "TOP"
+ local anchorToX = "RIGHT"
+ local xOffset = 0
+ local yOffset = -4
+
+ if Bagshui.components[self.dockTo].settings.windowAnchorXPoint == "LEFT" then
+ anchorX = "LEFT"
+ anchorToX = "LEFT"
+ end
+
+ if Bagshui.components[self.dockTo].settings.windowAnchorYPoint == "TOP" then
+ anchorY = "TOP"
+ anchorToY = "BOTTOM"
+ yOffset = -yOffset
+ end
+
+ self.uiFrame:SetPoint(
+ anchorY .. anchorX,
+ Bagshui.components[self.dockTo].uiFrame,
+ anchorToY .. anchorToX,
+ xOffset,
+ yOffset
+ )
+ else
+ -- Set scale and anchor for non-docked frame.
+ self.uiFrame:SetScale(self.settings.windowScale)
+ -- Ensure the window stays in the right place as it resizes.
+ -- WoW will change the anchor point to TOPLEFT when the frame is dragged, so we need to reset it.
+ self:FixWindowPosition()
+ end
+ end
+
+ -- Check for mouse over state for interact-able elements to ensure tooltips stay current.
+ Bagshui:QueueClassCallback(self, self.ItemSlotAndGroupMouseOverCheck)
+
+ -- Reset statuses.
+ self.lastExpandEmptySlotStacks = self.expandEmptySlotStacks
+ self.windowUpdateNeeded = false
+ end
+
+ --- Fill the given UI group frame with the items assigned to the given group.
+ ---@param groupId number ID of the group to which items should be assigned.
+ ---@param uiGroupNum number Frame in the `self.ui.frames.groups` table to use for display.
+ ---@param currentItemSlotButtonNum number Frame in the `self.ui.frames.buttons.itemSlots` table to use next.
+ ---@param groupWidthInItems number Count of items to place on each row of the group.
+ ---@param groupPadding number Padding between group edge and item slots.
+ ---@param itemSlotSize number Height/width of the item slot button.
+ ---@param itemSlotMargin number Margin around the outer edge of each item slot button.
+ ---@return any currentItemSlotButtonNum Index of the last frame from `self.ui.frames.buttons.itemSlots` that was used.
+ function Inventory:AssignItemsToSlots(
+ groupId,
+ uiGroupNum,
+ currentItemSlotButtonNum,
+ groupWidthInItems,
+ groupPadding,
+ itemSlotSize,
+ itemSlotMargin
+ )
+ -- self:PrintDebug(string.format("AssignItemsToSlots() groupId=%s uiGroupNum=%s groupWidthInItems=%s", groupId, uiGroupNum, groupWidthInItems))
+
+ local itemsPlacedInCurrentRow = 0
+ local rowNum = 0
+ local genericBagType
+ local button
+ local isEmptySlotStack
+
+ -- We need the real item count here to be able to step through all items.
+ -- (Using the count for layout from upstream won't work if empty slot
+ -- stacking is on because we can end up missing items).
+ local groupItemCount = table.getn(self.groupItems[groupId])
+
+ -- Make sure there are items to assign in this group.
+ if groupItemCount > 0 then
+ local buttons = self.ui.buttons
+ local frames = self.ui.frames
+
+ local groupFrame = frames.groups[uiGroupNum]
+
+ -- Prepare for layout
+ local initialOffset = groupPadding + itemSlotMargin
+ self:InitFramePositioningTable(
+ "AssignItemsToSlots", -- Position tracking table
+ itemSlotMargin, -- Frame X spacing
+ itemSlotMargin, -- Frame Y spacing
+ groupFrame, -- Initial frame to anchor to
+ -initialOffset, -- Initial X offset
+ initialOffset -- Initial Y offset
+ )
+
+ -- Update empty slot stack count for this group.
+ if self.emptySlotStackingAllowed then
+ self:CountEmptySlots(groupId)
+ end
+
+ -- Need to go in reverse because we're building right to left.
+ local firstItem = groupItemCount
+ local lastItem = 1
+ local step = -1
+ local item = nil
+ local hideItem = false
+ local bagIsHidden = false
+ for position = firstItem, lastItem, step do
+ -- Grab the item info.
+ item = self.groupItems[groupId][position]
+ genericBagType = self.containers[item.bagNum].genericType
+
+ -- Item hiding and empty slot handling
+ hideItem = false
+ isEmptySlotStack = false
+
+ -- Items (and empty slots) in bags the user has hidden.
+ bagIsHidden = self:IsBagHiddenInLayout(item.bagNum)
+
+ if bagIsHidden then
+ hideItem = true
+ -- Empty slot stacking.
+ elseif
+ self.emptySlotStackingAllowed -- Empty slot stacking must be on.
+ and item.emptySlot == 1 -- This item must be an empty slot.
+ and not item._bagshuiPreventEmptySlotStack -- This slot must not be temporarily blocked from stacking (usually if it just became empty and re-sorting hasn't occurred yet).
+ then
+ -- We've already shown one empty slot of this type in this group, so hide all the others.
+ if self.emptySlotStacks[genericBagType].displayed then
+ hideItem = true
+ else
+ -- Prevent any more empty slots of this generic container type from appearing in this group.
+ self.emptySlotStacks[genericBagType].displayed = true
+ -- Override the normal empty slot item with the stack so the count displays.
+ item = self.emptySlotStacks[genericBagType]
+ isEmptySlotStack = true
+ end
+ end
+
+ -- Determine whether this item is hidden.
+ if
+ not self.editMode
+ and not self.showHidden
+ and self.highlightItemsInContainerLocked ~= item.bagNum -- Unhide items when bag highlighting is locked on.
+ and self.hideItems[item.id]
+ then
+ hideItem = true
+ end
+
+ if not hideItem then
+ -- Ensure slot button exists.
+ self.ui:CreateInventoryItemSlotButton(currentItemSlotButtonNum)
+
+ button = buttons.itemSlots[currentItemSlotButtonNum]
+
+ -- Ensure parentage of item slot button is set to current group frame.
+ -- This is required to get the layering right -- without it, stuff can end up behind groups.
+ button:SetParent(groupFrame)
+
+ -- Record details of this button's assignments so we can access
+ -- them during UI events.
+ button.bagshuiData.groupId = groupId
+ button.bagshuiData.position = position
+ -- These always need to refer to the original item's location so
+ -- empty slot stacks magically work with click events
+ button.bagshuiData.bagNum = self.groupItems[groupId][position].bagNum
+ button.bagshuiData.slotNum = self.groupItems[groupId][position].slotNum
+ button.bagshuiData.isEmptySlotStack = isEmptySlotStack
+
+ -- Display the item slot button.
+ self:ShowFrameInNextPosition("AssignItemsToSlots", rowNum, button, itemSlotSize)
+
+ -- Add the item to the button (texture, tooltip, etc.).
+ self.ui:AssignItemToItemButton(button, item, groupId)
+
+ -- Should the Clam button be enabled?
+ if item.openable == 1 then
+ self.hasOpenables = true
+ -- The first openable item we come across will be the one the Clam button targets.
+ if not self.nextOpenableItemBagNum then
+ self.nextOpenableItemBagNum = item.bagNum
+ self.nextOpenableItemSlotNum = item.slotNum
+ self.nextOpenableItemSlotButton = button
+ end
+ end
+
+ -- Increment counters.
+ itemsPlacedInCurrentRow = itemsPlacedInCurrentRow + 1
+ currentItemSlotButtonNum = currentItemSlotButtonNum + 1
+
+ -- When we reach the end of a row, move to the next one.
+ if itemsPlacedInCurrentRow == groupWidthInItems then
+ itemsPlacedInCurrentRow = 0
+ rowNum = rowNum + 1
+ end
+ end
+ end
+ end
+
+ return currentItemSlotButtonNum
+ end
+
+ --- Call this before using `UpdateEmptySlotStackCount()` to zero the counts on the self.emptySlotStacks items
+ ---@param resetBagsRepresented boolean? Clear the `_bagsRepresented` property of the empty slot stack items. Should only be called with `true` once per window update.
+ function Inventory:ResetEmptySlotStackCounts(resetBagsRepresented)
+ for _, emptySlotStack in pairs(self.emptySlotStacks) do
+ emptySlotStack.count = 0
+ emptySlotStack.displayed = false
+ if resetBagsRepresented then
+ BsUtil.TableClear(emptySlotStack._bagsRepresented)
+ end
+ end
+ end
+
+ --- Loop through all items in a group and add their counts to the appropriate empty
+ --- slot stack using `UpdateEmptySlotStackCount()`
+ ---@param groupId number ID of group to count empty slots for.
+ function Inventory:CountEmptySlots(groupId)
+ self:ResetEmptySlotStackCounts()
+ for _, item in pairs(self.groupItems[groupId]) do
+ self:UpdateEmptySlotStackCount(item)
+ end
+ end
+
+ --- Has the user hidden this bag's items (right-click or Ctrl+Click on its bag button)?
+ ---@param bagNum number
+ ---@return boolean
+ function Inventory:IsBagHidden(bagNum)
+ local settingName = Bagshui.config.HideBagSettings[bagNum]
+ return settingName ~= nil and self.settings[settingName] == true
+ end
+
+ --- Should this bag's items be left out of the window right now?
+ --- Same exceptions as individually hidden items: Edit Mode, Show Hidden, and a locked bag highlight.
+ ---@param bagNum number
+ ---@return boolean
+ function Inventory:IsBagHiddenInLayout(bagNum)
+ return not self.editMode
+ and not self.showHidden
+ and self.highlightItemsInContainerLocked ~= bagNum
+ and self:IsBagHidden(bagNum)
+ end
+
+ --- Determine whether a slot is empty, and if so, add 1 to the appropriate per-bag-type empty slot stack.
+ ---@param item table Bagshui inventory cache entry.
+ ---@return boolean # true if the item is an empty slot.
+ function Inventory:UpdateEmptySlotStackCount(item)
+ -- Only increment the count for slots that are empty AND not temporarily prevented from stacking.
+ if item.emptySlot == 1 and not item._bagshuiPreventEmptySlotStack then
+ -- Empty slots in bags the user has hidden don't count toward any stack.
+ if self:IsBagHiddenInLayout(item.bagNum) then
+ return true
+ end
+ -- genericType SHOULD be filled in, but let's just be sure.
+ local genericBagType = self.containers[item.bagNum].genericType or BsGameInfo.itemSubclasses.Container.Bag
+ -- Increment the count.
+ self.emptySlotStacks[genericBagType].count = self.emptySlotStacks[genericBagType].count + 1
+ -- Record that the bag containing this item is represented by this stack
+ -- (used during container mouseover slot highlighting).
+ self.emptySlotStacks[genericBagType]._bagsRepresented[item.bagNum] = true
+ -- Return true so calling functions can know the item is an empty slot.
+ return true
+ end
+ return false
+ end
+
+ --- Return the number of items in a group that will be visible, accounting for stacked slots and hidden items.
+ --- Also updates `self.hasHiddenItems`.
+ ---@param groupId number ID of group to get the number of items for.
+ ---@param zeroUnlessHasItemsInBagNum number? Return 0 unless the group has items that are in the specified bag number -- in that case, return the normal count.
+ ---@return integer visibleItemCount
+ function Inventory:GetGroupItemCountForLayout(groupId, zeroUnlessHasItemsInBagNum)
+ -- No items.
+ if not self.groupItems[groupId] then
+ return 0
+ end
+
+ -- Quickly return real count when we're in Edit Mode since nothing can be hidden.
+ if self.editMode then
+ return table.getn(self.groupItems[groupId])
+ end
+
+ -- Slot stacking / item hiding is enabled, so prepare for more complex counting.
+ local visibleItemCount = 0
+ local itemVisible
+ local hasEmptySlots = false
+ local hasItemsInFilterBag = false
+
+ -- We'll be using the permanent self.emptySlotStacks items, so reset their counts first.
+ self:ResetEmptySlotStackCounts()
+
+ -- Count up the number of visible items while figuring out if there are any empty slots or hidden items.
+ for _, item in ipairs(self.groupItems[groupId]) do
+ -- Assume the item will be counted
+ itemVisible = true
+
+ -- Have we found an item in the bag that will require a full count to be returned?
+ if zeroUnlessHasItemsInBagNum and item.bagNum == zeroUnlessHasItemsInBagNum then
+ hasItemsInFilterBag = true
+ end
+
+ -- Empty slot stacks and hidden items.
+ if self:IsBagHiddenInLayout(item.bagNum) then
+ -- This item is in a bag the user has hidden. Mark that hidden items exist so the
+ -- toolbar's Show Hidden button is enabled.
+ self.hasHiddenItems = true
+ itemVisible = false
+ elseif self:UpdateEmptySlotStackCount(item) then
+ -- This is an empty slot stack, which we know because in addition to
+ -- updating the appropriate empty slot stack proxy item in self.emptySlotsStacks,
+ -- UpdateEmptySlotStackCount() returns true if the given item is an empty slot.
+ if self.emptySlotStackingAllowed then
+ itemVisible = false
+ hasEmptySlots = true
+ end
+ elseif self.hideItems[item.id] then
+ -- Hidden items.
+ -- We need to update hasHiddenItems even if we're not actually hiding this item so that the toolbar icon will be enabled.
+ self.hasHiddenItems = true
+ -- Need to count the item when in Edit Mode or hidden items are shown.
+ itemVisible = (self.editMode or self.showHidden or self.highlightItemsInContainerLocked == item.bagNum)
+ end
+
+ -- Increment count if visible.
+ if itemVisible then
+ visibleItemCount = visibleItemCount + 1
+ end
+ end
+
+ -- Add 1 for each empty slot stack that contains 1 or more slots.
+ if hasEmptySlots then
+ for _, emptySlotStack in pairs(self.emptySlotStacks) do
+ if emptySlotStack.count > 0 then
+ visibleItemCount = visibleItemCount + 1
+ end
+ end
+ end
+
+ -- We were asked for a full count ONLY If this group has items in a specific bag.
+ if zeroUnlessHasItemsInBagNum and not hasItemsInFilterBag then
+ return 0
+ end
+
+ return visibleItemCount
+ end
+
+ --- Prepare a layout table to be used by `ShowFrameInNextPosition()`.
+ ---@param positioningTableName string Identifier for this positioning table that will also be passed to `ShowFrameInNextPosition()`.
+ ---@param frameXSpacing number Horizontal space between frames.
+ ---@param frameYSpacing number Vertical space between frames.
+ ---@param initialAnchorToFrame table Frame to which the first frame should be attached.
+ ---@param initialXOffset number Horizontal space between initialAnchorToFrame and first frame.
+ ---@param initialYOffset number Vertical space between initialAnchorToFrame and first frame.
+ function Inventory:InitFramePositioningTable(
+ positioningTableName,
+ frameXSpacing,
+ frameYSpacing,
+ initialAnchorToFrame,
+ initialXOffset,
+ initialYOffset
+ )
+ -- Set up positioning variable storage.
+ -- The table doesn't need to be cleared if it already exists because all its values will be reset below.
+ if self.positioningTables[positioningTableName] == nil then
+ self.positioningTables[positioningTableName] = {}
+ end
+ local positioningTable = self.positioningTables[positioningTableName]
+
+ -- Define anchor points and spacing.
+ positioningTable.anchorXPoint = self.settings.windowContentAlignment
+ positioningTable.anchorLeft = positioningTable.anchorXPoint == "LEFT"
+ positioningTable.anchorToPointXRowStart = "TOP" .. positioningTable.anchorXPoint
+ positioningTable.anchorToPointXSubsequent = "BOTTOM" .. BsUtil.FlipAnchorPoint(positioningTable.anchorXPoint)
+ positioningTable.frameXSpacing = frameXSpacing
+ positioningTable.frameYSpacing = frameYSpacing
+
+ -- Track the current row so we know when to move to a new one.
+ positioningTable.currentRow = nil
+
+ -- Each time we move to a new row, this is the frame the first element of the new row will anchor to.
+ positioningTable.firstVisibleFrameInPreviousRow = nil
+
+ -- The very first frame anchors to the bottom corner of the given frame.
+ positioningTable.anchorPoint = "BOTTOM" .. positioningTable.anchorXPoint
+ positioningTable.initialAnchorToFrame = initialAnchorToFrame
+ positioningTable.anchorToFrame = initialAnchorToFrame
+ positioningTable.anchorToPoint = positioningTable.anchorPoint
+
+ -- These are the initial values for X and Y offsets.
+ positioningTable.anchorXOffset = initialXOffset
+ positioningTable.anchorYOffset = initialYOffset
+ end
+
+ --- Helper function for `UpdateWindow()` to manage the process of displaying the grid of
+ --- groups and items within those groups at the correct location.
+ ---@param positioningTableName string Identifier for this positioning table that was prepared in `InitFramePositioningTable()`.
+ ---@param rowNum number Current row number.
+ ---@param frame table Frame to display.
+ ---@param width number Frame width.
+ ---@param height number? Frame height (will use width if not specified).
+ ---@param scale number? Frame scale.
+ function Inventory:ShowFrameInNextPosition(positioningTableName, rowNum, frame, width, height, scale)
+ local positioningTable = self.positioningTables[positioningTableName]
+
+ assert(
+ positioningTable,
+ "Inventory:ShowFrameInNextPosition(): Positioning table '"
+ .. tostring(positioningTableName)
+ .. "' has not been initialized; must call InitFramePositioningTable() first!"
+ )
+
+ -- self:PrintDebug(
+ -- "Showing " .. frame:GetName() .. " for row " .. rowNum
+ -- .. "\nAnchor point:" .. positioningTable.anchorPoint
+ -- .. "\nAnchor frame:" .. positioningTable.anchorToFrame:GetName()
+ -- .. "\nAnchor frame point:" .. positioningTable.anchorToPoint
+ -- .. "\nX offset:" .. (positioningTable.anchorLeft and -positioningTable.anchorXOffset or positioningTable.anchorXOffset)
+ -- .. "\nY offset:" .. positioningTable.anchorYOffset
+ -- .. "\nWidth" .. width
+ -- .. "\nHeight" .. height
+ -- .. "\nScale" .. tostring(scale)
+ -- )
+
+ -- Is this a new row?
+ -- nil check is so this only happens once we're above the first row.
+ if positioningTable.currentRow ~= nil and positioningTable.currentRow ~= rowNum then
+ -- Anchor to the first frame in the previous row.
+ positioningTable.anchorToFrame = positioningTable.firstVisibleFrameInPreviousRow
+ positioningTable.anchorToPoint = positioningTable.anchorToPointXRowStart
+ positioningTable.anchorXOffset = 0
+ -- First frame in the row needs to be higher up.
+ positioningTable.anchorYOffset = positioningTable.frameYSpacing
+ end
+
+ -- Display the frame.
+ self:SizeAndShowFrame(
+ frame,
+ positioningTable.anchorPoint,
+ positioningTable.anchorToFrame,
+ positioningTable.anchorToPoint,
+ positioningTable.anchorLeft and -positioningTable.anchorXOffset or positioningTable.anchorXOffset,
+ positioningTable.anchorYOffset,
+ width,
+ height,
+ scale,
+ -positioningTable.frameXSpacing -- HitRectInsets
+ )
+
+ -- The next element in a row always anchors to the previous one.
+ positioningTable.anchorToFrame = frame
+
+ -- When we move to a new row, update positioning variables to the proper state for anchoring within the same row.
+ if positioningTable.currentRow ~= rowNum then
+ -- Update the row number of the tracking variable so we know we've found the first visible element in this row.
+ positioningTable.currentRow = rowNum
+ -- Change the anchor point for subsequent frames.
+ positioningTable.anchorToPoint = positioningTable.anchorToPointXSubsequent
+ -- Subsequent frames in this row will be spaced using the frameXSpacing set by InitFramePositioningTable().
+ positioningTable.anchorXOffset = -positioningTable.frameXSpacing
+ -- Subsequent frames in this same row should be at the same vertical offset.
+ positioningTable.anchorYOffset = 0
+ -- Keep a note of the first frame int this row because the first frame in the next row will anchor to it.
+ positioningTable.firstVisibleFrameInPreviousRow = frame
+ end
+ end
+
+ -- SizeAndShowFrame() gets called a LOT so let's reuse variables to cut down on garbage collection.
+ local sizeAndShow_frame, sizeAndShow_finalScale, sizeAndShow_inverseScale, sizeAndShow_sizeWasSetViaScaling
+
+ --- Display the given frame at the specified position and size.
+ ---@param frameObjOrName table|string Frame or name of frame.
+ ---@param frameAttachPoint string Point on `frameObjOrName` that should be be attached.
+ ---@param attachToFrame table|string Frame to which `frameObjOrName` should be attached.
+ ---@param attachToPoint string Point on `attachToFrame` to which `frameObjOrName` should be attached..
+ ---@param xOffset number? Horizontal offset for `frameObjOrName` (negative for left).
+ ---@param yOffset number? Vertical offset for `frameObjOrName` (negative for down).
+ ---@param width number Width of the frame.
+ ---@param height number? Height of the frame (will use width if not specified).
+ ---@param scale number? Scaling factor (0 - 1) that should be applied to the frame.
+ ---@param hitRectInsets number? Hit rectangle insets to should be applied to the frame.
+ function Inventory:SizeAndShowFrame(
+ frameObjOrName,
+ frameAttachPoint,
+ attachToFrame,
+ attachToPoint,
+ xOffset,
+ yOffset,
+ width,
+ height,
+ scale,
+ hitRectInsets
+ )
+ -- Get the frame object.
+ sizeAndShow_frame = frameObjOrName
+ if type(frameObjOrName) == "string" then
+ sizeAndShow_frame = _G[frameObjOrName]
+ end
+
+ assert(
+ sizeAndShow_frame ~= nil,
+ "Invalid frame '" .. tostring(frameObjOrName) .. "' provided to SizeAndShowFrame()."
+ )
+
+ -- When an item slot button is sized by calling SetItemButtonSize, calling SetWidth/SetHeight isn't necessary.
+ sizeAndShow_sizeWasSetViaScaling = false
+
+ -- Item slot buttons should always be sized by scaling if possible, so do it automatically.
+ if
+ not scale
+ and sizeAndShow_frame.bagshuiData
+ and sizeAndShow_frame.bagshuiData.type == BS_UI_ITEM_BUTTON_TYPE.ITEM
+ then
+ scale = self.ui:SetItemButtonSize(sizeAndShow_frame, width)
+ sizeAndShow_sizeWasSetViaScaling = scale ~= nil
+ end
+
+ -- We need to know both the scale factor to apply to the frame and the inverse scale,
+ -- because SetPoint() uses the frame scale for offsets, so we need to un-scale the offsets.
+ sizeAndShow_finalScale = scale or 1
+ sizeAndShow_inverseScale = 1 / sizeAndShow_finalScale
+
+ -- Normal behavior -- set height, width, and scale.
+ if not sizeAndShow_sizeWasSetViaScaling then
+ sizeAndShow_frame:SetWidth(width)
+ sizeAndShow_frame:SetHeight(height or width)
+ sizeAndShow_frame:SetScale(sizeAndShow_finalScale)
+ end
+
+ -- Move frame to the specified point.
+ sizeAndShow_frame:ClearAllPoints()
+ sizeAndShow_frame:SetPoint(
+ frameAttachPoint,
+ attachToFrame,
+ attachToPoint,
+ xOffset * sizeAndShow_inverseScale,
+ yOffset * sizeAndShow_inverseScale
+ )
+
+ -- Adjust hit rectangle insets.
+ if hitRectInsets then
+ sizeAndShow_frame:SetHitRectInsets(hitRectInsets, hitRectInsets, hitRectInsets, hitRectInsets)
+ end
+
+ sizeAndShow_frame:Show()
+
+ -- For some reason, an initial call to GetTop() is sometimes necessary to prevent it
+ -- from returning nil later.
+ sizeAndShow_frame:GetTop()
+ end
+
+ --- Automatically select the next Group Move Target based on groupMoveTargetCount,
+ --- creating it if needed, then display using `ShowFrameInNextPosition()`
+ ---@param width number Desired width of the group move target.
+ ---@param height number Desired height of the group move target.
+ ---@param uiRowNum number Current row number within the UI layout.
+ ---@param layoutRowNum number Current layout/structure row number that the group move target should act on.
+ ---@param layoutColumnNum number Current layout/structure column number that the group move target should act on.
+ ---@param insertType BS_INVENTORY_LAYOUT_DIRECTION Whether the roup move target should act on rows or columns.
+ function Inventory:ShowNextGroupMoveTarget(width, height, uiRowNum, layoutRowNum, layoutColumnNum, insertType)
+ -- Increment count of targets and create a new one if needed.
+ self.groupMoveTargetCount = self.groupMoveTargetCount + 1
+ self.ui:CreateGroupMoveTarget(self.groupMoveTargetCount)
+
+ local groupMoveTarget = self.ui.frames.groupMoveTargets[self.groupMoveTargetCount]
+
+ -- Snag the button within the group move target frame so we can resize it and set properties.
+ local button = self.ui.frames.groupMoveTargets[self.groupMoveTargetCount].bagshuiData.button
+
+ -- This information will be pulled when the group move target button is clicked.
+ button.bagshuiData.insert = insertType
+ button.bagshuiData.rowNum = layoutRowNum
+ button.bagshuiData.columnNum = layoutColumnNum
+
+ -- Select button offsets based on whether the group is horizontal or vertical (start by assuming vertical).
+ local xOffsetLeft = BsSkin.groupMoveTargetAnchorOffset
+ local xOffsetRight = xOffsetLeft
+ local yOffset = BsSkin.groupMoveTargetLongDimensionSubtraction
+ if width > height then
+ -- This one is horizontal.
+ xOffsetRight = -(BsSkin.groupMoveTargetLongDimensionSubtraction + BsSkin.groupMoveTargetThinDimension + 1)
+ xOffsetLeft = xOffsetRight - BsSkin.groupMoveTargetAnchorOffset * 2
+ yOffset = -BsSkin.groupMoveTargetAnchorOffset
+ end
+
+ -- Set button offsets so the move target is visually shorter than the height/width of the groups.
+ button:SetPoint("TOPLEFT", groupMoveTarget, "TOPLEFT", -xOffsetLeft, -yOffset)
+ button:SetPoint("BOTTOMRIGHT", groupMoveTarget, "BOTTOMRIGHT", xOffsetRight, yOffset)
+
+ -- Display the frame.
+ self:ShowFrameInNextPosition("UpdateWindow", uiRowNum, groupMoveTarget, width, height)
+ end
+
+ --- Update free/available slot counts. Subclasses should override for special bag bar stuff.
+ function Inventory:UpdateBagBar()
+ local showBagUsage = self.settings.bagUsageAlwaysShow
+ local showUsageSummary = false
+
+ -- We always need to do one loop through the bag buttons to refresh the appearance and make some decisions.
+ for _, bagSlotButton in ipairs(self.ui.buttons.bagSlots) do
+ -- Set button textures -- necessary for them to be correct when viewing another character's inventory.
+ _G.SetItemButtonTexture(bagSlotButton, self.containers[bagSlotButton.bagshuiData.bagNum].texture)
+
+ -- Locked highlight border.
+ if self.highlightItemsInContainerLocked == bagSlotButton.bagshuiData.bagNum then
+ bagSlotButton.bagshuiData.buttonComponents.border:SetBackdropBorderColor(
+ BS_COLOR.ITEM_SLOT_HIGHLIGHT[1],
+ BS_COLOR.ITEM_SLOT_HIGHLIGHT[2],
+ BS_COLOR.ITEM_SLOT_HIGHLIGHT[3],
+ BsSkin.itemSlotBorderContainerHighlightOpacity
+ )
+ bagSlotButton.bagshuiData.buttonComponents.innerGlow:SetVertexColor(
+ BS_COLOR.ITEM_SLOT_HIGHLIGHT[1],
+ BS_COLOR.ITEM_SLOT_HIGHLIGHT[2],
+ BS_COLOR.ITEM_SLOT_HIGHLIGHT[3],
+ BsSkin.itemSlotInnerGlowOpacity + 0.2
+ )
+ bagSlotButton.bagshuiData.highlightBorderApplied = true
+ elseif self.highlightItemsInContainerLocked ~= bagSlotButton.bagshuiData.bagNum then
+ bagSlotButton.bagshuiData.buttonComponents.border:SetBackdropBorderColor(
+ BsSkin.itemSlotBorderDefaultColor.r,
+ BsSkin.itemSlotBorderDefaultColor.g,
+ BsSkin.itemSlotBorderDefaultColor.b,
+ BsSkin.itemSlotBorderOpacity
+ )
+ bagSlotButton.bagshuiData.buttonComponents.innerGlow:SetVertexColor(0, 0, 0, 0)
+ bagSlotButton.bagshuiData.highlightBorderApplied = false
+ end
+
+ -- Offline icon texture opacity.
+ bagSlotButton.bagshuiData.buttonComponents.iconTexture:SetVertexColor(
+ BS_COLOR.ITEM_SLOT_STATE_NORMAL[1],
+ BS_COLOR.ITEM_SLOT_STATE_NORMAL[2],
+ BS_COLOR.ITEM_SLOT_STATE_NORMAL[3],
+ (not self.online and BsSkin.itemSlotTextureOfflineOpacity or 1)
+ -- Dim bags whose items are hidden.
+ * (self:IsBagHidden(bagSlotButton.bagshuiData.bagNum) and 0.4 or 1)
+ )
+
+ -- Overall button opacity.
+ bagSlotButton:SetAlpha(
+ self.editMode and 0.2
+ -- When an item in the inventory is moused over, we want to dim everything but the container it's in.
+ or (
+ self.hoveredItem
+ and (
+ (
+ -- Empty slot stack.
+self.hoveredItem._bagsRepresented
+ and self.hoveredItem._bagsRepresented[bagSlotButton.bagshuiData.bagNum]
+ -- Normal item.
+ or self.hoveredItem.bagNum == bagSlotButton.bagshuiData.bagNum
+ )
+ and 1
+ or 0.4
+ )
+ )
+ -- When the container highlight is locked on, dim other bags.
+ or ((self.highlightItemsInContainerLocked and self.highlightItemsInContainerLocked ~= bagSlotButton.bagshuiData.bagNum) and 0.7)
+ -- No item currently hovered.
+ or 1
+ )
+
+ -- Display free space information when the mouse is over any button.
+ if bagSlotButton.bagshuiData.mouseIsOver then
+ showBagUsage = true
+ showUsageSummary = true
+ end
+ end
+
+ -- Also display free space information when the mouse is over the summary area.
+ if self.alwaysShowUsageSummary or self.ui.frames.spaceSummary.bagshuiData.mouseIsOver then
+ showUsageSummary = true
+ end
+
+ -- Cancel free space display in Edit Mode.
+ if self.editMode then
+ showBagUsage = false
+ showUsageSummary = false
+ end
+
+ -- Width of bag space summary text area to the right of the bag bar that will be added
+ -- to the overall bag bar width.
+ local summaryWidth = 0
+
+ -- Reset space tracking - don't need to worry about whether the table members
+ -- are initialized because that's handled below. Here we're only iterating over
+ -- what already exists.
+ for _, spaceInfo in pairs(self.containerSpace) do
+ spaceInfo.available = 0
+ spaceInfo.used = 0
+ spaceInfo.total = 0
+ end
+ self.availableSlots, self.usedSlots, self.totalSlots = 0, 0, 0
+
+ -- Gether free space information.
+ for _, bagSlotButton in ipairs(self.ui.buttons.bagSlots) do
+ -- Step through bag slot buttons and determine available/used/total space.
+ local container = self.containers[bagSlotButton.bagshuiData.bagNum]
+ local available, used = 0, 0
+ local slotText = ""
+ if container.numSlots > 0 then
+ used = container.slotsFilled
+ if container.slotsFilled < container.numSlots then
+ available = container.numSlots - used
+ slotText = string.format("%s/%s", used, container.numSlots)
+ else
+ slotText = L.Full
+ end
+ end
+ if showBagUsage then
+ bagSlotButton.bagshuiData.buttonComponents.stock:SetText(slotText)
+ bagSlotButton.bagshuiData.buttonComponents.stock:Show()
+ else
+ bagSlotButton.bagshuiData.buttonComponents.stock:Hide()
+ end
+
+ local genericType = container.genericType or BsGameInfo.itemSubclasses["Container"]["Bag"]
+
+ -- Initialize space tracking if needed.
+ if not self.containerSpace[genericType] then
+ self.containerSpace[genericType] = {
+ available = 0,
+ used = 0,
+ total = 0,
+ }
+ end
+
+ -- Update calculations.
+ local spaceInfo = self.containerSpace[genericType]
+ spaceInfo.available = spaceInfo.available + available
+ spaceInfo.used = spaceInfo.used + used
+ spaceInfo.total = spaceInfo.total + container.numSlots
+ self.availableSlots = self.availableSlots + available
+ self.usedSlots = self.usedSlots + used
+ self.totalSlots = self.totalSlots + container.numSlots
+ end
+
+ -- Figure out what to display in the summary based on settings.
+
+ local textParts = BsUtil.Split(self.settings.bagUsageFormat, "_")
+
+ local mainText = tostring(textParts[1] == "Used" and self.usedSlots or self.availableSlots)
+ local miniText = mainText
+ local subText
+ if textParts[2] then
+ if textParts[2] == "Total" then
+ subText = self.totalSlots
+ elseif textParts[3] == "Total" then
+ subText =
+ string.format("%s/%s", (textParts[2] == "Used" and self.usedSlots or self.availableSlots), self.totalSlots)
+ -- miniText = miniText .. " (" .. subText .. ")"
+ end
+ end
+
+ if showUsageSummary then
+ self.ui.frames.spaceSummary.bagshuiData.text:SetText(mainText)
+ self.ui.frames.spaceSummary.bagshuiData.text:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.text:SetText(miniText)
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.text:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.text:SetText(miniText)
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.text:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ if subText then
+ self.ui.frames.spaceSummary.bagshuiData.text:ClearAllPoints()
+ self.ui.frames.spaceSummary.bagshuiData.text:SetPoint("BOTTOM", self.ui.frames.spaceSummary, "CENTER", 0, -2)
+ self.ui.frames.spaceSummary.bagshuiData.subtext:SetText(subText)
+ self.ui.frames.spaceSummary.bagshuiData.subtext:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ self.ui.frames.spaceSummary.bagshuiData.subtext:Show()
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.subtext:SetText(subText)
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.subtext:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.subtext:Show()
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.subtext:SetText(subText)
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.subtext:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.subtext:Show()
+ else
+ self.ui.frames.spaceSummary.bagshuiData.subtext:Hide()
+ self.ui.frames.spaceSummary.bagshuiData.text:ClearAllPoints()
+ self.ui.frames.spaceSummary.bagshuiData.text:SetPoint("CENTER", self.ui.frames.spaceSummary, "CENTER")
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.subtext:Hide()
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.subtext:SetText("")
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.subtext:Hide()
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.subtext:SetText("")
+ end
+
+ -- Resize and show/hide.
+ summaryWidth = math.max(
+ self.ui.frames.spaceSummary.bagshuiData.text:GetStringWidth(),
+ self.ui.frames.spaceSummary.bagshuiData.subtext:GetStringWidth()
+ )
+ self.ui.frames.spaceSummary:SetWidth(summaryWidth)
+ self.ui.frames.spaceSummary:SetAlpha(1) -- Reversing the SetAlpha(0) call that happens when we hide the summary.
+
+ self.ui.frames.miniSpaceSummaryBottom:SetWidth(
+ self.ui.frames.miniSpaceSummaryBottom.bagshuiData.text:GetStringWidth()
+ + self.ui.frames.miniSpaceSummaryBottom.bagshuiData.subtext:GetStringWidth()
+ + 2
+ )
+ self.ui.frames.miniSpaceSummaryTop:SetWidth(
+ self.ui.frames.miniSpaceSummaryTop.bagshuiData.text:GetStringWidth()
+ + self.ui.frames.miniSpaceSummaryTop.bagshuiData.subtext:GetStringWidth()
+ + 2
+ )
+ else
+ -- Hide space summary.
+ self.ui.frames.spaceSummary:SetAlpha(0) -- Using SetAlpha() instead of Hide() so it's still responsive to mouseover.
+ end
+
+ -- Set the Bag Bar to the correct width.
+ self.ui.frames.bagBar:SetWidth(
+ self.ui.frames.bagBar.bagshuiData.baseWidth
+ + (math.max(summaryWidth, 15) * self.ui.frames.spaceSummary:GetScale())
+ + 10 -- Buffer so nothing gets cut off.
+ )
+ end
+
+ --- Enable/disable/highlight toolbar buttons as appropriate.
+ function Inventory:UpdateToolbar()
+ local toolbarButtons = self.ui.buttons.toolbar
+
+ -- Resort icon.
+ self:SetToolbarButtonState(
+ toolbarButtons.resort,
+ nil,
+ (
+ self.enableResortIcon
+ or (
+ -- Docked inventory needs resorting.
+self.dockedInventory and self.dockedInventory.enableResortIcon
+ )
+ )
+ )
+
+ -- Restack icon.
+ self:SetToolbarButtonState(
+ toolbarButtons.restack,
+ nil,
+ (
+ self.multiplePartialStacks
+ or (
+ -- Docked inventory has needs restack.
+self.dockedInventory and self.dockedInventory.multiplePartialStacks
+ )
+ )
+ )
+
+ -- Show/Hide icon.
+ self:SetToolbarButtonState(
+ toolbarButtons.showHide,
+ nil,
+ (self.hasHiddenGroups or self.hasHiddenItems) and not self.editMode,
+ self.showHidden,
+ L.Toolbar_Hide_TooltipTitle,
+ L.Toolbar_Show_TooltipTitle
+ )
+
+ -- Highlight Changes icon.
+ self:SetToolbarButtonState(
+ toolbarButtons.highlightChanges,
+ nil,
+ self.highlightChangesEnabled and not self.editMode,
+ self.highlightChanges,
+ L.Toolbar_UnHighlightChanges_TooltipTitle,
+ L.Toolbar_HighlightChanges_TooltipTitle
+ )
+
+ -- Character icon.
+ self:SetToolbarButtonState(
+ toolbarButtons.character,
+ nil,
+ table.getn(BsCharacterData.characterIdList) > 1, -- Only enable when there's more than one character.
+ self.activeCharacterId ~= Bagshui.currentCharacterId
+ )
+
+ -- Lock highlights for inventories when they're open.
+ for inventoryType, _ in self:OtherInventoryTypesInToolbarIconOrder(true) do
+ self:SetToolbarButtonState(toolbarButtons[inventoryType], nil, true, Bagshui.components[inventoryType]:Visible())
+ end
+ -- Catalog.
+ self:SetToolbarButtonState(toolbarButtons.catalog, nil, true, BsCatalog:Visible())
+
+ -- Refresh toolbar button colors in case they've changed.
+ for _, button in pairs(toolbarButtons) do
+ self.ui:SetIconButtonColors(button)
+ end
+
+ -- State buttons.
+ self:SetToolbarButtonState(toolbarButtons.offline, not self.online)
+ self:SetToolbarButtonState(toolbarButtons.error, (string.len(self.errorText or "") > 0))
+ self:SetToolbarButtonState(toolbarButtons.editMode, self.editMode, nil, self.editMode)
+
+ -- Error button needs its tooltip updated.
+ toolbarButtons.error.bagshuiData.tooltipText = self.errorText
+
+ -- Update status text.
+ if self.editMode then
+ -- Show active Structure profile name when editing.
+ self.ui.text.status:SetText(
+ NORMAL_FONT_COLOR_CODE
+ .. string.format(L.Symbol_Colon, L.Profile_Structure)
+ .. FONT_COLOR_CODE_CLOSE
+ .. " "
+ .. BsProfiles:GetName(self.settings.profileStructure)
+ )
+ self.ui.frames.status:Show()
+ -- Move the offline character name into the tooltip in edit mode.
+ toolbarButtons.offline.bagshuiData.tooltipText = GRAY_FONT_COLOR_CODE
+ .. self.activeCharacterId
+ .. FONT_COLOR_CODE_CLOSE
+ else
+ self.ui.text.status:SetText(self.activeCharacterId)
+ if self.activeCharacterId ~= Bagshui.currentCharacterId then
+ self.ui.frames.status:Show()
+ else
+ self.ui.frames.status:Hide()
+ end
+ -- Don't need the offline character name in the tooltip normally.
+ toolbarButtons.offline.bagshuiData.tooltipText = nil
+ end
+
+ -- Money frame.
+ if self.settings.showMoney then
+ self.ui.frames.money:Show()
+ self.ui.frames.money:SetAlpha(self.editMode and 0.2 or 1)
+ -- Use label colors for money text.
+ for _, text in pairs(self.ui.frames.money.bagshuiData.texts) do
+ text:SetTextColor(
+ self.settings.groupLabelDefault[1],
+ self.settings.groupLabelDefault[2],
+ self.settings.groupLabelDefault[3],
+ self.settings.groupLabelDefault[4]
+ )
+ end
+ else
+ self.ui.frames.money:Hide()
+ end
+
+ -- Hearthstone button.
+ if self.hearthButton and self.settings.showFooter and self.settings.showHearthstone and self.hearthstoneItemRef then
+ toolbarButtons.hearthstone:Show()
+
+ -- Display cooldown.
+ local cooldownStart, cooldownDuration, isOnCooldown =
+ _G.GetContainerItemCooldown(self.hearthstoneItemRef.bagNum, self.hearthstoneItemRef.slotNum)
+ self.ui:SetIconButtonCooldown(toolbarButtons.hearthstone, cooldownStart, cooldownDuration, isOnCooldown)
+ else
+ toolbarButtons.hearthstone:Hide()
+ end
+
+ -- Clam (open container) button.
+ self:SetToolbarButtonState(
+ toolbarButtons.clam,
+ (self.clamButton and self.settings.showClam or false),
+ (
+ self.hasOpenables
+ and not self.editMode
+ -- Don't allow at Bank because UseContainerItem() moves the item
+ -- instead of opening it.
+ and not Bagshui.components.Bank.atBank
+ -- Avoid messing with item highlighting during a pending sale.
+ and not self.itemPendingSale
+ )
+ )
+
+ -- Disenchant button.
+ self:SetToolbarButtonState(
+ toolbarButtons.disenchant,
+ (
+ self.disenchantButton
+ and BsCharacter.spellNamesToIds[toolbarButtons.disenchant.bagshuiData.spellName]
+ and self.settings.showDisenchant
+ or false
+ ),
+ not self.editMode
+ )
+
+ -- Pick Lock button.
+ self:SetToolbarButtonState(
+ toolbarButtons.pickLock,
+ (
+ self.pickLockButton
+ and BsCharacter.spellNamesToIds[toolbarButtons.pickLock.bagshuiData.spellName]
+ and self.settings.showPickLock
+ or false
+ ),
+ not self.editMode
+ )
+
+ -- Utilization summaries.
+ if
+ self.alwaysShowUsageSummary
+ and (not self.settings.showFooter or (self.settings.showFooter and not self.settings.showBagBar))
+ then
+ if self.settings.showFooter then
+ self.ui.frames.miniSpaceSummaryBottom:Show()
+ self.ui.frames.miniSpaceSummaryTop:Hide()
+ else
+ self.ui.frames.miniSpaceSummaryBottom:Hide()
+ self.ui.frames.miniSpaceSummaryTop:Show()
+ end
+ else
+ self.ui.frames.miniSpaceSummaryBottom:Hide()
+ self.ui.frames.miniSpaceSummaryTop:Hide()
+ end
+
+ -- Re-anchor all toolbar widgets based on visibility.
+ self:UpdateToolbarAnchoring("topLeftToolbar", "LEFT")
+ self:UpdateToolbarAnchoring("topRightToolbar", "RIGHT")
+ self:UpdateToolbarAnchoring("bottomRightToolbar", "RIGHT")
+
+ -- Disable unusable stuff in Edit Mode.
+ local editModeState = self.editMode and "Disable" or "Enable"
+ -- Hearthstone
+ if self.hearthButton then
+ self.ui.buttons.toolbar.hearthstone[editModeState](self.ui.buttons.toolbar.hearthstone)
+ end
+ -- Search
+ self.ui.buttons.toolbar.search[editModeState](self.ui.buttons.toolbar.search)
+
+ -- Parent toolbar needs to sync state.
+ if self.dockedToInventory then
+ self.dockedToInventory:UpdateToolbar()
+ end
+ end
+
+ --- Control the state and appearance of a toolbar button.
+ ---@param button table Button object.
+ ---@param visible boolean? Whether the button should be displayed.
+ ---@param enable boolean? Whether the button should be enabled.
+ ---@param lockHighlight boolean? Whether the button should be highlighted.
+ ---@param lockedHighlightTooltip string? Tooltip title to display when the button highlight is locked.
+ ---@param unlockedHighlightTooltip string? Tooltip title to display when the button highlight is unlocked.
+ function Inventory:SetToolbarButtonState(
+ button,
+ visible,
+ enable,
+ lockHighlight,
+ lockedHighlightTooltip,
+ unlockedHighlightTooltip
+ )
+ if visible == nil then
+ visible = true
+ end
+ button[visible and "Show" or "Hide"](button)
+
+ if button.Enable then
+ if enable == nil then
+ enable = true
+ end
+ button[enable and "Enable" or "Disable"](button)
+ end
+
+ if button.LockHighlight then
+ if lockHighlight then
+ if lockedHighlightTooltip then
+ button.bagshuiData.tooltipTitle = lockedHighlightTooltip
+ end
+ button:LockHighlight()
+ else
+ if unlockedHighlightTooltip then
+ button.bagshuiData.tooltipTitle = unlockedHighlightTooltip
+ end
+ button:UnlockHighlight()
+ end
+ end
+
+ -- Update tooltip if it's visible so that text stays current.
+ if
+ button.bagshuiData.isIconButton
+ and button.bagshuiData.mouseIsOver
+ and BsIconButtonTooltip:IsVisible()
+ and BsIconButtonTooltip:IsOwned(button)
+ then
+ self.ui:ShowIconButtonTooltip(button, 0)
+ end
+ end
+
+ --- Toolbar icons need their anchors updated based on what's shown.
+ ---@param widgetOrderTable string Name of table in `self.ui.ordering`, which is an array of WoW UI widgets, in display order. Can also include numbers as spacing directives that override the default.
+ ---@param anchorPoint "LEFT"|"RIGHT" Place to anchor each widget. This point will be anchored to the opposing point of the previous widget.
+ function Inventory:UpdateToolbarAnchoring(widgetOrderTable, anchorPoint)
+ local widgetList = self.ui.ordering[widgetOrderTable]
+ local invert = (anchorPoint == "RIGHT" and -1 or 1)
+ local defaultOffset = invert * BsSkin.toolbarSpacing
+ local nextOffset
+
+ -- Go through the list of widgets in order, but skip the first one since
+ -- that's only there as the left/rightmost anchor.
+ for widgetPosition = 2, table.getn(widgetList) do
+ local widget = widgetList[widgetPosition]
+ -- Ignore spacing directives when finding widgets.
+ if type(widget) == "table" and widget:IsShown() then
+ -- Assume default spacing, adjusted if customized for the widget.
+ nextOffset = defaultOffset + (widget.bagshuiData and widget.bagshuiData.autoLayoutXOffset or 0)
+ -- Walk backwards through the list of widgets and anchor to the first visible one.
+ for anchorPosition = widgetPosition - 1, 1, -1 do
+ if type(widgetList[anchorPosition]) ~= "table" then
+ -- Spacing directive found. Use this instead of the default.
+ if type(widgetList[anchorPosition]) == "number" then
+ nextOffset = widgetList[anchorPosition]
+ end
+ else
+ -- Check visibility and update anchoring.
+ local anchorButton = widgetList[anchorPosition]
+ if anchorButton:IsShown() then
+ widget:SetPoint(anchorPoint, anchorButton, BsUtil.FlipAnchorPoint(anchorPoint), nextOffset, 0)
+ break
+ end
+ end
+ end -- Inner anchor finding loop.
+ end
+ end -- Widget iteration.
+ end
+
+ --- It seems like the client doesn't always catch situations where frames move out from
+ --- under the cursor. When empty slots are expanded/collapsed, that's exactly what happens, so
+ --- we have to manually hide the tooltip (handled in `ItemButton_OnClick()`) and we also need to determine
+ --- if the mouse is over an item button **after** the UI is redrawn. To do that, `UpdateWindow()` queues a
+ --- call to this function on the next frame update. Then we can evaluate whether the mouse is present,
+ --- and if so, update the UI to reflect that.
+ --- This is also an issue when picking up and putting down Categories/Groups/Items in Edit Mode,
+ --- where we manually hide tooltips and need to make them reappear when something is put back down.
+ function Inventory:ItemSlotAndGroupMouseOverCheck()
+ local mouseOverFound = false
+ for _, button in self.ui.buttons.itemSlots do
+ -- The IsVisible() check is needed because item slot frames that are hidden will still return true for MouseIsOver().
+ if _G.MouseIsOver(button) and button:IsVisible() then
+ self:ItemButton_OnEnter(button)
+ mouseOverFound = true
+ break
+ end
+ end
+ if self.editMode and not mouseOverFound then
+ for _, group in self.ui.frames.groups do
+ if _G.MouseIsOver(group) and group:IsVisible() then
+ self:Group_OnEnter(group)
+ end
+ end
+ end
+ end
+
+ --- Look through the inventory cache to find and record references to any special items.
+ --- Currently only used for the Hearthstone.
+ function Inventory:FindSpecialItems()
+ -- Reset Hearthstone item tracking.
+ self.hearthstoneItemRef = nil
+ self.hideItems[BS_INVENTORY_HEARTHSTONE_ITEM_ID] = (
+ self.hearthButton
+ and self.settings.showHearthstone
+ and self.settings.showFooter
+ ) or nil
+
+ for _, bagContents in pairs(self.inventory) do
+ for __, item in ipairs(bagContents) do
+ -- Check for Hearthstone.
+ if self.hearthButton and item.id == BS_INVENTORY_HEARTHSTONE_ITEM_ID then
+ self.hearthstoneItemRef = item
+ end
+ end
+ end
+ end
+
+ --- Toggle an inventory class property.
+ --- Used to display/hide hidden items, enable/disable highlighting of changes,
+ --- maybe more in the future?
+ ---@param propertyName any
+ function Inventory:ToggleProperty(propertyName)
+ if not propertyName then
+ return
+ end
+ _G.PlaySound("igMainMenuOptionCheckBox" .. (self[propertyName] and "Off" or "On"))
+ self[propertyName] = not self[propertyName]
+ self:ForceUpdateWindow()
+
+ if self.dockedInventory then
+ self.dockedInventory:ToggleProperty(propertyName)
+ end
+ end
+end)
diff --git a/Components/Inventory.Menus.Settings.lua b/Components/Inventory.Menus.Settings.lua
new file mode 100644
index 0000000..96a75d9
--- /dev/null
+++ b/Components/Inventory.Menus.Settings.lua
@@ -0,0 +1,699 @@
+-- Bagshui Inventory Prototype: Settings Menu
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ -- Maximum number of previously selected colors to store.
+ local COLOR_HISTORY_LIMIT = 10
+
+ -- Do-nothing function to be used when a setting doesn't have an update function.
+ local function emptyFunction() end
+
+ --- Show the Inventory settings menu.
+ ---@param menu table Menu table.
+ function Inventory:OpenSettingsMenu(menu)
+ -- Docked frames should show the parent frame's menu.
+ if self.dockTo then
+ Bagshui.components[self.dockTo].menus:OpenMenu("Settings")
+ return
+ end
+
+ -- This is where the menu should anchor, in theory. See comments on
+ -- Inventory:FixSettingsMenuPosition() for more.
+ self.lastSettingsAnchor = self.settings.windowAnchorYPoint .. self.settings.windowAnchorXPoint
+ self.lastSettingsXOffset = BsSkin.menuShiftRight * (self.settings.windowAnchorXPoint == "LEFT" and 1 or -1)
+
+ -- Special property
+ Bagshui.menuFrame.bagshuiData.noFirstLevelRepositionNeeded = true
+
+ -- Open the menu.
+ self.menus:ShowMenu(
+ "Settings",
+ self.uiFrame,
+ self.lastSettingsXOffset,
+ 0,
+ self.lastSettingsAnchor,
+ BsUtil.FlipAnchorPointComponent(self.lastSettingsAnchor, 2)
+ )
+
+ -- Anchor the menu to the side of the main window.
+ self:FixSettingsMenuPosition()
+
+ -- Prevent the settings menu from disappearing after 2 seconds.
+ self.menus:BlockAutoMenuClose(1, 3, "Settings")
+ end
+
+ --- The goal is to keep the settings menu anchored to the corner of the main window.
+ --- This is initially done naively by simply anchoring it to the opposite side of the window anchor.
+ --- Most of the time, it's probably ok, but if the window has been moved to the opposite side without changing
+ --- the anchor, the menu could be offscreen. This is addressed by a second pass where the menu is checked
+ --- to see if an X-adjustment would be necessary to bring it back onscreen. If so, move it to the other side.
+ --- There's also a bug in Blizzard's ToggleDropDownMenu() where custom menu positions aren't truly respected,
+ --- so we end up working around that here as well. It could probably be generalized into the
+ --- `Bagshui:ToggleDropDownMenu()` hook by checking the menu frame's anchor as compared to what was desired,
+ --- but since this is the only time we're trying for something other than `TOPLEFT` as the menu anchor it's
+ --- fine for now.
+ function Inventory:FixSettingsMenuPosition(secondPass)
+ if not self.menus then
+ return
+ end
+ if self.menus:IsMenuOpen("Settings") then
+ local menu = _G.DropDownList1 -- Grab the dropdown menu reference.
+
+ if not secondPass then
+ menu:ClearAllPoints()
+ menu:SetPoint(
+ self.lastSettingsAnchor,
+ self.uiFrame,
+ BsUtil.FlipAnchorPointComponent(self.lastSettingsAnchor, 2),
+ self.lastSettingsXOffset,
+ 0
+ )
+
+ -- Trigger second pass position check on next frame update.
+ Bagshui:QueueClassCallback(self, self.FixSettingsMenuPosition, nil, nil, true)
+ else
+ local positionChanged = false
+
+ -- Check to see if the Settings menu has been positioned offscreen horizontally
+ -- or is overlapping the Inventory window, and if so, move it to the other side.
+ if
+ (BsUtil.GetFrameOffscreenAmount(menu, "x") ~= 0)
+ or (
+ (menu:GetLeft() or 0) >= (self.uiFrame:GetLeft() or 0)
+ and (menu:GetRight() or 0) <= (self.uiFrame:GetRight() or 0)
+ )
+ then
+ self.lastSettingsAnchor = BsUtil.FlipAnchorPointComponent(self.lastSettingsAnchor, 2)
+ self.lastSettingsXOffset = -self.lastSettingsXOffset
+ positionChanged = true
+ end
+
+ -- Vertical adjustment to keep the entire menu onscreen.
+ local yOffset = BsUtil.GetFrameOffscreenAmount(menu, "y")
+
+ if positionChanged or yOffset ~= 0 then
+ menu:ClearAllPoints()
+ menu:SetPoint(
+ self.lastSettingsAnchor,
+ self.uiFrame,
+ BsUtil.FlipAnchorPointComponent(self.lastSettingsAnchor, 2),
+ self.lastSettingsXOffset,
+ yOffset
+ )
+ end
+ end
+ end
+ end
+
+ --- Construct the Settings menu table based on the Settings configuration. Can be called recursively for nested settings.
+ ---@param configTable table Settings configuration table from Config\Settings.lua.
+ ---@param menuBaseTable table Menu table to place the items in as generated by `Menus:AddMenu()`.
+ ---@param menuLevel number Level in which to place items (1-3).
+ ---@param menuValue any Used to set the `value` property of the menu item.
+ function Inventory:BuildSettingsMenuFromConfig(configTable, menuBaseTable, menuLevel, menuValue)
+ assert((menuLevel > 1 and menuValue ~= nil) or menuLevel <= 1, "menuValue must be provided when menuLevel > 1")
+
+ -- The menu level default is 1.
+ local level = menuLevel or 1
+ -- Level 1 of menu tables is flat, but submenu tables all have a value key.
+ local menuTable = level > 1 and menuBaseTable.levels[level][menuValue] or menuBaseTable.levels[level]
+
+ -- Pre-colored Control+Alt+Shift+Click hint.
+ local settingResetHint = GRAY_FONT_COLOR_CODE .. L.Setting_Reset_TooltipText .. FONT_COLOR_CODE_CLOSE
+
+ -- Loop through the given settings configuration table.
+ for _, settingInfo in ipairs(configTable) do
+ -- Omit hidden settings.
+ if not settingInfo.hidden and (type(settingInfo.hideFunc) ~= "function" or not settingInfo.hideFunc()) then
+ if type(settingInfo.settings) == "table" then
+ -- Recurse for nested submenu, indicated by the presence of settingInfo.settings.
+ -- However, if there's no submenuName or we've reached the cap of menu levels, nothing can be done with it.
+ if settingInfo.submenuName and level < _G.UIDROPDOWNMENU_MAXLEVELS then
+ local submenuValue = settingInfo.submenuValue or settingInfo.submenuName
+ -- Create a parent item to open the submenu.
+ table.insert(menuTable, {
+ text = settingInfo.submenuName,
+ tooltipTitle = settingInfo.tooltipTitle or settingInfo.submenuName,
+ tooltipText = settingInfo.tooltipText,
+ value = submenuValue,
+ hasArrow = true,
+ })
+ -- Initialize the submenu table.
+ menuBaseTable.levels[level + 1][submenuValue] = {}
+ -- Recurse to build it.
+ self:BuildSettingsMenuFromConfig(settingInfo.settings, menuBaseTable, level + 1, submenuValue)
+ end
+ elseif settingInfo.mainTitle then
+ -- The mainTitle property creates an automatically-named menu item
+ -- with the Close button graphic as its icon, providing an obvious
+ -- way to dismiss the menu.
+ local menuItem = {
+ text = BS_FONT_COLOR.BAGSHUI
+ .. string.format(L.Menu_Settings, self.inventoryTypeLocalized)
+ .. FONT_COLOR_CODE_CLOSE,
+ _bagshuiFakeTitle = true,
+ func = function()
+ self:CloseMenusAndClearFocuses()
+ end,
+ }
+ BsSkin.closeButtonSkinFunc(menuItem)
+ table.insert(menuTable, menuItem)
+ elseif settingInfo.menuTitle then
+ -- This is just a title, not a setting.
+ table.insert(menuTable, {
+ text = settingInfo.menuTitle,
+ isTitle = true,
+ notCheckable = true,
+ keepShownOnClick = true,
+ })
+ else
+ assert(
+ BsUtil.TableContainsValue(BS_SETTING_TYPE, settingInfo.type),
+ tostring(settingInfo.name) .. " does not have a valid type."
+ )
+
+ -- Now we've found a setting.
+ local settingName = settingInfo.name or ""
+ local settingFriendlyName = settingInfo.title or L[settingInfo.name] or settingInfo.name
+
+ -- There are situations (like an inline CHOICES setting) where we don't actually
+ -- want `menuItem` added because it will be taken care of already.
+ local skipAddingToMenu = false
+
+ -- Base menu item.
+ local menuItem = {
+ _bagshuiSettingName = settingInfo.name,
+ _bagshuiSettingFriendlyName = settingFriendlyName, -- Store the base friendly name so we can manipulate the text property later.
+ _bagshuiFakeTitle = settingInfo.fakeTitle, -- Fake titles are styled like titles but are can be clicked and can have submenus.
+ _bagshuiAlpha = (settingInfo.faded and 0.6 or 1),
+ text = settingFriendlyName,
+ notCheckable = settingInfo.notCheckable,
+ notClickable = settingInfo.notClickable,
+ disabled = settingInfo.disabled,
+ tooltipTitle = L_nil[settingName .. "_TooltipTitle"] or settingInfo.tooltipTitle or settingFriendlyName,
+ tooltipText = (L_nil[settingName .. "_TooltipText"] or settingInfo.tooltipText),
+ keepShownOnClick = true,
+ }
+
+ -- Add scope indicators.
+ local scopeTooltipText = ""
+ if settingInfo.scope == BS_SETTING_SCOPE.ACCOUNT then
+ scopeTooltipText = L.SettingScope_Account
+ menuItem.icon = BsUtil.GetFullTexturePath("Icons\\Characters")
+ menuItem._bagshuiTextureA = 0.45
+ elseif settingInfo.scope == BS_SETTING_SCOPE.CHARACTER then
+ scopeTooltipText = L.SettingScope_Character
+ menuItem.icon = BsUtil.GetFullTexturePath("Icons\\Character")
+ menuItem._bagshuiTextureA = 0.45
+ end
+
+ if string.len(scopeTooltipText) > 0 then
+ scopeTooltipText = GRAY_FONT_COLOR_CODE .. scopeTooltipText .. FONT_COLOR_CODE_CLOSE
+ menuItem.tooltipText = (menuItem.tooltipText and (menuItem.tooltipText .. BS_NEWLINE) or "")
+ .. scopeTooltipText
+ end
+
+ -- Choices, numbers, and colors need submenus.
+ if
+ settingInfo.type == BS_SETTING_TYPE.CHOICES
+ or settingInfo.type == BS_SETTING_TYPE.NUMBER
+ or settingInfo.type == BS_SETTING_TYPE.COLOR
+ then
+ menuItem.hasArrow = true
+ local subLevel = level + 1
+ local subMenu
+
+ if not settingInfo.inline then
+ menuBaseTable.levels[subLevel][settingInfo.name] = {}
+ subMenu = menuBaseTable.levels[subLevel][settingInfo.name]
+ end
+
+ if
+ settingInfo.type == BS_SETTING_TYPE.CHOICES
+ or (settingInfo.type == BS_SETTING_TYPE.NUMBER and settingInfo.choices)
+ then
+ -- Choices / Numbers with a static list of choices.
+
+ if settingInfo.choicesAutoSplitMenuType then
+ -- This setting has an auto-split menu instead of a static list of choices.
+
+ -- Upvalues here for auto-split menu functions.
+ local settings = self.settings
+ local settingName = settingInfo.name
+ local func = settingInfo.choicesAutoSplitMenuFunc
+ local tooltipTextFunc = settingInfo.choicesAutoSplitMenuTooltipTextFunc
+
+ -- Triggering value and parameters for auto-split menu.
+ menuItem.value = {
+ autoSplitMenuType = settingInfo.choicesAutoSplitMenuType,
+ menuLevel = 1,
+ func = function(menuLevel, settingValue)
+ if type(func) == "function" then
+ func(settings, settingName, settingValue)
+ end
+ self.settings[settingName] = settingValue
+ self.menus:Refresh(menuLevel)
+ end,
+ checkFunc = function(menuOption)
+ return self.settings[settingName] == menuOption
+ end,
+ tooltipTitleFunc = settingInfo.choicesAutoSplitMenuTooltipTitleFunc or function(_, defaultTitle)
+ -- Only provide a tooltip title if none was provided by the menu.
+ return defaultTitle or menuItem.tooltipTitle
+ end,
+ tooltipTextFunc = function(objectId, tooltipText)
+ if tooltipTextFunc then
+ tooltipText = tooltipTextFunc(objectId, tooltipText, self)
+ end
+ return tooltipText
+ end,
+ omitFunc = settingInfo.choicesAutoSplitMenuOmitFunc,
+ keepShownOnClick = true,
+ }
+ menuItem.hasArrow = true
+ elseif settingInfo.choices then
+ -- Static list of choices.
+
+ for _, choice in ipairs(settingInfo.choices) do
+ local choiceTooltipText = choice.tooltipText
+ and (choice.tooltipText .. BS_NEWLINE .. scopeTooltipText)
+ or ((menuItem.tooltipText or "") .. (choice.tooltip and " " .. choice.tooltip or ""))
+ choiceTooltipText = (
+ string.len(choiceTooltipText or "") > 0 and choiceTooltipText .. BS_NEWLINE or ""
+ ) .. settingResetHint
+ table.insert(
+ -- Add at current level or to submenu.
+
+ settingInfo.inline and menuTable or subMenu,
+ {
+ _bagshuiSettingName = settingInfo.name,
+ text = choice.text or choice.value,
+ value = choice.value,
+ tooltipTitle = choice.tooltipTitle or menuItem.tooltipTitle,
+ tooltipText = choiceTooltipText,
+ textR = choice.textR,
+ textG = choice.textG,
+ textB = choice.textB,
+ -- Copy scope indicators.
+ icon = menuItem.icon,
+ _bagshuiTextureA = menuItem._bagshuiTextureA,
+ }
+ )
+ end
+
+ -- When inline, don't add the parent menu item because everything is already done.
+ skipAddingToMenu = settingInfo.inline
+ end
+ elseif settingInfo.type == BS_SETTING_TYPE.NUMBER then
+ -- Numbers.
+
+ -- Range of values for the submenu.
+ if type(settingInfo.min) == "number" and type(settingInfo.max) == "number" then
+ for i = settingInfo.min, settingInfo.max, (settingInfo.step or 1) do
+ table.insert(subMenu, {
+ _bagshuiSettingName = settingInfo.name,
+ text = type(settingInfo.valueDisplayFunc) == "function" and settingInfo.valueDisplayFunc(i) or i,
+ value = i,
+ tooltipTitle = menuItem.tooltipTitle,
+ tooltipText = (menuItem.tooltipText and menuItem.tooltipText .. BS_NEWLINE or "")
+ .. settingResetHint,
+ -- Copy scope indicators.
+ icon = menuItem.icon,
+ _bagshuiTextureA = menuItem._bagshuiTextureA,
+ })
+ end
+ end
+ elseif settingInfo.type == BS_SETTING_TYPE.COLOR then
+ -- Colors.
+
+ -- Create submenu for color history.
+ self.menus:InitializeEmptyMenu(subMenu, {
+ _bagshuiSettingName = settingInfo.name,
+ _bagshuiSettingUpdateFunction = menuItem._bagshuiSettingUpdateFunction,
+ })
+ end
+ end
+
+ -- Apply text modifications.
+ if type(settingInfo.nameFunc) == "function" then
+ menuItem.text = settingInfo.nameFunc(menuItem.text)
+ menuItem.tooltipTitle = settingInfo.nameFunc(menuItem.tooltipTitle)
+ menuItem.tooltipText = settingInfo.nameFunc(menuItem.tooltipText)
+ end
+
+ -- Add the reset hint last because submenu items get it as needed.
+ if settingInfo.type ~= BS_SETTING_TYPE.TRIGGER then
+ menuItem.tooltipText = (menuItem.tooltipText and (menuItem.tooltipText .. BS_NEWLINE) or "")
+ .. "~1~" -- Insertion point for tooltip modifications.
+ .. GRAY_FONT_COLOR_CODE
+ .. L.Setting_Reset_TooltipText
+ .. FONT_COLOR_CODE_CLOSE
+ end
+
+ -- Store a backup copy of menu texts so they can be modified at display-time.
+ -- This probably could have been done in a more sane way but a quick hack is good enough.
+ menuItem._bagshuiOriginalText = menuItem.text
+ menuItem._bagshuiOriginalTooltipTitle = menuItem.tooltipTitle
+ menuItem._bagshuiOriginalTooltipText = menuItem.tooltipText
+
+ if not skipAddingToMenu then
+ table.insert(menuTable, menuItem)
+ end
+ end
+ end
+ end
+ end
+
+ --- Prepare a Settings menu item for display. Called from `Menus:LoadMenu()` when a menu item
+ --- has a `_bagshuiSettingName` property to build out the menu item properties.
+ ---@param menuItem table Properties for the menu item, which will be modified in-place.
+ ---@param menu table[] Parent table of `menuItem`.
+ ---@param menuLevel number Menu level, 1-3.
+ ---@param itemNum number Index of `menuItem` within `menu`.
+ ---@param menuType string Identifier for a menu that has been registered via `AddMenu()`.
+ function Inventory:GenerateSettingsMenuItem(menuItem, menu, menuLevel, itemNum, menuType)
+ local settingName = menuItem._bagshuiSettingName
+ local settingsStorage = menuItem._bagshuiSettingsStorage or self.settings
+ local settingInfo = menuItem._bagshuiSettingInfo or self.settings:GetSettingInfo(settingName)
+ local currentSettingValue = settingsStorage[settingName]
+ local defaultValue = settingInfo.defaultValue
+ local settingUpdateFunction = menuItem._bagshuiSettingUpdateFunction or emptyFunction
+
+ local disabledByFunc
+ if type(settingInfo.disableFunc) == "function" then
+ disabledByFunc = settingInfo.disableFunc(settingName, settingsStorage)
+ end
+
+ if not self._generateSettingsMenuItem_ResetOnModifierKeys then
+ --- Wrapper for `self:ResetSettingOnModifierKeys()`.
+ ---@param arg1 table Argument table containing `settingName`, `settingStorage`, and `menuLevel`.
+ self._generateSettingsMenuItem_ResetOnModifierKeys = function(arg1)
+ self:ResetSettingOnModifierKeys(arg1)
+ end
+ end
+
+ -- Update texts at display-time.
+ if menuItem._bagshuiOriginalText then
+ menuItem.text = menuItem._bagshuiOriginalText
+ if type(settingInfo.textDisplayFunc) == "function" then
+ menuItem.text = settingInfo.textDisplayFunc(menuItem.text, settingInfo.name, self.settings)
+ end
+ end
+ if menuItem._bagshuiOriginalTooltipText then
+ menuItem.tooltipText = menuItem._bagshuiOriginalTooltipText
+ if type(settingInfo.tooltipTextDisplayFunc) == "function" then
+ menuItem.tooltipText = settingInfo.tooltipTextDisplayFunc(menuItem.tooltipText, settingInfo.name, self.settings)
+ end
+ menuItem.tooltipText = string.gsub(menuItem.tooltipText, "~1~", "")
+ end
+
+ -- Add arg1 for use with ResetSettingOnModifierKeys().
+ if type(menuItem.arg1) ~= "table" then
+ menuItem.arg1 = {}
+ end
+ menuItem.arg1.settingName = settingName
+ menuItem.arg1.settingsStorage = settingsStorage
+ -- Need to store menu level because the global menu level variable will be wrong
+ -- for items with submenus.
+ menuItem.arg1.menuLevel = menuLevel
+
+ -- Figure out what the display text for the setting should be, when a `text`
+ -- property is available for the selected value within a list of choices.
+ local currentSettingFriendlyValue = currentSettingValue
+ if
+ settingInfo.choices
+ and (
+ (settingInfo.type == BS_SETTING_TYPE.CHOICES and not settingInfo.autoSplitMenuType)
+ or settingInfo.type == BS_SETTING_TYPE.NUMBER
+ )
+ then
+ for _, choice in ipairs(settingInfo.choices) do
+ if choice.value == currentSettingValue then
+ currentSettingFriendlyValue = choice.text or choice.value
+ end
+ end
+ end
+
+ -- Apply value display translation if available.
+ if type(settingInfo.valueDisplayFunc) == "function" then
+ currentSettingFriendlyValue = settingInfo.valueDisplayFunc(currentSettingFriendlyValue)
+ end
+
+ -- Process each type of setting into the appropriate menu item.
+ if settingInfo.choicesAutoSplitMenuType then
+ -- Stupid hack on our own code to pass menuLevel through to `arg1 of the
+ -- auto-split menu `func`'set up in `BuildSettingsMenuFromConfig()`.
+ menuItem.value.objectId = menuLevel
+ elseif settingInfo.type == BS_SETTING_TYPE.BOOLEAN then
+ -- Boolean -- checked/unchecked, toggle setting on click.
+
+ menuItem.checked = settingsStorage[settingName]
+ menuItem.keepShownOnClick = true
+ menuItem.func = function()
+ if not self:ResetSettingOnModifierKeys(menuItem.arg1, nil, true) then
+ settingsStorage[settingName] = not settingsStorage[settingName]
+ self.menus:Refresh(menuLevel)
+ end
+ end
+ elseif settingInfo.type == BS_SETTING_TYPE.CHOICES or settingInfo.type == BS_SETTING_TYPE.NUMBER then
+ -- Choices / Numbers -- submenu with choices, update setting to selected choice on click.
+
+ -- Determine whether this is INSIDE a submenu or whether an arrow to open the submenu is needed.
+ if menuItem.value and not menuItem.hasArrow then
+ -- Inside submenu -- checked/unchecked based on current value, change setting on click.
+ -- Need to use tostring() here so we don't get caught by float nonsense where 1 somehow doesn't equal 1.
+ menuItem.checked = type(settingInfo.choicesCheckedFunc) == "function"
+ and settingInfo.choicesCheckedFunc(settingName, settingsStorage, menuItem.value)
+ or tostring(menuItem.value) == tostring(currentSettingValue)
+ menuItem.keepShownOnClick = true
+ menuItem.func = function()
+ if not self:ResetSettingOnModifierKeys(menuItem.arg1, nil, true) then
+ settingsStorage[settingName] = _G.this.value
+ settingUpdateFunction()
+ self.menus:Refresh()
+ self.menus:Refresh(menuLevel, true)
+ end
+ end
+ if disabledByFunc ~= nil then
+ menuItem.disabled = true
+ end
+ else
+ -- Parent menu item to open the submenu.
+ menuItem.value = settingName
+ menuItem.hasArrow = true
+ -- Append current value to menu text.
+ menuItem.text = menuItem._bagshuiSettingFriendlyName
+ .. GRAY_FONT_COLOR_CODE
+ .. " ["
+ .. currentSettingFriendlyValue
+ .. "]"
+ .. FONT_COLOR_CODE_CLOSE
+ menuItem.func = self._generateSettingsMenuItem_ResetOnModifierKeys
+ end
+ elseif settingInfo.type == BS_SETTING_TYPE.COLOR then
+ -- Colors -- display swatch and prepare color history.
+
+ menuItem.opacity = nil
+
+ -- Determine whether this is INSIDE a submenu or whether an arrow to open the submenu is needed.
+ if menuItem.value and not menuItem.hasArrow then
+ -- Inside submenu -- populate color history, change setting on click.
+
+ if Bagshui.colorHistory[itemNum] and itemNum < COLOR_HISTORY_LIMIT then
+ local color = Bagshui.colorHistory[itemNum]
+ menuItem.text = " "
+ menuItem.tooltipTitle = L.Menu_Settings_ColorHistory_TooltipTitle
+ menuItem._bagshuiHide = false
+ menuItem._bagshuiColorSwatchRightAnchor = -20
+ menuItem.hasColorSwatch = true
+ menuItem.notCheckable = true
+ menuItem.r = color[1]
+ menuItem.g = color[2]
+ menuItem.b = color[3]
+ menuItem.opacity = color[4]
+ menuItem.func = function()
+ Bagshui:CloseMenus(_G.UIDROPDOWNMENU_MENU_LEVEL, nil, true)
+ settingsStorage[settingName] = color
+ settingUpdateFunction()
+ self.menus:Refresh(menuLevel, true)
+ self:AddToColorHistory(color)
+ end
+ menuItem._bagshuiColorSwatchFunc = menuItem.func
+ else
+ menuItem._bagshuiHide = true
+ end
+ else
+ -- Parent menu item to open the submenu -- add color swatch and related functions
+ -- to properly update colors as the color picker is changed, while handling things
+ -- correctly so that the value is only saved if Okay is clicked.
+
+ -- Opacity management variables that will be captured into the cancelFunc closure below.
+ -- This is necessary because since we don't receive opacity as a parameter to cancelFunc.
+ local hasOpacity = false
+ local currentOpacity = (currentSettingValue and currentSettingValue[4])
+ or (defaultValue and defaultValue[4])
+ or 1
+
+ -- Menu item updates
+ menuItem.hasArrow = true
+ menuItem.value = settingName
+ menuItem.hasColorSwatch = true
+ menuItem.r = (currentSettingValue and currentSettingValue[1]) or (defaultValue and defaultValue[1]) or 1
+ menuItem.g = (currentSettingValue and currentSettingValue[2]) or (defaultValue and defaultValue[2]) or 1
+ menuItem.b = (currentSettingValue and currentSettingValue[3]) or (defaultValue and defaultValue[3]) or 1
+ if settingInfo.hasOpacity then
+ hasOpacity = true
+ menuItem.hasOpacity = true
+ -- Inverting opacity slider because it's insane that 100% means transparent and 0% means opaque.
+ menuItem.opacity = 1.0 - currentOpacity
+ end
+
+ local oldColor = { menuItem.r, menuItem.g, menuItem.b, menuItem.opacity }
+
+ -- Shared function to reopen the menu that triggered the color picker.
+ -- Getting submenus to open would take more digging than is worth it.
+ local function reopenMenu()
+ if
+ self.menus._openMenu_last_MenuType == menuType
+ and _G.UIDROPDOWNMENU_OPEN_MENU == Bagshui.menuFrame.bagshuiData.name
+ then
+ self.menus:OpenMenu(
+ menuType,
+ self.menus._openMenu_last_Arg1,
+ self.menus._openMenu_last_Arg2,
+ _G.UIParent,
+ self.menus._openMenu_last_UIParentXOffset,
+ self.menus._openMenu_last_UIParentYOffset
+ )
+ end
+ end
+
+ -- Change setting on color / opacity change.
+ local swatchFuncCallCount = 0
+ menuItem.swatchFunc = function()
+ local color = { _G.ColorPickerFrame:GetColorRGB() }
+ if hasOpacity then
+ -- Un-invert opacity value since we're inverting it for the UI (see menuItem.opacity note above).
+ table.insert(color, 1.0 - _G.OpacitySliderFrame:GetValue())
+ end
+ settingsStorage[settingName] = color
+ settingUpdateFunction()
+
+ -- Only perform actions when Okay is clicked. swatchFunc() gets called
+ -- immediately when the ColorPickerFrame is opened, so we skip it the
+ -- first time. After that, we can tell whether Okay was clicked by
+ -- checking whether ColorPickerFrame is still visible.
+ if swatchFuncCallCount > 0 and not _G.ColorPickerFrame:IsVisible() then
+ self:AddToColorHistory(oldColor)
+ self:AddToColorHistory(color)
+
+ reopenMenu()
+ end
+
+ swatchFuncCallCount = swatchFuncCallCount + 1
+ end
+ menuItem.opacityFunc = menuItem.swatchFunc
+
+ -- Color picker Cancel button click -- revert the setting.
+ menuItem.cancelFunc = function(previousValues)
+ local color
+ -- Allow reverting a color setting to nil for Groups, which need
+ -- nil to indicate the default color should be used.
+ if currentSettingValue ~= nil then
+ color = {
+ previousValues.r,
+ previousValues.g,
+ previousValues.b,
+ }
+ -- Use upvalues that were created above since previousValues doesn't contain opacity.
+ if hasOpacity then
+ table.insert(color, currentOpacity)
+ end
+ end
+ settingsStorage[settingName] = color
+ settingUpdateFunction()
+ reopenMenu()
+ end
+
+ menuItem.func = self._generateSettingsMenuItem_ResetOnModifierKeys
+ end
+ elseif settingInfo.type == BS_SETTING_TYPE.TRIGGER then
+ -- Triggers -- just update the setting to true and the Settings class takes care of the rest.
+
+ menuItem.checked = false
+ menuItem.keepShownOnClick = false
+ menuItem.func = function()
+ settingsStorage[settingName] = true
+ end
+ end
+
+ -- Decide whether the menu item should be disabled.
+ if disabledByFunc ~= nil then
+ menuItem.disabled = disabledByFunc
+ end
+ end
+
+ --- Add the given color to the global Bagshui color picker history.
+ --- @param color table Color to add: `{ r, g, b, [a] }`.
+ function Inventory:AddToColorHistory(color)
+ -- Due to float shenanigans, colors values may not be seen as equal unless we round them.
+ for i = 1, 4 do
+ color[i] = BsUtil.Round(color[i], 4)
+ end
+
+ -- Add color to first position if it's not already there.
+ if
+ not Bagshui.colorHistory[1]
+ or (
+ Bagshui.colorHistory[1][1] ~= color[1]
+ or Bagshui.colorHistory[1][2] ~= color[2]
+ or Bagshui.colorHistory[1][3] ~= color[3]
+ or Bagshui.colorHistory[1][4] ~= color[4]
+ )
+ then
+ table.insert(Bagshui.colorHistory, 1, color)
+ end
+
+ -- Remove any duplicates.
+ for i = table.getn(Bagshui.colorHistory), 2, -1 do
+ if
+ Bagshui.colorHistory[i][1] == color[1]
+ and Bagshui.colorHistory[i][2] == color[2]
+ and Bagshui.colorHistory[i][3] == color[3]
+ and Bagshui.colorHistory[i][4] == color[4]
+ then
+ table.remove(Bagshui.colorHistory, i)
+ end
+ end
+
+ -- Trim history if needed.
+ while table.getn(Bagshui.colorHistory) > COLOR_HISTORY_LIMIT do
+ table.remove(Bagshui.colorHistory)
+ end
+ end
+
+ --- When certain modifier keys are held down, reset the setting provided in the
+ --- arg1 table to default.
+ ---@param arg1 table Menu item information for setting:
+ --- ```
+ --- {
+ --- settingName [string] = name of setting,
+ --- settingsStorage [table] = Settings class instance,
+ --- menuLevel [number] = Level of menu item,
+ --- }
+ --- ```
+ ---@param arg2 any? Ignored.
+ ---@param onlyRefreshOnReset boolean? Don't refresh the menu unless the setting was reset.
+ ---@return boolean settingWasReset
+ function Inventory:ResetSettingOnModifierKeys(arg1, arg2, onlyRefreshOnReset)
+ if _G.IsAltKeyDown() and _G.IsControlKeyDown() and _G.IsShiftKeyDown() then
+ arg1.settingsStorage:SetDefaults(true, nil, nil, arg1.settingName)
+ self.menus:Refresh(arg1.menuLevel)
+ return true
+ elseif not onlyRefreshOnReset then
+ self.menus:Refresh(arg1.menuLevel)
+ end
+ return false
+ end
+end)
diff --git a/Components/Inventory.Menus.lua b/Components/Inventory.Menus.lua
new file mode 100644
index 0000000..d9b5fc8
--- /dev/null
+++ b/Components/Inventory.Menus.lua
@@ -0,0 +1,1242 @@
+-- Bagshui Inventory Prototype: Menus
+--
+-- Most non-Settings menu-related things for Inventory classes are here, except for
+-- the calls to open menus - those are in OnClick functions.
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ -- Group menu for removing categories should not allow removing the default category (Uncategorized).
+ local GROUP_CATEGORY_REMOVE_MENU_IDS_TO_DISABLE = { BS_DEFAULT_CATEGORY_ID }
+
+ --- Prepare the per-Inventory-class-instance Menus class instance.
+ --- This includes building all the menus.
+ function Inventory:InitMenus()
+ -- Passing self to set the Menus class' inventoryClassInstance property so that
+ -- it will call Inventory:GenerateSettingsMenuItem() from Menus:LoadMenu() when
+ -- the menu item has a _bagshuiSettingName property.
+ self.menus = Bagshui.prototypes.Menus:New(self)
+
+ --- All Category menu entries should display which group they're assigned to.
+ --- Attaching this function to the `tooltipTextFunc` property of the `value`
+ --- table of the initiating menu item makes that happen.
+ ---@param categoryId any Category ID.
+ ---@return string tooltipText
+ self._autoSplitMenuItem_Categories_TooltipTextFunc = function(categoryId)
+ -- If assigned, return `Category is active in group ''`.
+ -- If unassigned, `Category is not currently in a group`.
+ local tooltipText = L.EditMode_CategoryNotInGroup
+ if self.categoriesToGroups[categoryId] and self.groups[self.categoriesToGroups[categoryId]] then
+ local row, column = self:GroupIdToRowColumn(self.categoriesToGroups[categoryId])
+ local rowColumnString = string.format(
+ GRAY_FONT_COLOR_CODE .. " [%s %s, %s %s]" .. FONT_COLOR_CODE_CLOSE,
+ L.Row,
+ row,
+ L.Column,
+ column
+ )
+ tooltipText = string.format(
+ L.EditMode_CategoryInGroup,
+ self.groups[self.categoriesToGroups[categoryId]].name or L.Unnamed
+ ) .. rowColumnString
+ end
+ return tooltipText
+ end
+
+ -- Character menu.
+ -- This MUST happen before the main menu is generated because the `generateMenuValueFunc`
+ -- property of the Character entry on the menu references it.
+ self.menus:AddMenu(
+ "Character",
+ -- Level 1 is an auto-split Characters menu full replacement.
+ {
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.CHARACTERS,
+ func = function(_, character)
+ self:SetCharacter(character)
+ end,
+ -- `self._characterMenuActiveCharacter` is managed by `Inventory:SetCharacter()` instead
+ -- of the `OpenMenu()` callback so that it's always up to date for both the toolbar
+ -- and the main menu.
+ idsToCheck = self._characterMenuActiveCharacter,
+ }
+ )
+
+ -- Main Menu.
+
+ self.menus:AddMenu(
+ "Main",
+ -- 1
+ {
+
+ -- Bagshui [Inventory Type]
+ {
+ text = BS_FONT_COLOR.BAGSHUI .. "Bagshui " .. self.inventoryTypeLocalized .. FONT_COLOR_CODE_CLOSE,
+ isTitle = true,
+ checked = false,
+ notCheckable = true,
+ },
+ {
+ text = L.EditMode,
+ tooltipText = L.Menu_Main_EditMode_TooltipText,
+ icon = BsUtil.GetFullTexturePath("Icons\\Edit"),
+ _bagshuiCheckedFunc = function()
+ return self.editMode
+ end,
+ func = function()
+ self:ToggleEditMode()
+ end,
+ },
+ {
+ text = string.format(L.Symbol_Ellipsis, L.Settings),
+ tooltipTitle = L.Settings,
+ tooltipText = L.Menu_Main_Settings_TooltipText,
+ value = "Settings",
+ checked = false,
+ icon = BsUtil.GetFullTexturePath("Icons\\Settings"),
+ func = function()
+ self.menus:OpenMenu("Settings")
+ end,
+ },
+
+ -- Actions (built out below from self.toolbarAndMainMenuItems).
+ {
+ text = L.Actions,
+ isTitle = true,
+ notCheckable = true,
+ checked = false,
+ },
+
+ -- Manage
+ {
+ text = L.Manage,
+ isTitle = true,
+ notCheckable = true,
+ checked = false,
+ },
+ {
+ text = string.format(L.Symbol_Ellipsis, L.Categories),
+ tooltipTitle = L.Categories,
+ tooltipText = L.Menu_Main_ManageCategories_TooltipText,
+ checked = false,
+ func = function()
+ BsCategories:Open()
+ end,
+ icon = BsUtil.GetFullTexturePath("Icons\\Category"),
+ },
+ {
+ text = string.format(L.Symbol_Ellipsis, L.SortOrders),
+ tooltipTitle = L.SortOrders,
+ tooltipText = L.Menu_Main_ManageSortOrders_TooltipText,
+ checked = false,
+ func = function()
+ BsSortOrders:Open()
+ end,
+ icon = BsUtil.GetFullTexturePath("Icons\\SortOrder"),
+ },
+ {
+ text = string.format(L.Symbol_Ellipsis, L.Profiles),
+ tooltipTitle = L.Profiles,
+ tooltipText = L.Menu_Main_ManageProfiles_TooltipText,
+ checked = false,
+ func = function()
+ BsProfiles:Open()
+ end,
+ icon = BsUtil.GetFullTexturePath("Icons\\Characters"),
+ },
+
+ -- Toggle (built out below).
+ {
+ text = L.Toggle,
+ isTitle = true,
+ notCheckable = true,
+ },
+ }
+ )
+
+ -- Add toolbar buttons as menu items.
+ -- Find insertion point.
+ local toolbarInsertPoint
+ for i, entry in ipairs(self.menus.menuList.Main.levels[1]) do
+ if entry.text == L.Actions then
+ toolbarInsertPoint = i + 1
+ break
+ end
+ end
+ -- Add to menu.
+ if toolbarInsertPoint then
+ for i = table.getn(self.toolbarAndMainMenuItems), 1, -1 do
+ local toolbarItem = self.toolbarAndMainMenuItems[i]
+
+ local menuItem = {
+ text = toolbarItem.tooltipTitle,
+ tooltipText = toolbarItem.tooltipText,
+ checked = false,
+ icon = toolbarItem.texture,
+ hasArrow = toolbarItem.menuHasArrow,
+ _bagshuiHideArrow = toolbarItem.menuHideArrow,
+ _bagshuiDisabledFunc = toolbarItem._bagshuiDisabledFunc or function()
+ return (self.ui.buttons.toolbar[toolbarItem.id]:IsEnabled() == 0)
+ end,
+ _bagshuiCheckedFunc = toolbarItem._bagshuiCheckedFunc,
+ }
+
+ if not toolbarItem.menuIgnoreOnClick then
+ menuItem.func = toolbarItem.func
+ or function()
+ self.ui.buttons.toolbar[toolbarItem.id]:GetScript("OnClick")()
+ end
+ end
+
+ if toolbarItem.getMenuValueProp then
+ menuItem.value = toolbarItem.getMenuValueProp()
+ end
+
+ table.insert(self.menus.menuList.Main.levels[1], toolbarInsertPoint, menuItem)
+ end
+ else
+ self:PrintWarning("Failed to find main menu insertion point for actions")
+ end
+
+ -- Add Toggle entries for inventory all types.
+ -- If anything is ever added below the Toggle menu section, this will need to
+ -- be modified to find the insertion point like the toolbar buttons above.
+ for inventoryType, inventoryTypeLocalized in self:OtherInventoryTypesInToolbarIconOrder(false, true) do
+ local inventoryClass = Bagshui.components[inventoryType]
+ table.insert(self.menus.menuList.Main.levels[1], {
+ text = inventoryTypeLocalized,
+ tooltipText = string.format(L.Menu_Main_Toggle_TooltipText, inventoryTypeLocalized),
+ _bagshuiHideFunc = function()
+ -- Hide the entry for the current inventory type unless the top toolbar is hidden.
+ return (inventoryClass == self and self.settings.showHeader)
+ end,
+ _bagshuiCheckedFunc = function()
+ return inventoryClass:Visible()
+ end,
+ func = function()
+ inventoryClass:Toggle(true)
+ end,
+ icon = BsUtil.GetFullTexturePath("Icons\\" .. inventoryType),
+ })
+ end
+ -- Catalog.
+ table.insert(self.menus.menuList.Main.levels[1], {
+ text = L.Catalog,
+ tooltipTitle = L.Toolbar_Catalog_TooltipTitle,
+ tooltipText = L.Toolbar_Catalog_TooltipText,
+ _bagshuiCheckedFunc = function()
+ return BsCatalog:Visible()
+ end,
+ func = function()
+ BsCatalog:Toggle(true)
+ end,
+ icon = BsUtil.GetFullTexturePath("Icons\\Catalog"),
+ })
+
+ -- Settings Menu.
+
+ -- Start with an empty menu since it'll be populated by BuildSettingsMenuFromConfig().
+ self.menus:AddMenu(
+ "Settings",
+ nil,
+ nil,
+ nil,
+ --- Settings OpenMenu callback.
+ ---@param menu table Menu table.
+ function(menu)
+ self:OpenSettingsMenu(menu)
+ -- Prevent a second call to ShowMenu() from Menus:OpenMenuCallback().
+ return false
+ end
+ )
+
+ -- Populate the Settings menu.
+ self:BuildSettingsMenuFromConfig(
+ Bagshui.config.Settings[BS_SETTING_APPLICABILITY.INVENTORY],
+ self.menus.menuList.Settings,
+ 1
+ )
+
+ -- Group Menu.
+ local groupDefaults = Bagshui.config.Settings[BS_SETTING_APPLICABILITY.GROUP]
+ local groupMenuFakeItem = BsUtil.TableCopy(BS_ITEM_SKELETON)
+ groupMenuFakeItem._bagshuiCategoryConfigTrigger = true
+
+ -- The Group menu can only be opened when Edit Mode is active, so it makes
+ -- sense to use the Edit Mode window update function when a Group setting
+ -- is changed instead of directly calling the Inventory:Update() function.
+ local editModeWindowUpdate = function()
+ self:EditModeWindowUpdate(false)
+ end
+
+ self.menus:AddMenu(
+ "Group",
+ -- 1
+ {
+ -- Group (first item is updated to the group name by the OpenMenu callback).
+ {
+ _bagshuiMenuItemId = "GroupNameTitle",
+ text = L.Group,
+ icon = BsUtil.GetFullTexturePath("Icons\\Group"),
+ isTitle = true,
+ notCheckable = true,
+ },
+ {
+ text = L.Settings,
+ tooltipTitle = L.Menu_Group_Settings_TooltipTitle,
+ tooltipText = L.Menu_Group_Settings_TooltipText,
+ value = "Options",
+ hasArrow = true,
+ },
+ {
+ text = L.Rename,
+ tooltipTitle = L.Menu_Group_Rename_TooltipTitle,
+ tooltipText = L.Menu_Group_Rename_TooltipText,
+ func = function(groupId)
+ self:RenameGroup(groupId, false)
+ end,
+ },
+ {
+ text = L.Move,
+ tooltipTitle = L.Menu_Group_Move_TooltipTitle,
+ tooltipText = L.Menu_Group_Move_TooltipText,
+ func = function(groupId)
+ self:EditModePickUpGroup(groupId)
+ end,
+ },
+ {
+ text = L.Delete,
+ tooltipTitle = L.Menu_Group_Delete_TooltipTitle,
+ tooltipText = L.Menu_Group_Delete_TooltipText,
+ func = function(groupId)
+ self:DeleteGroup(groupId)
+ end,
+ },
+
+ -- Categories indented sub-header.
+ {
+ text = BS_MENU_SUBTITLE_INDENT .. L.Categories,
+ icon = BsUtil.GetFullTexturePath("Icons\\Category"),
+ isTitle = true,
+ notCheckable = true,
+ },
+ {
+ -- The Add/Remove menu is the Categories auto-split menu with nothing hidden.
+ _bagshuiMenuItemId = "CategoryAddRemove",
+ text = L.AddSlashRemove,
+ tooltipTitle = string.format(L.Prefix_Add, L.Category),
+ tooltipText = L.Menu_Group_Add_Category_TooltipText,
+ value = {
+ -- Categories auto-split menu trigger and parameters.
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES,
+ func = function(groupId, categoryId)
+ self:ToggleCategoryGroupAssignment(categoryId, groupId, true)
+ end,
+ tooltipTextFunc = self._autoSplitMenuItem_Categories_TooltipTextFunc,
+
+ -- Disable categories (currently just Uncategorized) when they're checked to prevent removal from the layout.
+ disableFunc = function(categoryId, menuItem, triggeringMenuValue)
+ if
+ menuItem.checked
+ and BsUtil.TableContainsValue(GROUP_CATEGORY_REMOVE_MENU_IDS_TO_DISABLE, categoryId) ~= nil
+ then
+ return true
+ end
+ return false
+ end,
+
+ -- New...
+ autoSplitMenuExtraItems = {
+ {
+ text = string.format(L.Symbol_Ellipsis, L.New),
+ tooltipTitle = L.Menu_Group_New_Category_TooltipTitle,
+ tooltipText = L.Menu_Group_New_Category_TooltipText,
+ checked = false,
+ value = {
+ func = function(groupId, _)
+ local template = BsUtil.TableCopy(BS_NEW_CATEGORY_TEMPLATE)
+ BsCategories:NewObject(
+ template,
+ -- onFirstSave function
+ function(categoryId)
+ if string.len(categoryId or "") > 0 and string.len(groupId or "") > 0 then
+ self:AssignCategoryToGroup(categoryId, groupId, true)
+ end
+ end
+ )
+ end,
+ },
+ },
+ },
+ },
+ hasArrow = true,
+ },
+ {
+ -- The Move menu is the Categories auto-split menu with only the assigned categories visible.
+ _bagshuiMenuItemId = "CategoryMove",
+ text = L.Move,
+ tooltipTitle = string.format(L.Prefix_Move, L.Category),
+ tooltipText = L.Menu_Group_Move_Category_TooltipText,
+ value = {
+ -- Categories auto-split menu trigger and parameters.
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES,
+ func = function(_, categoryId)
+ self:EditModePickUpCategory(categoryId, true)
+ end,
+ tooltipTextFunc = self._autoSplitMenuItem_Categories_TooltipTextFunc,
+ },
+ hasArrow = true,
+ },
+ {
+ -- The Configure menu is the Categories auto-split menu with only the assigned categories visible.
+ _bagshuiMenuItemId = "CategoryConfig",
+ text = L.Menu,
+ tooltipTitle = string.format(L.Prefix_OpenMenuFor, L.Category),
+ tooltipText = L.Menu_Group_Configure_Category_TooltipText,
+ value = {
+ -- Categories auto-split menu trigger and parameters.
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES,
+ func = function(_, categoryId)
+ groupMenuFakeItem.bagshuiCategoryId = categoryId
+ self.menus:OpenMenu(
+ "Item",
+ groupMenuFakeItem,
+ nil,
+ _G.UIParent,
+ _G.DropDownList1:GetLeft(),
+ _G.DropDownList1:GetTop()
+ )
+ end,
+ tooltipTextFunc = self._autoSplitMenuItem_Categories_TooltipTextFunc,
+ },
+ hasArrow = true,
+ },
+
+ -- Sorting indented sub-header.
+ {
+ text = BS_MENU_SUBTITLE_INDENT .. L.Sorting,
+ icon = BsUtil.GetFullTexturePath("Icons\\SortOrder"),
+ isTitle = true,
+ notCheckable = true,
+ },
+ {
+ -- The Sort Order menu is the Sort Order auto-split menu with the active sort order checked.
+ text = L.SortOrder,
+ tooltipTitle = L.Menu_Group_SortOrder_TooltipTitle,
+ tooltipText = L.Menu_Group_SortOrder_TooltipText,
+ value = {
+ -- Sort Order auto-split menu trigger and parameters.
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.SORT_ORDERS,
+ func = function(groupId, sortOrderId)
+ self:AssignSortOrderToGroup(sortOrderId, groupId)
+ end,
+ -- Add [Structure Default] to the end of the default sort order's name.
+ nameRevisionFunc = function(itemId, itemName)
+ return (itemId == self.settings.defaultSortOrder) and string.format(L.Suffix_Default, itemName)
+ or itemName
+ end,
+ -- Add a (Use Default) item that will be checked by the OpenMenu callback
+ -- if this group doesn't have a defined sort order.
+ autoSplitMenuExtraItems = {
+ {
+ text = "(" .. L.UseDefault .. ")",
+ tooltipTitle = L.Menu_Group_DefaultSortOrder_TooltipTitle,
+ checked = false,
+ value = {
+ func = function(groupId, _)
+ self:AssignSortOrderToGroup(nil, groupId)
+ end,
+ tooltipTextFunc = function()
+ return string.format(
+ L.Menu_Group_DefaultSortOrder_TooltipText,
+ LIGHTYELLOW_FONT_COLOR_CODE
+ .. BsSortOrders.list[self.settings.defaultSortOrder].name
+ .. FONT_COLOR_CODE_CLOSE
+ )
+ end,
+ },
+ },
+ },
+ },
+ hasArrow = true,
+ },
+ },
+
+ -- 2
+ {
+ -- Per-group settings.
+ Options = {
+
+ {
+ text = L.Menu_Settings_View,
+ isTitle = true,
+ notCheckable = true,
+ keepShownOnClick = true,
+ },
+ {
+ text = L.Menu_Group_HideGroup,
+ tooltipText = L.Menu_Group_HideGroup_TooltipText,
+ _bagshuiCheckedFunc = function(groupId)
+ return (self.groups[groupId] and self.groups[groupId].hide) or not self.groups[groupId]
+ end,
+ func = function(groupId)
+ if not self.groups[groupId] then
+ return
+ end
+ self.groups[groupId].hide = not self.groups[groupId].hide
+ self:EditModeWindowUpdate(false)
+ end,
+ },
+ {
+ text = L.Menu_Group_HideStockBadge,
+ tooltipText = L.Menu_Group_HideStockBadge_TooltipText,
+ _bagshuiSettingName = "hideStockBadge",
+ _bagshuiSettingInfo = groupDefaults.hideStockBadge,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+
+ {
+ text = L.Background,
+ isTitle = true,
+ notCheckable = true,
+ keepShownOnClick = true,
+ },
+ {
+ text = L.UseDefault,
+ tooltipTitle = string.format(L.Menu_Group_DefaultColor_TooltipTitle, L.Background),
+ tooltipText = string.format(L.Menu_Group_DefaultColor_TooltipText, string.lower(L.Background)),
+ _bagshuiCheckedFunc = function(groupId)
+ return (self.groups[groupId] and self.groups[groupId].background == nil) or not self.groups[groupId]
+ end,
+ func = function(groupId)
+ if not self.groups[groupId] then
+ return
+ end
+ self.groups[groupId].background = nil
+ self:EditModeWindowUpdate(false)
+ end,
+ },
+ {
+ text = L.Color,
+ tooltipTitle = string.format(L.Menu_Group_Color_TooltipTitle, L.Background),
+ tooltipText = string.format(L.Menu_Group_Color_TooltipText, string.lower(L.Background)),
+ _bagshuiSettingName = "background",
+ _bagshuiSettingInfo = groupDefaults.background,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+
+ {
+ text = L.Border,
+ isTitle = true,
+ notCheckable = true,
+ keepShownOnClick = true,
+ },
+ {
+ text = L.UseDefault,
+ tooltipTitle = string.format(L.Menu_Group_DefaultColor_TooltipTitle, L.Border),
+ tooltipText = string.format(L.Menu_Group_DefaultColor_TooltipText, string.lower(L.Border)),
+ _bagshuiCheckedFunc = function(groupId)
+ return (self.groups[groupId] and self.groups[groupId].border == nil) or not self.groups[groupId]
+ end,
+ func = function(groupId)
+ if not self.groups[groupId] then
+ return
+ end
+ self.groups[groupId].border = nil
+ self:EditModeWindowUpdate(false)
+ end,
+ },
+ {
+ text = L.Color,
+ tooltipTitle = string.format(L.Menu_Group_Color_TooltipTitle, L.Border),
+ tooltipText = string.format(L.Menu_Group_Color_TooltipText, string.lower(L.Border)),
+ _bagshuiSettingName = "border",
+ _bagshuiSettingInfo = groupDefaults.border,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+
+ {
+ text = L.Label,
+ isTitle = true,
+ notCheckable = true,
+ keepShownOnClick = true,
+ },
+ {
+ text = L.UseDefault,
+ tooltipTitle = string.format(L.Menu_Group_DefaultColor_TooltipTitle, L.Label),
+ tooltipText = string.format(L.Menu_Group_DefaultColor_TooltipText, string.lower(L.Label)),
+ _bagshuiCheckedFunc = function(groupId)
+ return (self.groups[groupId] and self.groups[groupId].label == nil) or not self.groups[groupId]
+ end,
+ func = function(groupId)
+ if not self.groups[groupId] then
+ return
+ end
+ self.groups[groupId].label = nil
+ self:EditModeWindowUpdate(false)
+ end,
+ },
+ {
+ text = L.Color,
+ tooltipTitle = string.format(L.Menu_Group_Color_TooltipTitle, L.Label),
+ tooltipText = string.format(L.Menu_Group_Color_TooltipText, string.lower(L.Label)),
+ _bagshuiSettingName = "label",
+ _bagshuiSettingInfo = groupDefaults.label,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+ },
+ },
+
+ -- 3
+ {
+ -- Color history menu entries.
+ -- This is normally done automatically in BuildSettingsMenuFromConfig(),
+ -- but since the group settings menu is manually constructed, we need
+ -- to manually create these as well.
+ background = {
+ _initializeEmptyMenu = {
+ _bagshuiSettingName = "background",
+ _bagshuiSettingInfo = groupDefaults.background,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+ },
+ border = {
+ _initializeEmptyMenu = {
+ _bagshuiSettingName = "border",
+ _bagshuiSettingInfo = groupDefaults.border,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+ },
+ label = {
+ _initializeEmptyMenu = {
+ _bagshuiSettingName = "border",
+ _bagshuiSettingInfo = groupDefaults.label,
+ _bagshuiSettingUpdateFunction = editModeWindowUpdate,
+ },
+ },
+ },
+
+ --- Group OpenMenu callback.
+ ---@param menu table Menu table.
+ ---@param uiGroup table Group user interface object.
+ function(menu, uiGroup)
+ local groupId = uiGroup.bagshuiData.groupId
+ local group = self.groups[groupId]
+ local groupMenu = menu
+
+ for _, menuItem in ipairs(groupMenu.levels[1]) do
+ if menuItem._bagshuiMenuItemId == "GroupNameTitle" then
+ -- Update the menu title to the group name.
+ menuItem.text = string.len(group.name or "") > 0
+ and (string.format(L.Symbol_Colon, L.Group) .. " " .. LIGHTYELLOW_FONT_COLOR_CODE .. BsUtil.TruncateString(
+ group.name,
+ 45
+ ) .. FONT_COLOR_CODE_CLOSE)
+ or L.Group
+ elseif type(menuItem.value) == "table" and menuItem.value.autoSplitMenuType then
+ -- Category/SortOrder auto-split submenu updates.
+
+ -- Auto-split menus pass arg1 via value.objectId.
+ menuItem.value.objectId = groupId
+
+ if menuItem.value.autoSplitMenuType == BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES then
+ if menuItem._bagshuiMenuItemId == "CategoryAddRemove" then
+ -- "Add" needs the full category list with already assigned categories checked.
+ menuItem.value.idsToCheck = group.categories
+ elseif
+ menuItem._bagshuiMenuItemId == "CategoryMove" or menuItem._bagshuiMenuItemId == "CategoryConfig"
+ then
+ -- "Remove" needs only the already assigned categories (if any).
+ if group.categories and table.getn(group.categories) > 0 then
+ menuItem.disabled = false
+ menuItem.hasArrow = true
+ menuItem.value.idList = group.categories
+ else
+ -- This will be hit when there are no assigned categories, disabling these options.
+ menuItem.disabled = true
+ menuItem.hasArrow = false
+ end
+ end
+ elseif menuItem.value.autoSplitMenuType == BS_AUTO_SPLIT_MENU_TYPE.SORT_ORDERS then
+ -- Control whether "(Use Default)" is checked.
+ menuItem.value.autoSplitMenuExtraItems[1].checked = (group.sortOrder == nil)
+
+ -- Check the currently selected item.
+ if group.sortOrder then
+ menuItem.value.idsToCheck = { group.sortOrder }
+ else
+ menuItem.value.idsToCheck = nil
+ end
+ end
+ end
+ end
+
+ -- Add properties to all menu items (this is fine to do for all of them even if it is a little brute force-y).
+ self.menus:SetPropertyOnAllMenuItems(groupMenu, "arg1", groupId)
+ self.menus:SetPropertyOnAllMenuItems(groupMenu, "_bagshuiSettingsStorage", group)
+ end
+ )
+
+ -- Item Menu.
+
+ -- Shared OnClick function for itemInfo submenu to display the itemInfo window.
+ local function showItemInfo(item)
+ Bagshui:CloseMenus()
+ BsItemInfo:Open(item)
+ end
+
+ -- Reusable tables for item/category relationships.
+ self._itemMenuDirectCategoryAssignmentList = {}
+ self._itemMenuMatchedCategoryList = {}
+ self._itemMenuAssignedCategoryList = { "Uncategorized" }
+
+ -- Generate menu items for equipping bags.
+ -- This needs to contain entries from all inventory classes that have equippable bags.
+ local bagSwapMenu = {}
+ for _, inventoryClassName in ipairs(BS_INVENTORY_TYPE_UI_ORDER) do
+ local inventoryClass = Bagshui.components[inventoryClassName]
+ if
+ inventoryClass
+ and inventoryClass.containerIds
+ -- Need more than a primary container.
+ and table.getn(inventoryClass.containerIds) > 1
+ then
+ table.insert(bagSwapMenu, {
+ text = inventoryClass.inventoryTypeLocalized,
+ isTitle = true,
+ notCheckable = true,
+ })
+ for _, containerId in ipairs(inventoryClass.containerIds) do
+ if containerId ~= inventoryClass.primaryContainer.id then
+ table.insert(bagSwapMenu, {
+ text = containerId,
+ notCheckable = true,
+ -- arg1 will be the item.
+ arg2 = {
+ containerId = containerId,
+ inventoryClass = inventoryClass,
+ },
+ _bagshuiTextureR = 1,
+ _bagshuiTextureG = 1,
+ _bagshuiTextureB = 1,
+ func = function(item, bagInfo)
+ Bagshui:CloseMenus()
+ bagInfo.inventoryClass:SwapBag(item, bagInfo.containerId)
+ end,
+ })
+ end
+ end
+ end
+ end
+
+ self.menus:AddMenu(
+ "Item",
+ -- 1
+ {
+ -- Category title (will be renamed by the OpenMenu callback.)
+ {
+ _bagshuiMenuItemId = "CategoryTitle",
+ _bagshuiItemMenuCategoryEntry = true,
+ text = L.Category,
+ isTitle = true,
+ notCheckable = true,
+ icon = BsUtil.GetFullTexturePath("Icons\\Category"),
+ _bagshuiTextureSize = 12,
+ _bagshuiTextureXOffset = -12,
+ },
+ -- arg1 and arg2 are assigned by the OpenMenu callback
+ {
+ _bagshuiItemMenuCategoryEntry = true,
+ text = L.Move,
+ tooltipTitle = string.format(L.Prefix_Move, L.Category),
+ tooltipText = L.Menu_Category_Move_TooltipText,
+ func = function(_, categoryId)
+ self:EditModePickUpCategory(categoryId, true)
+ end,
+ },
+ {
+ _bagshuiItemMenuCategoryEntry = true,
+ text = L.Remove,
+ tooltipTitle = string.format(L.Prefix_Remove, L.Category),
+ tooltipText = L.Menu_Category_Remove_TooltipText,
+ func = function(groupId, categoryId)
+ self:RemoveCategoryFromGroup(categoryId, groupId)
+ self:EditModeWindowUpdate(true)
+ end,
+ },
+ {
+ _bagshuiItemMenuCategoryEntry = true,
+ text = string.format(L.Symbol_Ellipsis, L.Edit),
+ tooltipTitle = string.format(L.Prefix_Edit, L.Category),
+ tooltipText = L.Menu_Category_Edit_TooltipText,
+ func = function(_, categoryId)
+ BsCategories:EditObject(categoryId)
+ self:CloseMenusAndClearFocuses()
+ end,
+ },
+ {
+ _bagshuiItemMenuCategoryEntry = true,
+ text = string.format(L.Symbol_Ellipsis, L.Manage),
+ tooltipTitle = string.format(L.Prefix_Manage, L.Categories),
+ tooltipText = L.Menu_Main_ManageCategories_TooltipText,
+ func = function()
+ BsCategories:Open()
+ self:CloseMenusAndClearFocuses()
+ end,
+ },
+
+ -- Item title (will be renamed by the OpenMenu callback.)
+ {
+ _bagshuiMenuItemId = "ItemNameTitle",
+ text = L.Item,
+ isTitle = true,
+ notCheckable = true,
+ _bagshuiTextureR = 1,
+ _bagshuiTextureG = 1,
+ _bagshuiTextureB = 1,
+ },
+ -- Tracked Item Bar.
+ {
+ _bagshuiMenuItemId = "ItemTrack",
+ text = L.Menu_Item_TrackItem,
+ tooltipText = L.Menu_Item_TrackItem_TooltipText,
+ disabled = true,
+ keepShownOnClick = false,
+ func = function(item)
+ Bagshui.components.TrackedItemBar:Toggle(item.id, item.texture)
+ self:CloseMenusAndClearFocuses()
+ end,
+ },
+ {
+ text = L.Information,
+ tooltipTitle = L.Menu_Item_Information_TooltipTitle,
+ tooltipText = L.Menu_Item_Information_TooltipText,
+ hasArrow = true,
+ value = {
+ value = "itemInfo",
+ },
+ },
+ {
+ text = L.Manage,
+ tooltipTitle = L.Menu_Item_Manage_TooltipTitle,
+ tooltipText = L.Menu_Item_Manage_TooltipText,
+ hasArrow = true,
+ value = {
+ value = "manage",
+ },
+ },
+ {
+ text = L.Move,
+ tooltipTitle = string.format(L.Prefix_Move, L.Item),
+ tooltipText = L.Menu_Item_Move_TooltipText,
+ _bagshuiMenuItemId = "ItemMove",
+ func = function(item)
+ self:EditModePickUpItem(item)
+ end,
+ },
+
+ -- Indented subheader for item categories menu.
+ {
+ text = BS_MENU_SUBTITLE_INDENT .. L.Categories,
+ isTitle = true,
+ notCheckable = true,
+ },
+ -- Matched Categories is a Categories auto-spilt menu that only contains the list of
+ -- categories that this item could potentially match, as produced by
+ -- Categories:GetAllMatchingCategoriesForItem(). The category that the item has
+ -- actually matched and is currently in will be checked. These updates are made
+ -- in the OpenMenu callback function.
+ {
+ text = L.Menu_Item_MatchedCategories,
+ tooltipTitle = L.Menu_Item_MatchedCategories_TooltipTitle,
+ tooltipText = L.Menu_Item_MatchedCategories_TooltipText,
+ value = {
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES,
+ func = function(itemId, categoryId)
+ BsCategories:EditObject(categoryId)
+ end,
+ sortFunc = function(categoryIds)
+ BsCategories:SortIdList(categoryIds, nil, "sequence")
+ end,
+ nameFunc = function(categoryId)
+ -- Prefix the category name with the sequence number.
+ -- Gray out the category name if it's not assigned to the current layout.
+ local fontColor = ""
+
+ if not (self.categoriesToGroups[categoryId] and self.groups[self.categoriesToGroups[categoryId]]) then
+ fontColor = GRAY_FONT_COLOR_CODE
+ elseif categoryId == self._itemMenuAssignedCategoryList[1] then
+ fontColor = NORMAL_FONT_COLOR_CODE
+ end
+
+ return fontColor
+ .. string.format(L.Symbol_Colon, BsCategories.list[categoryId].sequence)
+ .. " "
+ .. (BsCategories:GetName(categoryId, false, GRAY_FONT_COLOR_CODE) or tostring(categoryId))
+ .. ((fontColor ~= "") and FONT_COLOR_CODE_CLOSE or "")
+ end,
+ tooltipTextFunc = function(categoryId)
+ local layoutLocation = LIGHTYELLOW_FONT_COLOR_CODE
+ .. self._autoSplitMenuItem_Categories_TooltipTextFunc(categoryId)
+ .. FONT_COLOR_CODE_CLOSE
+ return NORMAL_FONT_COLOR_CODE
+ .. L.Menu_Item_MatchedCategory_TooltipText
+ .. FONT_COLOR_CODE_CLOSE
+ .. BS_NEWLINE
+ .. layoutLocation
+ end,
+ -- These tables are updated by the OpenMenu callback.
+ idList = self._itemMenuMatchedCategoryList,
+ idsToCheck = self._itemMenuAssignedCategoryList,
+ -- Never show the default category (Uncategorized) since every item matches it.
+ idsToOmit = { BS_DEFAULT_CATEGORY_ID },
+ },
+ hasArrow = true,
+ },
+ -- Direct Assignment is a Categories auto-split menu that contains all
+ -- editable (custom) NON-CLASS categories. Any categories the item is directly
+ -- assigned to will be checked. These updates are performed by the
+ -- OpenMenu callback function.
+ {
+ _bagshuiMenuItemId = "ItemDirectCategoryAssignment",
+ text = L.Menu_Item_AssignToCategory,
+ tooltipTitle = L.Menu_Item_AssignToCategory_TooltipTitle,
+ tooltipText = L.Menu_Item_AssignToCategory_TooltipText
+ .. BS_NEWLINE
+ .. LIGHTYELLOW_FONT_COLOR_CODE
+ .. L.Menu_Item_AssignToCategory_Hint_CustomOnly
+ .. FONT_COLOR_CODE_CLOSE,
+ value = {
+ autoSplitMenuType = BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES,
+ func = function(itemAndGroupId, categoryId)
+ if BsCategories.list[categoryId].classes then
+ self.menus:OpenMenu("DirectAssignmentClasses", itemAndGroupId, categoryId, "cursor")
+ else
+ BsCategories:ToggleItemCategoryAssignment(categoryId, itemAndGroupId.item.id)
+ self:EditModeWindowUpdate(true)
+ end
+ end,
+ nameFunc = function(categoryId)
+ -- Need to override nameFunc to turn off duplicate suffix since we're only showing custom categories.
+ return BsCategories:GetName(categoryId, true) or tostring(categoryId)
+ end,
+ tooltipTextFunc = self._autoSplitMenuItem_Categories_TooltipTextFunc,
+ idsToCheck = self._itemMenuDirectCategoryAssignmentList,
+ idList = BsCategories.filteredIdLists.custom,
+
+ -- This will be passed as arg1 to the auto-split menu func()s.
+ objectId = {
+ item = nil,
+ groupId = nil,
+ },
+
+ autoSplitMenuExtraItems = {
+ {
+ text = string.format(L.Symbol_Ellipsis, L.New),
+ tooltipText = L.Menu_Item_AssignToCategory_CreateNew_TooltipText
+ .. BS_NEWLINE
+ .. LIGHTYELLOW_FONT_COLOR_CODE
+ .. L.Menu_Item_AssignToCategory_Hint_CustomOnly
+ .. FONT_COLOR_CODE_CLOSE,
+ checked = false,
+ value = {
+ func = function(itemAndGroupId, _)
+ local template = BsUtil.TableCopy(BS_NEW_CATEGORY_TEMPLATE)
+ table.insert(template.list, itemAndGroupId.item.id)
+ BsCategories:NewObject(
+ template,
+ -- onFirstSave callback.
+ function(categoryId)
+ if string.len(categoryId or "") > 0 and string.len(itemAndGroupId.groupId or "") > 0 then
+ self:AssignCategoryToGroup(categoryId, itemAndGroupId.groupId, true)
+ end
+ end
+ )
+ end,
+ },
+ },
+ },
+ },
+ hasArrow = true,
+ },
+ },
+
+ -- 2
+ {
+ -- Populated by the OpenMenu callback function.
+ itemInfo = {},
+
+ manage = {
+ -- Remove from worn gear list.
+ {
+ _bagshuiMenuItemId = "ItemRemoveFromWorn",
+ text = L.Menu_Item_RemoveFromEquippedGear,
+ tooltipText = L.Menu_Item_RemoveFromEquippedGear_TooltipText,
+ disabled = true,
+ keepShownOnClick = false,
+ func = function(item)
+ BsCharacter:RemoveFromEquippedGear(item.itemString)
+ self:CloseMenusAndClearFocuses()
+ end,
+ },
+ -- Reset item stock state.
+ {
+ _bagshuiMenuItemId = "ItemResetStockState",
+ text = L.Menu_Item_ResetStockState,
+ tooltipText = L.Menu_Item_ResetStockState_TooltipText,
+ disabled = true,
+ keepShownOnClick = false,
+ func = function(item)
+ self:ResetStockState()
+ end,
+ },
+ -- Bag menu for equipping.
+ {
+ _bagshuiMenuItemId = "ItemEquipBag",
+ text = L.Menu_Item_EquipBag,
+ tooltipText = L.Menu_Item_EquipBag_TooltipText,
+ hasArrow = true,
+ disabled = true,
+ value = {
+ value = "bags",
+ },
+ },
+ },
+ },
+
+ -- 3
+ {
+ bags = bagSwapMenu,
+ },
+
+ --- Item OpenMenu callback.
+ ---@param menu table Menu table.
+ ---@param item table Bagshui item table.
+ ---@param groupId string Group to which the item is being assigned. When present, all other menu items will be suppressed and only the direct category assignment submenu will be available.
+ function(menu, item, groupId)
+ local itemMenu = menu
+ local directAssignmentOnlyMode = (groupId ~= nil)
+ local directAssignmentCategoryIds = (groupId and self.groups[groupId] and self.groups[groupId].categories)
+ or nil
+ local isMeaningfulItemId = ((item.id or 0) > 0)
+
+ -- Get list of categories to which this item is manually assigned.
+ -- This table is assigned to the idsToCheck property of the
+ -- Direct Assignment auto-split submenu. It's also used to decide
+ -- whether the parent Direct Assignment item should be checked.
+ BsCategories:GetDirectCategoryAssignmentsForItem(
+ item.id,
+ self._itemMenuDirectCategoryAssignmentList,
+ directAssignmentCategoryIds
+ )
+
+ -- Update menu titles and values.
+ for _, menuItem in pairs(itemMenu.levels[1]) do
+ menuItem._bagshuiHide = false
+
+ -- Category: Move/Remove/Edit/Configure need values to pass through to their func()s.
+ if menuItem._bagshuiItemMenuCategoryEntry then
+ menuItem.arg1 = self.categoriesToGroups[item.bagshuiCategoryId]
+ menuItem.arg2 = item.bagshuiCategoryId
+ if not self.editMode then
+ menuItem._bagshuiHide = true
+ end
+ elseif item._bagshuiCategoryConfigTrigger then
+ menuItem._bagshuiHide = true
+ end
+
+ if menuItem._bagshuiMenuItemId == "CategoryTitle" then
+ -- Category: Set category name.
+ menuItem.text = string.format(L.Symbol_Colon, L.Category)
+ .. " "
+ .. LIGHTYELLOW_FONT_COLOR_CODE
+ .. (BsCategories:GetName(item.bagshuiCategoryId) or L.Error_ItemCategoryUnknown)
+ .. FONT_COLOR_CODE_CLOSE
+ elseif menuItem._bagshuiMenuItemId == "ItemNameTitle" then
+ -- Item: Set item name and texture.
+ menuItem.text = string.format(L.Symbol_Colon, L.Item)
+ .. " "
+ .. LIGHTYELLOW_FONT_COLOR_CODE
+ .. BsUtil.TruncateString(item.name, 45)
+ .. FONT_COLOR_CODE_CLOSE
+ menuItem.icon = item.texture
+ if BsSkin.itemSlotIconTexCoord then
+ menuItem.tCoordLeft = BsSkin.itemSlotIconTexCoord[1]
+ menuItem.tCoordRight = BsSkin.itemSlotIconTexCoord[2]
+ menuItem.tCoordTop = BsSkin.itemSlotIconTexCoord[3]
+ menuItem.tCoordBottom = BsSkin.itemSlotIconTexCoord[4]
+ end
+ elseif menuItem._bagshuiMenuItemId == "ItemTrack" then
+ -- Item: Track/untrack on the Tracked Item Bar.
+ menuItem.arg1 = item
+ menuItem.disabled = not isMeaningfulItemId
+ menuItem.text = Bagshui.components.TrackedItemBar:IsTracked(item.id) and L.Menu_Item_StopTrackingItem
+ or L.Menu_Item_TrackItem
+ elseif menuItem._bagshuiMenuItemId == "ItemMove" then
+ -- Item: Disable the Move menu for empty slots since they can't be directly assigned to a category, only via a rule function.
+ menuItem.disabled = not isMeaningfulItemId
+ menuItem.arg1 = item
+ menuItem._bagshuiHide = not self.editMode
+ elseif menuItem._bagshuiMenuItemId == "ItemDirectCategoryAssignment" then
+ -- Item: Disable the Direct Assignment menu for empty slots since they can't be directly assigned to a category, only via a rule function.
+ menuItem.disabled = not isMeaningfulItemId
+ menuItem.value.objectId.item = item
+ menuItem.value.objectId.groupId = groupId or self.categoriesToGroups[item.bagshuiCategoryId]
+ -- Check it if any categories are manually assigned.
+ menuItem.checked = table.getn(self._itemMenuDirectCategoryAssignmentList) > 0
+ menuItem.value.idList = directAssignmentCategoryIds or BsCategories.filteredIdLists.custom
+ menuItem.value.idsToOmit = directAssignmentCategoryIds and BsCategories.filteredIdLists.builtin or nil
+ end
+
+ -- Item: Hide everything other than Direct Assignment
+ -- when an item is picked up in Edit Mode and dropped on a group.
+ if
+ menuItem._bagshuiHide == false -- Don't force anything to be shown that is already hidden.
+ and menuItem._bagshuiMenuItemId ~= "ItemNameTitle"
+ and menuItem._bagshuiMenuItemId ~= "ItemDirectCategoryAssignment"
+ and not item._bagshuiCategoryConfigTrigger
+ then
+ menuItem._bagshuiHide = (directAssignmentCategoryIds ~= nil)
+ end
+ end
+
+ -- Item: These submenus only need to be updated on a normal right-click,
+ -- not when an item has been dropped on a group in Edit Mode.
+ if not directAssignmentOnlyMode then
+ -- Update Manage submenu.
+ for _, menuItem in pairs(itemMenu.levels[2].manage) do
+ menuItem.arg1 = item
+
+ if menuItem._bagshuiMenuItemId == "ItemRemoveFromWorn" then
+ menuItem.disabled = not BsCharacter.equippedHistory[item.itemString]
+ elseif menuItem._bagshuiMenuItemId == "ItemResetStockState" then
+ menuItem.disabled = item.bagshuiStockState == BS_ITEM_STOCK_STATE.NO_CHANGE
+ elseif menuItem._bagshuiMenuItemId == "ItemEquipBag" then
+ menuItem.disabled = not BsItemInfo:IsContainer(item)
+
+ -- Update the bag slot menu to reflect current bags.
+ if not menuItem.disabled then
+ for index, bagMenuItem in pairs(itemMenu.levels[3].bags) do
+ if not bagMenuItem.isTitle then
+ bagMenuItem.arg1 = item
+ local inventoryClass = bagMenuItem.arg2.inventoryClass
+ local containerInfo = inventoryClass.containers[bagMenuItem.arg2.containerId]
+ if containerInfo.numSlots > 0 then
+ bagMenuItem.text = containerInfo.name
+ bagMenuItem.icon = containerInfo.texture
+ else
+ bagMenuItem.text = string.format(L.Symbol_Parentheses, L.EmptyBagSlot)
+ bagMenuItem.icon = BsUtil.GetFullTexturePath(BS_INVENTORY_EMPTY_SLOT_TEXTURE[L.Bag])
+ end
+ bagMenuItem.tooltipTitle = containerInfo.name
+ bagMenuItem.tooltipText = inventoryClass:GetBagSlotSwappableText(bagMenuItem.arg2.containerId)
+ bagMenuItem.disabled = (
+ not inventoryClass.online
+ -- Disable if the slot isn't usable. This is primarily for the Bank to disable unpurchased slots.
+ or containerInfo.usable == false
+ )
+ end
+ end
+ end
+ end
+ end
+
+ -- Add items to Information submenu.
+ local infoMenu = itemMenu.levels[2].itemInfo
+ local infoMenuItemCount = 0
+ for _, itemPropertyFriendly, _, itemPropertyDisplay in BsItemInfo:ItemPropertyValuesForDisplay(item) do
+ infoMenuItemCount = infoMenuItemCount + 1
+ infoMenu[infoMenuItemCount]._bagshuiHide = false
+ infoMenu[infoMenuItemCount].text = string.format(L.Symbol_Colon, itemPropertyFriendly)
+ .. " "
+ .. itemPropertyDisplay
+ infoMenu[infoMenuItemCount].tooltipText = LIGHTYELLOW_FONT_COLOR_CODE
+ .. L.Menu_Item_Information_Submenu_TooltipText
+ .. FONT_COLOR_CODE_CLOSE
+ infoMenu[infoMenuItemCount].arg1 = item
+ end
+ -- Hide unused menu items.
+ self.menus:HideMenuItems(infoMenu, infoMenuItemCount + 1)
+
+ -- Update Matched Categories submenu.
+ BsCategories:GetAllMatchingCategoriesForItem(item, nil, self._itemMenuMatchedCategoryList)
+ self._itemMenuAssignedCategoryList[1] = item.bagshuiCategoryId
+ end
+
+ -- Hide tooltips that sometimes get stuck.
+ Bagshui:HideTooltips(nil, true)
+ end
+ )
+
+ -- Fill the item info level 2 menus with placeholder entries that will be
+ -- updated by the OpenMenu callback.
+ self.menus:InitializeEmptyMenu(self.menus.menuList.Item.levels[2].itemInfo, {
+ text = "",
+ arg1 = "",
+ func = showItemInfo,
+ })
+
+ -- Direct Assignment Class Selection Menu.
+ -- Triggered when a Class Category is chosen from the Direct Assignment menu.
+
+ local function toggleClassCategoryDirectAssignment(arg1)
+ BsCategories:ToggleItemCategoryAssignment(arg1.categoryId, arg1.itemId, _G.this.value)
+ end
+
+ local classMenu = BsUtil.TableCopy(BsGameInfo.characterClassMenu)
+ -- Reusable table for passing values through to the callback function.
+ local classMenuArg1 = {}
+
+ for _, menuItem in ipairs(classMenu) do
+ menuItem.arg1 = classMenuArg1
+ menuItem.func = toggleClassCategoryDirectAssignment
+ end
+ table.insert(classMenu, 1, {
+ text = "Item placeholder",
+ isTitle = true,
+ notCheckable = true,
+ })
+ table.insert(classMenu, 2, {
+ text = LIGHTYELLOW_FONT_COLOR_CODE .. L.Menu_Item_AssignToClassCategory .. FONT_COLOR_CODE_CLOSE,
+ isTitle = true,
+ notCheckable = true,
+ })
+ table.insert(classMenu, 3, {
+ text = "Category placeholder",
+ isTitle = true,
+ notCheckable = true,
+ })
+
+ self.menus:AddMenu(
+ "DirectAssignmentClasses",
+ classMenu,
+ -- Nothing for level 2/3.
+ nil,
+ nil,
+ -- OpenMenu callback.
+ function(menu, itemAndGroupId, categoryId)
+ classMenuArg1.itemId = itemAndGroupId.item.id
+ classMenuArg1.categoryId = categoryId
+
+ menu.levels[1][1].text = itemAndGroupId.item.name
+ menu.levels[1][1].icon = itemAndGroupId.item.texture
+ menu.levels[1][1]._bagshuiTextureR = 1
+ menu.levels[1][1]._bagshuiTextureG = 1
+ menu.levels[1][1]._bagshuiTextureB = 1
+ if BsSkin.itemSlotIconTexCoord then
+ menu.levels[1][1].tCoordLeft = BsSkin.itemSlotIconTexCoord[1]
+ menu.levels[1][1].tCoordRight = BsSkin.itemSlotIconTexCoord[2]
+ menu.levels[1][1].tCoordTop = BsSkin.itemSlotIconTexCoord[3]
+ menu.levels[1][1].tCoordBottom = BsSkin.itemSlotIconTexCoord[4]
+ end
+
+ menu.levels[1][3].text = BsCategories:GetName(categoryId, true) or tostring(categoryId)
+
+ for i = 4, table.getn(menu.levels[1]) do
+ local menuItem = menu.levels[1][i]
+ menuItem.checked =
+ BsCategories:ItemInDirectAssignmentList(nil, categoryId, itemAndGroupId.item.id, nil, nil, menuItem.value)
+ end
+ end
+ )
+ end
+end)
diff --git a/Components/Inventory.Ui.BagButton.lua b/Components/Inventory.Ui.BagButton.lua
new file mode 100644
index 0000000..58fa064
--- /dev/null
+++ b/Components/Inventory.Ui.BagButton.lua
@@ -0,0 +1,663 @@
+-- Bagshui Inventory Prototype: Bag Slot Buttons
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+ local InventoryUi = Bagshui.prototypes.InventoryUi
+
+ --- Create one bag slot button per container ID in the class instance.
+ function InventoryUi:CreateBagSlotButtons()
+ local ui = self
+ local inventory = self.inventory
+
+ local buttonParent = ui.frames.bagBar
+ local buttonTemplate, primaryBag, buttonName, previousBagSlot
+ -- Gets assigned to the bagshuiData.bagSlotNum property so that the Bank can
+ -- determine whether the slot has been purchased.
+ -- Needs to start at 1 for the first non-primary container, so it's initialized
+ -- to 0 and incremented in the loop.
+ local sequentialBagSlotNum = 0
+
+ for index, containerId in ipairs(inventory.containerIds) do
+ local bagSlotButton, inventoryId
+
+ buttonTemplate = inventory.bagButtonTemplate
+ primaryBag = false
+
+ if index == 1 and containerId == inventory.primaryContainer.id then
+ -- Special handling for the primary bag.
+ primaryBag = true
+ buttonName = ui:CreateElementName("PrimaryBag")
+ -- We use a normal ItemButtonTemplate that gets faked into being a faux bag button.
+ buttonTemplate = "ItemButtonTemplate"
+ else
+ -- All other bags.
+ -- Name needs to be in a specific format because we're depending on Blizzard
+ -- code for bag slots and there are several places where it parses the element
+ -- name using `strsub(this:GetName(), 10)`. It also expects a certain string
+ -- to be produced -- for PaperDollItemSlotButton, it's "Bag#Slot" but for
+ -- Bank bag buttons it's just "Bag#". The correct name formatting strings
+ -- need to be set in each Inventory class' configuration.
+ buttonName = string.format(inventory.bagSlotNameFormat, containerId + inventory.bagSlotNameNumberOffset)
+
+ sequentialBagSlotNum = sequentialBagSlotNum + 1
+ end
+
+ -- Create the button.
+ bagSlotButton = _G.CreateFrame("Button", buttonName, buttonParent, buttonTemplate)
+
+ -- Post-creation button changes.
+ if primaryBag then
+ -- Primary bag needs extra tweaks.
+ -- Blizzard code sets the `hasItem` property to 1 when a slot is occupied, so we're doing the same.
+ bagSlotButton.hasItem = 1
+ -- Blizzard also uses `tooltipText` to provide bag slot information adn we're reusing that too.
+ bagSlotButton.tooltipText = inventory.primaryContainer.name
+
+ -- Set the special texture for this faux bag button.
+ _G.SetItemButtonTexture(bagSlotButton, inventory.primaryContainer.texture)
+ else
+ inventoryId = containerId + inventory.bagButtonIdOffset
+ -- All other containers need this for compatibility with Blizzard code.
+ -- Also used in Inventory:ShowBagSlotTooltip().
+ bagSlotButton:SetID(inventoryId)
+ end
+
+ -- Add Bagshui properties.
+ bagSlotButton.bagshuiData = {
+ type = BS_UI_ITEM_BUTTON_TYPE.BAG,
+ bagNum = containerId,
+ containerId = containerId,
+ bagSlotNum = sequentialBagSlotNum,
+ inventoryId = inventoryId,
+ inventorySlotId = inventoryId
+ and (
+ inventory.getInventorySlotFunction
+ -- We absolutely have to use 1 instead of true here to get Blizzard's code to work.
+ and inventory.getInventorySlotFunction(containerId, 1)
+ or inventoryId
+ ),
+ primary = primaryBag,
+ highlightLocked = false,
+ cooldown = _G[buttonName .. "Cooldown"],
+ }
+
+ -- Need to nuke this property to prevent flickering when bags are being
+ -- swapped with adjusted TexCoords (i.e. pfUI skin) due to an
+ -- "our code vs Blizzard code" issue.
+ bagSlotButton.backgroundTextureName = nil
+
+ -- Create cooldown if needed.
+ if not bagSlotButton.bagshuiData.cooldown then
+ bagSlotButton.bagshuiData.cooldown = self:CreateItemButtonCooldown(bagSlotButton)
+ end
+ -- Set this even though we're not relying on it.
+ -- See `Ui:ShowProgressViaCooldown()` for details.
+ bagSlotButton.bagshuiData.cooldown.noCooldownCount = true
+
+ -- Apply all our appearance adjustments.
+ ui:SkinItemButton(bagSlotButton)
+
+ -- Size should be the same as an item slot button.
+ bagSlotButton:SetWidth(ui.buttons.itemSlots[1]:GetWidth())
+ bagSlotButton:SetHeight(ui.buttons.itemSlots[1]:GetHeight())
+
+ -- First button is anchored to bottom left, subsequent are anchored to the previous one.
+ if table.getn(ui.buttons.bagSlots) == 0 then
+ bagSlotButton:SetPoint("BOTTOMLEFT", 3, 0)
+ else
+ bagSlotButton:SetPoint("TOPLEFT", previousBagSlot, "TOPRIGHT", BsSkin.bagBarSpacing, 0)
+ end
+
+ -- Expand HitRectInsets to avoid item highlight flickering when moving mouse between buttons.
+ local hitRectInset = BsSkin.bagBarSpacing / 2
+ bagSlotButton:SetHitRectInsets(-hitRectInset, -hitRectInset, -hitRectInset, -hitRectInset)
+
+ -- Event handling.
+ -- We're not reusing Blizzard code for OnEnter/OnLeave/OnUpdate because
+ -- it gets really messy trying to add new lines to tooltips when they're
+ -- being reset on every frame by the OnUpdate functions.
+ -- A lot of this could probably be refactored and merged with ItemButton
+ -- code, but that's not a priority.
+
+ -- Note that Bagshui info tooltips are handled in Bagshui.Tooltips.lua, at least for now.
+ bagSlotButton:SetScript("OnEnter", function(bagButton)
+ if inventory.editMode then
+ return
+ end
+
+ local this = bagButton or _G.this
+ this.bagshuiData.mouseIsOver = true
+
+ -- Display tooltip.
+ inventory:ShowBagSlotTooltip(this)
+
+ -- Highlight this bag's contents.
+ if
+ inventory:BagSlotButtonHasBag(this)
+ and not inventory.highlightItemsInContainerLocked
+ and not inventory.highlightItemsContainerSlot
+ then
+ inventory.highlightItemsInContainerId = this.bagshuiData.bagNum
+ inventory:UpdateItemSlotColors()
+ end
+
+ -- Show slot available/used counts.
+ inventory.ui.frames.bagBar:GetScript("OnEnter")()
+ end)
+
+ bagSlotButton:SetScript("OnLeave", function()
+ _G.this.bagshuiData.mouseIsOver = false
+
+ -- Hide tooltip.
+ if _G.GameTooltip:IsOwned(_G.this) then
+ _G.GameTooltip:Hide()
+ end
+
+ -- Get rid of any cursor changes.
+ _G.ResetCursor()
+
+ -- Stop any further tooltip refreshes.
+ _G.this.bagshuiData.tooltipCooldownUpdate = nil
+
+ -- Remove bag highlight unless it's locked on.
+ if
+ inventory.highlightItemsInContainerId == _G.this.bagshuiData.bagNum
+ and not inventory.highlightItemsInContainerLocked
+ and not inventory.highlightItemsContainerSlot
+ then
+ inventory.highlightItemsInContainerId = nil
+ inventory:UpdateItemSlotColors()
+ end
+
+ -- Decide whether to keep displaying slot available/used counts.
+ inventory.ui.frames.bagBar:GetScript("OnLeave")()
+ end)
+
+ -- Storing at a higher scope to reduce garbage collector load.
+
+ local onUpdate_refreshTooltip, onUpdate_cooldownStart, onUpdate_cooldownDuration, onUpdate_cooldownEnable
+
+ bagSlotButton:SetScript("OnUpdate", function(elapsed)
+ onUpdate_refreshTooltip = false
+
+ -- Remove item highlighting if there's no longer a container in the slot.
+ if
+ inventory.highlightItemsInContainerLocked == _G.this.bagshuiData.bagNum
+ and not inventory:BagSlotButtonHasBag(_G.this)
+ and not inventory.highlightItemsContainerSlot
+ then
+ inventory.highlightItemsInContainerLocked = nil
+ inventory.highlightItemsInContainerId = nil
+ inventory:UpdateBagBar()
+ inventory:UpdateItemSlotColors()
+ end
+
+ -- "Progress bar" for bag swapping using the cooldown animation.
+ if type(_G.this.bagshuiData.progressPercent) == "number" then
+ -- Use this by setting the bagButton.bagshuiData.progressPercent property to a value 0-100.
+ -- At 100, the completion shine animation will trigger.
+
+ if not _G.this.bagshuiData.progressFinishing then
+ -- This has to run on every frame to hold the cooldown at the same spot.
+ self:ShowProgressViaCooldown(_G.this.bagshuiData.cooldown, _G.this.bagshuiData.progressPercent)
+ end
+
+ if _G.this.bagshuiData.progressPercent == 100 then
+ _G.this.bagshuiData.progressFinishing = true
+ end
+
+ if _G.this.bagshuiData.progressFinishing and not _G.this.bagshuiData.cooldown:IsVisible() then
+ _G.this.bagshuiData.progressPercent = nil
+ _G.this.bagshuiData.progressFinishing = nil
+ end
+
+ -- elseif this.bagshuiData.inventorySlotId then
+ -- Normal cooldown checking is disabled because I don't think bag cooldowns are even a thing.
+ -- Easy enough to restore if needed.
+ -- If it does get enabled, there will also need to be something added to
+ -- Ui:ShowProgressViaCooldown() to hide any cooldown text that was present.
+ --
+ -- Normal cooldown behavior (primary containers can't have cooldowns so they're excluded.)
+ -- onUpdate_cooldownStart, onUpdate_cooldownDuration, onUpdate_cooldownEnable = _G.GetInventoryItemCooldown("player", _G.this.bagshuiData.inventorySlotId)
+ -- _G.CooldownFrame_SetTimer(_G.this.bagshuiData.cooldown, onUpdate_cooldownStart, onUpdate_cooldownDuration, onUpdate_cooldownEnable);
+ end
+
+ -- Safeguard to prevent tooltips from popping up when the mouse has already left.
+ if not _G.this.bagshuiData.mouseIsOver then
+ _G.this.bagshuiData.tooltipCooldownUpdate = nil
+ return
+ end
+
+ if _G.this.bagshuiData.tooltipCooldownUpdate ~= nil then
+ -- tooltipCooldownUpdate is initially set to 1 by OnEnter when there's a cooldown.
+ -- Here we subtract the elapsed time in seconds, which will eventually go below 0
+ -- so long as the property isn't wiped by moving the mouse off this item.
+ _G.this.bagshuiData.tooltipCooldownUpdate = _G.this.bagshuiData.tooltipCooldownUpdate - elapsed
+
+ -- Don't proceed until it's been more than 1 second.
+ if _G.this.bagshuiData.tooltipCooldownUpdate < 0 then
+ onUpdate_refreshTooltip = true
+ end
+ end
+
+ if onUpdate_refreshTooltip then
+ inventory:ShowBagSlotTooltip(_G.this)
+ end
+ end)
+
+ -- Most of the default OnClick behaviors for bag buttons are fine, but we need to overlay a few things.
+ -- - Alt-click highlight lock/unlock.
+ -- - Block default functions if offline.
+ -- - Handle placing items in primary containers.
+ local oldOnClick = bagSlotButton:GetScript("OnClick")
+ bagSlotButton:SetScript("OnClick", function()
+ local this = _G.this
+ local bagNum = this.bagshuiData.bagNum
+
+ local hasBag = inventory:BagSlotButtonHasBag(this)
+
+ -- Right-click or Ctrl+Click: hide/show the items in this bag.
+ if inventory:ToggleBagHiddenOnClick(this) then
+ return
+ end
+
+ -- Alt-click highlight lock.
+ if
+ hasBag
+ and (
+ _G.IsAltKeyDown()
+ -- Normal clicking needs to remove the highlight lock since the bag might be getting picked up.
+ or (inventory.highlightItemsInContainerLocked == bagNum and inventory.online)
+ )
+ and not inventory.editMode
+ then
+ local oldHighlightItemsInContainerLocked = inventory.highlightItemsInContainerLocked
+
+ -- Don't do anything if a specific item is highlighted.
+ if inventory.highlightItemsContainerSlot then
+ return
+ end
+
+ -- Toggle highlight lock for this button.
+ inventory.highlightItemsInContainerLocked = inventory.highlightItemsInContainerLocked ~= bagNum and bagNum
+ or nil
+
+ inventory.highlightItemsInContainerId = bagNum
+ inventory:UpdateBagBar()
+ -- When the highlight lock is changed, we need to do a full window update
+ -- so hidden items in this bag can be shown or hidden.
+ if oldHighlightItemsInContainerLocked ~= inventory.highlightItemsInContainerLocked then
+ inventory:ForceUpdateWindow()
+ else
+ inventory:UpdateItemSlotColors()
+ end
+
+ -- Don't pick up bag or do anything else if alt key was down.
+ if _G.IsAltKeyDown() then
+ return
+ end
+ end
+
+ -- Can't do anything with bags other than highlight items if we're offline or in Edit Mode
+ if not inventory.online or inventory.editMode then
+ return
+ end
+
+ -- Two special cases are handled here.
+ --
+ -- 1. Placing items in the primary container.
+ -- This needs special handling because there doesn't seem to be a bag ID
+ -- that works with PutItemInBag for the primary Bank or Keyring containers.
+ -- So instead we're iterating the container and looking for empty slots,
+ -- then calling PickupContainerItem. This doesn't need to use Bagshui:PickupItem()
+ -- since cursor tracking data gets cleared via the ClearCursor() hook and we
+ -- don't need to call ContainerFrameItemButton_OnClick() either.
+ --
+ -- 2. Placing a bag in another bag via Shift+Click.
+ if
+ _G.CursorHasItem()
+ and (
+ this.bagshuiData.primary
+ or (
+ inventory:BagSlotButtonHasBag(this)
+ and BsItemInfo:IsContainer(Bagshui.cursorItem)
+ and _G.IsShiftKeyDown()
+ )
+ )
+ then
+ local noEmptySlots = true
+ for slotNum, slotContents in ipairs(inventory.inventory[this.bagshuiData.bagNum]) do
+ if slotContents.emptySlot == 1 then
+ _G.PickupContainerItem(this.bagshuiData.bagNum, slotNum)
+ noEmptySlots = false
+ end
+ end
+ if noEmptySlots then
+ Bagshui:ShowErrorMessage(_G.BAG_FULL, self.inventoryType)
+ end
+ return
+ end
+
+ -- Bag swapping.
+ if
+ _G.CursorHasItem()
+ and inventory:BagSlotButtonHasBag(this)
+ and BsItemInfo:IsContainer(Bagshui.cursorItem)
+ then
+ inventory:SwapBag(Bagshui.cursorItem, this.bagshuiData.bagNum)
+ return
+ end
+
+ -- Call original OnClick.
+ if oldOnClick then
+ -- Outfitter's `Outfitter_PaperDollItemSlotButton_OnClick()` makes the assumption that
+ -- it will only ever be called for the exact buttons it has defined. There is never a
+ -- check to verify that `vInventorySlot` isn't nil, and this presents a problem for
+ -- our use of `PaperDollItemSlotButtonTemplate` to build `BagshuiBagsContainerTemplate`.
+ -- When a Bags bag slot is clicked, Outfitter will throw an error from `OutfitterQuickSlots_Open()`:
+ -- ```
+ -- Interface\AddOns\Outfitter\Outfitter.lua:6011: attempt to concatenate local `pSlotName' (a nil value)
+ -- ```
+ -- Luckily we can trick Outfitter into not going down that path by temporarily showing
+ -- the OutfitterQuickSlots frame and then hiding it immediately afterwards.
+ -- https://github.com/veechs/Bagshui/issues/101
+ local outfitterQuickSlotsOldParent = nil
+ if
+ _G.OutfitterQuickSlots
+ and type(_G.OutfitterQuickSlots.IsVisible) == "function"
+ and not _G.OutfitterQuickSlots:IsVisible()
+ then
+ _G.OutfitterQuickSlots:ClearAllPoints()
+ -- Need to re-parent to UIParent so `:IsVisible()` will be true.
+ outfitterQuickSlotsOldParent = _G.OutfitterQuickSlots:GetParent()
+ _G.OutfitterQuickSlots:SetParent(_G.UIParent)
+ _G.OutfitterQuickSlots:Show()
+ end
+
+ oldOnClick()
+
+ -- Undo the Outfitter workaround.
+ if outfitterQuickSlotsOldParent then
+ _G.OutfitterQuickSlots:SetParent(outfitterQuickSlotsOldParent)
+ _G.OutfitterQuickSlots:Hide()
+ end
+ end
+
+ -- Equipping a new bag should highlight slots immediately.
+ if not hasBag then
+ -- Need a short delay for inventory cache to update.
+ Bagshui:QueueEvent(this:GetScript("OnEnter"), 0.25, nil, this)
+ end
+ end)
+
+ -- OnDragStart -- just use the original OnDragStart, but block it when offline or in Edit Mode.
+ local oldOnDragStart = bagSlotButton:GetScript("OnDragStart")
+ bagSlotButton:SetScript("OnDragStart", function()
+ if not inventory.online or inventory.editMode then
+ return
+ end
+
+ if oldOnDragStart then
+ oldOnDragStart()
+ end
+ end)
+
+ -- Perform final initialization.
+ -- This is done in a separate function so subclasses can override it.
+ if not primaryBag then
+ inventory:BagSlotButton_Init(bagSlotButton)
+ end
+
+ -- Add to bagSlots table.
+ table.insert(ui.buttons.bagSlots, bagSlotButton)
+
+ -- Update previousBagSlot for button placement.
+ previousBagSlot = bagSlotButton
+ end
+ end
+
+ --- Further initialization of bag slot buttons, called midway through CreateBagSlotButtons().
+ --- Broken out as a separate function so subclasses can deal with nuances of different Blizzard
+ --- code in Bags and Bank. Just a placeholder for subclasses to override.
+ --- This feels like it could be refactored somehow to be cleaner, but it also doesn't feel worth it.
+ ---@param bagSlotButton table Button widget.
+ function Inventory:BagSlotButton_Init(bagSlotButton) end
+
+ -- For the WoW API functions BagSlotButton_OnClick() and BagSlotButton_OnDrag)(),
+ -- we need to first call the original function, then make sure our window is up to date.
+ ---@param wowApiFunctionName string Hooked WoW API function that triggered this call.
+ function Inventory:BagSlotButton_OnHook(wowApiFunctionName)
+ -- Don't interfere if this isn't a Bagshui button
+ if not string.find(_G.this:GetName(), self.bagSlotNameFormat) then
+ self.hooks:OriginalHook(wowApiFunctionName)
+ return
+ end
+
+ -- Can't do anything offline.
+ if not self.online then
+ return
+ end
+ self.hooks:OriginalHook(wowApiFunctionName)
+ self:Update()
+ end
+
+ --- Does the given bagSlotButton contain a bag?
+ --- (We can't just use the button's `hasItem` property because it doesn't get
+ --- updated when viewing other characters.)
+ ---@param bagSlotButton any
+ ---@return boolean
+ function Inventory:BagSlotButtonHasBag(bagSlotButton)
+ return (
+ self.containers[bagSlotButton.bagshuiData.bagNum]
+ and (self.containers[bagSlotButton.bagshuiData.bagNum].numSlots or 0) > 0
+ )
+ end
+
+ --- Apply bag slot button tooltip modifications.
+ --- A lot of this could probably be refactored and merged with ItemButton
+ --- code, but that's not a priority.
+ ---@param bagSlotButton table Button widget.
+ function Inventory:ShowBagSlotTooltip(bagSlotButton)
+ local this = bagSlotButton or _G.this
+
+ -- Cases when nothing should happen.
+ if
+ -- OnEnter still fires even if mouse events are disabled so we need to avoid doing anything when we're in that state.
+ not this:IsMouseEnabled()
+ -- Don't pop up tooltips or do anything else when a menu is open.
+ or self.menus:IsMenuOpen()
+ then
+ return
+ end
+
+ -- Get the tooltip ready.
+ _G.GameTooltip:SetOwner(
+ this,
+ "ANCHOR_" .. BsUtil.FlipAnchorPoint(self.settings.windowAnchorXPoint),
+ -BsSkin.tooltipExtraOffset,
+ BsSkin.tooltipExtraOffset
+ )
+
+ -- Is the bag currently on cooldown?
+ local hasCooldown = false
+
+ if not this.bagshuiData.primary and self.online and this.bagshuiData.inventorySlotId then
+ -- Not using return value 1 (hasItem) because it's automatically captured into
+ -- the button's hasItem property by Blizzard code when an event that changes
+ -- bags fires.
+ _, hasCooldown = _G.GameTooltip:SetInventoryItem("player", this.bagshuiData.inventorySlotId)
+ end
+
+ -- Trigger the OnUpdate function to reload the tooltip after 1 second if there's an active cooldown.
+ -- (In other words, the 1 here represents number of seconds, NOT `true`).
+ this.bagshuiData.tooltipCooldownUpdate = hasCooldown and 1 or nil
+
+ -- Set bag name or slot status text in tooltip.
+ if
+ -- Primary bag always needs special handling.
+ this.bagshuiData.primary
+ -- Try to take care of other bags offline when the name is known.
+ or (not self.online and (self.containers[this.bagshuiData.bagNum].name or this.tooltipText or (self.activeCharacterId ~= Bagshui.currentCharacterId)))
+ -- When there's no inventoryId we can't use the tooltip's SetInventoryItem().
+ or not this.bagshuiData.inventorySlotId
+ -- No bag in slot.
+ or not this.hasItem
+ then
+ _G.GameTooltip:SetText(
+ (
+ self.containers[this.bagshuiData.bagNum].name
+ or this.tooltipText
+ or (self.activeCharacterId ~= Bagshui.currentCharacterId and L.EmptyBagSlot)
+ or _G.EQUIP_CONTAINER
+ ),
+ 1.0,
+ 1.0,
+ 1.0
+ )
+ end
+
+ -- Add key bindings to tooltip.
+ if this.hasItem and self.opensViaHooks and self.activeCharacterId == Bagshui.currentCharacterId then
+ local keyBinding = _G.GetBindingKey(
+ (
+ this.bagshuiData.primary and self.primaryContainer.bindingKey
+ or (
+ self.keyBindingPrefix
+ and table.getn(self.containerIdRange) > 0
+ -- Refer to Blizzard's BagSlotButton_OnEnter.
+ and self.keyBindingPrefix
+ .. (self.containerIdRange[2] - (this.bagshuiData.containerId - self.containerIdRange[1]))
+ )
+ )
+ )
+ if keyBinding then
+ _G.GameTooltip:AppendText(" " .. NORMAL_FONT_COLOR_CODE .. "(" .. keyBinding .. ")" .. FONT_COLOR_CODE_CLOSE)
+ end
+ end
+
+ -- Figure out how many slots are available and add it to the tooltip.
+ local totalSlots = self.containers[this.bagshuiData.bagNum].numSlots
+ if totalSlots > 0 then
+ local usedSlots = self.containers[this.bagshuiData.bagNum].slotsFilled
+ local availableSlots = totalSlots - usedSlots
+ local full = (availableSlots <= 0) and (" (" .. L.Full .. ")") or ""
+ _G.GameTooltip:AddDoubleLine(
+ string.format(L.Symbol_Colon, L.Available) .. " " .. tostring(availableSlots) .. full,
+ string.format(L.Symbol_Colon, L.Used) .. " " .. tostring(usedSlots) .. "/" .. tostring(totalSlots),
+ 1,
+ 1,
+ 0.5,
+ 1,
+ 1,
+ 0.5
+ )
+ end
+
+ -- Is bag swapping possible based on current free space?
+ if
+ not this.bagshuiData.primary
+ and _G.CursorHasItem()
+ and self:BagSlotButtonHasBag(this)
+ and BsItemInfo:IsContainer(Bagshui.cursorItem)
+ then
+ _G.GameTooltip:AddLine(self:GetBagSlotSwappableText(this.bagshuiData.bagNum))
+ -- Shift+Click to move into bag instead of swapping.
+ _G.GameTooltip:AddLine(
+ string.format(L.Tooltip_Inventory_PlaceBagInBagHint, L.ShiftClick),
+ GRAY_FONT_COLOR.r,
+ GRAY_FONT_COLOR.g,
+ GRAY_FONT_COLOR.b
+ )
+ end
+
+ -- Add hint about locking highlight if there's something in the slot and there are items in the inventory cache.
+ if
+ not self.editMode
+ and (((self.activeCharacterId == Bagshui.currentCharacterId) and this.hasItem or false) or (self.containers[this.bagshuiData.bagNum].numSlots or 0) > 0)
+ and self.inventory[this.bagshuiData.bagNum]
+ and not self.highlightItemsContainerSlot
+ then
+ _G.GameTooltip:AddLine(
+ string.format(
+ L.Tooltip_Inventory_ToggleBagSlotHighlightLockHint,
+ L.AltClick,
+ string.lower(
+ (self.highlightItemsInContainerLocked and self.highlightItemsInContainerId == this.bagshuiData.bagNum)
+ and L.Unlock
+ or L.Lock
+ )
+ ),
+ GRAY_FONT_COLOR.r,
+ GRAY_FONT_COLOR.g,
+ GRAY_FONT_COLOR.b
+ )
+ end
+
+ -- Add hint about hiding/showing the bag's items (not for the primary containers, which can't be hidden).
+ if not self.editMode and Bagshui.config.HideBagSettings[this.bagshuiData.bagNum] then
+ _G.GameTooltip:AddLine(
+ string.format(
+ L.Tooltip_Inventory_ToggleBagHiddenHint,
+ L.RightClick .. " / " .. L.CtrlClick,
+ string.lower(self:IsBagHidden(this.bagshuiData.bagNum) and L.Show or L.Hide)
+ ),
+ GRAY_FONT_COLOR.r,
+ GRAY_FONT_COLOR.g,
+ GRAY_FONT_COLOR.b
+ )
+ end
+
+ -- Use code in Bagshui.Tooltips.lua to show info tooltip.
+ _G.GameTooltip.bagshuiData.infoTooltipAlwayShow = true
+ _G.GameTooltip:Show()
+ end
+
+ --- Right-click or Ctrl+Click on a bag button hides/shows the items in that bag.
+ --- Called from the bag button's OnClick (and from Bank's replacement OnClick).
+ --- Does nothing when something is on the cursor, so bag swapping and item placing still work.
+ ---@param bagSlotButton table Bag slot button that was clicked.
+ ---@return boolean handled # `true` if the click was used to toggle the bag, so the caller should stop.
+ function Inventory:ToggleBagHiddenOnClick(bagSlotButton)
+ local settingName = Bagshui.config.HideBagSettings[bagSlotButton.bagshuiData.bagNum]
+ if
+ settingName
+ and (_G.arg1 == "RightButton" or _G.IsControlKeyDown())
+ and not _G.CursorHasItem()
+ and not self.editMode
+ then
+ self.settings[settingName] = not self.settings[settingName]
+ self:UpdateBagBar()
+ return true
+ end
+ return false
+ end
+
+ function Inventory:GetBagSlotSwappableText(bagNum, noColors)
+ local text
+ local neededSlots, inventoriesChecked = self:GetAdditionalSlotsNeededToSwapBag(bagNum)
+ if neededSlots > 0 then
+ text = string.format(L.Bag_SlotsNeededToSwap, neededSlots)
+ if
+ -- Other inventory classes that can be used during swapping are configured in this property.
+ type(self.bagSwappingSupplementalStorage) == "table" and table.getn(self.bagSwappingSupplementalStorage) > 0
+ then
+ local supplementalClasses = ""
+ for _, inventoryClassName in ipairs(self.bagSwappingSupplementalStorage) do
+ if not inventoriesChecked[inventoryClassName] then
+ supplementalClasses = (string.len(supplementalClasses) > 0 and "/" or "")
+ .. supplementalClasses
+ .. L[inventoryClassName]
+ end
+ end
+ if string.len(supplementalClasses) > 0 then
+ text = text
+ .. BS_NEWLINE
+ .. (not noColors and LIGHTYELLOW_FONT_COLOR_CODE or "")
+ .. string.format(L.Bag_SupplementalSpaceAvailable, supplementalClasses, self.inventoryTypeLocalized)
+ .. (not noColors and FONT_COLOR_CODE_CLOSE or "")
+ end
+ end
+ end
+ return text
+ end
+end)
diff --git a/Components/Inventory.Ui.Group.lua b/Components/Inventory.Ui.Group.lua
new file mode 100644
index 0000000..6051e8c
--- /dev/null
+++ b/Components/Inventory.Ui.Group.lua
@@ -0,0 +1,489 @@
+-- Bagshui Inventory Prototype: Groups
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+ local InventoryUi = Bagshui.prototypes.InventoryUi
+
+ --#region Groups
+
+ --- Trigger Edit Mode colors and tooltips.
+ --- This is part of the Inventory class instead of a local function like the other
+ --- group script functions so that it's available to call from Inventory:ItemSlotAndGroupMouseOverCheck().
+ ---@param group table Group UI frame.
+ function Inventory:Group_OnEnter(group)
+ local this = group or _G.this
+ local ui = this.bagshuiData.ui
+ local inventory = ui.inventory
+
+ -- It feels weird to have colors flashing under the cursor and tooltips appearing when menus are open.
+ -- (The IsMouseEnabled() check is just a safeguard.)
+ if inventory.menus:IsMenuOpen() or not this:IsMouseEnabled() then
+ return
+ end
+
+ -- Actual mouseover status tracking, since MouseIsOver() returns true even
+ -- when other frames are in front of this one.
+ this.bagshuiData.mouseIsOver = true
+
+ -- Update colors and borders for mouseover.
+ ui:SetGroupColors(this, false)
+
+ -- Show tooltip in Edit Mode when the user hasn't picked up an object.
+ if inventory.editMode and not inventory:EditModeCursorHasItem() then
+ local groupId = this.bagshuiData.groupId
+ local groupName = string.len(inventory.groups[groupId].name or "") > 0 and inventory.groups[groupId].name
+ or L.UnnamedGroup
+
+ -- Prepare tooltips.
+ _G.GameTooltip:SetOwner(this, "ANCHOR_" .. BsUtil.FlipAnchorPoint(inventory.settings.windowAnchorXPoint))
+
+ -- Format the group name.
+ _G.GameTooltip:AddLine(Bagshui:FormatTooltipLine(groupName, string.format(L.Symbol_Colon, L.Group), true))
+
+ -- Group is hidden.
+ if inventory.groups[groupId] and inventory.groups[groupId].hide then
+ _G.GameTooltip:AddLine(GRAY_FONT_COLOR_CODE .. "«" .. L.Hidden .. "»" .. FONT_COLOR_CODE_CLOSE)
+ end
+
+ -- Build bulleted category list.
+ _G.GameTooltip:AddLine(string.format(L.Symbol_Colon, L.Categories), nil, nil, nil, true) -- "Categories:"
+ local categoryList = inventory.groups[groupId] and inventory.groups[groupId].categories
+ if type(categoryList) == "table" and table.getn(categoryList) > 0 then
+ -- Need to smash them all into one line to circumvent the 30 line tooltip limit.
+ local categories = ""
+ BsCategories:SortIdList(categoryList)
+ for _, categoryId in ipairs(categoryList) do
+ if BsCategories.list[categoryId] then
+ categories = categories
+ .. GRAY_FONT_COLOR_CODE
+ .. "• "
+ .. FONT_COLOR_CODE_CLOSE
+ .. HIGHLIGHT_FONT_COLOR_CODE
+ .. (BsCategories:GetName(categoryId) or tostring(categoryId))
+ .. FONT_COLOR_CODE_CLOSE
+ .. BS_NEWLINE
+ end
+ end
+ _G.GameTooltip:AddLine(BsUtil.Trim(categories), nil, nil, nil, true)
+ else
+ -- No categories assigned.
+ _G.GameTooltip:AddLine(GRAY_FONT_COLOR_CODE .. L.NoneAssigned .. FONT_COLOR_CODE_CLOSE, nil, nil, nil, true)
+ end
+
+ -- Put instructions in Bagshui info tooltip.
+ Bagshui:SetInfoTooltipPosition(this, true, true)
+ inventory:AddBagshuiInfoTooltipLine(L.Click, string.format(L.Symbol_Colon, string.format(L.Prefix_Move, L.Group)))
+ inventory:AddBagshuiInfoTooltipLine(
+ L.RightClick,
+ string.format(L.Symbol_Colon, string.format(L.Suffix_Menu, L.Group))
+ )
+
+ _G.GameTooltip:Show()
+ Bagshui:ShowInfoTooltip()
+ end
+ end
+
+ --- Reset colors, hide tooltips.
+ local function Group_OnLeave()
+ local this = _G.this
+ local ui = this.bagshuiData.ui
+
+ this.bagshuiData.mouseIsOver = false
+ Bagshui:HideTooltips()
+ if not this:IsMouseEnabled() then
+ return
+ end
+ ui:SetGroupColors(this, false)
+ end
+
+ --- Edit Mode interactions for groups.
+ local function Group_OnClick()
+ local this = _G.this
+ local ui = this.bagshuiData.ui
+ local inventory = ui.inventory
+
+ -- This isn't really necessary because group interactivity is disabled outside Edit Mode, but let's have a safeguard.
+ if not inventory.editMode then
+ return
+ end
+
+ -- Left/Right click.
+ if _G.arg1 == "LeftButton" or inventory:EditModeCursorHasItem() then
+ -- When a menu is open, just close it and don't do anything else.
+ -- Without this, you can accidentally pick up a group when trying to close a menu.
+ if inventory.menus:IsMenuOpen() then
+ Bagshui:CloseMenus()
+ this:GetScript("OnEnter")()
+ -- Tooltips and highlighting may need to reappear.
+ inventory:ItemSlotAndGroupMouseOverCheck()
+ return
+ end
+
+ -- Something is on the cursor, so let's take it off.
+ if inventory:EditModeCursorHasItem() then
+ if inventory.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.GROUP then
+ inventory:AssignGroupToLayout(
+ inventory.editState.cursorItem,
+ BS_INVENTORY_OBJECT_TYPE.GROUP,
+ BS_INVENTORY_LAYOUT_DIRECTION.COLUMN,
+ this.bagshuiData.rowNum,
+ this.bagshuiData.columnNum
+ )
+ elseif inventory.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.CATEGORY then
+ inventory:AssignCategoryToGroup(inventory.editState.cursorItem, this.bagshuiData.groupId)
+ elseif inventory.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.ITEM then
+ inventory:ShowItemCategoryAssignmentMenuForGroup(inventory.editState.cursorItem, this)
+ else
+ -- Failsafe
+ inventory:ClearEditModeCursor()
+ end
+ _G.PlaySound("INTERFACESOUND_CURSORDROPOBJECT")
+ else
+ -- Nothing on the cursor - pick up the group.
+ -- _G.PlaySound is handled by PickUpGroup().
+ this:GetScript("OnLeave")()
+ inventory:EditModePickUpGroup(this.bagshuiData.groupId)
+ ui:SetGroupColors(this, false)
+ end
+ elseif _G.arg1 == "RightButton" then
+ inventory.menus:OpenMenu("Group", this, nil, "cursor")
+ end
+ end
+
+ --- Highlight group when the mouse button is pressed to indicate that it will be dragged.
+ local function Group_OnMouseDown()
+ if not _G.this:IsEnabled() then
+ return
+ end
+ _G.this.bagshuiData.ui:SetGroupColors(_G.this, true)
+ end
+
+ --- Remove group highlight.
+ local function Group_OnMouseUp()
+ _G.this.bagshuiData.ui:SetGroupColors(_G.this, false)
+ end
+
+ --- Pick up the group.
+ local function Group_OnDragStart()
+ _G.this:Click()
+ end
+
+ ---Create a new group frame that holds item slot buttons.
+ ---@param groupNum number? When provided, only create the group if it doesn't exist.
+ function InventoryUi:CreateGroup(groupNum)
+ local ui = self
+
+ -- Declare reusable group creation function.
+ if not ui.CreateGroupIfNotExists then
+ -- Wrapper function.
+ local function Group_OnEnter()
+ ui.inventory:Group_OnEnter()
+ end
+
+ function ui.CreateGroupIfNotExists(elementNum)
+ -- Group frame is actually a button so it can have an OnClick event.
+ ui.frames.groups[elementNum] =
+ _G.CreateFrame("Button", ui:CreateElementName("Group" .. elementNum), ui.frames.main)
+ local group = ui.frames.groups[elementNum]
+ group.bagshuiData = {
+ type = BS_INVENTORY_OBJECT_TYPE.GROUP,
+ text = nil,
+ mouseIsOver = false,
+ groupId = nil,
+ rowNum = nil,
+ ColumNum = nil,
+ ui = ui,
+ }
+
+ -- It feels better when the group is a little bigger than the visual would indicate.
+ group:SetHitRectInsets(2, 2, 2, 2)
+
+ -- Set default appearance.
+ ui:SetFrameBackdrop(group)
+
+ -- Add label text in top left corner.
+ -- This has to be in a frame so the full label can be revealed on mouseover.
+ group.bagshuiData.labelFrame, group.bagshuiData.text = ui:CreateLabel(group, ui.frames.main)
+
+ -- Add hidden indicator.
+ group.bagshuiData.hidden = group:CreateTexture(nil, "OVERLAY")
+ group.bagshuiData.hidden:SetTexture(BsUtil.GetFullTexturePath("Icons\\Mask"))
+ group.bagshuiData.hidden:SetHeight(10)
+ group.bagshuiData.hidden:SetWidth(10)
+ -- Adjust position based on border inset.
+ local backdrop = group:GetBackdrop()
+ local inset = ((backdrop.insets and backdrop.insets.top) or 0) + 1
+ group.bagshuiData.hidden:SetPoint("TOPRIGHT", group, "TOPRIGHT", -inset, 2 - inset)
+ -- Will be displayed by `Inventory:SetGroupColors()` when appropriate.
+ group.bagshuiData.hidden:Hide()
+
+ -- Add group behaviors
+ group:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ group:RegisterForDrag("LeftButton")
+ group:SetScript("OnEnter", Group_OnEnter)
+ group:SetScript("OnLeave", Group_OnLeave)
+ group:SetScript("OnClick", Group_OnClick)
+ group:SetScript("OnMouseDown", Group_OnMouseDown)
+ group:SetScript("OnMouseUp", Group_OnMouseUp)
+ group:SetScript("OnDragStart", Group_OnDragStart)
+ end
+ end
+
+ -- Perform group creation.
+ ui:CreateIfNotExists(groupNum, ui.frames.groups, ui.CreateGroupIfNotExists)
+
+ -- Create a corresponding group move target.
+ -- We'll need at least ` + 2` to complete
+ -- the layout, but UpdateWindow() will take care of that.
+ ui:CreateGroupMoveTarget(groupNum)
+ end
+
+ --- Set the colors of a group / group move target.
+ ---@param uiGroup table Group or group move target frame (type will be determined via `uiGroup.bagshuiData.type`).
+ ---@param mouseDown boolean? Control whether the extra highlighting to indicate mouse button press should be applied.
+ function InventoryUi:SetGroupColors(uiGroup, mouseDown)
+ local inventory = self.inventory
+
+ -- Default values.
+
+ local backdropOpacity = 0.1
+ local backdropColor = BS_COLOR.WHITE
+ local borderOpacity = 0.5
+ local borderColor = BS_COLOR.GRAY
+ local textOpacity = 0.5 -- This will be the base opacity in Edit Mode.
+ local textColor = BS_COLOR.WHITE
+
+ -- Track whether this Group has been picked up.
+ local groupIsOnCursor = false
+ local highlightThisGroup = false
+
+ -- These are only applicable to groups, not group move targets.
+ if uiGroup.bagshuiData.type == BS_INVENTORY_OBJECT_TYPE.GROUP then
+ local groupId = uiGroup.bagshuiData.groupId
+
+ -- Group color priority:
+ -- 1. Per-group setting.
+ -- 2. Skin colors, if groupUseSkinColors is enabled and applicable for the setting.
+ -- 3. Default colors.
+
+ backdropColor = inventory.groups[groupId] and inventory.groups[groupId].background
+ or inventory.settings.groupUseSkinColors and BsSkin.skinBackgroundColor
+ or inventory.settings.groupBackgroundDefault
+ backdropOpacity = backdropColor[4]
+
+ borderColor = inventory.groups[groupId] and inventory.groups[groupId].border
+ or inventory.settings.groupUseSkinColors and BsSkin.skinBorderColor
+ or inventory.settings.groupBorderDefault
+ borderOpacity = borderColor[4]
+
+ -- Outline the group in yellow if it's on the cursor or highlighting is requested.
+ groupIsOnCursor = inventory.editState.cursorItem == groupId
+ highlightThisGroup = inventory.editModeGroupHighlight == groupId
+
+ textColor = inventory.groups[groupId] and inventory.groups[groupId].label or inventory.settings.groupLabelDefault
+
+ -- Use configured label opacity outside Edit Mode.
+ if not inventory.editMode then
+ textOpacity = textColor[4]
+ end
+
+ -- Display hidden indicator in Edit Mode.
+ if inventory.editMode and inventory.groups[groupId].hide then
+ uiGroup.bagshuiData.hidden:Show()
+ uiGroup.bagshuiData.hidden:SetAlpha(uiGroup.bagshuiData.mouseIsOver and 0.8 or 0.5)
+ else
+ uiGroup.bagshuiData.hidden:Hide()
+ end
+ end
+
+ -- Highlight the group.
+ if
+ -- In Edit Mode when the cursor has an item and one of the following is true:
+ -- - This is a group move target.
+ -- - This group is on the cursor.
+ -- - There is another group on the cursor and this group mis moused over.
+ (
+ inventory:EditModeCursorHasItem()
+ and (
+ uiGroup.bagshuiData.type == BS_INVENTORY_OBJECT_TYPE.GROUP_MOVE_TARGET
+ or groupIsOnCursor
+ or (inventory.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.GROUP and uiGroup.bagshuiData.mouseIsOver)
+ )
+ )
+ -- When this group is being targeted in Edit Mode via self.editModeGroupHighlight.
+ or highlightThisGroup
+ then
+ borderOpacity = (groupIsOnCursor or highlightThisGroup) and 0.8 or 0.6
+ borderColor = BS_COLOR.YELLOW
+ textOpacity = 0.8
+ textColor = BS_COLOR.YELLOW
+ end
+
+ -- Apply mouse button down/mouseover opacity changes.
+ if mouseDown then
+ backdropOpacity = math.max(backdropOpacity, 0.3)
+ borderOpacity = math.max(borderOpacity, 1)
+ textOpacity = 1
+ elseif uiGroup.bagshuiData.mouseIsOver then
+ backdropOpacity = math.max(backdropOpacity, 0.2)
+ borderOpacity = math.max(borderOpacity, 0.9)
+ textOpacity = 0.9
+ end
+
+ -- Apply special backdrop color for highlighted group.
+ if highlightThisGroup or groupIsOnCursor then
+ backdropColor = BS_COLOR.YELLOW
+ backdropOpacity = 0.25
+ end
+
+ -- Set colors.
+ uiGroup:SetBackdropColor(backdropColor[1], backdropColor[2], backdropColor[3], backdropOpacity)
+ uiGroup:SetBackdropBorderColor(borderColor[1], borderColor[2], borderColor[3], borderOpacity)
+ if uiGroup.bagshuiData.text then
+ uiGroup.bagshuiData.text:SetTextColor(textColor[1], textColor[2], textColor[3], textOpacity)
+ end
+ end
+
+ --- Control whether a group has interactivity enabled. Used when entering/exiting Edit Mode.
+ ---@param uiGroup table Group frame.
+ ---@param enabled boolean Whether the group should receive mouse events.
+ function InventoryUi:SetGroupInteractivityEnabled(uiGroup, enabled)
+ uiGroup:EnableMouse(enabled)
+ end
+
+ --#endregion Groups
+
+ --#region Groups Move Targets
+
+ --- Trigger color changes and tooltips.
+ local function GroupMoveTarget_OnEnter()
+ local this = _G.this
+ local ui = this.bagshuiData.ui
+ local inventory = ui.inventory
+
+ -- It feels weird to have colors flashing under the cursor and tooltips appearing when menus are open.
+ if inventory.menus:IsMenuOpen() then
+ return
+ end
+
+ this.bagshuiData.mouseIsOver = true
+ ui:SetGroupColors(this, false)
+
+ -- Don't show tooltip if there's an item held on the Edit Mode cursor.
+ if inventory:EditModeCursorHasItem() then
+ return
+ end
+
+ -- Place tooltip in bottom right of the screen.
+ _G.GameTooltip_SetDefaultAnchor(_G.GameTooltip, this)
+ _G.GameTooltip:ClearLines()
+ _G.GameTooltip:SetText(string.format(L.Prefix_New, L.Group)) -- "Add Group"
+ _G.GameTooltip:Show()
+ end
+
+ --- Hide tooltips and reset colors.
+ local function GroupMoveTarget_OnLeave()
+ local this = _G.this
+ local ui = this.bagshuiData.ui
+
+ this.bagshuiData.mouseIsOver = false
+ ui:SetGroupColors(this, false)
+ Bagshui:HideTooltips()
+ end
+
+ --- Create a new group and move the object on the cursor into it if applicable.
+ local function GroupMoveTarget_OnClick()
+ local this = _G.this
+ local inventory = this.bagshuiData.ui.inventory
+
+ this.bagshuiData.ui.inventory:AssignGroupToLayout(
+ inventory.editState.cursorItem,
+ inventory.editState.cursorItemType,
+ this.bagshuiData.insert,
+ this.bagshuiData.rowNum,
+ this.bagshuiData.columnNum
+ )
+ end
+
+ --- Highlight.
+ local function GroupMoveTarget_OnMouseDown()
+ _G.this.bagshuiData.ui:SetGroupColors(_G.this, true)
+ end
+
+ --- Un-highlight.
+ local function GroupMoveTarget_OnMouseUp()
+ _G.this.bagshuiData.ui:SetGroupColors(_G.this, false)
+ end
+
+ ---Create a new UI group move target button.
+ ---@param groupMoveTargetNum number? When provided, only create the group move target if it doesn't exist.
+ function InventoryUi:CreateGroupMoveTarget(groupMoveTargetNum)
+ local ui = self
+ local inventory = self.inventory
+
+ -- Declare reusable group move target creation function.
+ if not ui.CreateGroupMoveTargetIfNotExists then
+ function ui.CreateGroupMoveTargetIfNotExists(elementNum)
+ ui.frames.groupMoveTargets[elementNum] =
+ _G.CreateFrame("Frame", ui:CreateElementName("GroupMoveTarget" .. elementNum), ui.frames.main)
+
+ local button = _G.CreateFrame(
+ "Button",
+ ui:CreateElementName("GroupMoveTarget" .. elementNum),
+ ui.frames.groupMoveTargets[elementNum]
+ )
+
+ -- Share Bagshui table between outer frame and actual button
+ ui.frames.groupMoveTargets[elementNum].bagshuiData = {
+ type = BS_INVENTORY_OBJECT_TYPE.GROUP_MOVE_TARGET,
+ button = button,
+ mouseIsOver = false,
+ insert = nil,
+ rowNum = nil,
+ columnNum = nil,
+ ui = ui,
+ }
+ button.bagshuiData = ui.frames.groupMoveTargets[elementNum].bagshuiData
+
+ button:EnableMouse(true)
+ button:SetHitRectInsets(-4, -4, -4, -4)
+
+ ui:SetFrameBackdrop(
+ button,
+ "SOLID", -- borderStyle
+ nil, -- bgFile
+ nil, -- edgeFile
+ nil, -- tileSize
+ nil, -- edgeSize
+ 0 -- insets
+ )
+
+ local text = button:CreateFontString(nil, nil, "GameFontNormal")
+ button.bagshuiData.text = text
+ text:SetJustifyH("CENTER")
+ text:SetJustifyV("MIDDLE")
+ text:SetPoint("TOPLEFT", button, -10, 10)
+ text:SetPoint("BOTTOMRIGHT", button, 10, -12)
+ text:SetShadowColor(0, 0, 0, 0)
+ text:SetShadowOffset(0, 0)
+ text:SetFont(text:GetFont(), 13, "THICKOUTLINE, MONOCHROME")
+ text:SetText("+")
+
+ -- Set initial colors.
+ ui:SetGroupColors(button, false)
+
+ -- Assign behaviors.
+ button:SetScript("OnEnter", GroupMoveTarget_OnEnter)
+ button:SetScript("OnLeave", GroupMoveTarget_OnLeave)
+ button:SetScript("OnClick", GroupMoveTarget_OnClick)
+ button:SetScript("OnMouseDown", GroupMoveTarget_OnMouseDown)
+ button:SetScript("OnMouseUp", GroupMoveTarget_OnMouseUp)
+ end
+ end
+
+ -- Perform group move target creation.
+ ui:CreateIfNotExists(groupMoveTargetNum, ui.frames.groupMoveTargets, ui.CreateGroupMoveTargetIfNotExists)
+ end
+
+ --#endregion Group Move Targets
+end)
diff --git a/Components/Inventory.Ui.ItemButton.lua b/Components/Inventory.Ui.ItemButton.lua
new file mode 100644
index 0000000..b64ce87
--- /dev/null
+++ b/Components/Inventory.Ui.ItemButton.lua
@@ -0,0 +1,1273 @@
+-- Bagshui Inventory Prototype: Item Slot Buttons
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+ local InventoryUi = Bagshui.prototypes.InventoryUi
+
+ --- Special OnHide for Inventory item slot buttons to also hide tooltips and
+ --- the stack split frame.
+ local function InventoryItemButton_OnHide()
+ -- oldOnHide was captured during button creation in CreateInventoryItemSlotButton().
+ _G.this.bagshuiData.oldOnHide()
+
+ if BsInfoTooltip:IsOwned(_G.this) then
+ BsInfoTooltip:Hide()
+ end
+
+ -- If the stack splitting frame is visible and owned by this inventory window, hide it.
+ -- (this.hasStackSplit is managed by Blizzard's FrameXML code).
+ if _G.this.hasStackSplit == 1 then
+ _G.StackSplitFrame:Hide()
+ end
+ end
+
+ --- Create a new item slot button.
+ --- This is a separate function instead of overriding InventoryUi:CreateItemSlotButton()
+ --- because they need to take completely different parameters.
+ ---@param buttonNum number? When provided, only create the group if it doesn't exist.
+ function InventoryUi:CreateInventoryItemSlotButton(buttonNum)
+ local ui = self
+ local inventory = self.inventory
+
+ -- Wrapper functions so we can reference self.
+ if not inventory._itemSlotButton_ScriptWrapper_OnClick then
+ function inventory._itemSlotButton_ScriptWrapper_OnClick()
+ inventory:ItemButton_OnClick(_G.arg1)
+ end
+ function inventory._itemSlotButton_ScriptWrapper_OnDragStart()
+ inventory:ItemButton_OnClick("LeftButton", true)
+ end
+ function inventory._itemSlotButton_ScriptWrapper_OnReceiveDrag()
+ if _G.CursorHasItem() then
+ inventory:ItemButton_OnClick("LeftButton", true)
+ end
+ end
+ function inventory._itemSlotButton_ScriptWrapper_OnUpdate()
+ inventory:ItemButton_OnUpdate(_G.arg1)
+ end
+ function inventory._itemSlotButton_ScriptWrapper_OnEnter()
+ inventory:ItemButton_OnEnter()
+ end
+ function inventory._itemSlotButton_ScriptWrapper_OnLeave()
+ inventory:ItemButton_OnLeave()
+ end
+ function inventory._itemSlotButton_ScriptWrapper_OnHide()
+ inventory:ItemButton_OnEnter()
+ end
+ end
+
+ ui:CreateIfNotExists(buttonNum, ui.buttons.itemSlots, function(elementNum)
+ -- The button is created normally, then customized for Inventory use.
+ local slotButton = ui:CreateItemSlotButton("Item" .. elementNum, inventory.uiFrame)
+ ui.buttons.itemSlots[elementNum] = slotButton
+
+ -- Used by OnUpdate to manage real-time stock badge fading.
+ slotButton.bagshuiData.lastStockStateRefresh = _G.GetTime()
+
+ -- Consumed by InventoryItemButton_OnHide(). Must be captured before changing OnHide
+ -- to the Inventory-specific version.
+ slotButton.bagshuiData.oldOnHide = slotButton:GetScript("OnHide")
+
+ -- Inventory button events.
+ slotButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ slotButton:RegisterForDrag("LeftButton")
+
+ slotButton:SetScript("OnClick", inventory._itemSlotButton_ScriptWrapper_OnClick)
+ slotButton:SetScript("OnDragStart", inventory._itemSlotButton_ScriptWrapper_OnDragStart)
+ slotButton:SetScript("OnReceiveDrag", inventory._itemSlotButton_ScriptWrapper_OnReceiveDrag)
+ slotButton:SetScript("OnUpdate", inventory._itemSlotButton_ScriptWrapper_OnUpdate)
+ slotButton:SetScript("OnEnter", inventory._itemSlotButton_ScriptWrapper_OnEnter)
+ slotButton:SetScript("OnLeave", inventory._itemSlotButton_ScriptWrapper_OnLeave)
+ slotButton:SetScript("OnHide", InventoryItemButton_OnHide)
+
+ self:AddItemSlotButtonGetIdProxy(slotButton)
+ end)
+ end
+
+ --- ### Now it's time for fun with metatables!
+ ---
+ --- We have a little problem when it comes to ensuring maximum interoperability
+ --- and compatibility with other addons and it's called GetID(). See, the
+ --- Blizzard code in ContainerFrameItemButton_OnClick/OnEnter uses
+ --- :GetParent():GetID()and :GetID() to determine
+ --- bag and slot number, respectively. We need to call those functions
+ --- because they're hooked by other addons (which probably should be hooking
+ --- other things instead, but we can't do anything about that).
+ ---
+ --- Slot number isn't an issue -- we can easily and safely set that in
+ --- OnEnter (and OnClick). Bag number is a problem though, because slots
+ --- from different bags are going to share the same parent (Group) frame.
+ --- This means that if you click an item and then mouse over another before
+ --- the :GetParent():GetID() call is made, the wrong bag
+ --- number is likely to be returned.
+ ---
+ --- The bag number problem was first identified with stack splitting,
+ --- so a solution that only applied to stack splitting was put in place.
+ --- A more generalized approach is desirable though, and so here it is.
+ ---
+ --- The code below sets up two proxy frames that are outfitted with metatables
+ --- so that they act as if they are the item button, but with overridden
+ --- functions that give us what we need to ensure code outside our control
+ --- receives the correct bag and slot numbers.
+ ---
+ --- These proxies are picked up by Inventory:ItemButton_OnClick/OnEnter()
+ --- and set up so Blizzard's GetID() calls will be intercepted and redirected
+ --- to our custom functions.
+ ---
+ --- (Now it's entirely possible there's a simpler way to do this, and
+ --- if there is, I'd love to know it. There were past experiments with
+ --- intermediate parent frames, but frame levels between item buttons
+ --- and groups are slightly finicky and led to item buttons disappearing.
+ --- Another attempt was made along those lines in the disastrous 1.2.17
+ --- release. So having something that works is and doesn't seem to cause
+ --- any side effects is preferable.)
+ ---
+ --- ### All that to say...
+ --- What this function does is create a proxy button frame and assign it to the
+ --- `slotButton.bagshuiData.getIdProxy' property. It's then available for other
+ --- code to use and pass upstream (or is it downstream?) to Blizzard code.
+ ---@param slotButton table Button to create proxy frames for.
+ function InventoryUi:AddItemSlotButtonGetIdProxy(slotButton)
+ if not slotButton.bagshuiData then
+ slotButton.bagshuiData = {}
+ end
+
+ -- Proxy for `slotButton` that was created above.
+ -- Will override `GetID()` and `GetParent()`.
+ --
+ -- This is deliberately a plain table, NOT a frame made with CreateFrame(). Its `[0]` is pointed at
+ -- another frame's userdata (below), and EnumerateFrames() follows `[0]`: a real proxy frame would send
+ -- any addon that walks every frame back to the item button forever, freezing the game.
+ -- Frame methods only need `[0]`, so a table with `[0]` works the same.
+ local slotButtonProxy = {}
+ -- Store the proxy so it can be used by `Inventory:ItemButton_OnClick/OnEnter()`
+ slotButton.bagshuiData.getIdProxy = slotButtonProxy
+ -- Proxy for `slotButton`'s parent frame.
+ -- Will override `GetID()` only.
+ -- (Also a plain table, for the same reason as `slotButtonProxy`.)
+ local parentProxy = {}
+ -- Reference to `slotButton`'s actual parent frame, updated
+ -- by `getItemButtonParent()`.
+ local realParent
+
+ --- Proxy function for `slotButton:GetID()`.
+ --- Always returns the slot number of the item assigned to the button.
+ local function getItemSlot(_)
+ return slotButton.bagshuiData.slotNum
+ end
+
+ --- Proxy function for `slotButton:GetParent()`.
+ --- Stores the real parent frame so the metatable knows where to
+ --- redirect everything other than GetID().
+ local function getItemButtonParent(_)
+ realParent = slotButton:GetParent()
+ -- Update userdata property of proxy frame.
+ -- rawset() because the proxy has no `[0]` of its own and `__newindex` would redirect the write.
+ rawset(parentProxy, 0, realParent[0])
+ return parentProxy
+ end
+
+ --- Proxy function for `slotButton:GetParent():GetID()`.
+ --- Always returns the bag number of the item assigned to the button.
+ local function getItemBag(_)
+ return slotButton.bagshuiData.bagNum
+ end
+
+ -- Metatable that makes `slotButtonProxy` work.
+ local slotButtonMetatable = {
+ __index = function(_, key)
+ if key == "GetID" then
+ return getItemSlot
+ elseif key == "GetParent" then
+ return getItemButtonParent
+ else
+ return slotButton[key]
+ end
+ end,
+ __newindex = function(_, key, val)
+ slotButton[key] = val
+ end,
+ }
+ setmetatable(slotButtonProxy, slotButtonMetatable)
+ -- Redirect frame userdata to avoid errors.
+ -- Credit: https://www.wowinterface.com/forums/showthread.php?t=53928
+ -- rawset() because the proxy has no `[0]` of its own and `__newindex` would redirect the write.
+ rawset(slotButtonProxy, 0, slotButton[0])
+
+ -- Metatable that makes `parentProxy` work.
+ local parentMetatable = {
+ __index = function(_, key)
+ if key == "GetID" then
+ return getItemBag
+ else
+ return realParent[key]
+ end
+ end,
+ __newindex = function(_, key, val)
+ realParent[key] = val
+ end,
+ }
+ -- The userdata (table key 0) is updated in getItemButtonParent() since it changes.
+ setmetatable(parentProxy, parentMetatable)
+ end
+
+ --- OnEnter mostly handles tooltip stuff.
+ ---@param itemButton table? Item slot button widget.
+ function Inventory:ItemButton_OnEnter(itemButton)
+ itemButton = itemButton or _G.this
+
+ -- Cases when nothing should happen.
+ if
+ -- OnEnter still fires even if mouse events are disabled so we need to avoid doing anything when we're in that state.
+ not itemButton:IsMouseEnabled()
+ -- Don't pop up tooltips or do anything else when a menu is open.
+ or self.menus:IsMenuOpen()
+ then
+ return
+ end
+
+ local buttonInfo = itemButton.bagshuiData
+ local item = itemButton.bagshuiData.item or self.inventory[buttonInfo.bagNum][buttonInfo.slotNum]
+
+ -- Record that the mouse has moved over this button (used by OnUpdate to determine
+ -- whether the tooltip should be shown when the Edit Mode cursor puts down an item).
+ -- This is used instead of MouseIsOver() because that function returns true even
+ -- when the frame is behind other frames.
+ buttonInfo.mouseIsOver = true
+
+ -- Highlight all items belonging to a category on mouseover in Edit Mode,
+ -- but only if an object hasn't been picked up.
+ if self.editState.cursorItem == nil then
+ if _G.IsAltKeyDown() and item.id > 0 then
+ self.editState.highlightItem = item.id
+ self.editState.highlightCategory = nil
+ else
+ self.editState.highlightItem = nil
+ self.editState.highlightCategory = item.bagshuiCategoryId
+ end
+ end
+
+ -- Nothing further to do when there's an Edit Mode item on the cursor,
+ -- This can't be combined with the initial "return immediately" checks above
+ -- because we want to trigger Edit Mode highlighting.
+ if self:EditModeCursorHasItem() then
+ return
+ end
+
+ -- Turn off the Bagshui tooltip hint once Alt is held.
+ if not self.settings.hint_bagshuiTooltip and _G.IsAltKeyDown() then
+ self.settings.hint_bagshuiTooltip = true
+ end
+
+ -- Record current modifier key state (referenced by OnUpdate() to decide if it has changed).
+ -- Translating return values from 1/nil to true/false so the *KeyDown properties don't have
+ -- to be initialized to non-nil.
+ buttonInfo.altKeyDown = (_G.IsAltKeyDown() == 1)
+ buttonInfo.controlKeyDown = (_G.IsControlKeyDown() == 1)
+ buttonInfo.shiftKeyDown = (_G.IsShiftKeyDown() == 1)
+
+ -- In Edit Mode and/or "Bagshui Info" mode with Alt down, special behavior needs to be enabled:
+ -- - Tooltips get truncated at the first blank line so there's more room for the Bagshui tooltip (if enabled).
+ local truncateTooltip = (
+ (BS_INVENTORY_TRUNCATE_TOOLTIPS_EDIT_MODE and self.editMode)
+ or (BS_INVENTORY_TRUNCATE_TOOLTIPS_INFO_MODE and _G.IsAltKeyDown())
+ )
+ -- Empty slots deserve a tooltip too sometimes.
+ local displayTooltipForEmptySlots = (_G.IsAltKeyDown() or self.editMode)
+
+ -- When Alt is held in Edit Mode, swap from the Category tooltip to the Item tooltip.
+ local editModeShowItemTooltip = self.editMode and _G.IsAltKeyDown()
+
+ -- GameTooltip normally anchors to item frame, but in Edit Mode it attaches to the group frame.
+ local gameTooltipAnchorPoint = BsUtil.FlipAnchorPoint(self.settings.windowAnchorXPoint)
+ local tooltipAnchorFrame = self.editMode and itemButton:GetParent() or itemButton
+ local tooltipOffset = BsSkin.tooltipExtraOffset
+ _G.GameTooltip:ClearAllPoints()
+ _G.GameTooltip:SetOwner(
+ itemButton,
+ -- Using ANCHOR_PRESERVE here for Edit Mode instead of ANCHOR_NONE
+ -- so pfUI doesn't mess with the position.
+ "ANCHOR_" .. (self.editMode and "PRESERVE" or gameTooltipAnchorPoint),
+ -tooltipOffset,
+ tooltipOffset
+ )
+ if self.editMode then
+ _G.GameTooltip:SetPoint(
+ "BOTTOM" .. BsUtil.FlipAnchorPoint(gameTooltipAnchorPoint),
+ tooltipAnchorFrame,
+ "TOP" .. gameTooltipAnchorPoint,
+ -tooltipOffset,
+ tooltipOffset
+ )
+ end
+
+ -- When true, the class instance had a `itemSlotTooltipFunction` defined
+ -- and we need to reapply our preferred tooltip position.
+ local setTooltipPositionAgain = false
+
+ -- Populate GameTooltip with Item or Edit Mode information, as appropriate.
+
+ if not self.editMode or editModeShowItemTooltip then
+ -- Item tooltip outside Edit Mode, or if Alt is held in Edit Mode.
+
+ -- Empty slots have special handling, as usual.
+ if item.emptySlot == 1 then
+ -- This is an empty slot.
+
+ _G.GameTooltip:ClearLines()
+
+ -- Add name.
+ if (displayTooltipForEmptySlots or self.settings.stackEmptySlots) and item.name ~= nil then
+ _G.GameTooltip:AddLine(BsItemInfo:GetQualityColoredName(item), 1, 1, 1, true)
+ end
+
+ -- Empty slot stacking/unstacking instructions should only be added when appropriate.
+ if not self.editMode and self.settings.stackEmptySlots and not _G.CursorHasItem() then
+ _G.GameTooltip:AddLine(
+ string.format(
+ L.Tooltip_Inventory_ToggleEmptySlotStacking,
+ L.Click,
+ string.lower(self.expandEmptySlotStacks and L.Stack or L.Unstack)
+ ),
+ nil,
+ nil,
+ nil,
+ true
+ )
+ end
+ else
+ -- There's an item in this slot.
+
+ -- These will be populated with the return values of ItemInfo:LoadTooltip().
+ local hasCooldown, repairCost
+
+ -- Populate the tooltip.
+ -- There doesn't seem to be a way to get hasCooldown/repairCost without
+ -- loading the tooltip, so we have to call this even if there's an
+ -- itemSlotTooltipFunction that's going to load it again.
+ hasCooldown, repairCost = BsItemInfo:LoadTooltip(_G.GameTooltip, item, self)
+
+ -- This class instance has a custom tooltip loading function.
+ -- Spoiler: It's probably ContainerFrameItemButton_OnEnter().
+ -- (itemSlotTooltipFunction is the function name, not the actual function
+ -- see declaration in Inventory.lua for reasoning.)
+ if
+ self.online -- Definitely have to be online for this.
+ and not self.editMode -- Not calling in Edit Mode so we cut out some hooks and have a little more control.
+ and not truncateTooltip -- Also, we don't need extra stuff if we're just going to truncate anyway.
+ and self.itemSlotTooltipFunction
+ and type(_G[self.itemSlotTooltipFunction]) == "function"
+ then
+ -- Ugh, this is stupid, but the GFW tooltip hooking code doesn't
+ -- have `ContainerFrameItemButton_OnEnter()`'s button parameter,
+ -- so the Blizzard function ends up pulling the global `this`
+ -- instead of the passed parameter. When `Inventory:ItemSlotAndGroupMouseOverCheck()`
+ -- calls us, global `this` isn't the item slot button and
+ -- `ContainerFrameItemButton_OnEnter()`'s call to `GetRight()`
+ -- returns nil, throwing an error. We can work around their bug by
+ -- temporarily changing the value of global `this`, then restoring it.
+ local oldGlobalThis = _G.this
+ -- In addition to the above, we need to use our metatable'd proxy frame
+ -- (set up in InventoryUi:CreateInventoryItemSlotButton()) so the
+ -- GetID() and GetParent():GetID() functions will be overridden.
+ _G.this = itemButton.bagshuiData.getIdProxy or _G.this
+ _G[self.itemSlotTooltipFunction](itemButton.bagshuiData.getIdProxy)
+ _G.this = oldGlobalThis
+ -- `ContainerFrameItemButton_OnEnter()` will change the tooltip position
+ -- to something that ignores our custom offsets, so we need to fix that.
+ setTooltipPositionAgain = true
+ end
+
+ -- There are situations where we may want to truncate the tooltip
+ -- at the first empty line to leave more room for the Bagshui tooltip.
+ -- This code was originally written when information was being added
+ -- to GameTooltip instead of a separate tooltip, so it was very
+ -- necessary due to the tooltip line limit. Now that the Bagshui Info
+ -- tooltip is used instead, it's not as relevant, but it's still nice to
+ -- not have GameTooltip so giant when the Bagshui Info tooltip is shown.
+ -- The BS_INVENTORY_TRUNCATE_TOOLTIPS_*_MODE constants control whether
+ -- tooltip truncation is enabled.
+ if truncateTooltip then
+ local firstBlankLine, ttTextFrame, ttText, leftText, rightText
+ for ttLineNum = 1, _G.GameTooltip:NumLines() do
+ leftText = nil
+ rightText = nil
+ for i, lr in ipairs(BsHiddenTooltip.bagshuiData.textFieldsPerLine) do
+ ttTextFrame = _G["GameTooltipText" .. lr .. ttLineNum]
+ if ttTextFrame ~= nil and ttTextFrame:IsVisible() then
+ ttText = ttTextFrame:GetText()
+
+ if ttText ~= nil and not string.find(ttText, "^" .. BS_NEWLINE) then
+ if i == 1 then
+ leftText = BsUtil.Trim(ttText)
+ else
+ rightText = BsUtil.Trim(ttText)
+ end
+ end
+ end
+ end
+
+ -- We found a blank line and can truncate here..
+ if string.len(leftText or "") == 0 and string.len(rightText or "") == 0 then
+ firstBlankLine = ttLineNum
+ break
+ end
+ end
+
+ -- Hide lines.
+ if firstBlankLine then
+ for line = firstBlankLine, _G.GameTooltip:NumLines() do
+ for _, lr in ipairs(BsHiddenTooltip.bagshuiData.textFieldsPerLine) do
+ if _G["GameTooltipText" .. lr .. line] then
+ _G["GameTooltipText" .. lr .. line]:SetText(nil)
+ _G["GameTooltipText" .. lr .. line]:Hide()
+ end
+ end
+ end
+ _G.GameTooltipMoneyFrame:Hide()
+ end
+ end
+
+ -- Don't want to apply this stuff in Edit Mode since normal functionality isn't available.
+ if not self.editMode then
+ _G.ResetCursor()
+
+ -- Trigger ItemButton_OnUpdate to reload the tooltip after 1 second if there's an active cooldown.
+ -- (In other words, the 1 here represents number of seconds, NOT `true`).
+ buttonInfo.tooltipCooldownUpdate = hasCooldown and 1 or nil
+
+ -- Special cases.
+ if _G.InRepairMode() and (repairCost and repairCost > 0) then
+ -- Repair mode.
+ _G.GameTooltip:AddLine(_G.TEXT(_G.REPAIR_COST), 1, 1, 1)
+ _G.SetTooltipMoney(_G.GameTooltip, repairCost)
+ elseif self.ui:IsFrameVisible(_G.MerchantFrame) and _G.MerchantFrame.selectedTab ~= 2 then
+ -- At merchant (not on buyback tab).
+ _G.ShowContainerSellCursor(item.bagNum, item.slotNum)
+ elseif (item.readable == 1 or (_G.IsControlKeyDown() and not _G.IsAltKeyDown())) and item.emptySlot ~= 1 then
+ -- Readable items (books, etc) / Control key dressup.
+ _G.ShowInspectCursor()
+ end
+ end
+ end
+
+ -- Show Alt+Click Trade hint when appropriate.
+ if (self.settings.rightClickAttach or self.settings.altClickAttach) and self:IsInitiateTradeAllowed(item) then
+ _G.GameTooltip:AddLine(string.format(L.Tooltip_Inventory_TradeShortcut, L.AltClick, _G.UnitName("target")))
+ end
+ elseif self.editMode then
+ -- By default in Edit Mode, show category info as the primary tooltip.
+ -- This gets overridden when Alt is held.
+ _G.GameTooltip:AddLine(
+ Bagshui:FormatTooltipLine(
+ (BsCategories:GetName(item.bagshuiCategoryId) or L.Error_ItemCategoryUnknown),
+ string.format(L.Symbol_Colon, L.Category),
+ self.editMode
+ )
+ )
+ end
+
+ -- Bagshui Info Tooltip
+ if
+ self.editMode
+ or _G.IsAltKeyDown()
+ or (BsSettings.showInfoTooltipsWithoutAlt and not _G.IsShiftKeyDown())
+ or self.itemPendingSale
+ then
+ -- 4th parameter: Always place the info tooltip under GameTooltip in edit mode.
+ Bagshui:SetInfoTooltipPosition(itemButton, true, self.editMode)
+
+ if not self.editMode then
+ -- There's no real information to show for empty slot stacks.
+ if not buttonInfo.isEmptySlotStack then
+ -- Populate the tooltip.
+ if _G.IsControlKeyDown() and not self.itemPendingSale then
+ -- All item properties.
+ BsItemInfo:AddTooltipInfo(item, BsInfoTooltip)
+ else
+ -- Normal mode.
+
+ -- Sell protection confirmation instructions.
+ if self.ui:IsFrameVisible("MerchantFrame") and self.itemPendingSale == item then
+ self:AddBagshuiInfoTooltipLine(
+ BS_FONT_COLOR.UI_ORANGE .. L.Inventory_Item_SellProtection_ConfirmSale .. FONT_COLOR_CODE_CLOSE
+ )
+ self:AddBagshuiInfoTooltipLine(
+ LIGHTYELLOW_FONT_COLOR_CODE
+ .. string.format(L.Inventory_Item_SellProtection_Reason, self:GetItemSellProtectionReason(item))
+ .. FONT_COLOR_CODE_CLOSE
+ )
+ self:AddBagshuiInfoTooltipLine(
+ GRAY_FONT_COLOR_CODE .. L.Inventory_Item_SellProtection_OverrideHint .. FONT_COLOR_CODE_CLOSE
+ )
+ end
+
+ -- Inventory counts.
+ BsCatalog:AddTooltipInfo(item.itemString, BsInfoTooltip)
+
+ -- Only show more in-depth info when Alt is held, even if showInfoTooltipsWithoutAlt is on.
+ if _G.IsAltKeyDown() then
+ -- Active quest info.
+ if Bagshui.activeQuestItems[item.name] then
+ if BsInfoTooltip:NumLines() > 0 then
+ self:AddBagshuiInfoTooltipLine(" ")
+ end
+ self:AddBagshuiInfoTooltipLine(
+ tostring(Bagshui.activeQuestItems[item.name].obtained or "?")
+ .. "/"
+ .. tostring(Bagshui.activeQuestItems[item.name].needed or "?"),
+ string.format(L.Symbol_Colon, L.ItemPropFriendly_activeQuest)
+ )
+ end
+
+ -- Stock state.
+ if
+ (item.bagshuiDate or 0) > 0
+ and item.bagshuiStockState ~= nil
+ and item.bagshuiStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE
+ then
+ if BsInfoTooltip:NumLines() > 0 then
+ self:AddBagshuiInfoTooltipLine(" ")
+ end
+ self:AddBagshuiInfoTooltipLine(
+ L["Stock_" .. item.bagshuiStockState],
+ string.format(L.Symbol_Colon, L.StockState)
+ )
+ self:AddBagshuiInfoTooltipLine(
+ BsUtil.FormatTimeRemainingString(_G.time() - item.bagshuiDate),
+ string.format(L.Symbol_Colon, L.StockLastChange)
+ )
+ end
+
+ -- Assigned category and bag name.
+ if BsInfoTooltip:NumLines() > 0 then
+ self:AddBagshuiInfoTooltipLine(" ")
+ end
+ self:AddBagshuiInfoTooltipLine(
+ (BsCategories:GetName(item.bagshuiCategoryId) or L.Error_ItemCategoryUnknown),
+ string.format(L.Symbol_Colon, L.Category),
+ false
+ )
+ self:AddBagshuiInfoTooltipLine(
+ (self.containers[item.bagNum].name or L.Unknown),
+ string.format(L.Symbol_Colon, L.Bag)
+ )
+
+ -- Instructions.
+ self:AddBagshuiInfoTooltipLine(" ")
+ self:AddBagshuiInfoTooltipLine(
+ L.AltRightClick,
+ string.format(L.Symbol_Colon, string.format(L.Suffix_Menu, L.Item))
+ )
+ self:AddBagshuiInfoTooltipLine(L.HoldControlAlt, string.format(L.Symbol_Colon, L.MoreInformation))
+ end
+ end
+ end
+ else
+ -- Edit Mode.
+
+ if editModeShowItemTooltip then
+ -- Item instructions.
+
+ if item.id > 0 then
+ self:AddBagshuiInfoTooltipLine(
+ L.AltClick,
+ string.format(L.Symbol_Colon, string.format(L.Prefix_Move, L.Item))
+ )
+ end
+ self:AddBagshuiInfoTooltipLine(
+ L.RightClick,
+ string.format(L.Symbol_Colon, string.format(L.Suffix_Menu, L.CategorySlashItem))
+ )
+
+ self:AddBagshuiInfoTooltipLine(" ")
+
+ -- Category name.
+ self:AddBagshuiInfoTooltipLine(
+ (BsCategories:GetName(item.bagshuiCategoryId) or L.Error_ItemCategoryUnknown),
+ string.format(L.Symbol_Colon, L.Category),
+ self.editMode
+ )
+ -- Target Category: Release Alt.
+ self:AddBagshuiInfoTooltipLine(
+ L.ReleaseAlt,
+ string.format(L.Symbol_Colon, string.format(L.Prefix_Target, L.Category))
+ )
+ else
+ -- Category instructions.
+
+ -- Move Category: Click.
+ self:AddBagshuiInfoTooltipLine(
+ L.Click,
+ string.format(L.Symbol_Colon, string.format(L.Prefix_Move, L.Category))
+ )
+ -- Category/Item Menu: Right-Click.
+ self:AddBagshuiInfoTooltipLine(
+ L.RightClick,
+ string.format(L.Symbol_Colon, string.format(L.Suffix_Menu, L.CategorySlashItem))
+ )
+ self:AddBagshuiInfoTooltipLine(" ")
+
+ -- Item name.
+ if string.len(item.name or "") > 0 then
+ self:AddBagshuiInfoTooltipLine(
+ BsItemInfo:GetQualityColoredName(item),
+ string.format(L.Symbol_Colon, L.Item),
+ true
+ )
+ end
+
+ -- Target Item: Hold Alt.
+ self:AddBagshuiInfoTooltipLine(
+ L.HoldAlt,
+ string.format(L.Symbol_Colon, string.format(L.Prefix_Target, L.Item))
+ )
+ end
+ end
+ elseif not self.settings.hint_bagshuiTooltip then
+ -- Show tip about holding alt to display Bagshui info tooltip.
+ Bagshui:SetInfoTooltipPosition(itemButton, true)
+ self:AddBagshuiInfoTooltipLine(L.HoldAlt, string.format(L.Symbol_Colon, L.BagshuiTooltipIntro))
+ else
+ -- Clear the tooltip so we know not to display it.
+ BsInfoTooltip:ClearLines()
+ end
+
+ -- Normal tooltip.
+ if _G.GameTooltip:NumLines() > 0 or _G.GameTooltip:IsVisible() then
+ -- Quality color tooltip borders (currently a hidden setting).
+ if
+ self.settings.qualityColorTooltipBorders
+ and type(buttonInfo.qualityColor) == "table"
+ and item.quality > -1
+ and itemButton.bagshuiData.buttonComponents
+ and itemButton.bagshuiData.buttonComponents.border
+ then
+ _G.GameTooltip:SetBackdropBorderColor(
+ buttonInfo.qualityColor.r,
+ buttonInfo.qualityColor.g,
+ buttonInfo.qualityColor.b
+ )
+ end
+
+ -- Need to reapply customized tooltip position.
+ -- See setTooltipPositionAgain declaration for details.
+ if setTooltipPositionAgain then
+ _G.GameTooltip:ClearAllPoints()
+ _G.GameTooltip:SetPoint(
+ "BOTTOM" .. BsUtil.FlipAnchorPoint(gameTooltipAnchorPoint),
+ tooltipAnchorFrame,
+ "TOP" .. gameTooltipAnchorPoint,
+ -tooltipOffset,
+ tooltipOffset
+ )
+ end
+
+ _G.GameTooltip:Show()
+ else
+ _G.GameTooltip:Hide()
+ end
+
+ -- Bagshui info tooltip.
+ if BsInfoTooltip:NumLines() > 0 then
+ Bagshui:ShowInfoTooltip()
+ else
+ BsInfoTooltip:Hide()
+ end
+
+ -- If the mouse is obscuring the tooltip, move the tooltip to the other side of the item button.
+ local offset = 10
+ if
+ (
+ _G.MouseIsOver(_G.GameTooltip, offset, offset, offset, -offset)
+ or (BsInfoTooltip:IsVisible() and _G.MouseIsOver(BsInfoTooltip, offset, offset, offset, -offset))
+ ) and (_G.GameTooltip:GetNumPoints() or 0) > 0
+ then
+ local point, anchorTo, anchorToPoint, xOffset, yOffset = _G.GameTooltip:GetPoint(1)
+ _G.GameTooltip:ClearAllPoints()
+ _G.GameTooltip:SetPoint(
+ BsUtil.FlipAnchorPointComponent(point, 2),
+ anchorTo,
+ BsUtil.FlipAnchorPointComponent(anchorToPoint, 2),
+ -xOffset,
+ yOffset
+ )
+
+ if BsInfoTooltip:IsVisible() then
+ point, anchorTo, anchorToPoint, xOffset, yOffset = BsInfoTooltip:GetPoint(1)
+ BsInfoTooltip:ClearAllPoints()
+ BsInfoTooltip:SetPoint(
+ BsUtil.FlipAnchorPointComponent(point, 2),
+ anchorTo,
+ BsUtil.FlipAnchorPointComponent(anchorToPoint, 2),
+ -xOffset,
+ yOffset
+ )
+ end
+ end
+
+ -- Edit Mode highlighting.
+ self:EditModeWindowUpdate(nil, true)
+
+ -- Highlight bag this item is in.
+ self.hoveredItem = item
+ self:UpdateBagBar()
+ end
+
+ --- OnLeave: Reset everything and hide the tooltip.
+ function Inventory:ItemButton_OnLeave(itemButton)
+ itemButton = itemButton or _G.this
+
+ -- Clear Edit Mode category highlighting.
+ if self.editState.cursorItemType ~= BS_INVENTORY_OBJECT_TYPE.CATEGORY or self.editState.cursorItem == nil then
+ self.editState.highlightCategory = nil
+ end
+ if self.editState.cursorItemType ~= BS_INVENTORY_OBJECT_TYPE.ITEM or self.editState.cursorItem == nil then
+ self.editState.highlightItem = nil
+ end
+
+ -- Reset tracking properties.
+ itemButton.bagshuiData.tooltipCooldownUpdate = nil
+ itemButton.bagshuiData.altKeyDown = nil
+ itemButton.bagshuiData.controlKeyDown = nil
+ itemButton.bagshuiData.shiftKeyDown = nil
+
+ -- Record that the mouse has left this button (used by OnUpdate to determine
+ -- whether the tooltip should be shown when the Edit Mode cursor puts down an item).
+ -- This is used instead of MouseIsOver() because that function returns true
+ -- even when the frame is behind other frames.
+ itemButton.bagshuiData.mouseIsOver = false
+
+ -- Hide the main tooltip.
+ if _G.GameTooltip:IsOwned(itemButton) then
+ _G.GameTooltip:Hide()
+ -- Go back to the normal cursor unless the Edit Mode cursor is holding something.
+ if not BsCursorTooltip:IsVisible() then
+ _G.ResetCursor()
+ end
+ end
+
+ -- Hide our tooltip.
+ if BsInfoTooltip:IsOwned(itemButton) then
+ BsInfoTooltip:Hide()
+ end
+
+ -- Update Edit Mode colors.
+ self:EditModeWindowUpdate(nil, true)
+
+ -- Un-highlight bag this item is in.
+ self.hoveredItem = nil
+ self:UpdateBagBar()
+ end
+
+ -- Store reusable variable outside OnUpdate to keep the garbage collector happy.
+ local itemButton_OnUpdate_RefreshTooltip
+
+ -- The main goal of OnUpdate is to keep the tooltip up to date by calling OnEnter once
+ -- a second (if needed) so long as the mouse remains over the item slot. It also
+ -- does a couple of other things.
+ --
+ -- We need a fresh call to OnEnter when his itemButton owns the tooltip and one
+ -- of the following is true:
+ -- - The `bagshuiData.tooltipCooldownUpdate` property exists and it's been more than 1 second since it was set.
+ -- - The `bagshuiData.KeyDown` property doesn't match `IsKeyDown()`.
+ ---@param elapsed number? Time since the last OnUpdate call.
+ function Inventory:ItemButton_OnUpdate(elapsed)
+ -- Refresh stock state every 60 seconds so badges fade in almost real-time.
+ if
+ _G.this.bagshuiData
+ and _G.this.bagshuiData.item
+ and _G.this.bagshuiData.item.bagshuiStockState ~= BS_ITEM_STOCK_STATE.NO_CHANGE
+ and _G.GetTime() - _G.this.bagshuiData.lastStockStateRefresh > 60
+ and _G.this.bagshuiData.type == BS_UI_ITEM_BUTTON_TYPE.ITEM
+ then
+ self.ui:UpdateItemButtonStockState(_G.this)
+ self.ui:UpdateItemButtonColorsAndBadges(_G.this)
+ _G.this.bagshuiData.lastStockStateRefresh = _G.GetTime()
+ end
+
+ -- Edit Mode help - make tooltips disappear as soon as the cursor holds an item.
+ if
+ self.editMode
+ and _G.this.bagshuiData.mouseIsOver
+ and self:EditModeCursorHasItem()
+ and _G.GameTooltip:IsOwned(_G.this)
+ then
+ self:ItemButton_OnLeave()
+ return
+ end
+
+ -- Clear state tracking variables when the mouse isn't present.
+ if not _G.this.bagshuiData.mouseIsOver then
+ _G.this.bagshuiData.tooltipCooldownUpdate = nil
+ _G.this.bagshuiData.altKeyDown = nil
+ _G.this.bagshuiData.controlKeyDown = nil
+ _G.this.bagshuiData.shiftKeyDown = nil
+ return
+ end
+
+ -- Does the tooltip need to be updated?
+ itemButton_OnUpdate_RefreshTooltip = false
+
+ -- More Edit Mode help - display tooltips as soon as the cursor no longer holds an item.
+ if self.editMode and _G.this.bagshuiData.mouseIsOver and not _G.GameTooltip:IsOwned(_G.this) then
+ itemButton_OnUpdate_RefreshTooltip = true
+ end
+
+ -- Update cooldown info in tooltip.
+ if _G.this.bagshuiData.tooltipCooldownUpdate ~= nil then
+ -- tooltipCooldownUpdate is initially set to 1 by OnEnter when there's a cooldown.
+ -- Here we subtract the elapsed time in seconds, which will eventually go below 0
+ -- so long as the property isn't wiped by moving the mouse off this item.
+ _G.this.bagshuiData.tooltipCooldownUpdate = _G.this.bagshuiData.tooltipCooldownUpdate - elapsed
+
+ -- Don't proceed until it's been more than 1 second.
+ if _G.this.bagshuiData.tooltipCooldownUpdate < 0 then
+ itemButton_OnUpdate_RefreshTooltip = true
+ end
+ end
+
+ -- Show/hide tooltip when modifier key state changes.
+ -- The 1/nil to true/false translation was done in our ItemButton_OnEnter
+ -- for reasons explained there, so we need to mirror it here.
+ if
+ (_G.this.bagshuiData.altKeyDown ~= nil and _G.this.bagshuiData.altKeyDown ~= (_G.IsAltKeyDown() == 1))
+ or (_G.this.bagshuiData.controlKeyDown ~= nil and _G.this.bagshuiData.controlKeyDown ~= (_G.IsControlKeyDown() == 1))
+ or (_G.this.bagshuiData.shiftKeyDown ~= nil and _G.this.bagshuiData.shiftKeyDown ~= (_G.IsShiftKeyDown() == 1))
+ then
+ itemButton_OnUpdate_RefreshTooltip = true
+ end
+
+ -- Time to update the tooltip.
+ if itemButton_OnUpdate_RefreshTooltip then
+ self:ItemButton_OnEnter(_G.this)
+ end
+ end
+
+ --- Left and right click and all the modifier key combinations.
+ ---@param mouseButton string
+ ---@param isDrag number|nil|boolean
+ function Inventory:ItemButton_OnClick(mouseButton, isDrag)
+ local itemButton = _G.this
+
+ local buttonInfo = itemButton.bagshuiData
+ local item = self.inventory[buttonInfo.bagNum][buttonInfo.slotNum]
+
+ -- Nothing normal should happen in Edit Mode.
+ if self.editMode then
+ if mouseButton == "LeftButton" then
+ -- When a menu is open, just close it and don't do anything else.
+ -- Without this, you can accidentally pick up a group/category/item when trying to close a menu.
+ if self.menus:IsMenuOpen() then
+ Bagshui:CloseMenus()
+ -- Need to update state so that things know the cursor is present.
+ self:ItemButton_OnEnter()
+ return
+ end
+
+ if self:EditModeCursorHasItem() then
+ -- Something has been picked up. We can only take action here if it's a category.
+ if self.editState.cursorItemType == BS_INVENTORY_OBJECT_TYPE.CATEGORY then
+ self:AssignCategoryToGroup(self.editState.cursorItem, buttonInfo.groupId)
+ end
+ else
+ -- Nothing on the cursor, so pick up the Item/Category.
+ if _G.IsAltKeyDown() then
+ self:EditModePickUpItem(item)
+ else
+ self:EditModePickUpCategory(item.bagshuiCategoryId)
+ end
+ end
+ elseif mouseButton == "RightButton" then
+ -- Just open the item menu on right-click.
+ self:ClearEditModeCursor()
+ self.menus:OpenMenu("Item", item, nil, "cursor")
+ end
+ else
+ -- Normal processing (non-Edit Mode).
+
+ -- Make sure there's an item of some sort to work with (can be an empty slot representation).
+ if item then
+ -- Clear stock state when interacting with an item.
+ if self.settings.itemStockChangeClearOnInteract then
+ item.bagshuiDate = -1
+ end
+
+ -- Flip the expandEmptySlotStacks flag when clicking empty slots if stacking is enabled.
+ if
+ self.settings.stackEmptySlots
+ and not isDrag
+ and mouseButton == "LeftButton"
+ and not _G.CursorHasItem()
+ and item.emptySlot == 1
+ then
+ self.expandEmptySlotStacks = buttonInfo.isEmptySlotStack
+ self:ItemButton_OnLeave()
+ self:ForceUpdateWindow()
+ return
+ end
+
+ -- Can't do anything else offline.
+ if not self.online then
+ return
+ end
+
+ -- Reset pending item sale.
+ if self.itemPendingSale and (mouseButton == "LeftButton" or self.itemPendingSale ~= item) then
+ self:ClearItemPendingSale(itemButton)
+ return
+ end
+
+ -- Bag swapping.
+ if
+ Bagshui.cursorBagSlotNum
+ and BsItemInfo:IsContainer(item)
+ and self.inventoryIdsToContainerIds[Bagshui.cursorBagSlotNum]
+ then
+ self:SwapBag(item, self.inventoryIdsToContainerIds[Bagshui.cursorBagSlotNum])
+ return
+ end
+
+ -- Try to do something with the item. When that fails, go on to default actions.
+ local clickHandled = true
+ local callPickupContainerItemFromBagshuiPickupItem = false
+ if
+ -- Drag action.
+ -- Don't allow anything else to happen when a drag action has been initiated,
+ -- since this should always pick up the item, no matter what modifiers are pressed.
+ isDrag
+ then
+ -- Use the default left button handler but go directly to PickupContainerItem()
+ -- instead of proxying through ContainerFrameItemButton_OnClick() since we
+ -- absolutely know we want to pick up the item immediately.
+ mouseButton = "LeftButton"
+ clickHandled = false
+ callPickupContainerItemFromBagshuiPickupItem = true
+ elseif
+ -- Item information menu Alt+right-click blocks everything else.
+ mouseButton == "RightButton"
+ and _G.IsAltKeyDown()
+ and not _G.IsControlKeyDown()
+ and not _G.IsShiftKeyDown()
+ then
+ self.menus:OpenMenu("Item", item, nil, "cursor")
+ -- Makes sense to return here since there's no need for a window update.
+ return
+ elseif
+ -- Sell protection: Trigger confirmation prompt.
+ self.ui:IsFrameVisible("MerchantFrame")
+ and mouseButton == "RightButton"
+ and not _G.IsControlKeyDown()
+ and not (_G.IsControlKeyDown() and _G.IsAltKeyDown() and _G.IsShiftKeyDown())
+ and self:GetItemSellProtectionReason(item)
+ then
+ self.itemPendingSale = item
+ self.highlightItemsInContainerId = item.bagNum
+ self.highlightItemsContainerSlot = item.slotNum
+ self:ItemButton_OnEnter(itemButton)
+ self:UpdateItemSlotColors()
+ -- Don't let default actions happen because we don't want the tooltip hidden.
+ return
+ elseif
+ -- Sell protection: Sale confirmed.
+ self.ui:IsFrameVisible("MerchantFrame")
+ and (
+ -- Confirming the previously-selected item.
+(self.itemPendingSale == item and mouseButton == "RightButton" and _G.IsControlKeyDown())
+ -- All modifiers down to bypass sale protection.
+ or (_G.IsControlKeyDown() and _G.IsAltKeyDown() and _G.IsShiftKeyDown())
+ )
+ then
+ self:ContainerItemAction(item, "Use", false)
+ elseif
+ -- Blizzard Mail Attachments - Right-click/Alt+click.
+ self:IsItemClickActionAllowed(mouseButton, "InboxFrame", "SendMailFrame")
+ -- Doing two checks here for "Mail" addon in case another addon ends up replicating
+ -- the way it handles attachments.
+ and not _G.IsAddOnLoaded("Mail")
+ and _G.SendMailPackageButton:IsEnabled() == 1
+ then
+ -- Switch to the Send Mail tab and attach the item.
+ self:AttachItem(item, _G.MailFrameTab_OnClick, 2, _G.ClickSendMailItemButton)
+ elseif
+ -- "Mail" addon - Alt+click (it only provides right-click).
+ (mouseButton == "LeftButton" and _G.IsAltKeyDown())
+ and self.settings.altClickAttach
+ and self.ui:IsFrameVisible("MailFrame")
+ and _G.IsAddOnLoaded("Mail")
+ then
+ -- Pretend Alt isn't down so Mail's UseContainerItem will do the work.
+ self:ContainerItemAction(item, "Use", false)
+ elseif
+ -- CT_MailMod, Postal, and Postal Returned [Postal is descended from CT_MailMod] -
+ -- Right-click (they only provide Alt+click).
+ mouseButton == "RightButton"
+ and self.settings.rightClickAttach
+ and (
+ (_G.IsAddOnLoaded("CT_MailMod") and self.ui:IsFrameVisible("CT_MailFrame"))
+ or (
+ (
+ -- The Postal family.
+_G.IsAddOnLoaded("Postal")
+ or _G.IsAddOnLoaded("Postal Returned")
+ -- IsAddOnLoaded goes by TOC file name, and Postal Returned
+ -- can be loaded from Postal-Returned.toc as well
+ -- due to .
+ or _G.IsAddOnLoaded("Postal-Returned")
+ ) and self.ui:IsFrameVisible("PostalFrame")
+ )
+ )
+ then
+ -- Pretend Alt is down so their code will do the item attachment work.
+ self:ContainerItemAction(item, "Pickup", true)
+ elseif
+ -- "Old" Aux .
+ -- Unlike the newer aux, old Aux has Alt/Control/Shift+click handling
+ -- but does *not* do right-click. It also hooks ContainerFrameItemButton_OnClick()
+ -- instead of UseContainerItem() so it really is totally different.
+ -- Must come before Blizzard Auction House because the old
+ -- version doesn't have its own frame and uses the Blizz one.
+ _G.IsAddOnLoaded("aux-addon")
+ and _G.AuxVersion
+ and _G.Aux
+ and _G.Aux_ContainerFrameItemButton_OnClick
+ and self.ui:IsFrameVisible("AuctionFrame")
+ then
+ if mouseButton == "RightButton" and self.settings.rightClickAttach then
+ -- Make right-clicking behave like all other auction house right-clicks (send to Sell tab).
+ local oldGlobalThis = _G.this
+ local oldIsAltKeyDown = _G.IsAltKeyDown
+ _G.this = itemButton.bagshuiData.getIdProxy or _G.this -- Have to force the use of the item button proxy for GetID()/GetParent():GetID().
+ _G.IsAltKeyDown = BsUtil.ReturnTrue
+ _G.Aux_ContainerFrameItemButton_OnClick("LeftButton")
+ _G.IsAltKeyDown = oldIsAltKeyDown
+ _G.this = oldGlobalThis
+ else
+ -- Let things fall through the normal path so `Aux_ContainerFrameItemButton_OnClick()`
+ -- will handle Alt/Control/Shift+click.
+ clickHandled = false
+ end
+ elseif
+ -- Blizzard Auction House - Right-click/Alt+click.
+ self:IsItemClickActionAllowed(mouseButton, "AuctionFrame")
+ then
+ -- Switch to the Auctions tab and attach the item.
+ self:AttachItem(item, _G.AuctionFrameTab_OnClick, 3, _G.ClickAuctionSellItemButton)
+ elseif
+ -- aux - Alt+click (it only provides right-click).
+ (mouseButton == "LeftButton" and _G.IsAltKeyDown())
+ and self.settings.altClickAttach
+ and _G.IsAddOnLoaded("aux-addon")
+ and self.ui:IsFrameVisible("aux_frame")
+ then
+ -- Pretend Alt isn't down so aux's UseContainerItem will do the work.
+ self:ContainerItemAction(item, "Use", false)
+ elseif
+ -- Trade - Right-click/Alt+click.
+ self:IsItemClickActionAllowed(mouseButton, "TradeFrame")
+ then
+ local tradeSlot = _G.TradeFrame_GetAvailableSlot()
+ if tradeSlot then
+ self:AttachItem(item, nil, nil, _G.ClickTradeButton, tradeSlot)
+ end
+ elseif
+ -- Initiate Trade on Al+click when friendly unit is targeted.
+ (self.settings.rightClickAttach or self.settings.altClickAttach)
+ and mouseButton == "LeftButton"
+ and _G.IsAltKeyDown()
+ and self:IsInitiateTradeAllowed(item)
+ then
+ -- We've already hooked TradeFrame_OnShow so that once the Trade window opens,
+ -- Inventory:TradeQueuedItem() can add self.queuedTradeItem.
+ self.queuedTradeItem = item
+ _G.InitiateTrade("target")
+ else
+ -- No conditions were met, so go on to default actions.
+ clickHandled = false
+ end
+
+ -- Keeping this as a separate statement instead of putting it in the else
+ -- clause for the other actions above in case there's a situation where an
+ -- action might want to fall through to default behavior when it fails.
+ if not clickHandled then
+ -- We need to ensure use our metatable'd proxy frame (set up in
+ -- InventoryUi:CreateInventoryItemSlotButton()) is used so the
+ -- GetID() and GetParent():GetID() functions will be overridden.
+ -- The best way to guarantee this is to temporarily change the
+ -- meaning of global this while any code outside our control
+ -- is executed.
+ local oldGlobalThis = _G.this
+ _G.this = itemButton.bagshuiData.getIdProxy or _G.this
+
+ if mouseButton == "LeftButton" then
+ -- Normal left-click.
+ -- This will eventually become a call to ContainerFrameItemButton_OnClick(), which can handle:
+ -- - Control+click dress-up.
+ -- - Shift+click chat links.
+ -- - Shift+click stack splitting.
+ -- - Calling PickupContainerItem() if none of the above are met.
+ -- It also allows hooks to both ContainerFrameItemButton_OnClick() and PickupContainerItem() to work.
+ -- (See the declaration of Bagshui:PickupItem() for details about why it exists.)
+ Bagshui:PickupItem(item, self, itemButton, callPickupContainerItemFromBagshuiPickupItem)
+ else
+ -- Normal right-click.
+ -- ContainerFrameItemButton_OnClick() for right button will handle:
+ -- - Shift+right-click stack splitting at merchant.
+ -- - Otherwise calling UseContainerItem().
+ -- It also allows hooks to both ContainerFrameItemButton_OnClick() and UseContainerItem() to work.
+ _G.ContainerFrameItemButton_OnClick(mouseButton)
+ end
+ -- Restore global this.
+ _G.this = oldGlobalThis
+ end
+
+ -- Clear pending sale item (must happen before window update).
+ self:ClearItemPendingSale(nil, true)
+ -- Hide tooltip on click -- UpdateWindow() will call ItemSlotAndGroupMouseOverCheck(), which will show it again if needed.
+ self:ItemButton_OnLeave()
+ -- Something was clicked, so make sure the window is up to date.
+ self:ForceUpdateWindow()
+ end
+ end
+ end
+
+ --- Return the appropriate empty slot texture (generic or profession-specific).
+ ---@param emptySlot table Bagshui item table for the empty slot.
+ ---@return string texturePath
+ function Inventory:GetEmptySlotTexture(emptySlot)
+ local texture = BS_INVENTORY_EMPTY_SLOT_TEXTURE[L.Bag]
+ if self.containers[emptySlot.bagNum] then
+ texture = BS_INVENTORY_EMPTY_SLOT_TEXTURE[self.containers[emptySlot.bagNum].genericType]
+ end
+ return texture
+ end
+
+ --- Call `PickupContainerItem()` for the given item, forcing Alt to be up, and
+ --- optionally calling the specified function(s) before/after.
+ --- The cursor will be cleared afterwards if an item is present, since the whole
+ --- point of this function is to get an item attached. To pick up an item, use
+ --- Bagshui:PickupItem() instead.
+ ---@param item table Bagshui item.
+ ---@param beforePickupFunction function? Function to call before `PickupContainerItem()`.
+ ---@param beforePickupParam any? Parameter to pass to `beforePickupFunction()`.
+ ---@param afterPickupFunction function? Function to call after `PickupContainerItem()`.
+ ---@param afterPickupParam any? Parameter to pass to `afterPickupFunction()`.
+ function Inventory:AttachItem(item, beforePickupFunction, beforePickupParam, afterPickupFunction, afterPickupParam)
+ if type(beforePickupFunction) == "function" then
+ beforePickupFunction(beforePickupParam)
+ end
+
+ -- Ensure Alt is NOT down so addons like CT_MailMod, Postal, and Postal Returned that hook
+ -- PickupContainerItem and alter its behavior when Alt is down won't mess things up.
+ self:ContainerItemAction(item, "Pickup", false)
+
+ if type(afterPickupFunction) == "function" then
+ afterPickupFunction(afterPickupParam)
+ end
+
+ -- Put item back if our action wasn't successful or the item was swapped with another.
+ if _G.CursorHasItem() then
+ _G.ClearCursor()
+ end
+ end
+
+ --- Call `PickupContainerItem()` or `UseContainerItem()` for the given item while forcing
+ --- the Alt key to be in a specific state by temporarily overriding `IsAltKeyDown()`.
+ ---@param item table Bagshui item.
+ ---@param action string `"Pickup"` or `"Use"` as in `PickupContainerItem` or `UseContainerItem`.
+ ---@param altDown boolean? `true` for Alt down, `false` for Alt up.
+ function Inventory:ContainerItemAction(item, action, altDown)
+ local oldIsAltKeyDown = _G.IsAltKeyDown
+ _G.IsAltKeyDown = altDown and BsUtil.ReturnTrue or BsUtil.ReturnFalse
+ _G[(string.lower(action or "") == "use" and "UseContainerItem" or "PickupContainerItem")](item.bagNum, item.slotNum)
+ _G.IsAltKeyDown = oldIsAltKeyDown
+ end
+
+ --- Determine whether an Alt+click or right-click action is allowed.
+ --- If there's currently an item on the cursor, the answer will be no, regardless of anything else.
+ ---@param mouseButton string OnClick arg1.
+ ---@param frame1 string? Any of the frameN parameters must be visible for this to return `true`.
+ ---@param frame2 string?
+ ---@param frame3 string?
+ ---@param frame4 string?
+ ---@return boolean
+ function Inventory:IsItemClickActionAllowed(mouseButton, frame1, frame2, frame3, frame4)
+ return not _G.CursorHasItem()
+ and ((self.settings.altClickAttach and mouseButton == "LeftButton" and _G.IsAltKeyDown()) or (self.settings.rightClickAttach and mouseButton == "RightButton"))
+ and (
+ self.ui:IsFrameVisible(frame1)
+ or self.ui:IsFrameVisible(frame2)
+ or self.ui:IsFrameVisible(frame3)
+ or self.ui:IsFrameVisible(frame4)
+ )
+ end
+
+ --- Determine whether trade can be initiated with the targeted unit.
+ ---@param item table Bagshui item.
+ ---@return boolean
+ function Inventory:IsInitiateTradeAllowed(item)
+ return not _G.CursorHasItem()
+ -- Must be a valid item, not an empty slot.
+ and item
+ and item.emptySlot ~= 1
+ and item.itemString
+ and item.itemString ~= ""
+ -- Can't trade soulbound items.
+ and not string.find(item.tooltip, _G.ITEM_SOULBOUND)
+ -- No "action" frames visible.
+ and not self.ui:IsFrameVisible("TradeFrame")
+ and not self.ui:IsFrameVisible("AuctionFrame")
+ and not self.ui:IsFrameVisible("MailFrame")
+ -- Can't trade with yourself.
+ and not _G.UnitIsUnit("target", "player")
+ -- Friendly units only.
+ and _G.UnitExists("target")
+ and _G.UnitIsFriend("player", "target")
+ and _G.UnitIsPlayer("target")
+ -- Must be close enough.
+ and _G.CheckInteractDistance("target", 2)
+ end
+
+ --- Hook for trade frame so we can add the Alt+clicked item after it opens.
+ ---@param wowApiFunctionName string Hooked WoW API function that triggered this call.
+ function Inventory:TradeFrame_OnShow(wowApiFunctionName)
+ self.hooks:OriginalHook(wowApiFunctionName)
+ Bagshui:QueueClassCallback(self, self.TradeQueuedItem, 0.05)
+ end
+
+ --- Helper for TradeFrame_OnShow to try and ensure the item we want to Alt+click trade
+ --- actually gets traded, even if things are running slow.
+ ---@param attemptCount number How many passes we've made while trying to trade the item.
+ function Inventory:TradeQueuedItem(attemptCount)
+ attemptCount = attemptCount or 1
+
+ -- Reasons to stop:
+ if
+ -- Too many tries.
+ attemptCount > 30
+ -- Nothing has been queued to trade.
+ or not self.queuedTradeItem
+ -- There's an item on the cursor that ISN'T the one we're trying to put in the trade window.
+ or (_G.CursorHasItem() and (Bagshui.cursorItem ~= self.queuedTradeItem or Bagshui.cursorItem == nil))
+ -- First slot in the trade window has been filled.
+ or (_G.TradePlayerItem1ItemButton and _G.TradePlayerItem1ItemButton.hasItem)
+ -- Trade frame was closed.
+ or not self.ui:IsFrameVisible("TradeFrame")
+ then
+ self.queuedTradeItem = nil
+ return
+ end
+
+ -- We might have the item we want already on the cursor based on the checks above.
+ if not _G.CursorHasItem() then
+ -- Calling PickupContainerItem() directly since we don't need any of the things
+ -- Bagshui:PickupItem() provides (and calling ContainerFrameItemButton_OnClick()
+ -- will generate an error now since _G.this isn't the item button.)
+ self:ContainerItemAction(self.queuedTradeItem, "Pickup", false)
+ end
+ _G.ClickTradeButton(1)
+
+ -- Do another pass to make sure it worked.
+ Bagshui:QueueClassCallback(self, self.TradeQueuedItem, 0.05, nil, attemptCount + 1)
+ end
+end)
diff --git a/Components/Inventory.Ui.lua b/Components/Inventory.Ui.lua
new file mode 100644
index 0000000..1dfcf6c
--- /dev/null
+++ b/Components/Inventory.Ui.lua
@@ -0,0 +1,1647 @@
+-- Bagshui Inventory Prototype: UI
+--
+-- Creation of the main window frame and general Inventory-specific UI stuff
+
+Bagshui:AddComponent(function()
+ local Inventory = Bagshui.prototypes.Inventory
+
+ --- Set up everything related to the UI that needs to be done at startup.
+ --- This could probably be broken into multiple functions because it's a bit of a monstrosity at this point.
+ function Inventory:InitUi()
+ -- Instance of the `InventoryUi` class specific to this Inventory class instance.
+ -- `InventoryUi` is built up in the Inventory.Ui.*.lua files.
+ self.ui = Bagshui.prototypes.InventoryUi:New(self)
+ local ui = self.ui
+
+ -- Fill `self.toolbarAndMainMenuItems` so it can be iterated to build the toolbar and menus.
+ self:PopulateToolbarAndMainMenuItems()
+
+ -- Tables to store UI object references.
+ for _, name in ipairs({ "buttons", "frames", "tooltips", "text", "ordering" }) do
+ ui[name] = {}
+ end
+ local buttons = ui.buttons
+ buttons.bagSlots = {}
+ buttons.itemSlots = {}
+ buttons.toolbar = {}
+ local frames = ui.frames
+ frames.groups = {}
+ frames.groupMoveTargets = {}
+ local tooltips = ui.tooltips
+
+ -- Prepare the window.
+
+ self.uiFrame = ui:CreateWindowFrame("Frame")
+
+ -- Skins can give the inventory window a different backdrop than other windows.
+ if BsSkin.inventoryWindowBackdrop then
+ local backdrop = BsSkin.inventoryWindowBackdrop
+ self.uiFrame:SetBackdrop({
+ bgFile = backdrop.bgFile,
+ edgeFile = backdrop.edgeFile,
+ tile = true,
+ tileSize = backdrop.tileSize,
+ edgeSize = backdrop.edgeSize,
+ insets = { left = backdrop.insets, top = backdrop.insets, right = backdrop.insets, bottom = backdrop.insets },
+ })
+ end
+ local uiFrame = self.uiFrame
+
+ -- Keep the window on the screen so it can't be dragged out of reach.
+ if uiFrame.SetClampedToScreen then
+ uiFrame:SetClampedToScreen(true)
+ end
+
+ -- Start with the window in the correct position and hidden.
+ self:FixWindowPosition()
+ uiFrame:Hide()
+
+ -- Add scripts.
+
+ uiFrame.bagshuiData.lastDirtyCheck = _G.GetTime()
+ uiFrame:SetScript("OnUpdate", function()
+ -- Mark this window as dirty if any "child" windows are open.
+ if _G.GetTime() - _G.this.bagshuiData.lastDirtyCheck > 0.075 then
+ _G.this.bagshuiData.dirty = Bagshui:ChildWindowsVisible()
+ _G.this.bagshuiData.lastDirtyCheck = _G.GetTime()
+ end
+ end)
+
+ local oldOnShow = uiFrame:GetScript("OnShow")
+ uiFrame:SetScript("OnShow", function()
+ self:UiFrame_OnShow()
+ if oldOnShow then
+ oldOnShow()
+ end
+ end)
+ local oldOnHide = uiFrame:GetScript("OnHide")
+ uiFrame:SetScript("OnHide", function()
+ self:UiFrame_OnHide()
+ if oldOnHide then
+ oldOnHide()
+ end
+ end)
+
+ local function resetClick()
+ self.clickedOnce = nil
+ end
+
+ uiFrame:SetScript("OnMouseDown", function()
+ -- Clear a pending item sale.
+ if self.itemPendingSale then
+ self:ClearItemPendingSale()
+ end
+
+ if _G.arg1 == "LeftButton" then
+ -- Close non-Settings menus on left mouse down (Settings is closed OnMouseUp).
+ if not self.menus:IsMenuOpen("Settings") then
+ Bagshui:CloseMenus()
+ end
+
+ -- When the Hearthstone was picked up by dragging it from the
+ -- Hearthstone button, take it off the cursor.
+ if self.pickedUpHearthstoneFromButton and Bagshui:GetCursorItem() == self.hearthstoneItemRef then
+ _G.ClearCursor()
+ end
+ self.pickedUpHearthstoneFromButton = nil
+
+ -- Double-click actions.
+ if
+ self.settings.windowDoubleClickActions
+ and self.lastMouseDown
+ and _G.GetTime() - self.lastMouseDown < 0.5
+ and self.clickedOnce
+ then
+ if _G.IsAltKeyDown() then
+ -- Toggle window position lock.
+ self.settings.windowLocked = not self.settings.windowLocked
+ else
+ -- Toggle toolbars.
+ self.ignoreNextSettingChange = true
+ if
+ self.settings.showHeader == self.settings.showFooter
+ and self.headerWasHiddenOnDoubleClick == self.footerWasHiddenOnDoubleClick
+ then
+ self.headerWasHiddenOnDoubleClick = false
+ self.footerWasHiddenOnDoubleClick = false
+ self.settings.showHeader = not self.settings.showHeader
+ self.settings.showFooter = not self.settings.showFooter
+ else
+ if self.settings.showHeader == false then
+ self.headerWasHiddenOnDoubleClick = true
+ self.settings.showHeader = true
+ elseif self.headerWasHiddenOnDoubleClick then
+ self.headerWasHiddenOnDoubleClick = false
+ self.settings.showHeader = false
+ elseif self.settings.showFooter == false then
+ self.footerWasHiddenOnDoubleClick = true
+ self.settings.showFooter = true
+ elseif self.footerWasHiddenOnDoubleClick then
+ self.footerWasHiddenOnDoubleClick = false
+ self.settings.showFooter = false
+ end
+ end
+ self.ignoreNextSettingChange = true
+ -- self.settings.showHeader = not self.settings.showHeader
+ -- self.settings.showFooter = self.settings.showHeader
+ self:Update()
+ end
+ end
+ self.lastMouseDown = _G.GetTime()
+ self.clickedOnce = true
+ Bagshui:QueueEvent(resetClick, 0.25)
+ elseif _G.arg1 == "RightButton" then
+ -- Show right-click menu.
+ Bagshui:HideTooltips()
+ self.ui.tooltips.mini:Hide()
+ self.menus:OpenMenu("Main", nil, nil, "cursor")
+ end
+ end)
+
+ uiFrame:SetScript("OnMouseUp", function()
+ -- Only close Settings menu on mouse up so window can be dragged without closing it.
+ -- (All other menus are closed OnMouseDown).
+ if self.menus:IsMenuOpen("Settings") then
+ Bagshui:CloseMenus()
+ end
+ ui.frames.searchBox:ClearFocus()
+ self:ClearEditModeCursor()
+ self:EditModeWindowUpdate()
+ end)
+
+ -- Util.CreateWindowFrame() sets default handlers for these to make window dragging
+ -- worked, but Inventory windows need their own handlers due to docking.
+ uiFrame:SetScript("OnDragStart", function()
+ self:UiFrame_OnDragStart()
+ end)
+ uiFrame:SetScript("OnDragStop", function()
+ self:UiFrame_OnDragStop()
+ end)
+
+ uiFrame:SetScript("OnEnter", function()
+ -- If the cursor was holding an item in Edit Mode and left the window,
+ -- OnLeave would have hidden it, so bring it back.
+ self:ShowEditModeCursor()
+ -- Ensure the Bag Bar state is correct.
+ self:UpdateBagBar()
+ -- Prevent menus from closing.
+ _G.UIDropDownMenu_StopCounting(_G.DropDownList1)
+ end)
+
+ uiFrame:SetScript("OnLeave", function()
+ -- Edit Mode actions are Inventory class instance-specific, so it makes sense to
+ -- turn the cursor back to normal when it leaves the window.
+ if not _G.MouseIsOver(_G.this) then
+ self:HideEditModeCursor()
+ end
+ end)
+
+ -- Header and footer frames.
+
+ ui.frames.header = ui:CreateHeaderFooter(
+ "FrameHeader",
+ uiFrame,
+ "TOP",
+ BsSkin.inventoryHeaderFooterHeight,
+ BsSkin.inventoryWindowPadding,
+ -BsSkin.inventoryWindowPadding - BsSkin.inventoryHeaderFooterYAdjustment
+ )
+ local header = ui.frames.header
+
+ ui.frames.footer = ui:CreateHeaderFooter(
+ "FrameFooter",
+ uiFrame,
+ "BOTTOM",
+ BsSkin.inventoryHeaderFooterHeight,
+ BsSkin.inventoryWindowPadding,
+ BsSkin.inventoryWindowPadding + BsSkin.inventoryHeaderFooterYAdjustment
+ )
+ local footer = ui.frames.footer
+
+ footer:SetScript("OnLeave", function()
+ self:PrintDebug("footer OnLeave")
+ end)
+
+ -- Main content frame for groups and items.
+ ui.frames.main = _G.CreateFrame("Frame", ui:CreateElementName("FrameMain"), uiFrame)
+ local mainFrame = ui.frames.main
+ mainFrame:SetPoint("TOP", header, "BOTTOM", 0, 0)
+ mainFrame:SetPoint("BOTTOM", footer, "TOP", 0, 0)
+ mainFrame:SetPoint("LEFT", uiFrame, BsSkin.inventoryWindowPadding, 0)
+ mainFrame:SetPoint("RIGHT", uiFrame, -BsSkin.inventoryWindowPadding, 0)
+ mainFrame:EnableMouse()
+ mainFrame:RegisterForDrag("LeftButton")
+ -- Need to hack up the mouse methods so they're passed to the parent frame.
+ ui:PassMouseEventsThrough(mainFrame, uiFrame)
+
+ -- "No inventory data" message for when the cache has never been populated
+ -- (like when we haven't yet gotten Bank data).
+ ui.text.noData = mainFrame:CreateFontString(nil, nil, "GameFontHighlightSmall")
+ ui.text.noData:SetText(L.Inventory_NoData)
+ ui.text.noData:SetAllPoints(mainFrame)
+ ui.text.noData:SetAlpha(0.4)
+ ui.text.noData:Hide()
+
+ -- Toolbar.
+
+ -- Shared functions for spell cast buttons.
+
+ --- Return `true` if the current button does *not* have an associated spell
+ --- in its `bagshuiData.spellName` property.
+ ---@return boolean
+ local function inventory_SpellButton_NoSpell()
+ return (
+ not _G.this.bagshuiData
+ or not _G.this.bagshuiData.spellName
+ or not BsCharacter.spellNamesToIds[_G.this.bagshuiData.spellName]
+ )
+ end
+
+ --- Cast the configured spell on a button.
+ local function inventory_SpellButton_OnClick()
+ if inventory_SpellButton_NoSpell() then
+ return
+ end
+ _G.CastSpell(BsCharacter.spellNamesToIds[_G.this.bagshuiData.spellName], _G.BOOKTYPE_SPELL)
+ end
+
+ --- Show the spell tooltip associated with a button.
+ local function inventory_SpellButton_OnEnter()
+ if inventory_SpellButton_NoSpell() then
+ return
+ end
+ _G.GameTooltip:SetOwner(_G.this, "ANCHOR_" .. BsUtil.FlipAnchorPoint(self.settings.windowAnchorXPoint))
+ _G.GameTooltip:SetSpell(BsCharacter.spellNamesToIds[_G.this.bagshuiData.spellName], _G.BOOKTYPE_SPELL)
+ _G.GameTooltip:Show()
+ return false
+ end
+
+ --- Hide the tooltip.
+ local function inventory_SpellButton_OnLeave()
+ if _G.GameTooltip:IsOwned(_G.this) then
+ _G.GameTooltip:Hide()
+ end
+ return false
+ end
+
+ -- Permanent variables for OnUpdate to keep the garbage collector happy.
+ local spellButton_OnUpdate_cooldownStart, spellButton_OnUpdate_cooldownDuration, spellButton_OnUpdate_cooldownEnable
+
+ --- Manage the spell cooldown.
+ local function inventory_SpellButton_OnUpdate()
+ if inventory_SpellButton_NoSpell() then
+ return
+ end
+ spellButton_OnUpdate_cooldownStart, spellButton_OnUpdate_cooldownDuration, spellButton_OnUpdate_cooldownEnable =
+ _G.GetSpellCooldown(BsCharacter.spellNamesToIds[_G.this.bagshuiData.spellName], _G.BOOKTYPE_SPELL)
+ if spellButton_OnUpdate_cooldownEnable and (spellButton_OnUpdate_cooldownDuration or 0) > 0 then
+ self.ui:SetIconButtonCooldown(
+ _G.this,
+ spellButton_OnUpdate_cooldownStart,
+ spellButton_OnUpdate_cooldownDuration,
+ spellButton_OnUpdate_cooldownEnable
+ )
+ end
+ end
+
+ -- Top left icon.
+ buttons.toolbar.menu = ui:CreateIconButton({
+ name = "Menu",
+ parentFrame = header,
+ anchorPoint = "LEFT",
+ anchorToFrame = header,
+ anchorToPoint = "LEFT",
+ width = 16,
+ height = 16,
+ xOffset = 5,
+ onClick = function()
+ Bagshui:HideTooltips()
+ self.ui.tooltips.mini:Hide()
+ self.menus:OpenMenu("Main", nil, nil, _G.this, -5, -5)
+ end,
+ onEnter = function()
+ _G.this.bagshuiData.overrideTooltip = (
+ (not self.settings.showFooter and not self.alwaysShowUsageSummary)
+ or (self.settings.bagUsageDisplay ~= BS_INVENTORY_BAG_USAGE_DISPLAY.ALWAYS and not self.settings.showBagBar)
+ )
+
+ if _G.this.bagshuiData.overrideTooltip then
+ self:ShowUsageSummary(_G.this, false)
+ return false
+ end
+ end,
+ onLeave = function()
+ if _G.this.bagshuiData.overrideTooltip then
+ self:HideUsageSummary(_G.this, false)
+ return false
+ end
+ end,
+ texture = self.inventoryType,
+ tooltipTitle = L.Toolbar_Menu_TooltipTitle,
+ tooltipAnchorPoint = "",
+ })
+
+ -- Offline indicator.
+ buttons.toolbar.offline = ui:CreateIconButton({
+ name = "State",
+ parentFrame = header,
+ anchorPoint = "LEFT",
+ anchorToFrame = buttons.toolbar.menu,
+ anchorToPoint = "RIGHT",
+ xOffset = BsSkin.toolbarSpacing,
+ onClick = function()
+ -- Return to current character if viewing an alt.
+ if self.activeCharacterId ~= Bagshui.currentCharacterId then
+ self:SetCharacter(Bagshui.currentCharacterId)
+ return
+ end
+ end,
+ texture = "Offline",
+ tooltipTitle = string.format(_G.FRIENDS_LIST_OFFLINE_TEMPLATE, self.inventoryTypeLocalized),
+ noTooltipDelay = true,
+ noTooltipTextDelay = true,
+ vertexColor = BS_COLOR.RED,
+ })
+ buttons.toolbar.offline.bagshuiData.noRecolor = true
+ buttons.toolbar.offline:Hide()
+
+ -- Error indicator.
+ buttons.toolbar.error = ui:CreateIconButton({
+ name = "Error",
+ parentFrame = header,
+ anchorPoint = "LEFT",
+ anchorToFrame = buttons.toolbar.menu, -- Will be updated by UpdateToolbar().
+ anchorToPoint = "RIGHT",
+ xOffset = BsSkin.toolbarSpacing,
+ onClick = function()
+ BsLogWindow:Open()
+ end,
+ texture = "Exclamation",
+ tooltipTitle = L.Error,
+ tooltipDelay = 0,
+ vertexColor = BS_COLOR.UI_ORANGE,
+ })
+ buttons.toolbar.error.bagshuiData.noRecolor = true
+ buttons.toolbar.error:Hide()
+
+ -- Edit Mode indicator.
+ buttons.toolbar.editMode = ui:CreateIconButton({
+ name = "EditMode",
+ parentFrame = header,
+ anchorPoint = "LEFT",
+ anchorToFrame = buttons.toolbar.menu, -- Will be updated by UpdateToolbar().
+ anchorToPoint = "RIGHT",
+ xOffset = BsSkin.toolbarSpacing,
+ onClick = function()
+ -- Exit Edit Mode.
+ if self.editMode then
+ _G.PlaySound("igMainMenuOptionCheckBoxOff")
+ self:ToggleEditMode()
+ return
+ end
+ end,
+ texture = "Edit",
+ tooltipTitle = L.Toolbar_ExitEditMode,
+ })
+ buttons.toolbar.editMode:Hide()
+
+ -- Close button.
+ buttons.toolbar.close = ui:CreateCloseButton(
+ "ButtonClose",
+ header,
+ "RIGHT",
+ -6 + (BsSkin.closeButtonInventoryWindowXOffsetAdjustment or 0),
+ 0 + (BsSkin.closeButtonInventoryWindowYOffsetAdjustment or 0),
+ function()
+ self:Close()
+ end
+ )
+ buttons.toolbar.close.bagshuiData.noRecolor = true
+
+ local nextAnchor = buttons.toolbar.close
+ local nextOffset = -BsSkin.toolbarCloseButtonOffset
+
+ -- Catalog is a special case.
+ buttons.toolbar.catalog = ui:CreateIconButton({
+ name = "Catalog",
+ parentFrame = header,
+ anchorToFrame = nextAnchor,
+ onClick = function()
+ BsCatalog:Toggle()
+ end,
+ texture = "Catalog",
+ tooltipTitle = L.Toolbar_Catalog_TooltipTitle,
+ tooltipText = L.Toolbar_Catalog_TooltipText,
+ })
+ nextAnchor = buttons.toolbar.catalog
+ nextOffset = -BsSkin.toolbarSpacing
+
+ -- Right toolbar order, consumed by `Inventory:UpdateToolbarAnchoring()`
+ -- to manage anchoring based on what is visible.
+ self.ui.ordering.topRightToolbar = {
+ buttons.toolbar.close,
+ -BsSkin.toolbarCloseButtonOffset,
+ buttons.toolbar.catalog,
+ }
+
+ -- Inventory type buttons -- iterating backwards (true parameter) because buttons are added right to left.
+ for inventoryType, inventoryTypeLocalized in self:OtherInventoryTypesInToolbarIconOrder(true) do
+ local inventoryClass = Bagshui.components[inventoryType]
+ buttons.toolbar[inventoryType] = ui:CreateIconButton({
+ name = inventoryType,
+ parentFrame = header,
+ anchorToFrame = nextAnchor,
+ onClick = function()
+ inventoryClass:Toggle()
+ end,
+ texture = inventoryType,
+ tooltipTitle = inventoryTypeLocalized,
+ })
+ nextAnchor = buttons.toolbar[inventoryType]
+ nextOffset = -BsSkin.toolbarSpacing
+
+ table.insert(self.ui.ordering.topRightToolbar, buttons.toolbar[inventoryType])
+ end
+ table.insert(self.ui.ordering.topRightToolbar, -BsSkin.toolbarGroupSpacing)
+
+ -- Add remainder of toolbar buttons right to left.
+ nextOffset = -BsSkin.toolbarGroupSpacing
+ for i = table.getn(self.toolbarAndMainMenuItems), 1, -1 do
+ local item = self.toolbarAndMainMenuItems[i]
+ item.parentFrame = header
+ item.anchorToFrame = nextAnchor
+ item.tooltipSecondDelay = BS_TOOLTIP_DELAY_SECONDS.TOOLBAR_DEFAULT
+ item.xOffset = item.xOffset or nextOffset
+
+ local button = ui:CreateIconButton(item)
+
+ -- The toolbar button is stored in buttons.toolbar with the first letter
+ -- of its name lowercased (button.toolbar.showHide instead of buttons.toolbar.ShowHide).
+ -- (item.id is set in InventoryUi:PopulateToolbarAndMainMenuItems()).
+ buttons.toolbar[item.id] = button
+
+ nextAnchor = button
+
+ -- Store special spacing directives so they can be read by Inventory:UpdateToolbarAnchoring().
+ if item.xOffset and item.xOffset ~= nextOffset then
+ table.insert(self.ui.ordering.topRightToolbar, item.xOffset)
+ end
+ table.insert(self.ui.ordering.topRightToolbar, button)
+ end
+
+ -- Search box (hidden until Search button is clicked).
+ frames.searchBox = ui:CreateSearchBox(
+ "SearchBox",
+ header,
+ 85, -- Width
+ 18, -- Height
+
+ -- OnTextChanged - trigger search.
+ function()
+ self.searchText = _G.this.bagshuiData.searchText -- Set by autogenerated function in CreateSearchBox().
+ self:UpdateItemSlotColors()
+ if self.dockedInventory then
+ self.dockedInventory:UpdateItemSlotColors()
+ end
+ if not self.searchTextSetFromEvent then
+ Bagshui:RaiseEvent("BAGSHUI_INVENTORY_SEARCH", nil, self.inventoryType, self.searchText)
+ end
+ self.searchTextSetFromEvent = false
+ end,
+
+ -- OnEnterPressed -- open Catalog if a modifier is down.
+ function()
+ if self.searchText and (_G.IsAltKeyDown() or _G.IsShiftKeyDown() or _G.IsControlKeyDown()) then
+ self:SearchCatalog()
+ end
+ end,
+
+ -- OnIconClick -- open Catalog on right-click.
+ function()
+ if self.searchText and (_G.arg1 == "RightButton" or (_G.arg1 == "LeftButton" and _G.IsAltKeyDown())) then
+ self:SearchCatalog()
+ end
+ end
+ )
+ frames.searchBox:SetPoint("TOPRIGHT", buttons.toolbar.resort, -BsSkin.toolbarGroupSpacing - 12, 2)
+ frames.searchBox:Hide()
+
+ table.insert(self.ui.ordering.topRightToolbar, frames.searchBox)
+
+ -- Hide search box when empty.
+ local oldOnEditFocusLost = frames.searchBox:GetScript("OnEditFocusLost")
+ frames.searchBox:SetScript("OnEditFocusLost", function()
+ oldOnEditFocusLost()
+ if not self.searchText then
+ _G.PlaySound("igMainMenuOptionCheckBoxOff")
+ frames.searchBox:Hide()
+ buttons.toolbar.search:Show()
+ if self.temporarilyShowWindowHeader then
+ self.temporarilyShowWindowHeader = false
+ self:ForceUpdateWindow()
+ else
+ self:UpdateToolbar()
+ end
+ end
+ end)
+
+ -- Add tooltip text for search icon inside search box.
+ frames.searchBox.bagshuiData.searchIcon.bagshuiData.tooltipText =
+ string.format(L.Toolbar_Search_TooltipText, self.inventoryTypeLocalized)
+
+ -- Status text (can't be done sooner because it anchors to the search button).
+
+ ui.frames.status, ui.text.status = self.ui:CreateLabel(header)
+ ui.text.status:SetText(" ")
+ ui.frames.status:SetHeight(ui.text.status:GetHeight() + 2)
+ ui.frames.status:SetPoint("LEFT", buttons.toolbar.main, "RIGHT", BsSkin.toolbarSpacing, 0)
+ ui.frames.status:SetPoint("RIGHT", buttons.toolbar.search, "LEFT", -BsSkin.toolbarSpacing, 0)
+ ui.frames.status:Show()
+
+ -- The last thing anchored to the leftmost right toolbar icon is the status text.
+ table.insert(self.ui.ordering.topRightToolbar, ui.frames.status)
+
+ -- Left toolbar order, consumed by `Inventory:UpdateToolbarAnchoring()`
+ -- to manage anchoring based on what is visible.
+ self.ui.ordering.topLeftToolbar = {
+ buttons.toolbar.menu,
+ buttons.toolbar.offline,
+ buttons.toolbar.error,
+ buttons.toolbar.editMode,
+ ui.frames.status,
+ }
+
+ -- Pre-create item slot buttons. More will be created on the fly as needed.
+ -- This needs to happen before bag slot buttons because the size of item slot buttons dictates things about bag slot buttons
+ for buttonNum = 1, self.initialItemSlotButtons do
+ ui:CreateInventoryItemSlotButton(buttonNum)
+ end
+
+ -- Create groups we know need to exist. Like tem slot buttons, more will be created if required.
+ for _, row in pairs(self.layout) do
+ for _, group in pairs(row) do
+ ui:CreateGroup()
+ end
+ end
+
+ -- Ensure at least one group exists so its label text can be measured for sizing the mini
+ -- tooltip and for use during UpdateWindow().
+ if table.getn(frames.groups) == 0 then
+ ui:CreateGroup()
+ end
+
+ -- Bag bar.
+
+ frames.bagBar = _G.CreateFrame("Frame", ui:CreateElementName("BagBar"), footer)
+ frames.bagBar:SetPoint("LEFT", footer)
+ frames.bagBar:SetPoint("TOP", footer)
+ frames.bagBar:SetPoint("BOTTOM", footer)
+ frames.bagBar.bagshuiData = {
+ baseWidth = (buttons.itemSlots[1].bagshuiData.originalSizeAdjusted + BsSkin.bagBarSpacing)
+ * table.getn(self.containerIds),
+ }
+ frames.bagBar:SetWidth(frames.bagBar.bagshuiData.baseWidth)
+ frames.bagBar:EnableMouse(true)
+ frames.bagBar:RegisterForDrag("LeftButton")
+ ui:PassMouseEventsThrough(frames.bagBar, uiFrame)
+
+ -- Toggle available space display on mouseover.
+ frames.bagBar:SetScript("OnEnter", function()
+ frames.bagBar.bagshuiData.mouseIsOver = true
+ self:UpdateBagBar()
+ end)
+ frames.bagBar:SetScript("OnLeave", function()
+ frames.bagBar.bagshuiData.mouseIsOver = false
+ self:UpdateBagBar()
+ end)
+
+ -- Actual bag slot setup.
+ ui:CreateBagSlotButtons()
+
+ -- Shared functions for utilization displays.
+
+ local function usageSummary_OnEnter()
+ self:ShowUsageSummary(_G.this, true)
+ end
+ local function usageSummary_OnLeave()
+ self:HideUsageSummary(_G.this, true)
+ end
+
+ --- Create a space utilization display.
+ --- Frame and FontStrings will *not* have positions set.
+ ---@param parent table? Parent frame.
+ ---@param justifyH string? FontString justification.
+ ---@return table spaceSummary
+ local function CreateSpaceSummary(parent, justifyH)
+ local spaceSummary = _G.CreateFrame("Frame", nil, parent)
+ spaceSummary:SetHitRectInsets(
+ -(BsSkin.bagBarSpacing / 2),
+ -(BsSkin.bagBarSpacing / 2),
+ -(BsSkin.bagBarSpacing / 2),
+ -(BsSkin.bagBarSpacing / 2)
+ )
+ spaceSummary.bagshuiData = {
+ text = spaceSummary:CreateFontString(nil, nil, "NumberFontNormalSmall"),
+ subtext = spaceSummary:CreateFontString(nil, nil, "NumberFontNormalSmall"),
+ }
+ spaceSummary.bagshuiData.text:SetTextColor(1, 1, 1, 0.75)
+ spaceSummary.bagshuiData.text:SetShadowColor(0, 0, 0, 0.75)
+ spaceSummary.bagshuiData.text:SetShadowOffset(1, -1)
+ spaceSummary.bagshuiData.text:SetJustifyH(justifyH or "CENTER")
+ spaceSummary.bagshuiData.text:SetJustifyV("MIDDLE")
+ spaceSummary.bagshuiData.subtext:SetTextColor(1, 1, 1, 0.65)
+ spaceSummary.bagshuiData.subtext:SetShadowColor(0, 0, 0, 0.75)
+ spaceSummary.bagshuiData.subtext:SetShadowOffset(1, -1)
+ spaceSummary.bagshuiData.subtext:SetJustifyH(justifyH or "CENTER")
+ spaceSummary.bagshuiData.subtext:SetJustifyV("MIDDLE")
+ spaceSummary.bagshuiData.subtext:SetFont(spaceSummary.bagshuiData.subtext:GetFont(), 9)
+
+ -- Show individual bag type information on mouseover.
+ spaceSummary:EnableMouse(true)
+ spaceSummary:RegisterForDrag("LeftButton")
+ ui:PassMouseEventsThrough(spaceSummary, uiFrame, true)
+ spaceSummary:SetScript("OnEnter", usageSummary_OnEnter)
+ spaceSummary:SetScript("OnLeave", usageSummary_OnLeave)
+
+ return spaceSummary
+ end
+
+ -- This is the main utilization display that goes to the right of the bag bar.
+ frames.spaceSummary = CreateSpaceSummary(frames.bagBar, "CENTER")
+ frames.spaceSummary:SetPoint(
+ "LEFT",
+ buttons.bagSlots[table.getn(buttons.bagSlots)],
+ "RIGHT",
+ BsSkin.bagBarSpacing - 2,
+ 0
+ )
+ frames.spaceSummary:SetPoint("TOP", frames.bagBar)
+ frames.spaceSummary:SetPoint("BOTTOM", frames.bagBar)
+ frames.spaceSummary:SetWidth(50)
+ frames.spaceSummary.bagshuiData.text:SetPoint("BOTTOM", frames.spaceSummary, "CENTER", 0, -2)
+ frames.spaceSummary.bagshuiData.subtext:SetPoint("TOP", frames.spaceSummary, "CENTER", 0, -3)
+
+ -- Create two "mini" utilization displays to use when the bag bar is hidden.
+ -- One is for the bottom left and the other for the top left when the bottom toolbar is hidden.
+ -- Reusing just one would be nice but this is more expedient.
+ for _, position in ipairs({ "Bottom", "Top" }) do
+ local name = "miniSpaceSummary" .. position
+ frames[name] = CreateSpaceSummary(footer, "LEFT")
+ frames[name]:SetPoint("LEFT", footer, "LEFT", BsSkin.toolbarSpacing, 0)
+ frames[name]:SetWidth(100)
+ frames[name]:SetHeight(18)
+ frames[name].bagshuiData.text:SetPoint("LEFT", frames[name], "LEFT", 0, 0)
+ frames[name].bagshuiData.subtext:SetPoint("LEFT", frames[name].bagshuiData.text, "RIGHT", 1, 0.5)
+ end
+ -- Make the top one conditionally part of the top left toolbar.
+ frames.miniSpaceSummaryTop:SetParent(header)
+ frames.miniSpaceSummaryTop:ClearAllPoints()
+ frames.miniSpaceSummaryTop.bagshuiData.text:SetPoint("LEFT", frames.miniSpaceSummaryTop, "LEFT", 0, -1)
+ table.insert(self.ui.ordering.topLeftToolbar, 2, frames.miniSpaceSummaryTop)
+
+ -- Bottom right toolbar anchor.
+ frames.bottomRightToolbarAnchor = _G.CreateFrame("Frame", nil, footer)
+ frames.bottomRightToolbarAnchor:SetHeight(25)
+ frames.bottomRightToolbarAnchor:SetWidth(1)
+ frames.bottomRightToolbarAnchor:SetPoint("RIGHT", footer, "RIGHT", 5, 0)
+
+ -- Money frame.
+
+ local moneyFrameName = ui:CreateElementName("Money")
+ frames.money = _G.CreateFrame("Frame", ui:CreateElementName("Money"), footer, "SmallMoneyFrameTemplate")
+ frames.money.bagshuiData = {
+ name = moneyFrameName,
+ texts = {
+ gold = _G[moneyFrameName .. "GoldButtonText"],
+ silver = _G[moneyFrameName .. "SilverButtonText"],
+ copper = _G[moneyFrameName .. "CopperButtonText"],
+ },
+ autoLayoutXOffset = 12,
+ }
+ frames.money:SetWidth(22)
+ frames.money:SetHeight(25)
+ frames.money:SetPoint("RIGHT", 8, 0)
+
+ -- Need custom OnEnter/OnLeave to show tooltip with all characters' money.
+
+ frames.money:EnableMouse(true)
+
+ frames.money:SetScript("OnEnter", function()
+ if self.editMode then
+ return
+ end
+ _G.GameTooltip:SetOwner(frames.money, "ANCHOR_TOPRIGHT", -8, 0)
+ BsCatalog:AddTooltipInfo(BS_CATALOG_LOCATIONS.MONEY, _G.GameTooltip)
+ _G.GameTooltip:Show()
+ end)
+
+ frames.money:SetScript("OnLeave", function()
+ if _G.GameTooltip:IsOwned(frames.money) then
+ _G.GameTooltip:Hide()
+ end
+ end)
+
+ local function moneyFrameOnEnter()
+ frames.money:GetScript("OnEnter")()
+ end
+ local function moneyFrameOnLeave()
+ frames.money:GetScript("OnLeave")()
+ end
+
+ for _, child in ipairs({ frames.money:GetChildren() }) do
+ if child.HasScript and child:HasScript("OnEnter") then
+ child:SetScript("OnEnter", moneyFrameOnEnter)
+ child:SetScript("OnLeave", moneyFrameOnLeave)
+ end
+ end
+
+ -- Hearthstone button.
+
+ buttons.toolbar.hearthstone = ui:CreateIconButton({
+ name = "Hearthstone",
+ parentFrame = footer,
+ anchorPoint = "RIGHT",
+ anchorToFrame = frames.money,
+ anchorToPoint = "LEFT",
+ disable = false,
+ onClick = function()
+ if Bagshui:GetCursorItem() == self.hearthstoneItemRef then
+ _G.ClearCursor()
+ return
+ end
+ if self.hearthstoneItemRef then
+ _G.UseContainerItem(self.hearthstoneItemRef.bagNum, self.hearthstoneItemRef.slotNum, true)
+ else
+ Bagshui:ShowAndLogErrorMessage(L.Error_HearthstoneNotFound)
+ end
+ end,
+ texture = "Hearthstone",
+ onEnter = function()
+ if self.hearthstoneItemRef then
+ _G.this.bagshuiData.bagNum = self.hearthstoneItemRef.bagNum
+ _G.this.bagshuiData.slotNum = self.hearthstoneItemRef.slotNum
+ self:ItemButton_OnEnter()
+ end
+ end,
+ onLeave = function()
+ self:ItemButton_OnLeave()
+ end,
+ onUpdate = function()
+ self:ItemButton_OnUpdate(_G.arg1)
+ end,
+ })
+ -- Make the Hearthstone toolbar button compatible with our `ContainerFrameItemButton_` hackery.
+ ui:AddItemSlotButtonGetIdProxy(buttons.toolbar.hearthstone)
+
+ -- Allow picking up the Hearthstone from the button.
+ buttons.toolbar.hearthstone:RegisterForDrag("LeftButton")
+ buttons.toolbar.hearthstone:SetScript("OnDragStart", function()
+ local hearthstone = self.hearthstoneItemRef
+ if hearthstone then
+ Bagshui:PickupItem(hearthstone, self, nil, true)
+ end
+ self.pickedUpHearthstoneFromButton = true
+ end)
+
+ -- Even though we always create the hearthstone button, it may not be enabled
+ -- for this class instance.
+ if not self.hearthButton then
+ buttons.toolbar.hearthstone:Hide()
+ end
+
+ -- Clam (open container) button.
+ buttons.toolbar.clam = ui:CreateIconButton({
+ name = "Clam",
+ parentFrame = footer,
+ anchorPoint = "RIGHT",
+ anchorToFrame = buttons.toolbar.hearthstone,
+ anchorToPoint = "LEFT",
+ disable = false,
+ texture = "Clam",
+ tooltipTitle = L.OpenContainer,
+ onClick = function()
+ if self.nextOpenableItemBagNum and self.nextOpenableItemSlotNum then
+ _G.UseContainerItem(self.nextOpenableItemBagNum, self.nextOpenableItemSlotNum)
+ end
+ end,
+ onEnter = function()
+ -- Actual work will be handled in OnUpdate.
+ _G.this.bagshuiData.mouseIsOver = true
+ end,
+ onLeave = function()
+ _G.this.bagshuiData.mouseIsOver = false
+ self.highlightItemsInContainerId = nil
+ self.highlightItemsContainerSlot = nil
+ self:UpdateItemSlotColors()
+ if self.nextOpenableItemSlotButton then
+ self:ItemButton_OnLeave(self.nextOpenableItemSlotButton)
+ elseif self.lastHighlightedOpenableButton then
+ self:ItemButton_OnLeave(self.lastHighlightedOpenableButton)
+ end
+ self.lastHighlightedOpenableButton = nil
+ end,
+ onUpdate = function()
+ if not _G.this.bagshuiData.mouseIsOver then
+ -- We need to essentially fake an OnLeave event when the
+ -- last container is opened because the normal OnLeave
+ -- won't fire, which leads to the light highlighted item
+ -- slot button thinking it's still moused over.
+ if _G.this.bagshuiData.wasUpdated then
+ _G.this.bagshuiData.wasUpdated = false
+ end
+ return
+ end
+ self:HighlightNextOpenable()
+ _G.this.bagshuiData.wasUpdated = true
+ end,
+ })
+
+ -- Disenchant button.
+ buttons.toolbar.disenchant = ui:CreateIconButton({
+ name = "Disenchant",
+ parentFrame = footer,
+ anchorPoint = "RIGHT",
+ anchorToFrame = buttons.toolbar.clam,
+ anchorToPoint = "LEFT",
+ disable = false,
+ texture = "Disenchant",
+ onClick = inventory_SpellButton_OnClick,
+ onEnter = inventory_SpellButton_OnEnter,
+ onLeave = inventory_SpellButton_OnLeave,
+ onUpdate = inventory_SpellButton_OnUpdate,
+ })
+ buttons.toolbar.disenchant.bagshuiData.spellName = L.Spell_Disenchant
+
+ -- Pick Lock button.
+ buttons.toolbar.pickLock = ui:CreateIconButton({
+ name = "PickLock",
+ parentFrame = footer,
+ anchorPoint = "RIGHT",
+ anchorToFrame = buttons.toolbar.disenchant,
+ anchorToPoint = "LEFT",
+ disable = false,
+ texture = "PickLock",
+ onClick = inventory_SpellButton_OnClick,
+ onEnter = inventory_SpellButton_OnEnter,
+ onLeave = inventory_SpellButton_OnLeave,
+ onUpdate = inventory_SpellButton_OnUpdate,
+ })
+ buttons.toolbar.pickLock.bagshuiData.spellName = L.Spell_PickLock
+
+ -- Bottom right toolbar order, consumed by `Inventory:UpdateToolbarAnchoring()`
+ -- to manage anchoring based on what is visible.
+ self.ui.ordering.bottomRightToolbar = {
+ frames.bottomRightToolbarAnchor,
+ frames.money,
+ -BsSkin.toolbarGroupSpacing,
+ buttons.toolbar.hearthstone,
+ -BsSkin.toolbarGroupSpacing,
+ buttons.toolbar.clam,
+ buttons.toolbar.pickLock,
+ buttons.toolbar.disenchant,
+ }
+
+ -- Tooltips.
+
+ -- Mini is used to display full group label names on mouseover and for toolbar tooltips.
+ -- It's scaled based on the first group's label text, so that needs to be created first.
+ -- There is a mini tooltip per Inventory class instance because it is sized based on the
+ -- group label text.
+ tooltips.mini = Bagshui:CreateTooltip("MiniTooltip")
+ local miniTooltipName = tooltips.mini.bagshuiData.name -- We'll need to know the name in a bit to grab the TextLeft1 font string.
+
+ -- Scale the Mini tooltip down to match the size of the group label text (as close as possible).
+ -- This is being done in a loop because it's the only way I could figure to get an accurate result.
+ local miniTooltipTargetFontSize = ui.frames.groups[1].bagshuiData.text:GetHeight()
+ local miniTooltipScaleStep = 0.03
+ local miniTooltipScale = 1 + miniTooltipScaleStep -- Start higher than 1 because the next step is to subtract
+ local miniTooltipActualFontSize
+ -- Tooltip needs to have and owner and content to measure.
+ tooltips.mini:SetOwner(uiFrame, "ANCHOR_PRESERVE")
+ tooltips.mini:SetText(" ")
+ repeat
+ -- Set the scale, measure the result, see if it's small enough yet.
+ miniTooltipScale = miniTooltipScale - miniTooltipScaleStep
+ tooltips.mini:SetScale(miniTooltipScale)
+ tooltips.mini:Show()
+ miniTooltipActualFontSize = _G[miniTooltipName .. "TextLeft1"]:GetHeight() * miniTooltipScale
+ until miniTooltipActualFontSize <= miniTooltipTargetFontSize or miniTooltipScale <= 0.5
+ tooltips.mini:SetScale(miniTooltipScale - miniTooltipScaleStep)
+ -- Prepare for later use.
+ tooltips.mini:Hide()
+ end
+
+ --- Creates the list of items stored in `self.toolbarAndMainMenuItems`, which is
+ --- used to build both the main window toolbar and the main menu Actions list.
+ function Inventory:PopulateToolbarAndMainMenuItems()
+ -- Notes:
+ -- - Textures are assumed to be in the Icons folder.
+ -- - Items are in top-to-bottom menu order and left-to-right toolbar order.
+ -- - onClick is the toolbar button click function.
+ -- - Unless func is specified, the menu item will click the toolbar button.
+
+ self.toolbarAndMainMenuItems = {
+
+ {
+ -- Changing this name property will break several things, including
+ -- the `onClick` function below and `Inventory:Search()`.
+ name = "Search",
+ texture = "Search",
+ tooltipTitle = L.Toolbar_Search_TooltipTitle,
+ tooltipText = string.format(L.Toolbar_Search_TooltipText, self.inventoryTypeLocalized),
+ xOffset = -BsSkin.toolbarGroupSpacing,
+ onClick = function()
+ _G.PlaySound("igMainMenuOptionCheckBoxOn")
+ self.ui.buttons.toolbar.search:Hide() -- This relies on the `name` property of this button being "Search".
+ if not self.settings.showHeader then
+ self.temporarilyShowWindowHeader = true
+ self:ForceUpdateWindow()
+ end
+ self.ui.frames.searchBox:Show()
+ self:UpdateToolbar()
+ self.ui.frames.searchBox:SetFocus()
+ end,
+ },
+
+ {
+ name = "Resort",
+ texture = "Resort",
+ tooltipTitle = L.Toolbar_Resort_TooltipTitle,
+ tooltipText = L.Toolbar_Resort_TooltipText,
+ disable = true,
+ onClick = function()
+ _G.PlaySound("igQuestLogOpen")
+ self:Resort()
+ end,
+ },
+
+ {
+ name = "Restack",
+ texture = "Restack",
+ tooltipTitle = L.Toolbar_Restack_TooltipTitle,
+ tooltipText = L.Toolbar_Restack_TooltipText,
+ xOffset = -BsSkin.toolbarGroupSpacing,
+ disable = true,
+ onClick = function()
+ _G.PlaySound("UChatScrollButton")
+ self:Restack()
+ end,
+ },
+
+ {
+ name = "ShowHide",
+ texture = "Show",
+ tooltipTitle = L.Toolbar_Show_TooltipTitle,
+ tooltipText = L.Toolbar_Show_TooltipText,
+ onClick = function()
+ self:ToggleProperty("showHidden")
+ end,
+ _bagshuiCheckedFunc = function()
+ return self.showHidden
+ end,
+ },
+
+ {
+ name = "HighlightChanges",
+ texture = "HighlightChanges",
+ tooltipTitle = L.Toolbar_HighlightChanges_TooltipTitle,
+ tooltipText = L.Toolbar_HighlightChanges_TooltipText,
+ xOffset = -BsSkin.toolbarGroupSpacing,
+ onClick = function()
+ if _G.IsAltKeyDown() then
+ self:ResetStockState()
+ else
+ self:ToggleProperty("highlightChanges")
+ end
+ end,
+ _bagshuiCheckedFunc = function()
+ return self.highlightChanges
+ end,
+ },
+
+ {
+ name = "Character",
+ texture = "CharacterRobed",
+ tooltipTitle = L.Toolbar_Character_TooltipTitle,
+ tooltipText = string.format(L.Toolbar_Character_TooltipText, self.inventoryTypeLocalized),
+ xOffset = -BsSkin.toolbarGroupSpacing,
+ menuHasArrow = true,
+ menuHideArrow = true,
+ menuIgnoreOnClick = true,
+ -- There's a chicken-and-egg issue between building the toolbar item
+ -- list and constructing the menus because the menu won't exist yet.
+ -- when this code is executed. Placing the property retrieval
+ -- in a function is the workaround.
+ getMenuValueProp = function()
+ return self.menus.menuList.Character.levels[1]
+ end,
+ onClickBeforeCloseMenusAndClearFocuses = function()
+ -- Whether the character menu was open before the default button OnClick closed it.
+ _G.this.bagshuiData.characterMenuWasOpen = self.menus:IsMenuOpen("Character")
+ end,
+ onClick = function()
+ if _G.this.bagshuiData.characterMenuWasOpen then
+ Bagshui:CloseMenus()
+ else
+ -- Open the menu with a sensible alignment based on window anchoring.
+ self.menus:OpenMenu(
+ "Character", -- menuType
+ _G.this, -- arg1
+ nil, -- arg2
+ _G.this, -- anchorFrame
+ (5 * (self.settings.windowAnchorXPoint == "LEFT" and -1 or 1)), -- xOffset
+ -5, -- yOffset
+ "TOP" .. self.settings.windowAnchorXPoint, -- anchorPoint
+ "BOTTOM" .. self.settings.windowAnchorXPoint -- anchorToPoint
+ )
+ end
+ end,
+ },
+ }
+
+ -- Pre-set full texture paths and firstLetterLowercased name.
+ for _, item in ipairs(self.toolbarAndMainMenuItems) do
+ item.id = BsUtil.LowercaseFirstLetter(item.name)
+ if item.texture then
+ item.texture = BsUtil.GetFullTexturePath("Icons\\" .. item.texture)
+ end
+ end
+ end
+
+ --- Iterator function to provide the list of inventory types that AREN'T the current class, in the
+ --- order defined by INVENTORY_TYPE_UI_ORDER (reversed if requested).
+ ---@param reverse boolean?
+ ---@param includeSelf boolean? The current instance's inventory type will be omitted unless this is true.
+ ---@return function # Iterator function that returns the inventory type along with the localized value.
+ function Inventory:OtherInventoryTypesInToolbarIconOrder(reverse, includeSelf)
+ local inventoryTypes = BS_INVENTORY_TYPE_UI_ORDER
+ local startIndex = reverse and table.getn(inventoryTypes) + 1 or 0
+ local endIndex = reverse and 0 or table.getn(inventoryTypes) + 1
+ local step = reverse and -1 or 1
+ local i = startIndex
+ ---@return string inventoryType
+ ---@return string inventoryTypeLocalized
+ return function()
+ repeat
+ i = i + step
+ until inventoryTypes[i] ~= self.inventoryType or includeSelf or i == endIndex
+ -- Need to use L_nil here so that nil is returned at the end of the loop
+ return inventoryTypes[i], L_nil[inventoryTypes[i]]
+ end
+ end
+
+ --- Reset everything in preparation for displaying the window, including the items below,
+ --- then trigger categorization and sorting.
+ --- - Edit Mode off.
+ --- - Empty slot stacks collapsed.
+ --- - Top toolbar hidden if it was temporarily visible.
+ --- - Pending trade-with-another player item cleared.
+ function Inventory:UiFrame_OnShow()
+ if self.dockTo then
+ -- Used by SetDockedToFrameVisibility() to decide whether the frame to which this
+ -- one is docked should also be closed when this frame is closed.
+ self.dockingFrameVisibleOnLastOpen = Bagshui.components[self.dockTo]:Visible()
+ end
+
+ _G.PlaySound(self.openSound)
+
+ self.online = true
+ self.editMode = false
+ self.expandEmptySlotStacks = false
+ self.temporarilyShowWindowHeader = false
+ self.highlightChanges = false
+ self.queuedTradeItem = nil -- Used by Inventory:TradeFrame_OnShow().
+ self.itemPendingSale = nil -- Used by Inventory:ItemButton_OnClick() to confirm sale of protected items.
+
+ -- Reset any item highlighting.
+ self.highlightItemsInContainerId = nil
+ self.highlightItemsContainerSlot = nil
+
+ -- Because Update() is called from the UI frame's OnShow function, self:Visible() becomes true
+ -- before CategorizeAndSort() executes. Normally, CategorizeAndSort() bails if self:Visible() is true,
+ -- so forceResort is set to true to ensure inventory is properly sorted whenever the window is opened.
+ -- Of course, we don't want this to happen when automatic resorting is off.
+ if not self.settings.disableAutomaticResort then
+ self.forceResort = true
+ end
+ self.resortNeeded = true
+ self.inventoryUpdateAllowed = true
+ self.cacheUpdateNeeded = true
+ self:NotifySkinOfPositionChange()
+ -- Do NOT use QueueUpdate() here.
+ self:Update()
+ end
+
+ --- Perform tasks that need to occur when the window is closed, including:
+ --- - Take the frame out of move mode.
+ --- - Close all menus.
+ --- - Reset the search box.
+ --- - Clear the Edit Mode cursor.
+ --- - Reset the
+ --- - If there's a frame docked to this one, close it.
+ function Inventory:UiFrame_OnHide()
+ self.uiFrame:StopMovingOrSizing()
+ -- If the mouse button was released while the game hitched, OnDragStop may never have fired.
+ -- A stuck flag would make FixWindowPosition() (and window rescue) skip this window forever.
+ self.dragInProgress = false
+ self:ClearSearch()
+ self:CloseMenusAndClearFocuses()
+ self:ClearEditModeCursor()
+ self:SetCharacter(Bagshui.currentCharacterId)
+
+ _G.PlaySound(self.closeSound)
+
+ -- If there's a frame docked to this frame, close it too.
+ if self.dockedInventory then
+ self.dockedInventory:Close()
+ end
+ end
+
+ --- Handle dragging (start).
+ function Inventory:UiFrame_OnDragStart()
+ if self.dockTo then
+ Bagshui.components[self.dockTo]:UiFrame_OnDragStart()
+ elseif not self.settings.windowLocked then
+ -- Has to be self instead of "this" because it can be called from docked windows.
+ self.dragInProgress = true
+ self.uiFrame:StartMoving()
+ end
+ end
+
+ --- Handle dragging (stop).
+ function Inventory:UiFrame_OnDragStop()
+ if self.dockTo then
+ Bagshui.components[self.dockTo]:UiFrame_OnDragStop()
+ elseif not self.settings.windowLocked then
+ -- Has to be self instead of "this" because it can be called from docked windows.
+ self.dragInProgress = false
+ self.uiFrame:StopMovingOrSizing()
+ self:FixSettingsMenuPosition()
+ self:SaveWindowPosition()
+ -- If the window was dropped (nearly) off the screen, bring it back right away
+ -- instead of saving an unreachable position.
+ self:RescueWindow()
+ self:NotifySkinOfPositionChange()
+ end
+ end
+
+ --- Display the inventory utilization summary tooltip.
+ ---@param owner table Frame to which the tooltip should bet attached.
+ ---@param attachedToBagBar boolean? When `true`, call the bag bar's OnEnter.
+ ---@param extraText string? Text to append to the tooltip.
+ ---@param delay boolean|number? Time to wait before showing the tooltip.
+ ---@param tooltip table? Tooltip to use instead of GameTooltip.
+ function Inventory:ShowUsageSummary(owner, attachedToBagBar, tooltip, extraText, delay)
+ if self.editMode then
+ return
+ end
+
+ owner = owner or _G.this
+ tooltip = tooltip or _G.GameTooltip
+
+ _G.this.bagshuiData.mouseIsOver = true
+
+ -- Trigger bag bar OnEnter to avoid flickering.
+ if attachedToBagBar then
+ if self.ui.frames.bagBar:IsVisible() then
+ self.ui.frames.bagBar:GetScript("OnEnter")()
+ end
+ end
+
+ -- Add overall info.
+ tooltip:SetOwner(
+ owner,
+ "ANCHOR_" .. BsUtil.FlipAnchorPoint(self.settings.windowAnchorXPoint),
+ -BsSkin.tooltipExtraOffset,
+ BsSkin.tooltipExtraOffset
+ )
+ tooltip:AddDoubleLine(
+ -- Available: 10 Used: 8/18
+ string.format(L.Symbol_Colon, L.Available)
+ .. " "
+ .. tostring(self.availableSlots),
+ string.format(L.Symbol_Colon, L.Used)
+ .. " "
+ .. string.format("%s/%s", tostring(self.usedSlots), tostring(self.totalSlots))
+ )
+
+ -- Only show per-bag-type info if there are profession bags.
+ local hasProfessionBags = false
+ for containerType, spaceInfo in pairs(self.containerSpace) do
+ if (spaceInfo.total or 0) > 0 and containerType ~= L.Bag then
+ hasProfessionBags = true
+ end
+ end
+
+ -- Add per-bag-type info, excluding any bag types where total is 0 (necessary
+ -- because we don't bother cleaning up the containerSpace table when all bags
+ -- of a given type are unequipped).
+ if hasProfessionBags then
+ for _, containerType in ipairs(BS_INVENTORY_CONTAINER_TYPE_ORDER) do
+ local spaceInfo = self.containerSpace[containerType]
+ if spaceInfo and (spaceInfo.total or 0) > 0 then
+ -- Bag: 2 6/8
+ tooltip:AddDoubleLine(
+ string.format(L.Symbol_Colon, containerType) .. " " .. tostring(spaceInfo.available),
+ string.format("%s/%s", tostring(spaceInfo.used), tostring(spaceInfo.total)),
+ HIGHLIGHT_FONT_COLOR.r,
+ HIGHLIGHT_FONT_COLOR.g,
+ HIGHLIGHT_FONT_COLOR.b,
+ HIGHLIGHT_FONT_COLOR.r,
+ HIGHLIGHT_FONT_COLOR.g,
+ HIGHLIGHT_FONT_COLOR.b
+ )
+ end
+ end
+ end
+
+ if delay then
+ Bagshui:ShowTooltipAfterDelay(
+ tooltip,
+ owner,
+ nil,
+ type(delay) == "number" and delay or BS_TOOLTIP_DELAY_SECONDS.DEFAULT,
+ nil,
+ function()
+ if extraText then
+ Bagshui:QueueEvent(function()
+ if tooltip:IsOwned(owner) and tooltip:IsVisible() then
+ tooltip:AddLine(extraText)
+ tooltip:Show()
+ end
+ end, BS_TOOLTIP_DELAY_SECONDS.TOOLBAR_DEFAULT)
+ end
+ end
+ )
+ else
+ if extraText then
+ tooltip:AddLine(extraText)
+ tooltip:Show()
+ end
+ tooltip:Show()
+ end
+ end
+
+ --- Companion to `ShowUsageSummary()`.
+ ---@param owner table Tooltip will only be hidden if still owned by this frame.
+ ---@param attachedToBagBar boolean? When `true`, call the bag bar's OnLeave.
+ ---@param tooltip table? Tooltip to use instead of GameTooltip.
+ function Inventory:HideUsageSummary(owner, attachedToBagBar, tooltip)
+ owner = owner or _G.this
+ tooltip = tooltip or _G.GameTooltip
+
+ owner.bagshuiData.mouseIsOver = false
+
+ -- Hide tooltip.
+ if tooltip:IsOwned(owner) then
+ tooltip:Hide()
+ end
+
+ -- Decide whether to keep displaying slot available/used counts.
+ if attachedToBagBar and self.ui.frames.bagBar:IsVisible() then
+ self.ui.frames.bagBar:GetScript("OnLeave")()
+ end
+ end
+
+ --- Spotlights the item that will be targeted by clicking the Open Container button.
+ function Inventory:HighlightNextOpenable()
+ -- Avoid firing OnEnter constantly.
+ if self.lastHighlightedOpenableButton ~= self.nextOpenableItemSlotButton then
+ self.highlightItemsInContainerId = self.nextOpenableItemBagNum
+ self.highlightItemsContainerSlot = self.nextOpenableItemSlotNum
+ if self.nextOpenableItemSlotButton then
+ self:ItemButton_OnEnter(self.nextOpenableItemSlotButton)
+ end
+ self:UpdateItemSlotColors()
+ self.lastHighlightedOpenableButton = self.nextOpenableItemSlotButton
+ end
+ end
+
+ --- Visible serves two functions:
+ --- 1. Get the current visibility of `self.uiFrame`.
+ --- 2. Optionally perform a UI visibility action prior to checking visibility.
+ ---@param visibilityAction BS_INVENTORY_UI_VISIBILITY_ACTION? Action to perform, if any.
+ ---@return number|boolean|nil isVisible Result of `self.uiFrame:IsVisible()`.
+ function Inventory:Visible(visibilityAction)
+ if not self.uiFrame then
+ return false
+ end
+ if visibilityAction and type(self[visibilityAction]) == "function" then
+ self[visibilityAction](self)
+ end
+ return self.uiFrame:IsVisible()
+ end
+
+ --- Display the window.
+ function Inventory:Open()
+ if not self.uiFrame:IsVisible() then
+ -- Set to `EVENT_PREFIX_` when the event ends in `_OPENED`.
+ -- This will allow for matching against the corresponding
+ -- `EVENT_PREFIX_CLOSED`.
+ self.lastOpenEventTrigger = _G.event
+ and ((string.find(_G.event, "_OPENED$")) and (string.gsub(_G.event, "OPENED$", "")) or (string.find(
+ _G.event,
+ "_SHOW$"
+ )) and (string.gsub(_G.event, "SHOW$", "")))
+ or nil
+ end
+ self.uiFrame:Show()
+ self.uiFrame:Raise()
+ self:SetDockedToFrameVisibility(BS_INVENTORY_UI_VISIBILITY_ACTION.OPEN)
+ end
+
+ --- Hide the window.
+ function Inventory:Close()
+ if self.uiFrame:IsVisible() then
+ -- Don't close if the close request came from an event trigger but
+ -- the window not opened by the corresponding event.
+ if
+ type(_G.event) == "string"
+ and (string.find(_G.event, "_CLOSED$"))
+ and (self.lastOpenEventTrigger == nil or (not string.find(_G.event, "^" .. self.lastOpenEventTrigger)))
+ then
+ return
+ end
+ self.uiFrame:Hide()
+ end
+ end
+
+ --- Open if closed, close if opened.
+ function Inventory:Toggle(keepDockedToFrameOpen)
+ if self.uiFrame:IsVisible() then
+ self:Close()
+ if not keepDockedToFrameOpen then
+ self:SetDockedToFrameVisibility(BS_INVENTORY_UI_VISIBILITY_ACTION.CLOSE)
+ end
+ else
+ self:Open()
+ end
+ end
+
+ --- OpenCloseToggle is called from hooked functions and checks whether the hook is
+ --- enabled before performing the action. It calls `self:Visible()` when the hook
+ --- is enabled and the original hooked function when the hook is disabled.
+ ---@param uiVisibilityAction BS_INVENTORY_UI_VISIBILITY_ACTION
+ ---@param wowApiFunctionName string Hooked WoW API function that triggered this call.
+ ---@param bagNumParam number? `arg1` from the hooked API function, if applicable.
+ ---@param forceCallOriginalHook boolean? When true, always call the hooked API function.
+ function Inventory:OpenCloseToggle(uiVisibilityAction, wowApiFunctionName, bagNumParam, forceCallOriginalHook)
+ local callOriginalHook = true
+ if self:GetHookEnabled(wowApiFunctionName, bagNumParam) then
+ callOriginalHook = false
+ -- Only perform the action if it's unconditional or if it's for a container belonging to this instance.
+ if not bagNumParam or self.myContainerIds[bagNumParam] then
+ self:Visible(uiVisibilityAction)
+ end
+ end
+
+ if callOriginalHook or forceCallOriginalHook then
+ self.hooks:OriginalHook(wowApiFunctionName, bagNumParam)
+ end
+ end
+
+ --- Make sure the visibility state of the frame this one is docked to doesn't get out of sync.
+ ---@param visibilityAction BS_INVENTORY_UI_VISIBILITY_ACTION
+ function Inventory:SetDockedToFrameVisibility(visibilityAction)
+ if not self.dockTo then
+ return
+ end
+ -- Only close the docking frame if it wasn't visible when this frame was opened.
+ if
+ visibilityAction ~= BS_INVENTORY_UI_VISIBILITY_ACTION.CLOSE
+ or (visibilityAction == BS_INVENTORY_UI_VISIBILITY_ACTION.CLOSE and not self.dockingFrameVisibleOnLastOpen)
+ then
+ Bagshui.components[self.dockTo]:Visible(visibilityAction)
+ end
+ end
+
+ --- Keep the window in the right place.
+ ---@param noRescueAttempts boolean? Don't call `Inventory:RescueWindow()`.
+ function Inventory:FixWindowPosition(noRescueAttempts)
+ if self.dragInProgress then
+ return
+ end
+
+ -- As soon as a frame is dragged, its anchor changes to TOPLEFT, which leads to undesirable behavior
+ -- when the frame is resized. We have to reset the anchor and position to keep things happy.
+ self.uiFrame:ClearAllPoints()
+ self.uiFrame:SetPoint(
+ self.settings.windowAnchorYPoint .. self.settings.windowAnchorXPoint,
+ _G.UIParent,
+ self.settings.windowAnchorYPoint .. self.settings.windowAnchorXPoint,
+ self.settings.windowAnchorXOffset / self.uiFrame:GetScale(),
+ self.settings.windowAnchorYOffset / self.uiFrame:GetScale()
+ )
+
+ -- Ensure the Settings menu is in the right spot.
+ self:FixSettingsMenuPosition()
+
+ -- Make sure the window is onscreen.
+ if not noRescueAttempts then
+ self:RescueWindow()
+ end
+ end
+
+ --- Get the window back on the screen if it goes wandering.
+ ---@param requested boolean? User-requested reset. Don't check, just do it.
+ function Inventory:RescueWindow(requested)
+ -- Can't check if the frame isn't positioned.
+ if not self.uiFrame:GetTop() then
+ return
+ end
+
+ local rescued = false
+
+ -- Make sure boundary frames are scaled correctly.
+ Bagshui:ManageBoundaryFrames()
+
+ -- These two could be collapsed to one shared function but it doesn't seem worth it.
+ -- Originally this was implemented with checks against UIParent but that seemed to
+ -- fall apart at really large resolutions. So instead we're using four invisible
+ -- boundary frames that share the same scale as the Bagshui inventory windows.
+ -- HOPEFULLY this will be accurate. (There's probably a better way to do this?)
+
+ if
+ requested
+ or self.uiFrame:GetTop() <= Bagshui.boundaryFrames.BOTTOM:GetBottom() + BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD
+ or self.uiFrame:GetBottom() >= Bagshui.boundaryFrames.TOP:GetTop() - BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD
+ then
+ self.settings:SetDefaults(true, nil, nil, "windowAnchorYOffset", true)
+ if
+ (self.settings.windowAnchorYPoint == "TOP" and self.settings.windowAnchorYOffset > 0)
+ or (self.settings.windowAnchorYPoint == "BOTTOM" and self.settings.windowAnchorYOffset < 0)
+ then
+ self.settings.windowAnchorYOffset = -self.settings.windowAnchorYOffset
+ end
+ rescued = true
+ end
+
+ if
+ requested
+ or self.uiFrame:GetRight() <= Bagshui.boundaryFrames.LEFT:GetLeft() + BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD
+ or self.uiFrame:GetLeft() >= Bagshui.boundaryFrames.RIGHT:GetRight() - BS_WINDOW_OFFSCREEN_RESCUE_THRESHOLD
+ then
+ self.settings:SetDefaults(true, nil, nil, "windowAnchorXOffset", true)
+ if
+ (self.settings.windowAnchorXPoint == "RIGHT" and self.settings.windowAnchorXOffset > 0)
+ or (self.settings.windowAnchorXPoint == "LEFT" and self.settings.windowAnchorXOffset < 0)
+ then
+ self.settings.windowAnchorXOffset = -self.settings.windowAnchorXOffset
+ end
+ rescued = true
+ end
+
+ -- Apply the new position.
+ if rescued then
+ Bagshui:Print(
+ requested and L.SettingReset_WindowPositionManual or L.SettingReset_WindowPositionAuto,
+ self.inventoryTypeLocalized
+ )
+ self:FixWindowPosition(true)
+ end
+ end
+
+ --- Record window offsets so they can be used by `Inventory:FixWindowPosition()`.
+ --- `GetLeft/Right/Top/Bottom()` always return values based on the bottom left of the screen,
+ --- so we need to do some work to figure out how these relate to the desired frame anchor.
+ --- Here's how it works, based on the frame's anchor:
+ --- - *Left/Bottom:* Easy - Use the returned value, adjusted by the frame scale
+ --- - *Right/Top:*
+ --- 1. Save the returned value, adjusted by frame scale.
+ --- 2. Get the appropriate UIParent dimension (Width/Height) and un-scale it.
+ --- 3. Subtract #1 from #2, then turn it negative.
+ function Inventory:SaveWindowPosition()
+ -- Prevent UpdateWindow() from being fired by settings changes so X and Y can both be saved
+ -- Without this, X will be saved but Y will be overwritten because FixWindowPosition() will
+ -- be called by UpdateWindow() before the Y setting is updated.
+ self.windowUpdateBlocked = true
+
+ -- Save new values.
+ self.settings.windowAnchorXOffset = self:GetWindowOffset(self.settings.windowAnchorXPoint)
+ self.settings.windowAnchorYOffset = self:GetWindowOffset(self.settings.windowAnchorYPoint)
+
+ -- Allow window updates again.
+ self.windowUpdateBlocked = false
+ end
+
+ --- Calculate the current window offset from the given point.
+ ---@param point string LEFT/RIGHT/TOP/BOTTOM
+ ---@return number
+ function Inventory:GetWindowOffset(point)
+ -- Calling, for example, self.uiFrame:GetLeft()
+ local offset = self.uiFrame["Get" .. BsUtil.Capitalize(point)](self.uiFrame) * self.uiFrame:GetScale()
+
+ if string.upper(point) == "RIGHT" or string.upper(point) == "TOP" then
+ -- Measure against the actual right/top edge of UIParent, which is what FixWindowPosition()
+ -- anchors to. (Dividing the width/height by the UI scale made the saved and restored
+ -- positions disagree whenever the UI scale wasn't 1, shoving the window to the left/bottom.)
+ local parentEdge = _G.UIParent["Get" .. BsUtil.Capitalize(point)](_G.UIParent)
+ if not parentEdge then
+ local dimension = (string.upper(point) == "RIGHT") and "Width" or "Height"
+ parentEdge = _G.UIParent["Get" .. dimension](_G.UIParent)
+ end
+ offset = offset - parentEdge
+ end
+
+ return offset
+ end
+
+ -- Let the skin know that the window position has changed.
+ function Inventory:NotifySkinOfPositionChange()
+ if type(BsSkin.inventoryPositionFunc) == "function" then
+ BsSkin.inventoryPositionFunc(self)
+ end
+ end
+
+ --- Do "cleanup" activities when we need to get rid of menus, de-focus the search box, etc.
+ --- **DO NOT RENAME** without updating `Ui:CloseMenusAndClearFocuses()`.
+ function Inventory:CloseMenusAndClearFocuses()
+ Bagshui:CloseMenus()
+ self.ui.frames.searchBox:ClearFocus()
+ end
+
+ --- Wrapper for `Bagshui:AddTooltipLine()` that automatically adds to the Bagshui info 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 Inventory:AddBagshuiInfoTooltipLine(text, label, title, indent)
+ Bagshui:AddTooltipLine(BsInfoTooltip, text, label, title, indent)
+ end
+
+ --- Open the inventory window and set focus on the search box.
+ ---@param searchString string? Search string to pre-populate.
+ function Inventory:Search(searchString)
+ self:Open()
+ self.ui.buttons.toolbar.search:Click()
+ if type(searchString) == "string" then
+ self.ui.frames.searchBox:SetText(searchString)
+ end
+ end
+
+ --- Clear the current search text and store it in history.
+ function Inventory:ClearSearch()
+ if self.searchText then
+ self.ui.frames.searchBox:AddHistoryLine(self.searchText)
+ self.ui.frames.searchBox:SetText("")
+ self.ui.frames.searchBox:GetScript("OnTextChanged")(self.ui.frames.searchBox)
+ end
+ end
+
+ --- Transfer the current search to the Catalog.
+ function Inventory:SearchCatalog()
+ if self.searchText then
+ local searchText = self.searchText
+ self:ClearSearch()
+ self:CloseMenusAndClearFocuses()
+ BsCatalog:Search(searchText)
+ end
+ end
+
+ --- Determine whether a given API function hook is enabled.
+ ---@param wowApiFunctionName string Hooked WoW API function that triggered this call.
+ ---@param bagNumParam number|boolean? `arg1` from the hooked API function, if applicable.
+ ---@return boolean hookEnabled
+ function Inventory:GetHookEnabled(wowApiFunctionName, bagNumParam)
+ local hookPreference = self:GetHookSettingName(wowApiFunctionName, bagNumParam)
+ local hookPreferenceValue = true
+ if self.settings[hookPreference] ~= nil then
+ hookPreferenceValue = self.settings[hookPreference]
+ end
+ return hookPreferenceValue
+ end
+
+ --- Some hooked functions can be enabled/disabled through settings, but the setting
+ --- name isn't always the API function name. For example, Bags stores the
+ --- OpenBackpack/CloseBackpack/ToggleBackpack preference in hookBag0,
+ --- to match the autogenerated hookBagX settings. By configuring the
+ --- hookSettingTranslations class property, this substitution can be made.
+ ---@param hookFunctionName string WoW API function being hooked.
+ ---@param bagNumParam number|boolean? When given and there's no hookSettingTranslations match, return hookBagX, where X is bagNumParam.
+ ---@return string settingName
+ function Inventory:GetHookSettingName(hookFunctionName, bagNumParam)
+ local retVal = nil
+
+ -- Find any special case matches
+ for pattern, preferenceName in pairs(self.hookSettingTranslations) do
+ if string.find(hookFunctionName, pattern) then
+ retVal = preferenceName
+ end
+ end
+
+ -- Make sure the bag number is within the declared range for this class.
+ -- Checking to ensure it's a number because some hooked bag functions
+ -- could be called with booleans (like OpenAllBags()).
+ if retVal == nil and type(bagNumParam) == "number" and self.myContainerIds[bagNumParam] then
+ retVal = "hookBag" .. bagNumParam
+ end
+
+ -- For anything else, the preference name is the hook function name.
+ if retVal == nil and self.hooks[hookFunctionName] then
+ retVal = hookFunctionName
+ end
+
+ ---@diagnostic disable-next-line: return-type-mismatch
+ return retVal
+ end
+end)
diff --git a/Components/Inventory.lua b/Components/Inventory.lua
new file mode 100644
index 0000000..a8689ef
--- /dev/null
+++ b/Components/Inventory.lua
@@ -0,0 +1,1027 @@
+-- Bagshui Inventory Class Prototype
+-- Exposes: Bagshui.prototypes.Inventory
+-- Raises:
+-- - BAGSHUI_INVENTORY_CACHE_UPDATE
+-- - BAGSHUI_INVENTORY_EDIT_MODE_UPDATE
+-- All inventory types (Bags, Bank, etc.) are instances of this class.
+
+Bagshui:AddComponent(function()
+ Bagshui:AddConstants({
+
+ -- Control the order in which bag types are displayed in the free space tooltip
+ -- that appears when mousing over the free space display next to the bag bar.
+ BS_INVENTORY_CONTAINER_TYPE_ORDER = {
+ L["Bag"],
+ L["Ammo Pouch"],
+ L["Enchanting Bag"],
+ L["Herb Bag"],
+ L["Quiver"],
+ L["Soul Bag"],
+ },
+
+ -- Different bag types get different empty slot textures.
+ BS_INVENTORY_EMPTY_SLOT_TEXTURE = {
+ [L["Bag"]] = "ItemSlot\\EmptySlot",
+ [L["Ammo Pouch"]] = "ItemSlot\\EmptySlot-AmmoPouch",
+ [L["Enchanting Bag"]] = "ItemSlot\\EmptySlot-EnchantingBag",
+ [L["Herb Bag"]] = "ItemSlot\\EmptySlot-HerbBag",
+ [L["Quiver"]] = "ItemSlot\\EmptySlot-Quiver",
+ [L["Soul Bag"]] = "ItemSlot\\EmptySlot-SoulBag",
+ },
+
+ -- Properties listed here are appended when building the new class object instead of
+ -- overriding what's in the prototype. Obviously only works for tables.
+ -- - Keys must match case with class property names.
+ BS_INVENTORY_SUBCLASS_APPEND_PROPERTY = {
+ events = true,
+ apiFunctionsToHook = true,
+ },
+
+ -- Quality badges are set via these parameters for `SetTexCoord()` to crop UI-RaidTargetingIcons for finer
+ -- control than `SetRaidTargetIconTexture()` allows (mostly to make the purple diamond more square).
+ -- - `[Quality Number] = { left, right, top, bottom }`
+ BS_INVENTORY_QUALITY_BADGE_COORD = {
+ [2] = { 0.00, 0.25, 0.25, 0.5 }, -- White Crescent Moon
+ -- Green alternates
+ -- [2] = { 0.72, 1.00, 0.00, 0.26 }, -- Green Triangle
+ -- [2] = { 0.00, 0.25, 0.00, 0.25 }, -- Yellow Cross
+ [3] = { 0.235, 0.515, 0.235, 0.515 }, -- Blue Square
+ [4] = { 0.53, 0.74, 0.00, 0.26 }, -- Purple Diamond
+ [5] = { 0.24, 0.49, 0.00, 0.24 }, -- Orange Circle
+ [6] = { 0.00, 0.25, 0.00, 0.25 }, -- Yellow Cross
+ },
+
+ -- Set `[Quality Number] = true` if a badge requires recoloring.
+ -- This will cause it to be recolored to the standard item quality color via `SetVertexColor()`.
+ BS_INVENTORY_QUALITY_BADGE_RECOLOR = {
+ [2] = true, -- The moon is white, so turn it green.
+ },
+
+ -- Textures and cropping for item usable badges.
+ -- ```
+ -- USABLE_STATE = {
+ -- texture = "path",
+ -- texCoord = { left, right, top, bottom },
+ -- }
+ -- ```
+ -- Note: `texCoord` is **required**; use `{ 0, 1, 0, 1 }` for uncropped.
+ BS_INVENTORY_USABLE_BADGE = {
+ UNUSABLE = {
+ texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons",
+ texCoord = { 0.49, 0.761, 0.241, 0.511 }, -- Red X
+ },
+ KNOWN = {
+ -- There's no check mark in UI-RaidTargetingIcons, so we have to provide our own.
+ texture = "ItemSlot\\Check",
+ texCoord = { 0, 1, 0, 1 },
+ },
+ },
+
+ -- Lowest stock badge opacity allowed (percentage as decimal between 0 and 1).
+ BS_INVENTORY_STOCK_BADGE_MAX_FADE = 0.4,
+
+ -- Truncate long tooltips at the first empty line when the Bagshui Info tooltip
+ -- is displayed or in Edit Mode.
+ BS_INVENTORY_TRUNCATE_TOOLTIPS_INFO_MODE = true,
+ BS_INVENTORY_TRUNCATE_TOOLTIPS_EDIT_MODE = true,
+
+ -- Use these strings to build the full string that is stored in each item's tooltip property.
+ -- MUST be an array-type table where:
+ -- ```
+ -- [1] =
+ -- [2] =
+ -- ```
+ BS_INVENTORY_TOOLTIP_JOIN_CHARACTERS = {
+ Left = BS_NEWLINE,
+ Right = " ",
+ },
+
+ -- Enum used by `Visible()` (passed from `OpenCloseToggle()`) to call the correct UI visibility function.
+ -- Values must correspond to class function names.
+ ---@enum BS_INVENTORY_UI_VISIBILITY_ACTION
+ BS_INVENTORY_UI_VISIBILITY_ACTION = {
+ OPEN = "Open",
+ CLOSE = "Close",
+ TOGGLE = "Toggle",
+ },
+
+ -- Inventory-specific object identifiers.
+ ---@enum BS_INVENTORY_OBJECT_TYPE
+ BS_INVENTORY_OBJECT_TYPE = {
+ GROUP = "Group",
+ GROUP_MOVE_TARGET = "GroupMoveTarget",
+ CATEGORY = "Category",
+ ITEM = "Item",
+ },
+
+ -- Used in Edit Mode to determine whether a layout change is being made in
+ -- a horizontal or vertical direction.
+ ---@enum BS_INVENTORY_LAYOUT_DIRECTION
+ BS_INVENTORY_LAYOUT_DIRECTION = {
+ ROW = "Row",
+ COLUMN = "Column",
+ },
+
+ -- Used to find the Hearthstone for the Hearthstone button.
+ BS_INVENTORY_HEARTHSTONE_ITEM_ID = 6948,
+ })
+
+ local Inventory = {}
+ Bagshui.prototypes.Inventory = Inventory
+
+ -- Instance of UI class with Inventory-specific modifications. Additions are made
+ -- in the various Inventory.Ui.*.lua files. Each instance of the Inventory class
+ -- then creates its own instance of InventoryUi in Inventory:InitUi().
+ Bagshui.prototypes.InventoryUi = Bagshui.prototypes.Ui:New()
+
+ --- Create new Inventory class instance.
+ ---@param newPropsOrInventoryType BS_INVENTORY_TYPE|table Inventory type configured in Config\Inventory.lua or a table of Inventory class properties.
+ ---@return table inventoryClassInstance
+ function Inventory:New(newPropsOrInventoryType)
+ local newProps = newPropsOrInventoryType --[[@as table]]
+ if type(newPropsOrInventoryType) == "string" then
+ newProps = Bagshui.config.Inventory[newPropsOrInventoryType]
+ assert(newProps, "Inventory:New() - " .. newPropsOrInventoryType .. " not found in Inventory config")
+ -- Transfer inventory type identifier to the instance properties.
+ newProps.inventoryType = newPropsOrInventoryType
+ end
+ assert(newProps.inventoryType, "Inventory:New() - property table must contain inventoryType")
+
+ -- Default class properties.
+ local classObj = {
+ _super = Inventory,
+
+ --#region MUST be filled in by subclasses. --------------------------------
+
+ ---@type string A Bagshui BS_INVENTORY_TYPE.
+ inventoryType = nil,
+
+ ---@type number How many item slot buttons to create during initialization.
+ -- More will automatically be created if needed; this is just the starting point.
+ initialItemSlotButtons = 160,
+
+ ---@type number[] {start, end} inclusive range that controls which bag numbers
+ -- are included in this subclass' containerIds array.
+ containerIdRange = {},
+
+ ---@type table Handling instructions for the primary immutable container
+ -- belonging to this class (the Backpack, initial Bank bag, Keyring).
+ -- Items are optional unless noted.
+ -- ```
+ -- {
+ -- ---@type number id of the bag (required).
+ -- id = ,
+ -- ---@type string Name of the bag (required).
+ -- name = "Name",
+ -- ---@type string Texture to display (required).
+ -- texture = "Path\\To\\Texture",
+ -- ---@type string Parameter for GetBindingKey() that will return the key binding for this container.
+ -- bindingKey = "BINDINGKEY"
+ -- ---@type function Call this in lieu of GetContainerNumSlots() (will be passed a bagNum parameter).
+ -- numSlotsFunction = ,
+ -- }
+ -- ```
+ primaryContainer = {},
+
+ ---@type function? Called as the second parameter to :SetInventoryItem()
+ --- and passed the item's slotNum. This is necessary because some containers'
+ --- contents can't be loaded into tooltips using SetBagItem().
+ getInventorySlotFunction = nil,
+
+ ---@type string? Name of game function to call so other addons' tooltip hooks work.
+ --- Will be passed the current item slot button.
+ --- This is a string instead of the actual function so we can always be sure to
+ --- call the last link in the hook chain. If the function pointer was captured
+ --- at startup in Config\Inventory.lua then there's the risk of missing hooks.
+ itemSlotTooltipFunction = nil,
+
+ -- Bag button handling (none of these are applied to primaryContainer).
+ ---@type string Name of XML template for bag buttons.
+ bagButtonTemplate = "BagshuiBagsContainerTemplate",
+ ---@type number Add this to ContainerId when creating bag slots to avoid issues.
+ -- with conflicting frame IDs (relevant for Bags, not Bank).
+ bagButtonIdOffset = 0,
+ ---@type string When creating bag buttons, use this as the name prefix instead.
+ -- of the usual CreateElementName. This is needed because the Blizzard code
+ -- we rely on for bag slot buttons parses information from the element's
+ -- name via `strsub(, 10)`. See the bagSlotName reference in
+ -- `InventoryUi:CreateBagSlotButtons()` along with Config\Inventory.lua
+ -- for more details.
+ bagSlotNameFormat = "Bagshui__%d",
+ ---@type number When creating bag buttons, add this to ContainerId to populate bagSlotNameFormat.
+ bagSlotNameNumberOffset = 0,
+
+ --#endregion MUST be filled in by subclasses.
+
+ --#region Additional subclass override properties. --------------------------------
+
+ ---@type BS_INVENTORY_TYPE? to attach frame to.
+ dockTo = nil,
+
+ ---@type BS_RULE_MATCH_TYPE[]? Inventory classes to use as temporary space when swapping bags.
+ bagSwappingSupplementalStorage = nil,
+
+ ---@type boolean Should the special toolbar buttons be available?
+
+ clamButton = false,
+ hearthButton = false,
+ disenchantButton = false,
+ pickLockButton = false,
+
+ ---@type string Default sound to play when window is opened.
+ openSound = "igMainMenuOpen",
+ ---@type string Default sound to play when window is closed.
+ closeSound = "igMainMenuClose",
+
+ ---@type table Table of events to register.
+ -- Subclasses can add or remove events by providing their own Events table.
+ -- Keys are event names.
+ -- Values can be any of the below (note that some of the advanced functionality may not currently be used):
+ -- * true = Just handle the event normally.
+ -- * number = Queue the event to bae raised again with this delay and a BAGSHUI_ prefix.
+ -- * string = Call the function with this name, if it exists.
+ -- * false = Disable this event.
+ events = {
+ PLAYER_LOGIN = true,
+ PLAYER_ENTERING_WORLD = true,
+ BAG_UPDATE = true,
+ BAG_UPDATE_COOLDOWN = true,
+ ITEM_LOCKED = true, -- Doesn't seem to actually ever fire but let's register for it anyway.
+ ITEM_LOCK_CHANGED = true, -- Required for multiple reasons, including preventing items from staying gray if you attempt to place them in an incompatible container (ex. non-ammo in ammo bags).
+ MERCHANT_CLOSED = true, -- Clear pending sale item when leaving the merchant.
+ BAGSHUI_INVENTORY_INIT_RETRY = true,
+ BAGSHUI_INVENTORY_EDIT_MODE_UPDATE = true, -- Need to register for this so inventory classes that share the same Structure profile can stay in sync.
+ BAGSHUI_INVENTORY_SEARCH = true,
+ BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE = true,
+ BAGSHUI_INITIAL_INVENTORY_UPDATE = true,
+ BAGSHUI_CATEGORY_UPDATE = true,
+ BAGSHUI_CHARACTER_LEARNED_RECIPE = true,
+ BAGSHUI_CHARACTER_UPDATE = true,
+ BAGSHUI_CHARACTERDATA_UPDATE = true,
+ BAGSHUI_GAME_UPDATE = true,
+ BAGSHUI_PROFESSION_ITEM_UPDATE = true,
+ BAGSHUI_PROFILE_UPDATE = true,
+ BAGSHUI_SETTING_UPDATE = true,
+ BAGSHUI_SORTORDER_UPDATE = true,
+ BAGSHUI_EQUIPPED_HISTORY_UPDATE = true,
+ BAGSHUI_WINDOW_OPENED = true,
+ BAGSHUI_WINDOW_CLOSED = true,
+ },
+
+ ---@type table WoW API functions to hook and the corresponding class function to call.
+ -- The WoW API function name will be passed as the first parameter with any additional parameters after that.
+ apiFunctionsToHook = {
+ BagSlotButton_OnClick = "BagSlotButton_OnHook",
+ BagSlotButton_OnDrag = "BagSlotButton_OnHook",
+ TradeFrame_OnShow = "TradeFrame_OnShow",
+ UIErrorsFrame_OnEvent = "UIErrorsFrame_OnEvent", -- See Inventory.Actions.lua.
+ },
+
+ ---@type table See Inventory:GetHookSettingName() for details.
+ -- Keys are pattern strings to match against the API function name and
+ -- values are the setting name to return.
+ hookSettingTranslations = {},
+
+ ---@type boolean When true, add entries to the Hooks setting menu for this class' bag numbers.
+ opensViaHooks = false,
+
+ ---@type string The string that goes in front of key binding indexes to look them up.
+ keyBindingPrefix = nil,
+
+ ---@type boolean Debugging options.
+ debug = false and BS_DEBUG,
+ clearItemCacheAtStartup = false and BS_DEBUG,
+ wipeSettingsOnLoad = false and BS_DEBUG,
+
+ --#endregion Additional subclass override properties.
+
+ --#region Runtime properties. --------------------------------
+
+ ---@type boolean Should inventory changes be allowed?
+ online = true,
+
+ -- Container-related lookup tables.
+
+ ---@type number[] Full list of bag numbers owned by this subclass.
+ -- * The primary container ID will always be at index 1.
+ -- * This is filled by code at the bottom of Config\Inventory.lua because other
+ -- configuration files depend on it.
+ -- * No reason to create a table here because it will be pulled from the config.
+ containerIds = nil,
+ ---@type table `{ = }` for every ContainerId in self.containerIds -- used to quickly check whether a given container ID belongs to this class.
+ myContainerIds = {},
+ ---@type table `{ = }`
+ inventoryIdsToContainerIds = {},
+ ---@type table `{ = { available = , total = } }` -- Managed by UpdateBagBar().
+ containerSpace = {},
+
+ -- Partial stack tracking -- Managed by UpdateCache().
+ ---@type table `{ itemId = count of partial stacks }`
+ partialStacks = {},
+
+ -- "Shadow" cache of item stock states.
+ -- See comment above the declaration of shadowId in Inventory.Cache.lua.
+ shadowStockState = {},
+ shadowBagshuiDate = {},
+
+ -- Item move queue -- used for restacking and bag swapping.
+ queuedMoveSources = {},
+ queuedMoveTargets = {},
+ tempMoveTargets = {},
+
+ -- Available empty slots (excluding profession bags -- see `Inventory:SwapBag()` comments).
+ emptyGenericContainerSlots = {},
+
+ -- Tracking tables used in UpdateCache().
+ preUpdateItemCounts = {},
+ postUpdateItemCounts = {},
+ lastUpdateLockedContainers = {},
+
+ -- Category/group-related lookup tables -- See `UpdateLayoutLookupTables()` for descriptions of most of these.
+ -- This is stored in the `currentLayoutState` table because there is a parallel `proposedLayoutState`
+ -- table used for dry-run calculations (automatically created during `Init()`).
+ currentLayoutState = {
+ activeCategoryIds = {},
+ activeGroups = {},
+ categoriesToGroups = {},
+ categoryIdsGroupedBySequence = {},
+ categorySequenceNumbers = {},
+ groupItems = {},
+ groups = {},
+ sortedCategorySequenceNumbers = {},
+ },
+
+ -- Tracking tables and state variables for UpdateWindow() and friends.
+ -- Detailed descriptions typically can be found where each is first referenced.
+ actualGroupWidths = {},
+ emptySlotStacks = {}, -- Fake item entries used to create empty slot stacks in the UI
+ groupItemCounts = {},
+ groupsIdsToFrames = {},
+ groupWidthsInItems = {},
+ expandEmptySlotStacks = false,
+ lastExpandEmptySlotStacks = false,
+ hasSlotsWithStackingPrevented = false, -- Whether any item in the cache has the `_bagshuiPreventEmptySlotStack` property set to `true` (managed by `UpdateCache()`).
+ highlightItemsInContainerId = nil,
+ highlightItemsInContainerLocked = false,
+ highlightItemsContainerSlot = nil,
+ showHidden = false, -- Toggle display of hidden groups and items (like Hearthstone).
+ hasHiddenGroups = false, -- Whether any objects are hidden and the toolbar icon should appear.
+ multiplePartialStacks = false, -- Whether there are multiple partial stacks of the same item, meaning the restack toolbar icon should be enabled.
+ positioningTables = {},
+ enableResortIcon = false, -- Managed by `Inventory:CategorizeAndSort()` and consumed by `Inventory:UpdateToolbar()`.
+ hideItems = {},
+ hasHiddenItems = false,
+ hasChanges = false,
+ highlightChangesEnabled = false,
+ hasOpenables = false,
+ nextOpenableItemBagNum = nil,
+ nextOpenableItemSlotNum = nil,
+ nextOpenableItemSlotButton = nil,
+
+ -- Used to track changes that occur when bags are moved between slots (see `Bagshui:PickupInventoryItem()` for details).
+ pendingContainerChanges = {},
+
+ -- Update tracking.
+ -- Start by requiring an update of everything.
+ inventoryUpdateAllowed = false, -- This will be flipped to true once the BAGSHUI_INITIAL_INVENTORY_UPDATE event is raised.
+ initialInventoryUpdateNeeded = true, -- This starts out true so that on the very first cache update, everything will be rebuilt.
+ cacheUpdateNeeded = true,
+ resortNeeded = true,
+ windowUpdateNeeded = true,
+ updating = false,
+ forceCacheUpdate = false,
+ forceResort = false,
+
+ -- Dirty flags for pipeline short-circuiting -- allow Update() to skip stages when nothing changed.
+ cacheChanged = false,
+ lookupTablesStale = true,
+ lookupTablesWereRebuilt = false,
+ windowUpdateNeededByCategorize = false,
+
+ ---@type string|nil Item search will occur when this is a string instead of nil.
+ searchText = nil,
+ ---@type boolean Highlight mode - dim items whose stock state has not changed
+ highlightChanges = false,
+
+ ---@type boolean Alter frame close behavior -- see UiFrame_OnShow().
+ dockingFrameVisibleOnLastOpen = false,
+
+ ---@type boolean Make the bag utilization summary visible at all times - managed by UpdateWindow().
+ alwaysShowUsageSummary = false,
+
+ ---@type table|nil Hearthstone cache entry tracking.
+ hearthstoneItemRef = nil,
+
+ -- Edit Mode state tracking -- see Components\Inventory.EditMode.lua.
+ ---@type boolean
+ editMode = false,
+ ---@type table
+ editState = {
+ cursorItem = nil,
+ cursorItemType = nil,
+ cursorTooltip = nil,
+ highlightCategory = nil,
+ highlightItem = nil,
+ },
+
+ -- UI element storage.
+ ui = nil, -- Ui class instance that we also hang UI elements on.
+ uiFrame = nil,
+
+ ---@type boolean When true, this indicates that on the initial cache update, items should NOT be
+ -- treated as new. This avoids the situation where everything receives the new item badge on a
+ -- clean install. It gets set to true if needed during `Inventory:Init()`.
+ freshCache = false,
+
+ ---@type string When this is NOT an empty string, the error icon will appear in the top left of the window.
+ errorText = "",
+
+ -- Table pointers.
+ -- These are set during `Inventory:Init()` but are documented here for reference.
+ settings = nil, -- Settings class instance
+ inventory = nil, -- `Bagshui.characters[][self.inventoryTypeSavedVars].inventory`
+ layout = nil, -- Active layout.
+ -- `containers` stores the following information about equipped/available bags (subclasses may add more):
+ -- ```
+ -- = {
+ -- name = "",
+ -- numSlots = ,
+ -- genericType = "", -- (A bag's "genericType" is the the item class returned by GetItemInfo for profession bags or the localized version of "Bag" for any other bag and the primary container)
+ -- isProfessionBag = ,
+ -- slotsFilled = ,
+ -- texture = "",
+ -- type = "",
+ -- usable = , -- OPTIONAL value. Will disable the slot in the "Equip Bag" item menu if `false`.
+ -- }
+ -- ```
+ containers = nil, -- `Bagshui.characters[][self.inventoryTypeSavedVars].containers`
+ profiles = {}, -- Pointers to active profiles, mostly so that the Settings class can access them easily.
+
+ -- Used by `Inventory:Init()` to avoid initializing multiple times.
+ initialized = false,
+
+ --#endregion Runtime properties.
+ }
+
+ -- Append/overwrite additional properties for class instance.
+ if newProps ~= nil then
+ for key, value in pairs(newProps) do
+ -- Some properties need to be appended instead of overwriting.
+ if BS_INVENTORY_SUBCLASS_APPEND_PROPERTY[key] then
+ for subKey, subVal in pairs(value) do
+ classObj[key][subKey] = subVal
+ end
+ else
+ classObj[key] = value
+ end
+ end
+ end
+
+ -- We'll need the lowercase version of the inventory type for SavedVariables and the localized version for the UI.
+ classObj.inventoryTypeSavedVars = BsUtil.LowercaseFirstLetter(classObj.inventoryType)
+ classObj.inventoryTypeLocalized = L[classObj.inventoryType]
+
+ -- Build the list of containerIds belonging to this class along with the reverse inventory ID (slot) mapping.
+ for index = 1, table.getn(classObj.containerIds) do
+ local containerId = classObj.containerIds[index]
+ classObj.myContainerIds[containerId] = index
+ -- Starting inventory ID mapping at index 2 because primary containers are never inventory slots into which bags can be equipped.
+ if index > 1 then
+ classObj.inventoryIdsToContainerIds[_G.ContainerIDToInventoryID(containerId)] = containerId
+ end
+ end
+
+ -- Store docked inventory relationship.
+ if classObj.dockTo and Bagshui.components[classObj.dockTo] then
+ Bagshui.dockedInventories[classObj.dockTo] = classObj.inventoryType
+ classObj.dockedToInventory = Bagshui.components[classObj.dockTo]
+ Bagshui.components[classObj.dockTo].dockedInventory = classObj
+ end
+
+ -- Make the class work.
+ setmetatable(classObj, self)
+ self.__index = self
+
+ -- Register events.
+ for event, eventAction in pairs(classObj.events) do
+ if eventAction ~= false then
+ Bagshui:RegisterEvent(event, classObj)
+ -- Events with number actions get queued for a re-raise as BAGSHUI_,
+ -- so we need to register for that version of the event too.
+ if type(eventAction) == "number" then
+ Bagshui:RegisterEvent("BAGSHUI_" .. event, classObj)
+ end
+ end
+ end
+
+ -- Register slash command handler with additional verbs.
+ BsSlash:AddOpenCloseHandler(classObj.inventoryType, classObj, {
+ L.Settings,
+ L.ResetPosition,
+ L.Lock,
+ L.Unlock,
+ }, function(tokens)
+ if BsUtil.MatchLocalizedOrNon(tokens[2], "settings") then
+ classObj:Open()
+ classObj.menus:OpenMenu("Settings")
+ return true
+ end
+
+ if BsUtil.MatchLocalizedOrNon(tokens[2], "resetposition") then
+ classObj:Open()
+ classObj:RescueWindow(true)
+ return true
+ end
+
+ if BsUtil.MatchLocalizedOrNon(tokens[2], "lock") or BsUtil.MatchLocalizedOrNon(tokens[2], "unlock") then
+ classObj.settings.windowLocked = BsUtil.MatchLocalizedOrNon(tokens[2], "lock")
+ return true
+ end
+ end)
+
+ -- Expose as `Bagshui.`.
+ Bagshui.components[classObj.inventoryType] = classObj
+
+ return classObj
+ end
+
+ --- Perform initialization tasks.
+ function Inventory:Init()
+ if self.initialized then
+ return
+ end
+
+ -- Ensure config is initialized for current player.
+ if Bagshui.currentCharacterData[self.inventoryTypeSavedVars] == nil then
+ Bagshui.currentCharacterData[self.inventoryTypeSavedVars] = {
+ neverOnline = true,
+ }
+ self.freshCache = true
+ end
+
+ -- Prepare settings (docked classes use settings of the class they're docked to).
+ self.settings = (
+ self.dockTo and Bagshui.components[self.dockTo].settings
+ or Bagshui.prototypes.Settings:New(
+ self,
+ self.debugSettingsOnLoad -- Wipe settings on load if configured.
+ )
+ )
+
+ -- Default to current character at startup.
+ self:SetCharacter(Bagshui.currentCharacterId)
+
+ -- Convenience properties for current character that can be referenced
+ -- regardless of what the active character is.
+ self.currentCharacterInventory =
+ Bagshui.characters[Bagshui.currentCharacterId][self.inventoryTypeSavedVars].inventory
+
+ -- Activate profiles.
+ for _, profileType in pairs(BS_PROFILE_TYPE) do
+ self:SetProfile(self.settings["profile" .. profileType], profileType, true)
+ end
+
+ -- Prepare dry-run lookup tables and set initial lookup table pointers.
+ self.proposedLayoutState = {}
+ for key, _ in pairs(self.currentLayoutState) do
+ self.proposedLayoutState[key] = {}
+ self[key] = self.currentLayoutState[key]
+ end
+
+ -- Initialize settings.
+ self.settings:SetDefaults(false, BS_SETTING_SCOPE.INVENTORY)
+ self.settings:InitCache()
+
+ -- Clean the inventory cache at startup (not usually needed but can be enabled).
+ if self.clearItemCacheAtStartup then
+ for index, bagNum in ipairs(self.containerIds) do
+ self.inventory[bagNum] = {}
+ end
+ self.freshCache = true
+ end
+
+ -- Set up WoW API function hooks.
+ self.hooks = Bagshui.prototypes.Hooks:New(self.apiFunctionsToHook, self)
+
+ -- Build the GUI.
+ self:InitUi()
+ self:InitMenus()
+
+ -- Ensure lookup tables are correct from the start.
+ self:UpdateLayoutLookupTables()
+
+ -- Initialize the frame docked to this one, if present.
+ if self.dockedInventory then
+ self.dockedInventory:Init()
+ end
+
+ -- We're done.
+ self.initialized = true
+ end
+
+ --- Calling this function means that an update is needed, but if other update-triggering
+ --- events arrive within the delay period, go ahead and allow them to reset the delay.
+ --- This allows us to minimize our updates while still staying responsive.
+ ---@param delay number? Delay before update in seconds.
+ ---@param cascade boolean? Parameter for `Inventory:Update()`.
+ function Inventory:QueueUpdate(delay, cascade)
+ -- Don't push the default delay too low on this or we'll end up performing updates too quickly
+ -- during things like moving bags between slots, and stock states will be lost.
+ Bagshui:QueueClassCallback(self, self.Update, delay or 0.07, false, cascade)
+ end
+
+ --- Event handling.
+ ---@param event string Event identifier.
+ ---@param arg1 any? Argument 1 from the event.
+ ---@param arg2 any? Argument 2 from the event.
+ function Inventory:OnEvent(event, arg1, arg2)
+ -- self:PrintDebug("OnEvent(): " .. event .. " // " .. tostring(arg1) .. " // " .. tostring(arg2))
+
+ -- Store the name of this event so it can be checked when updating the inventory
+ -- cache to see if the cache update should be delayed (see self.lastEvent check
+ -- near the top of Inventory:UpdateCache()).
+ self.lastEvent = event
+
+ -- Initialization.
+ -- Triggering off both PLAYER_LOGIN and PLAYER_ENTERING_WORLD because there seem
+ -- to be occasional issues with PLAYER_LOGIN not firing in 1.12.
+ if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then
+ -- Don't initialize if we're docked to another frame -- that frame will initialize us.
+ if not self.dockTo then
+ -- It's safe to call `Inventory:Init()` multiple times since it will
+ -- just return immediately if it has already executed successfully.
+ self:Init()
+ end
+
+ -- Queue initial inventory cache update so long as it hasn't been done yet.
+ -- PLAYER_ENTERING_WORLD also fires when zoning, so we avoid repeating it.
+ if self.initialInventoryUpdateNeeded and event == "PLAYER_ENTERING_WORLD" then
+ -- The delay is added because if we try to update immediately, GetItemInfo() may return nil.
+ Bagshui:QueueEvent("BAGSHUI_INITIAL_INVENTORY_UPDATE", 2)
+ end
+
+ return
+ end
+
+ if not self.initialized then
+ return
+ end
+
+ -- BAG_UPDATE: Don't do anything if arg1 is for a bag not handled by this class.
+ if event == "BAG_UPDATE" and not self.myContainerIds[arg1] then
+ return
+ end
+
+ -- MERCHANT_CLOSED: Clear pending sale item.
+ -- We also clear this when the window is opened but this is here just to be safe.
+ if event == "MERCHANT_CLOSED" then
+ self:ClearItemPendingSale()
+ return
+ end
+
+ -- When a character learns a new recipe, we have to re-cache all item tooltips
+ -- so learned indicators will be correct.
+ if event == "BAGSHUI_CHARACTER_LEARNED_RECIPE" then
+ self.forceFullCacheUpdate = true
+ self.windowUpdateNeeded = true
+ self:QueueUpdate()
+ return
+ end
+
+ -- Active quest / character changes / equipped gear / profession items.
+ -- Putting this early since it will be very common.
+ if
+ event == "BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE"
+ or event == "BAGSHUI_CHARACTER_UPDATE"
+ or event == "BAGSHUI_EQUIPPED_HISTORY_UPDATE"
+ or event == "BAGSHUI_GAME_UPDATE"
+ or event == "BAGSHUI_PROFESSION_ITEM_UPDATE"
+ then
+ self.cacheUpdateNeeded = true
+ self.windowUpdateNeeded = true
+ self.resortNeeded = true -- Light up the resort icon if the window is open.
+ -- DO NOT force a resort here or things will move around when the inventory
+ -- window is open and one of the change events is raised.
+ self:QueueUpdate()
+ return
+ end
+
+ -- Toolbar updates.
+ if
+ -- Window changes should update the toolbar since icon highlights need to
+ -- be locked for inventories and catalog.
+ event == "BAGSHUI_WINDOW_OPENED"
+ or event == "BAGSHUI_WINDOW_CLOSED"
+ -- Refresh toolbar state when alts are deleted.
+ or event == "BAGSHUI_CHARACTERDATA_UPDATE"
+ then
+ self:UpdateToolbar()
+ return
+ end
+
+ -- When a character learns a new recipe, we have to re-cache all item tooltips
+ -- so learned indicators will be correct.
+ if event == "BAGSHUI_INVENTORY_SEARCH" then
+ if self:Visible() and arg1 ~= self.inventoryType then
+ self.searchTextSetFromEvent = true
+ if string.len(arg2 or "") > 0 then
+ self.ui.buttons.toolbar.search:Hide()
+ self.ui.frames.searchBox:Show()
+ self.ui.frames.searchBox:SetText(arg2)
+ else
+ self:ClearSearch()
+ self.ui.buttons.toolbar.search:Show()
+ self.ui.frames.searchBox:Hide()
+ end
+ end
+ return
+ end
+
+ -- Special action handling - see description of the Events table in Inventory:New() for details.
+ local eventAction = self.events[event]
+
+ -- Re-queue the event with a delay if configured.
+ if type(eventAction) == "number" then
+ Bagshui:QueueEvent("BAGSHUI_" .. event, eventAction, false, arg1, arg2)
+ return
+ end
+
+ -- Call the event function if it exists.
+ -- If the function returns false, don't continue processing.
+ local eventFunction = self[eventAction]
+ if type(eventFunction) == "function" then
+ if eventFunction(self) == false then
+ return
+ end
+ end
+
+ -- Actually perform initial inventory cache update.
+ if event == "BAGSHUI_INITIAL_INVENTORY_UPDATE" then
+ self.inventoryUpdateAllowed = true
+ self.cacheUpdateNeeded = true
+ self.forceCacheUpdate = true
+ self.forceResort = true
+ self:QueueUpdate()
+ return
+ end
+
+ -- Settings changes should trigger a window update with optional cache update.
+ -- arg1 = Setting name.
+ -- arg2 = Setting info table as defined in Config\Settings.lua.
+ if event == "BAGSHUI_SETTING_UPDATE" then
+ -- If any of the "inventory*OnChange" options are set, an update is needed.
+ if
+ arg2
+ and (arg2.inventoryCacheUpdateOnChange or arg2.inventoryResortOnChange or arg2.inventoryWindowUpdateOnChange)
+ then
+ -- Reset header/footer show/hide double-click state when the corresponding setting is changed.
+ if (arg1 == "showHeader" or arg1 == "showFooter") and not self.ignoreNextSettingChange then
+ self.headerWasHiddenOnDoubleClick = false
+ self.footerWasHiddenOnDoubleClick = false
+ end
+
+ self.cacheUpdateNeeded = arg2.inventoryCacheUpdateOnChange or self.cacheUpdateNeeded or false
+ self.forceResort = self.forceResort or arg2.inventoryResortOnChange or false
+ self.lookupTablesStale = self.lookupTablesStale or (arg2.inventoryResortOnChange and true) or false
+ self.windowUpdateNeeded = true
+ self:QueueUpdate(0.005)
+ end
+
+ -- Even if no update was needed, we don't need anything else to happen.
+ return
+ end
+
+ -- Keep layouts of inventories that share the same Structure profile in sync.
+ if event == "BAGSHUI_INVENTORY_EDIT_MODE_UPDATE" then
+ -- arg1 = Raising class.
+ -- arg2 = forceResort.
+ if arg1 ~= self and arg1.settings.profileStructure == self.settings.profileStructure then
+ self:EditModeWindowUpdate(arg2, false, true)
+ end
+ return
+ end
+
+ -- Fall back to another profile if the active one is deleted.
+ if event == "BAGSHUI_PROFILE_UPDATE" then
+ for _, profileType in pairs(BS_PROFILE_TYPE) do
+ -- Trigger a profile update when one of the active profiles has changed.
+ if self.settings["profile" .. profileType] == arg1 then
+ self:SetProfile(arg1, profileType)
+ if profileType == BS_PROFILE_TYPE.STRUCTURE then
+ self.lookupTablesStale = true
+ self.forceResort = true -- Required to avoid issues during UpdateWindow() since all groups may need to be reassessed.
+ end
+ self.windowUpdateNeeded = true
+ self:QueueUpdate(0.05)
+ end
+ end
+ return
+ end
+
+ -- Re-sort and re-categorize when fundamental objects change.
+ if event == "BAGSHUI_CATEGORY_UPDATE" or event == "BAGSHUI_SORTORDER_UPDATE" then
+ self.lookupTablesStale = true
+ self.forceResort = true
+ self.windowUpdateNeeded = true
+ self:QueueUpdate()
+ return
+ end
+
+ -- BAG_UPDATE_COOLDOWN: refresh cooldown animations on visible item buttons directly without
+ -- triggering a full pipeline update. Before the performance optimizations, the full update
+ -- path ran unconditionally and kept cooldowns current as a side effect. The early-exit added
+ -- for no-op BAG_UPDATE events now suppresses that path when nothing in the cache has changed,
+ -- so we need a dedicated lightweight refresh here.
+ if event == "BAG_UPDATE_COOLDOWN" then
+ if self:Visible() then
+ self:UpdateItemSlotCooldowns()
+ end
+ return
+ end
+
+ -- Lock/unlock events need a cache update, but not when a container has
+ -- been picked up.
+ if event == "ITEM_LOCK_CHANGED" and not Bagshui.pickedUpBagSlotNum and not Bagshui.putDownBagSlotNum then
+ self.forceCacheUpdate = true
+ end
+
+ -- Assume any other event that gets this far may require a cache update.
+ self.cacheUpdateNeeded = true
+
+ -- If we get this far, it's a normal event that should just trigger inventory and window updates.
+ self:QueueUpdate()
+ end
+
+ --- Conditions that determine whether the inventory is online.
+ function Inventory:UpdateOnlineStatus()
+ self.online = true
+
+ -- Can only be online if the active character is the one currently logged in.
+ if self.activeCharacterId ~= Bagshui.currentCharacterId then
+ self.online = false
+ end
+ end
+
+ local setCharacterConvenienceProps = {
+ "containers",
+ "inventory",
+ }
+
+ --- Change the active character for this inventory class.
+ --- Alters the properties listed in `setCharacterConvenienceProps`
+ --- to point to the given character's data storage.
+ --- Also updates the class `activeCharacterId` property.
+ ---@param characterId any
+ function Inventory:SetCharacter(characterId)
+ local characterData = Bagshui.characters[characterId]
+ if not characterData and Bagshui.sharedCharacters[characterId] then
+ -- Character from another account: bring in its full inventory from the shared file first.
+ local loaded, reason = false, nil
+ if BsCatalog then
+ loaded, reason = BsCatalog:LoadSharedInventory(characterId)
+ end
+ if loaded then
+ characterData = Bagshui.sharedCharacters[characterId]
+ else
+ Bagshui:PrintWarning("Can't show " .. characterId .. ": " .. tostring(reason))
+ return
+ end
+ end
+ if not characterData then
+ Bagshui:PrintWarning(characterId .. " is not a known character")
+ return
+ end
+
+ -- No change.
+ if characterId == self.activeCharacterId then
+ return
+ end
+
+ -- Used for online checks.
+ self.activeCharacterId = characterId
+
+ if not self._characterMenuActiveCharacter then
+ -- self._characterMenuActiveCharacter is assigned to the Character menu's idsToCheck
+ -- property, so this will place a check mark next to the active character.
+ self._characterMenuActiveCharacter = {}
+ end
+ BsUtil.TableClear(self._characterMenuActiveCharacter)
+ table.insert(self._characterMenuActiveCharacter, self.activeCharacterId)
+
+ -- Point convenience properties to character's SavedVariables.
+ for _, propName in ipairs(setCharacterConvenienceProps) do
+ if characterData[self.inventoryTypeSavedVars] == nil then
+ characterData[self.inventoryTypeSavedVars] = {}
+ end
+ if characterData[self.inventoryTypeSavedVars][propName] == nil then
+ characterData[self.inventoryTypeSavedVars][propName] = {}
+ end
+ self[propName] = characterData[self.inventoryTypeSavedVars][propName]
+ end
+
+ -- Initialize container info tables if needed.
+ for _, bagNum in ipairs(self.containerIds) do
+ if self.containers[bagNum] == nil or (self.containers[bagNum] and not self.containers[bagNum].numSlots) then
+ self.containers[bagNum] = {
+ numSlots = 0,
+ }
+ end
+ -- Need to call this here to make sure there aren't errors when Bank is
+ -- brought up offline before visiting a banker.
+ self:InitializeEmptySlotStackTracking(self.containers[bagNum])
+ end
+
+ -- Remove any bag slot highlighting since bags are going to change.
+ self.highlightItemsInContainerId = nil
+ self.highlightItemsInContainerLocked = nil
+
+ self.forceResort = true
+ self:QueueUpdate()
+
+ -- Update character of docked inventory class to match.
+ if self.initialized and self.dockedInventory then
+ self.dockedInventory:SetCharacter(characterId)
+ end
+ end
+
+ --- Given a profile ID and type, activate it for use if possible. Uses fallback
+ --- logic in the Profile class' `GetUsableProfile()` function if the desired profile
+ --- isn't available.
+ ---@param desiredProfileId string|number ID of profile to activate.
+ ---@param profileType BS_PROFILE_TYPE Type for which this profile should be used.
+ ---@param startup boolean? Changing the value of the profile* settings triggers a call to this function, so this needs to be true during startup to prevent repeated calls.
+ ---@return string|nil # ID of selected profile, if any.
+ function Inventory:SetProfile(desiredProfileId, profileType, startup)
+ -- Prevent double-call during initialization from settings change.
+ if not self.initialized and not startup then
+ return
+ end
+
+ -- Ensure the desired profile is actually valid.
+ -- GetUsableProfile() takes care of figuring out what profile to use if there's an issue.
+ -- It also handles first login logic when desiredProfileId will be nil.
+ local profile, profileId = BsProfiles:GetUsableProfile(desiredProfileId, profileType)
+
+ -- Store a reference to the profile so the Settings class can update it.
+ self.profiles[profileType] = profile[BsUtil.LowercaseFirstLetter(profileType)]
+
+ -- Assign the Structure profile's layout to the correct convenience property.
+ if profileType == BS_PROFILE_TYPE.STRUCTURE then
+ if self.dockTo then
+ -- Docked inventory is stored in its own table.
+ self.layout = profile.structure.docked[self.inventoryType].layout
+ else
+ self.layout = profile.structure.primary.layout
+ end
+ end
+
+ -- Store the selected profile in settings.
+ if not self.dockTo then
+ self.settings["profile" .. profileType] = profileId
+ -- Ensure the profile has all default values filled in and reset the settings cache.
+ -- Only do this if already initialized. Otherwise it'll happen multiple times at startup.
+ -- `Init()` takes care of these steps at startup.
+ if self.initialized then
+ self.settings:SetDefaults(false, BS_SETTING_SCOPE.INVENTORY)
+ self.settings:InitCache()
+ end
+ end
+
+ -- Update docked inventory to use the same profile.
+ if not self.dockTo and self.dockedInventory then
+ self.dockedInventory:SetProfile(profileId, profileType)
+ end
+
+ return profileId
+ end
+
+ --- Enable self:PrintDebug() for Inventory classes that prefixes the output with the
+ --- inventory type and enables control of debug output at the class instance level.
+ function Inventory:PrintDebug(msg, r, g, b)
+ if self.debug then
+ Bagshui:PrintDebug(msg, self.inventoryType, r, g, b)
+ end
+ end
+
+ --- Call the same function for every Inventory class instance.
+ ---@param functionName string
+ function Bagshui:CallInventoryClassFunctionForAll(functionName, arg1, arg2, arg3)
+ if self.prototypes.Inventory[functionName] then
+ for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
+ self.components[inventoryType][functionName](self.components[inventoryType], arg1, arg2, arg3)
+ end
+ end
+ end
+
+ --- Trigger `Resort()` for every every Inventory class instance.
+ --- Used by key bindings.
+ function Bagshui:ResortAll()
+ self:CallInventoryClassFunctionForAll("Resort")
+ end
+
+ --- Trigger `Restack()` for every every Inventory class instance.
+ --- Used by key bindings.
+ function Bagshui:RestackAll()
+ self:CallInventoryClassFunctionForAll("Restack")
+ end
+end)
diff --git a/Components/ItemInfo.lua b/Components/ItemInfo.lua
new file mode 100644
index 0000000..21ccd81
--- /dev/null
+++ b/Components/ItemInfo.lua
@@ -0,0 +1,936 @@
+-- Bagshui Item Information
+--
+-- Obtain information about a given item.
+-- Provide PeriodicTable interface.
+-- Display a window with an edit box to provide copyable item information.
+
+-- Using `LoadComponent()` to get `BsItemInfo` into the environment immediately so
+-- `Bagshui:AddonLoaded()` can access `InitializeItem()` for inventory cache validation.
+Bagshui:LoadComponent(function()
+ Bagshui:AddConstants({
+
+ ---@enum BS_ITEM_INFO_DISPLAY_TYPE
+ -- Some item properties need to display differently depending on where they're
+ -- being shown. For example, periodicTable only provides a count of sets in
+ -- menus and tooltips, but gives the entire list of sets for text display.
+ -- This is a parameter for ItemInfo:ItemPropertyValuesForDisplay() and is passed
+ -- to the dynamic item property functions.
+ -- ```
+ -- { UPPERCASE = "UPPERCASE" }
+ -- ```
+ ---@type table
+ BS_ITEM_INFO_DISPLAY_TYPE = {
+ MENU = "MENU",
+ TEXT = "TEXT",
+ TOOLTIP = "TOOLTIP",
+ },
+
+ -- ItemInfo:InitializeItem() will never reset these properties.
+ ---@type table
+ BS_ITEM_INFO_PROTECTED_PROPERTIES = {
+ bagNum = true,
+ bagType = true,
+ slotNum = true,
+ },
+
+ -- Values for `itemUsableStatusCache`.
+ -- It's assumed that anything already known is also usable.
+ ---@type table
+ BS_ITEM_USABLE = {
+ NO = "No",
+ YES = "Yes",
+ KNOWN = "AlreadyKnown",
+ },
+ })
+
+ local ItemInfo = {
+ -- Last set of periodic table sets, which correspond to `periodicTableSetCacheItemLink`.
+ periodicTableSetCache = {},
+
+ -- This is the item for which the `periodicTableSetCache` was generated.
+ periodicTableSetCacheItemLink = "_", -- Not using "" so that empty slots don't match.
+
+ -- Usability of items, cached so we don't have to constantly reload tooltips.
+ itemUsableStatusCache = {},
+ -- Last update for each item in `itemUsableStatusCache`.
+ itemUsableStatusCacheTimestamps = {},
+ -- Cache invalidation.
+ itemUsableStatusMinTimestamp = 0,
+
+ -- Usability of items, cached so we don't have to constantly reload tooltips.
+ itemUsableStatusCache = {},
+ -- Last update for each item in `itemUsableStatusCache`.
+ itemUsableStatusCacheTimestamps = {},
+ -- Cache invalidation.
+ itemUsableStatusMinTimestamp = 0,
+
+ -- This is the ItemInfo window, declared in `ItemInfo:Init()`.
+ window = nil,
+ }
+ Bagshui.components.ItemInfo = ItemInfo
+ Bagshui.environment.BsItemInfo = ItemInfo
+
+ --- Given an item link or string, obtain as much information as possible.
+ --- # Important best practice for this function!
+ --- Except in special cases, provide fallback values from `BS_ITEM_SKELETON`:
+ --- ```
+ --- item.name = newName or BS_ITEM_SKELETON.name
+ --- ```
+ --- This makes it safe for other code to assume that item property values won't be nil.
+ --- Notable special cases, both used for empty slot differentiation:
+ --- * `itemString`
+ --- * `itemLink`
+ ---@param itemIdentifier string|number|nil Item ID, link, or string. Allowed to be nil for empty slots.
+ ---@param itemInfoTable table Table that will be filled with item information.
+ ---@param initialize boolean? When true, always wipe the provided `itemInfoTable` before doing anything else.
+ ---@param reinitialize boolean? `itemInfoTable` will be wiped after a successful call to `GetItemInfo()` when this is true.
+ ---@param forceIntoLocalGameCache boolean? When true, call `LoadItemIntoLocalGameCache()` before doing anything else.
+ ---@param inventory table? Inventory class instance associated with this item.
+ ---@return boolean infoRetrievalSuccess # False if `GetItemInfo()` returned nil.
+ function ItemInfo:Get(itemIdentifier, itemInfoTable, reinitialize, initialize, forceIntoLocalGameCache, inventory)
+ assert(type(itemInfoTable) == "table", "ItemInfo:Get(): itemInfoTable must be a table")
+ -- Bagshui:PrintDebug(itemIdentifier)
+
+ local itemName, itemTexture, itemString, itemStringFromGetItemInfo, itemQuality, itemMinLevel, itemType, itemSubtype, itemMaxStackCount, itemEquipLocation, itemId, itemLinkWithoutRandomSuffix, itemBaseName, itemSuffixName
+
+ local item = itemInfoTable
+
+ local itemAlreadyInitialized = false
+
+ -- Load the item into the local game cache if required
+ if forceIntoLocalGameCache then
+ self:LoadItemIntoLocalGameCache(itemIdentifier)
+ end
+
+ -- When the itemInfoTable is empty, it always makes sense to initialize.
+ if initialize or (item.id == nil and item.name == nil and item.itemLink == nil) then
+ self:InitializeItem(item)
+ itemAlreadyInitialized = true
+ end
+
+ -- Attempt initial item information retrieval.
+ -- This is all done before anything else to avoid wiping the cache entry if we
+ -- get nil values from GetItemInfo().
+ -- We can only call GetItemInfo if there's an item in the slot.
+ if itemIdentifier ~= nil then
+ -- Get the various permutations of information we need from the item identifier, if it's a link or item:#:#:#:# string.
+ if type(itemIdentifier) == "string" then
+ -- itemStringGeneric currently isn't utilized in Bagshui
+ itemString, itemId, _, itemLinkWithoutRandomSuffix = self:ParseItemLink(itemIdentifier)
+ end
+
+ -- Call and store GetItemInfo results
+ -- itemString can be nil if itemIdentifier was numeric or ParseItemLink couldn't get results,
+ -- so try with the original identifier in that case.
+ itemName, itemStringFromGetItemInfo, itemQuality, itemMinLevel, itemType, itemSubtype, itemMaxStackCount, itemEquipLocation, itemTexture =
+ _G.GetItemInfo(itemString or itemIdentifier)
+
+ -- Post-server query item identifier processing.
+ if not itemString and itemStringFromGetItemInfo then
+ -- The given itemIdentifier was just an ID, but now we have the itemString.
+ itemString, itemId, _, itemLinkWithoutRandomSuffix = self:ParseItemLink(itemStringFromGetItemInfo)
+ end
+
+ -- When GetItemInfo returns nil there's (almost) nothing more we can do.
+ if itemName == nil then
+ Bagshui:PrintDebug("GetItemInfo() returned nil - bailing")
+ return false
+ end
+ end
+
+ -- If we make it this far, it's safe to proceed.
+
+ -- Start fresh if allowed to do so.
+ -- This is separate from the previous initialization because for Inventory cache
+ -- entries, we don't want to wipe them until a successful call to GetItemInfo()
+ -- has been made. Without this protection, cache entries can be wiped at startup
+ -- before the client is getting answers from the server and opening the Inventory
+ -- at that point will be like a first-run experience instead of instantly being
+ -- able to see what was cached from the previous session. It's not a HUGE deal,
+ -- but preserving the cache does make things feel better.
+ if reinitialize and not itemAlreadyInitialized then
+ -- Bagshui:PrintDebug("re-initialized " .. tostring(item.bagNum) .. ":" .. tostring(item.slotNum))
+ self:InitializeItem(item)
+ end
+
+ -- Add item information that will be applicable regardless of whether this is
+ -- an empty slot (or nil'd if it's an empty slot).
+
+ item.itemString = itemString -- Must be allowed to be nil for empty slot identification.
+ item.id = itemId or BS_ITEM_SKELETON.id
+ item.texture = itemTexture or BS_ITEM_SKELETON.texture
+ item.bagshuiInventoryType = (inventory and inventory.inventoryType or BS_ITEM_SKELETON.bagshuiInventoryType) -- Item location (Bags, Bank, etc).
+
+ -- itemStringGeneric is disabled -- see BS_ITEM_SKELETON in Bagshui.lua for details.
+ -- To restore this, bring back:
+ -- - local itemStringGeneric at top of function.
+ -- - Replace _ with itemStringGeneric in both calls to BsItemInfo:ParseItemLink above.
+ --item.itemStringGeneric = (itemStringGeneric ~= itemString) and itemStringGeneric or nil
+
+ -- There's an item, so we can proceed with populating all the item info.
+ if item.itemString ~= nil then
+ -- GetItemInfo() results.
+
+ item.name = itemName or BS_ITEM_SKELETON.name
+ -- EngInventory/EngBags had an "unknown" quality value of -1 but that doesn't
+ -- seem likely to occur (or particularly relevant to convey?), so let's just
+ -- use the default quality color if we somehow didn't get one.
+ item.quality = itemQuality or BS_ITEM_SKELETON.quality
+ item.minLevel = itemMinLevel or BS_ITEM_SKELETON.minLevel
+ item.type = itemType or BS_ITEM_SKELETON.type
+ item.subtype = itemSubtype or BS_ITEM_SKELETON.subtype
+ item.maxStackCount = itemMaxStackCount or BS_ITEM_SKELETON.maxStackCount
+ item.equipLocation = itemEquipLocation or BS_ITEM_SKELETON.equipLocation
+
+ -- Apply any property overrides from Config\ItemFixes.lua.
+ if Bagshui.config.ItemFixes[item.id] then
+ for property, value in pairs(Bagshui.config.ItemFixes[item.id]) do
+ if BS_ITEM_SKELETON[property] then
+ item[property] = value
+ end
+ end
+ end
+
+ -- Store localized versions of information.
+ if item.equipLocation and string.len(item.equipLocation) > 0 then
+ item.equipLocationLocalized = _G[item.equipLocation] or BS_ITEM_SKELETON.equipLocationLocalized
+ -- This is also the best place to add the sortable version of equipLocation.
+ item.equipLocationSort = BS_INVENTORY_EQUIP_LOCATION_SORT_ORDER[item.equipLocation]
+ or BS_ITEM_SKELETON.equipLocationSort
+ end
+ item.qualityLocalized = _G[string.format("ITEM_QUALITY%d_DESC", item.quality)]
+ or BS_ITEM_SKELETON.qualityLocalized
+
+ -- If this is an item with a random suffix, figure out what it is.
+ -- This will be used for reversed name sorting so that "Amazing Helmet of the Bear"
+ -- can be sorted as "Helmet Amazing Bear the of" instead of "Bear the of Helmet Amazing".
+ if itemLinkWithoutRandomSuffix ~= nil then
+ itemBaseName = _G.GetItemInfo(itemLinkWithoutRandomSuffix)
+ itemSuffixName = BsUtil.Trim(string.gsub(itemName, itemBaseName, ""))
+ end
+ item.baseName = itemBaseName or BS_ITEM_SKELETON.baseName
+ item.suffixName = itemSuffixName or BS_ITEM_SKELETON.suffixName
+
+ -- Load tooltip.
+ self:GetTooltip(item, inventory)
+ end -- itemString exists
+
+ -- Construct an item link if the itemIdentifier wasn't one.
+ -- itemLink must be allowed to be nil for empty slot identification.
+ if itemString ~= nil and not string.find(tostring(itemIdentifier), "^|") then
+ local itemQualityColor = _G.ITEM_QUALITY_COLORS[item.quality or 1].hex
+ item.itemLink = itemQualityColor .. "|H" .. itemString .. "|H[" .. (itemName or L.Unknown) .. "]|H|r"
+ else
+ item.itemLink = itemIdentifier
+ end
+
+ return true
+ end
+
+ --- Set all values of an inventory cache item to default.
+ --- It's up to calling functions to manage any of the properties in
+ --- BS_ITEM_PROTECTED_PROPERTIES when they already have values.
+ ---@param itemInfoTable table Table that will be filled with item information.
+ ---@param initializeProtected boolean? Reset properties protected by `BS_ITEM_PROTECTED_PROPERTIES`.
+ ---@param validate boolean? Never overwrite anything. In this mode, missing properties are filled and anything not in `BS_ITEM_SKELETON` is removed.
+ function ItemInfo:InitializeItem(itemInfoTable, initializeProtected, validate, temp)
+ -- Existing property resets/missing property filling.
+ for itemKey, defaultValue in pairs(BS_ITEM_SKELETON) do
+ if
+ (itemInfoTable[itemKey] == nil and not BS_ITEM_NIL_PROPERTIES[itemKey])
+ or (
+ -- Don't reset properties with values during validation.
+not validate
+ -- Never reset a protected property unless forced.
+ and not (BS_ITEM_PROTECTED_PROPERTIES[itemKey] and not initializeProtected)
+ )
+ then
+ if type(defaultValue) == "table" then
+ itemInfoTable[itemKey] = BsUtil.TableCopy(defaultValue)
+ else
+ itemInfoTable[itemKey] = defaultValue
+ end
+ end
+ end
+
+ -- Validation only: remove extraneous properties.
+ if validate then
+ for itemKey in next, itemInfoTable do
+ if BS_ITEM_SKELETON[itemKey] == nil then
+ rawset(itemInfoTable, itemKey, nil)
+ end
+ end
+ end
+ end
+
+ --- Set the proper cache values for an empty slot item.
+ ---@param item table Filled by `ItemInfo:Get()`.
+ ---@param isEmptySlotStack boolean? true for empty slot stack proxy entries.
+ function ItemInfo:InitializeEmptySlotItem(item, isEmptySlotStack)
+ item.id = 0
+ -- Using 0/1 for ease of sorting.
+ item.emptySlot = 1
+ -- Add "[Profession Bag Type]" to the name when needed.
+ item.name = (isEmptySlotStack and item.bagType ~= BsGameInfo.itemSubclasses.Container.Bag)
+ and string.format(L.Suffix_EmptySlot, item.bagType)
+ or L.ItemPropFriendly_emptySlot
+ item.subtype = item.bagType
+ item.quality = -1
+ item.charges = -1
+ item.texture = nil
+ item.readable = nil
+ if not item._bagsRepresented then
+ item._bagsRepresented = {}
+ else
+ BsUtil.TableClear(item._bagsRepresented)
+ end
+ end
+
+ --- `GetItemInfo()` in Vanilla doesn't accept the full item links returned by `GetContainerItemLink()`,
+ --- so we need to parse out just the `item:itemId:enchantId:suffixId:uniqueId` part.
+ ---
+ --- While we're doing that, we might as well pull out the item ID and the generic item link,
+ --- which is the same as the item link with enchantId and uniqueId set to 0, (even though we're not using it).
+ ---
+ --- If applicable, also figure out the version of the item string associated with the base item that
+ --- doesn't have a the random suffix (i.e. "of the Bear") for use during reverse name sorting
+ --- (see comment about `itemLinkWithoutRandomSuffix` in `ItemInfo:Get()`).
+ ---
+ --- Reference: https://warcraft.wiki.gg/index.php?title=ItemLink&oldid=4718845
+ ---@param itemLink string Item link or item string.
+ ---@return string? itemString
+ ---@return number? itemId
+ ---@return string? itemStringGeneric
+ ---@return string? itemStringWithoutRandomSuffix
+ function ItemInfo:ParseItemLink(itemLink)
+ if not itemLink then
+ return
+ end
+ local itemString, itemStringGeneric, itemStringWithoutRandomSuffix
+ local found, _, itemId, enchantId, suffixId, uniqueId =
+ string.find(tostring(itemLink), "item:(%d+):(%d+):(%d+):(%d+)")
+ if found then
+ local itemFormatString = "item:%s:%s:%s:%s"
+ itemString = string.format(itemFormatString, itemId, enchantId, suffixId, uniqueId)
+ itemStringGeneric = string.format(itemFormatString, itemId, "0", suffixId, "0")
+ if suffixId ~= "0" then
+ itemStringWithoutRandomSuffix = string.format(itemFormatString, itemId, "0", "0", "0")
+ end
+ end
+ return itemString, tonumber(itemId), itemStringGeneric, itemStringWithoutRandomSuffix
+ end
+
+ --- Store item's tooltip on the item.tooltip property as a single string.
+ --- Also parse charges from the tooltip if possible.
+ ---@param item table Item information table in the format of BS_ITEM_SKELETON.
+ ---@param inventory table? Inventory class, if applicable.
+ ---@param forceItemString boolean? Ignore bagNum/slotNum properties on `item` and use `itemString`.
+ function ItemInfo:GetTooltip(item, inventory, forceItemString)
+ -- Reset charges first since this is where they're picked up (when SuperWoW isn't loaded).
+ if not BS_SUPER_WOW_LOADED then
+ item.charges = 0
+ end
+
+ item.tooltip = ""
+ item.lockPickable = BS_ITEM_SKELETON.lockPickable
+ item.openable = BS_ITEM_SKELETON.openable
+
+ -- Pull the tooltip text into our hidden tooltip.
+ BsHiddenTooltip:SetOwner(_G.WorldFrame, "ANCHOR_NONE") -- Need to clear lines first.
+ self:LoadTooltip(BsHiddenTooltip, item, inventory, forceItemString)
+
+ -- Loop through all lines of tooltip and concatenate.
+ -- This is also where we can figure out if an item has charges.
+ local ttTextFrame, ttText
+ local ttBagshuiData = BsHiddenTooltip.bagshuiData
+ local chargesFound, chargeCount
+ -- Lines
+ for ttLineNum = 2, BsHiddenTooltip:NumLines() do
+ -- Left/Right
+ -- Don't use _ as a placeholder here since it's used inside the loop.
+ for __, lr in ipairs(ttBagshuiData.textFieldsPerLine) do
+ ttTextFrame = _G[ttBagshuiData.name .. "Text" .. lr .. ttLineNum]
+ if ttTextFrame ~= nil and ttTextFrame:IsVisible() then
+ ttText = ttTextFrame:GetText()
+ if ttText ~= nil then
+ -- Append to tooltip.
+ ttText = string.gsub(ttText, "^%s+$", "")
+ item.tooltip = item.tooltip .. BS_INVENTORY_TOOLTIP_JOIN_CHARACTERS[lr] .. ttText
+
+ -- Check whether openable or lock-pickable.
+ if ttText == L.TooltipIdentifier_Openable then
+ item.openable = 1
+ end
+ if ttText == L.TooltipIdentifier_Locked then
+ item.lockPickable = 1
+ end
+
+ -- Parse charges and store, but don't overwrite if we already know them.
+ if not BS_SUPER_WOW_LOADED and item.charges == 0 then
+ chargesFound, _, chargeCount = string.find(ttText, L.TooltipParse_Charges)
+ if chargesFound then
+ item.charges = tonumber(chargeCount)
+ end
+ end
+ end -- ttText exists
+ end -- ttTextFrame exists
+ end -- Left/Right
+ end -- Lines
+
+ item.tooltip = BsUtil.Trim(item.tooltip)
+
+ -- Might as well update usable status while we have the tooltip loaded.
+ self:CacheUsableStatusFromHiddenTooltip(item, false)
+ end
+
+ --- Load the tooltip for the given item into the provided `tooltipFrame`.
+ --- Normally we can use `SetBagItem()`, but some containers need to use `SetInventoryItem()` instead.
+ ---@param tooltipFrame table WoW UI tooltip frame created with `CreateFrame("GameTooltip" ... )`.
+ ---@param item table|string Item information table populated by ItemInfo:Get() or an itemString ("item:#:#:#:#").
+ ---@param inventory table? Inventory class instance, if applicable.
+ ---@param forceItemString boolean? Ignore bagNum/slotNum properties on `item` and use `itemString`.
+ ---@return number|nil|boolean hasCooldown Is the item on cooldown?
+ ---@return number|nil repairCost If the item is damaged, value in copper to repair.
+ function ItemInfo:LoadTooltip(tooltipFrame, item, inventory, forceItemString)
+ local hasCooldown, repairCost
+
+ -- Wipe the tooltip first, just to be safe.
+ tooltipFrame:ClearLines()
+
+ -- Determine whether the tooltip needs to be loaded using the itemString (via SetHyperlink()).
+ -- There are multiple reasons this can occur:
+ --
+ -- 1. A string or number was passed instead of a table.
+ local itemString = type(item) == "string" and item
+ or type(item) == "number" and ("item:" .. item .. ":0:0:0:0:0:0:0:0")
+ or nil --[[@as string|number|nil]]
+ --
+ -- 2a. An an item table was passed but bagNum and slotNum are both the default, meaning we
+ -- can't use SetInventoryItem()/SetBagItem().
+ -- 2b. Offline mode, where the Set*Item() functions aren't available.
+ -- 2C. forceItemString is set.
+ if
+ type(item) == "table"
+ and (
+ (item.bagNum == BS_ITEM_SKELETON.bagNum and item.slotNum == BS_ITEM_SKELETON.slotNum)
+ or (inventory and not inventory.online)
+ or forceItemString
+ )
+ then
+ itemString = item.itemString
+ end
+
+ -- When itemString was given a value for one of the reasons above, that means
+ -- we should use SetHyperlink().
+ if itemString then
+ tooltipFrame:SetHyperlink(itemString)
+ elseif type(item) == "table" then
+ -- Inventory configurations can provide a function that translates the item's slotNum
+ -- from the sequential 0-N bag slot to the global inventory slot number when
+ -- they need to use SetInventoryItem() instead of SetBagItem().
+ if inventory and inventory.primaryContainer.id == item.bagNum and inventory.getInventorySlotFunction then
+ -- SetInventoryItem() returns hasItem, hasCooldown, repairCost.
+ _, hasCooldown, repairCost =
+ tooltipFrame:SetInventoryItem("player", inventory.getInventorySlotFunction(item.slotNum))
+ else
+ hasCooldown, repairCost = tooltipFrame:SetBagItem(item.bagNum, item.slotNum)
+ end
+ end
+
+ return hasCooldown, repairCost
+ end
+
+ --- Fill the given tooltip with a list of all item properties.
+ ---@param item table Filled by `ItemInfo:Get()`.
+ ---@param tooltip table WoW UI tooltip.
+ function ItemInfo:AddTooltipInfo(item, tooltip)
+ Bagshui:AddTooltipLine(tooltip, nil, L.ItemProperties, true)
+ -- Iterate all item properties.
+ for _, itemPropertyFriendly, itemPropertyValue, itemPropertyDisplay in
+ self:ItemPropertyValuesForDisplay(item, BS_ITEM_INFO_DISPLAY_TYPE.TOOLTIP)
+ do
+ if itemPropertyValue ~= nil then
+ Bagshui:AddTooltipLine(tooltip, itemPropertyDisplay, string.format(L.Symbol_Colon, itemPropertyFriendly))
+ end
+ end
+ end
+
+ --- Ensure GetItemInfo will work for an item by forcing it into the local game cache via hidden tooltip load.
+ ---@param itemIdentifier string|number|nil itemString ("item:#:#:#:#") or item number.
+ function ItemInfo:LoadItemIntoLocalGameCache(itemIdentifier)
+ if not itemIdentifier or itemIdentifier == 0 then
+ return
+ end
+ local itemString
+ if type(itemIdentifier) == "number" or (type(itemIdentifier) == "string" and tonumber(itemIdentifier)) then
+ -- Extra 0s seem to sometimes be necessary to make SetHyperlink work, and they never have hurt during testing.
+ itemString = "item:" .. itemIdentifier .. ":0:0:0:0:0:0:0:0"
+ else
+ itemString = self:ParseItemLink(itemIdentifier)
+ end
+ if itemString then
+ BsHiddenTooltip:SetHyperlink(itemString)
+ end
+ end
+
+ --- Check whether the given Bagshui item is usable by the current character.
+ --- This originally determined status by processing item properties and parsing
+ --- the tooltip to compare against character level, class, and skills.
+ --- That worked great until it didn't (Fist Weapons totally broke it), and
+ --- it was completely reworked to use the new method in `ItemInfo:CacheUsableStatusFromHiddenTooltip()`.
+ ---@param item table Bagshui Inventory cache entry.
+ ---@return boolean usable
+ ---@return boolean? alreadyKnown
+ function ItemInfo:IsUsable(item)
+ -- When there's no information about the item or character, assume it IS usable.
+ if not item.id or item.id == 0 then
+ return true
+ end
+
+ -- Update cache if needed.
+ if
+ not self.itemUsableStatusCache[item.id]
+ or not self.itemUsableStatusCacheTimestamps[item.id]
+ or self.itemUsableStatusCacheTimestamps[item.id] < self.itemUsableStatusMinTimestamp
+ then
+ self:CacheUsableStatusFromHiddenTooltip(item, true)
+ end
+
+ -- Determine status.
+ if self.itemUsableStatusCache[item.id] == BS_ITEM_USABLE.NO then
+ return false
+ elseif self.itemUsableStatusCache[item.id] == BS_ITEM_USABLE.KNOWN then
+ return true, true
+ end
+
+ return true
+ end
+
+ --- Determine whether the given item is usable based on information in its tooltip.
+ --- The result will be stored in `ItemInfo.itemUsableStatusCache`.
+ ---
+ --- It's built this way so the function can be called from `ItemInfo:GetTooltip()`
+ --- and `ItemInfo:IsUsable()`. By calling from the former, we can pre-cache
+ --- usable status for many items and avoid loading the tooltip multiple times.
+ ---
+ --- Based on techniques from pfUI's `unusable:UpdateSlot()` and libtipscan `findColor()`.
+ ---@param item table Item information table in the format of BS_ITEM_SKELETON.
+ ---@param needToLoadTooltip boolean? `true` if the item's tooltip is NOT currently loaded into `BsHiddenTooltip`.
+ function ItemInfo:CacheUsableStatusFromHiddenTooltip(item, needToLoadTooltip)
+ local usable
+
+ -- Already known check.
+ -- Unfortunately the "Already Known" text in the tooltip is red, not green,
+ -- so we need to actually look for the text itself.
+ if item.tooltip and string.find(item.tooltip, L.TooltipParse_AlreadyKnown) then
+ -- Bagshui:PrintDebug(item.name .. " already known")
+ usable = BS_ITEM_USABLE.KNOWN
+ end
+
+ -- Look for red text in the tooltip, which indicates it's unusable.
+ -- We're going to stop at the first blank line because recipes indicate usability
+ -- of the recipe itself, then an empty line, followed by information about the item
+ -- created by the recipe, which includes whether the item is usable. We only want
+ -- to know about the recipe's usability.
+ if not usable then
+ if needToLoadTooltip then
+ self:LoadTooltip(BsHiddenTooltip, item)
+ end
+
+ local ttTextFrame, ttText, leftText, rightText, hasRedText, r, g, b
+ local ttBagshuiData = BsHiddenTooltip.bagshuiData
+
+ -- This duplicates code and logic in Inventory:ItemButton_OnEnter() as well
+ -- as ItemInfo:GetTooltip(), so it would be nice to refactor eventually.
+ for ttLineNum = 1, BsHiddenTooltip:NumLines() do
+ leftText = nil
+ rightText = nil
+ hasRedText = false
+ -- Left/Right
+ for i, lr in ipairs(ttBagshuiData.textFieldsPerLine) do
+ ttTextFrame = _G[ttBagshuiData.name .. "Text" .. lr .. ttLineNum]
+ if ttTextFrame ~= nil and ttTextFrame:IsVisible() then
+ ttText = ttTextFrame:GetText()
+
+ if ttText ~= nil and not string.find(ttText, "^" .. BS_NEWLINE) then
+ if i == 1 then
+ leftText = BsUtil.Trim(ttText)
+ else
+ rightText = BsUtil.Trim(ttText)
+ end
+ end
+
+ r, g, b = ttTextFrame:GetTextColor()
+ r, g, b = BsUtil.Round(r, 1), BsUtil.Round(g, 1), BsUtil.Round(b, 1)
+ if r == _G.RED_FONT_COLOR.r and g == _G.RED_FONT_COLOR.g and b == _G.RED_FONT_COLOR.b then
+ -- Just track whether there's red text until we're sure it's
+ -- not a blank red line (hey, it could happen).
+ hasRedText = true
+ end
+ end
+ end
+
+ -- We found a blank line and need to stop.
+ if string.len(leftText or "") == 0 and string.len(rightText or "") == 0 then
+ break
+ end
+
+ -- Once we're sure it's not a blank line, we can take action if it's not usable.
+ if hasRedText then
+ usable = BS_ITEM_USABLE.NO
+ break
+ end
+ end
+ end
+
+ -- Update cache, defaulting to usable if we haven't found a reason it's not.
+ self.itemUsableStatusCache[item.id] = usable or BS_ITEM_USABLE.YES
+ self.itemUsableStatusCacheTimestamps[item.id] = _G.GetTime()
+ end
+
+ --- Call this to require all item usability statues to be updated.
+ function ItemInfo:InvalidateUsableCache()
+ self.itemUsableStatusMinTimestamp = _G.GetTime()
+ end
+
+ --- Is the given item a bag?
+ ---@param item table Inventory cache entry.
+ ---@return boolean
+ function ItemInfo:IsContainer(item)
+ return (type(item) == "table" and (item.type == L.Container or item.type == L.Quiver))
+ end
+
+ --- Wrap an item's name in the appropriate color escape sequence for its quality level.
+ ---@param item table Item information table populated by ItemInfo:Get().
+ ---@return string
+ function ItemInfo:GetQualityColoredName(item)
+ if type(item) ~= "table" then
+ return L.Unknown
+ end
+ return _G.ITEM_QUALITY_COLORS[item.quality or 1].hex
+ .. ((string.len(item.name or "") > 0) and item.name or L.Unknown)
+ .. FONT_COLOR_CODE_CLOSE
+ end
+
+ --- Obtain a sorted array of either the full list of PeriodicTable sets or
+ --- those pertaining to the given item.
+ ---@param itemOrItemLink table|string|number? Item information table populated by ItemInfo:Get() or item link.
+ ---@return table
+ function ItemInfo:GetPeriodicTableSets(itemOrItemLink)
+ -- When an item info table is provided, grab the item link.
+ if type(itemOrItemLink) == "table" then
+ itemOrItemLink = itemOrItemLink.itemLink
+ end
+
+ -- We've just seen this item and already know its sets.
+ if
+ (itemOrItemLink and itemOrItemLink == self.periodicTableSetCacheItemLink)
+ or (not itemOrItemLink and self.periodicTableSetCacheItemLink == "~")
+ then
+ return self.periodicTableSetCache
+ end
+
+ -- Reset the cache and update the cached item identifier.
+ BsUtil.TableClear(self.periodicTableSetCache)
+ self.periodicTableSetCacheItemLink = (itemOrItemLink or "~")
+
+ -- We need to directly access PeriodicTable's k set storage structure because
+ -- it doesn't seem to provide any way to request the listing of built-in sets.
+ for setName, _ in pairs(Bagshui.libs.PT.k.customsets) do
+ if not itemOrItemLink or (itemOrItemLink and Bagshui.libs.PT:ItemInSet(itemOrItemLink, setName)) then
+ table.insert(self.periodicTableSetCache, setName)
+ end
+ end
+
+ table.sort(self.periodicTableSetCache)
+
+ return self.periodicTableSetCache
+ end
+
+ --- Create a string consisting of itemString, count, and item location that allows
+ --- an item to be (almost) uniquely identified. Used by the "shadow ID" system in
+ --- the inventory cache -- see comment above the definition of shadowId in
+ --- Inventory.Cache.lua for detail.
+ ---@param item table Item information table populated by ItemInfo:Get().
+ ---@param bagNumOverride number? Get the unique ID as if the item is in a different container.
+ ---@param slotNumOverride number? Get the unique ID as if the item is in a different bag slot.
+ ---@return string
+ function ItemInfo:GetUniqueItemId(item, bagNumOverride, slotNumOverride)
+ return (bagNumOverride or item.bagNum or "?")
+ .. ":"
+ .. (slotNumOverride or item.slotNum or "?")
+ .. ":"
+ .. (item.itemString or "?")
+ .. ":"
+ .. (item.count or 0)
+ .. ":"
+ .. (item.bagshuiInventoryType or "?")
+ end
+
+ --- Display the Item Information Window for the given item
+ ---@param item table Item information table populated by ItemInfo:Get().
+ function ItemInfo:Open(item)
+ self.window:Open(item)
+ end
+
+ local arbitraryItemInfo = {}
+ function ItemInfo:OpenWithArbitraryItem(itemIdentifier)
+ if self.lastArbitraryItem == itemIdentifier then
+ self.arbitraryItemTries = (self.arbitraryItemTries or 0) + 1
+ else
+ self.arbitraryItemTries = 0
+ end
+ self.lastArbitraryItem = itemIdentifier
+ if not self:Get(itemIdentifier, arbitraryItemInfo, true, true, true) then
+ if self.arbitraryItemTries < 5 then
+ Bagshui:QueueClassCallback(self, self.OpenWithArbitraryItem, 0.75, nil, itemIdentifier)
+ else
+ Bagshui:PrintError(string.format(L.Error_ItemNotFound, tostring(itemIdentifier)))
+ end
+ return
+ end
+
+ self:Open(arbitraryItemInfo)
+ end
+
+ --- Construct the code examples for the info window.
+ ---@param ruleFunctionExamples string[] Rule functions. If `%s` is found, the item property value will be substituted.
+ ---@param itemPropertyValue string|number? Value to fill if %s is present in the rule function.
+ ---@param itemPropertyFriendly string User-facing localized name for the item property, used if `itemPropertyValue` is nil.
+ ---@return string
+ function ItemInfo:BuildRuleFunctionExampleText(ruleFunctionExamples, itemPropertyValue, itemPropertyFriendly)
+ local ret = ""
+ for _, ruleFunctionExample in ipairs(ruleFunctionExamples) do
+ -- The substitution text for rule function parameters is either the actual value with
+ -- double quotes escaped or `` if the property doesn't have a value.
+ local ruleFunctionParameter = itemPropertyValue ~= nil and string.gsub(tostring(itemPropertyValue), '"', '\\"')
+ or ("<" .. tostring(itemPropertyFriendly) .. ">")
+ -- Replace %s or %d in the example with the parameter value.
+ if string.find(ruleFunctionExample, "%%s") then
+ ruleFunctionExample = string.format(ruleFunctionExample, tostring(ruleFunctionParameter))
+ end
+ -- Add to info text.
+ ret = ret .. BS_FONT_COLOR.CODE_EXAMPLE .. ruleFunctionExample .. FONT_COLOR_CODE_CLOSE .. BS_NEWLINE
+ end
+ return ret
+ end
+
+ --- Get an iterator function that will return item properties in the order of `BS_ITEM_PROPERTIES_SORTED`.
+ ---@param item table Item information table populated by ItemInfo:Get().
+ ---@param displayType BS_ITEM_INFO_DISPLAY_TYPE?
+ ---@return function iterableFunction function with the following return values, in order:
+ --- ```
+ --- ---@return string propertyName # Actual property name.
+ --- ---@return string propertyDisplayName # Friendly localized property name for display.
+ --- ---@return any propertyValue # Actual property value.
+ --- ---@return any propertyDisplayValue # Same as `propertyValue`, but will be the localized "(No Value)" string if `propertyValue` is empty.
+ --- ```
+ function ItemInfo:ItemPropertyValuesForDisplay(item, displayType)
+ self:BuildSortedItemTemplatePropertyList()
+
+ -- These upvalues are needed for the iterator function.
+ local i = 0
+ local realtimeItemProperties = BS_REALTIME_ITEM_INFO_PROPERTIES
+ local L = L
+
+ return function()
+ i = i + 1
+
+ local itemProperty = BS_ITEM_PROPERTIES_SORTED[i]
+
+ local itemPropertyFriendly = itemProperty and L["ItemPropFriendly_" .. itemProperty]
+
+ local itemPropertyValue = (
+ (
+ not (
+ displayType == BS_ITEM_INFO_DISPLAY_TYPE.TOOLTIP
+ and item.bagshuiInventoryType == nil
+ and BS_ITEM_PROPERTIES_SUPPRESSED_IN_TOOLTIP_OUTSIDE_INVENTORY[itemProperty]
+ ) and itemProperty
+ )
+ and (realtimeItemProperties[itemProperty] and realtimeItemProperties[itemProperty](item, displayType) or (item[itemProperty] ~= nil and item[itemProperty] ~= "") and tostring(
+ item[itemProperty]
+ ) or nil)
+ or nil
+ )
+
+ -- Display value is the actual value if it exists or "(No Value)" if not.
+ -- Will be truncated unless it's for text display.
+ local itemPropertyDisplay = itemPropertyValue
+ and (type(itemPropertyValue) ~= "table" and (NORMAL_FONT_COLOR_CODE .. (displayType ~= Bagshui.environment.BS_ITEM_INFO_DISPLAY_TYPE.TEXT and BsUtil.TruncateString(
+ tostring(itemPropertyValue),
+ 35
+ ) or tostring(itemPropertyValue)) .. FONT_COLOR_CODE_CLOSE) or "")
+ or GRAY_FONT_COLOR_CODE .. L.NoValue .. FONT_COLOR_CODE_CLOSE
+
+ if itemPropertyValue == "" then
+ itemPropertyValue = nil
+ end
+
+ return itemProperty, itemPropertyFriendly, itemPropertyValue, itemPropertyDisplay
+ end
+ end
+
+ --- Fill BS_ITEM_PROPERTIES_SORTED with a list of item properties that have a corresponding
+ --- rule function, sorted in friendly (localized) name order.
+ function ItemInfo:BuildSortedItemTemplatePropertyList()
+ -- Only do this once.
+ if not Bagshui.environment.BS_ITEM_PROPERTIES_SORTED then
+ local itemPropertiesSorted = {}
+ -- Only add properties that have a corresponding rule function.
+ for prop, _ in pairs(BS_ITEM_PROPERTIES_TO_FUNCTIONS) do
+ table.insert(itemPropertiesSorted, prop)
+ end
+ -- Sort the list.
+ table.sort(itemPropertiesSorted, function(itmA, itmB)
+ return L["ItemPropFriendly_" .. itmA] < L["ItemPropFriendly_" .. itmB]
+ end)
+ -- Save to Bagshui environment.
+ Bagshui.environment.BS_ITEM_PROPERTIES_SORTED = itemPropertiesSorted
+ end
+ end
+
+ --- Just need to do this little bit after initialization because the UI classes
+ --- aren't loaded until well after ItemInfo.
+ function ItemInfo:Init()
+ -- Add /bagshui info
+ -- The Character parameter is handled here for convenience.
+ local itemInfoSlashCache = {}
+ BsSlash:AddHandler("Info", function(tokens)
+ if not tokens[2] then
+ -- Character may be added in a future update.
+ BsSlash:PrintHandlers({ L.Group, L.ItemId, L.Location }, "Info")
+ elseif BsGameInfo:HandleInfoSlash(tokens) then
+ -- Let GameInfo handle its own stuff.
+ return
+ elseif BsUtil.MatchLocalizedOrNon(tokens[2], "itemid") or BsUtil.MatchLocalizedOrNon(tokens[2], "help") then
+ Bs:PrintBare(L.Slash_Help_ItemInfo)
+ else
+ self:OpenWithArbitraryItem(tokens[2])
+ end
+ end)
+
+ -- Get the UI ready.
+ self.window = Bagshui.prototypes.ScrollableTextWindow:New({
+ name = "ItemInfo",
+ title = "Item Info", -- Will be updated to the name of the item in Open().
+ readOnly = true,
+ width = 400,
+ height = 650,
+ selectAllOnFocus = false,
+ })
+
+ --- ScrollableTextWindow override for item info display.
+ ---@param item table Item information table populated by ItemInfo:Get().
+ function self.window:Open(item)
+ -- Nothing to do.
+ if not item then
+ return
+ end
+
+ local infoText = ""
+
+ if type(item) == "table" then
+ -- Build item information text.
+
+ for itemProperty, itemPropertyFriendly, itemPropertyValue, itemPropertyDisplay in
+ ItemInfo:ItemPropertyValuesForDisplay(item, BS_ITEM_INFO_DISPLAY_TYPE.TEXT)
+ do
+ -- Start this property with `: `.
+ infoText = infoText
+ .. string.format(L.Symbol_Colon, itemPropertyFriendly)
+ .. " "
+ .. itemPropertyDisplay
+ .. BS_NEWLINE
+
+ -- Add rule function examples.
+ local ruleFunctionExamples = BS_ITEM_PROPERTIES_TO_FUNCTIONS[itemProperty]
+ if type(ruleFunctionExamples) == "table" then
+ if type(itemPropertyValue) == "table" then
+ for _, val in ipairs(itemPropertyValue) do
+ infoText = infoText
+ .. ItemInfo:BuildRuleFunctionExampleText(ruleFunctionExamples, val, itemPropertyFriendly)
+ end
+ else
+ infoText = infoText
+ .. ItemInfo:BuildRuleFunctionExampleText(ruleFunctionExamples, itemPropertyValue, itemPropertyFriendly)
+ end
+ else
+ infoText = infoText .. GRAY_FONT_COLOR_CODE .. L.NoRuleFunction .. FONT_COLOR_CODE_CLOSE .. BS_NEWLINE
+ end
+
+ -- End with a final newline so that there are two newlines to separate from
+ -- the next property (extra newlines at the very end don't matter).
+ infoText = infoText .. BS_NEWLINE
+ end
+ end
+
+ -- Prepare to open the window.
+ if self._super.Open(self, infoText, true) == false then
+ return
+ end
+
+ -- Set title to item name.
+ self.uiTitle:SetText(
+ string.format(L.Symbol_Colon, L.BagshuiItemInformation)
+ .. " "
+ .. HIGHLIGHT_FONT_COLOR_CODE
+ .. tostring(item.name)
+ .. FONT_COLOR_CODE_CLOSE
+ )
+
+ -- Set item slot button in top left to item.
+ self.ui:AssignItemToItemButton(self.itemSlotButton, item)
+ self.itemSlotButton.bagshuiData.itemString = item.itemString -- This will be picked up for the tooltip.
+
+ -- Actually open the window.
+ -- self:Open()
+ end
+
+ --- ScrollableTextWindow override for initializing UI.
+ function self.window:InitUi()
+ if not self then
+ return
+ end
+
+ -- Calls ScrollableTextWindow:InitUi().
+ if self._super.InitUi(self) == false then
+ return
+ end
+
+ -- Add item slot button to top left of window.
+ self.itemSlotButton = self.ui:CreateItemSlotButton("ItemSlotButton", self.uiHeader)
+ self.itemSlotButton.bagshuiData.tooltipAnchorDefault = true
+ self.itemSlotButton.bagshuiData.noBorderScale = true
+ self.ui:SetItemButtonSize(self.itemSlotButton, self.uiTitle:GetHeight() + 2)
+ self.itemSlotButton:SetPoint("LEFT", self.uiFrame.bagshuiData.header)
+ -- Remove clickability and pass drag events to the window.
+ self.itemSlotButton:RegisterForClicks(nil)
+ self.itemSlotButton:RegisterForDrag("LeftButton")
+ self.itemSlotButton:SetScript("OnDragStart", function()
+ if _G.GameTooltip:IsOwned(_G.this) then
+ _G.GameTooltip:Hide()
+ end
+ self.uiFrame:StartMoving()
+ end)
+ self.itemSlotButton:SetScript("OnDragStop", function()
+ self.uiFrame:StopMovingOrSizing()
+ end)
+
+ -- Anchor title to item slot button.
+ self.uiTitle:ClearAllPoints()
+ self.uiTitle:SetPoint("LEFT", self.itemSlotButton, "RIGHT", 4, 0)
+ end
+ end
+
+ --- Event handling.
+ ---@param event string Event identifier.
+ ---@param arg1 any? Argument 1 from the event.
+ function ItemInfo:OnEvent(event, arg1)
+ if event == "ADDON_LOADED" then
+ self:Init()
+ end
+ end
+
+ Bagshui:RegisterEvent("ADDON_LOADED", ItemInfo)
+end)
diff --git a/Components/Keyring.lua b/Components/Keyring.lua
new file mode 100644
index 0000000..eb8df67
--- /dev/null
+++ b/Components/Keyring.lua
@@ -0,0 +1,14 @@
+-- Bagshui Keyring Inventory Class Instance
+-- Exposes: Bagshui.components.Bags [via Inventory:New()]
+
+Bagshui:AddComponent(function()
+ -- Create class instance.
+ local Keyring = Bagshui.prototypes.Inventory:New(BS_INVENTORY_TYPE.KEYRING)
+
+ --- The only special thing about Keyring is that it hooks ToggleKeyring.
+ ---@param hookFunctionName string Name of the original WoW API function.
+ ---@param bagNumParam number Container ID.
+ function Keyring:ToggleKeyring(hookFunctionName, bagNumParam)
+ self:OpenCloseToggle(BS_INVENTORY_UI_VISIBILITY_ACTION.TOGGLE, hookFunctionName, bagNumParam)
+ end
+end)
diff --git a/Components/Localization.lua b/Components/Localization.lua
new file mode 100644
index 0000000..110ba28
--- /dev/null
+++ b/Components/Localization.lua
@@ -0,0 +1,242 @@
+-- Bagshui Localization
+-- Exposes:
+-- - BSLocalization (and Bagshui.components.Localization)
+-- - L[] and L_nil[]
+-- Raises: BAGSHUI_LOCALIZATION_LOADED
+--
+-- Localization is performed in the Bagshui environment as via the L table, and
+-- can be referenced as L.StringKey or L["StringKey"].
+-- There is also the L_nil variant, which returns nil on a localization failure
+-- instead of returning the original string.
+
+-- Loading the Localization class happens in 2 phases:
+-- 1. The LoadComponent() portion immediately injects the localization class into
+-- the Bagshui namespace so that AddLocale() becomes available.
+-- 2. Once everything is up and running, AddComponent() is processed and the localization is loaded.
+
+-- Need to declare the class here so it's available to both LoadComponent() and AddComponent().
+local Localization = {
+ -- Proxy tables that handle localization calls -- see localizationMetatable below for details.
+ localize = {},
+ localizeNilOnMiss = {}, -- Used to trigger a nil return on a localization miss instead of returning the original string.
+
+ -- Track the name of the active locale.
+ activeLocaleId = nil,
+
+ -- Localization tables.
+ activeLocale = {},
+ fallbackLocale = {},
+
+ -- List of available locales in the form of `{ = { } }`.
+ locales = {},
+
+ -- Used by `Localization:AutoLocalize()` to keep track of what has already been automatically localized
+ -- so we don't double-localize (see that function for further explanation).
+ autoLocalized = {},
+}
+Bagshui.environment.BsLocalization = Localization
+Bagshui.components.Localization = Localization
+Bagshui.environment.L = Localization.localize
+Bagshui.environment.L_nil = Localization.localizeNilOnMiss
+
+-- Phase 1: Set up the Localization class.
+Bagshui:LoadComponent(function()
+ -- Metatable that handles localization magic.
+ --
+ -- The `localize`/`localizeNilOnMiss` tables are always empty, so any access will trigger
+ -- the `__index()` method. We can then intercept it and provide the correct localization.
+ --
+ -- Similarly, by providing `__newindex()`, we can redirect any changes into the correct
+ -- activeLocale table.
+ local localizationMetatable = {
+
+ __index = function(localeTable, str)
+ -- Localization debugging should be enabled when Bagshui debugging is on,
+ -- but will short-circuit and not evaluate otherwise.
+ if
+ BS_DEBUG
+ and (
+ localeTable ~= Localization.localizeNilOnMiss
+ and not (Localization.activeLocale[tostring(str)] or Localization.activeLocale[string.lower(tostring(str))])
+ )
+ then
+ Bagshui:PrintDebug("!MISS!: " .. tostring(str), "Localization", 1, 0, 0)
+ end
+
+ return
+ -- Active locale match.
+ (Localization.activeLocale[tostring(str)] or Localization.activeLocale[string.lower(tostring(str))])
+ -- Default locale match.
+ or (Localization.fallbackLocale[tostring(str)] or Localization.fallbackLocale[string.lower(tostring(str))])
+ -- No match -- return nil if requested or the original string.
+ or (localeTable ~= Localization.localizeNilOnMiss and str or nil)
+ end,
+
+ -- Set a value in the active localization.
+ __newindex = function(_, str, localized)
+ if not Localization.activeLocaleId then
+ return
+ end
+ Localization.activeLocale[str] = localized
+ end,
+ }
+
+ -- Assign metatable to both of the proxy tables.
+ setmetatable(Localization.localize, localizationMetatable)
+ setmetatable(Localization.localizeNilOnMiss, localizationMetatable)
+
+ --- Set the locale and do other localization preparations.
+ function Localization:Init()
+ -- Choose the correct locale -- try the current locale first, then fall back to the default if needed.
+ local locale = _G.GetLocale()
+
+ if locale and self.locales[locale] then
+ Localization.activeLocaleId = locale
+ else
+ Bagshui:PrintWarning(
+ "No localization available for "
+ .. locale
+ .. "; falling back to "
+ .. BS_DEFAULT_LOCALE
+ .. ". "
+ .. "Many items will not be properly categorized!"
+ )
+ Localization.activeLocaleId = BS_DEFAULT_LOCALE
+ end
+ self.activeLocale = self.locales[Localization.activeLocaleId]
+
+ -- Add a the default localization as a fallback.
+ if Localization.activeLocaleId ~= BS_DEFAULT_LOCALE then
+ self.fallbackLocale = self.locales[BS_DEFAULT_LOCALE]
+ end
+
+ -- Localize key bindings in Blizzard UI.
+ for i = 1, _G.GetNumBindings() do
+ local localized
+ local bindingName = (_G.GetBinding(i))
+
+ -- First check to see if there is a localizable prefix.
+ local found, _, prefix, toLocalize = string.find((_G.GetBinding(i)), "Bagshui_(.-)_(.+)")
+ if found and L_nil["Prefix_" .. prefix] then
+ localized = string.format(L["Prefix_" .. prefix], (L_nil["Binding_" .. toLocalize] or L[toLocalize]))
+ else
+ -- No prefix found, so we need to localize the whole thing.
+ found, _, toLocalize = string.find((_G.GetBinding(i)), "Bagshui_(.+)")
+ if found then
+ -- Try to localize with a Binding_ prefix to allow for binding-only naming (Binding_Resort, Binding_Restack)
+ -- and fall back to just the name (Bags, Bank, etc.).
+ localized = (L_nil["Binding_" .. toLocalize] or L[toLocalize])
+ end
+ end
+ if localized then
+ _G["BINDING_NAME_" .. bindingName] = localized
+ elseif found then
+ Bagshui:PrintWarning("Failed to localize key binding " .. bindingName)
+ end
+ end
+
+ -- Automatic localizations (as much as we can).
+
+ -- Item slots need to be handled before item classes and subclasses, because Bag ITEMS
+ -- and bag SLOTS are different in some languages, and Bagshui expects L["Bag"] to refer to ITEMS.
+ -- (For example, in German, Bag ITEMS are Behälter, but bag SLOTS are Tasche.)
+ self:AutoLocalize(BsGameInfo.inventorySlots, locale)
+
+ self:AutoLocalize(BsGameInfo.itemClasses, locale)
+ for _, subClassList in pairs(BsGameInfo.itemSubclasses) do
+ self:AutoLocalize(subClassList, locale)
+ end
+
+ -- System chat messages.
+ -- Needed for CHAT_MSG_SYSTEM events in `Inventory:OnEvent()`.
+ self.activeLocale.ChatMsgIdentifier_LearnedRecipe = self.activeLocale.ChatMsgIdentifier_LearnedRecipe
+ or string.format(_G.ERR_LEARN_RECIPE_S, ".+")
+
+ -- Add lowercase versions.
+ -- A temp table is required here because pairs() gets confused if the table
+ -- being iterated is modified during iteration.
+ local temp = BsUtil.TableCopy(self.activeLocale)
+ for enUS, localized in pairs(temp) do
+ local lowercase = string.lower(enUS)
+ if not self.activeLocale[lowercase] then
+ self.activeLocale[lowercase] = localized
+ end
+ end
+
+ -- Populate a few useful combinations.
+ L.UnnamedGroup = string.format(L.Prefix_Unnamed, L.Group)
+
+ -- Initialization complete.
+ Bagshui:RaiseEvent("BAGSHUI_LOCALIZATION_LOADED")
+ end
+
+ --- Add a new localization table.
+ ---@param locale string [Locale identifier](https://warcraft.wiki.gg/index.php?title=API_GetLocale&oldid=4228097)
+ ---@param strings table List of `{ ["BagshuiLocalizationString"] = "localized string" }`.
+ function Localization:AddLocale(locale, strings)
+ if self.locales[locale] then
+ -- When a primary localization has already been loaded, additional strings
+ -- can still be added by calling a second time. This won't override any
+ -- of the existing strings though.
+ for localizationString, localized in pairs(strings) do
+ if not self.locales[locale][localizationString] then
+ self.locales[locale][localizationString] = localized
+ end
+ end
+ else
+ self.locales[locale] = strings
+ end
+
+ -- Replace any !!placeholders!! with their actual localized values.
+ for localizationString, localized in pairs(self.locales[locale]) do
+ self.locales[locale][localizationString] = self:ReplacePlaceholders(localized, locale)
+ end
+ end
+
+ --- Build automatic localization from a provided table.
+ ---@param enUSToLocalized table English to localized strings.
+ ---@param clientLocale string Current client locale.
+ function Localization:AutoLocalize(enUSToLocalized, clientLocale)
+ for english, localized in pairs(enUSToLocalized) do
+ if
+ localized
+ and (
+ -- Avoid overriding human-provided translations.
+not self.activeLocale[english]
+ -- Go ahead and override if the default locale (enUS) is only being used
+ -- because there isn't a Bagshui localization for the client locale.
+ or clientLocale ~= BS_DEFAULT_LOCALE
+ )
+ then
+ self.activeLocale[english] = localized
+ end
+ end
+ end
+
+ --- Replace any !!placeholders!! with their actual localized values.
+ --- Recurses as needed.
+ ---@param str string String to search for !!placeholders!!.
+ ---@param locale string Locale in which placeholder keys can be found.
+ ---@return string
+ function Localization:ReplacePlaceholders(str, locale)
+ return (
+ string.gsub(str, "(!!(.-)!!)", function(match, placeholderToReplace)
+ if self.locales[locale][placeholderToReplace] then
+ return Localization:ReplacePlaceholders(self.locales[locale][placeholderToReplace], locale)
+ else
+ Bagshui:PrintDebug(
+ "Localization placeholder " .. tostring(match) .. " not found! (from " .. tostring(str) .. ")"
+ )
+ -- Logging system isn't fully initialized yet, so this is being disabled for now.
+ --Bagshui:PrintWarning("Localization placeholder " .. tostring(match) .. " not found!")
+ return match
+ end
+ end)
+ )
+ end
+end) -- LoadComponent (Phase 1) complete.
+
+-- Phase 2: Load localization and make localization functions available.
+Bagshui:AddComponent(function()
+ Localization:Init()
+end)
diff --git a/Components/LogWindow.lua b/Components/LogWindow.lua
new file mode 100644
index 0000000..65812a3
--- /dev/null
+++ b/Components/LogWindow.lua
@@ -0,0 +1,74 @@
+-- Bagshui Log Window
+-- Exposes: BsLogWindow (and Bagshui.components.LogWindow)
+--
+-- Display Bagshui errors in a scrollable window.
+
+Bagshui:AddComponent(function()
+ -- Initialize component.
+ local LogWindow = Bagshui.prototypes.ScrollableTextWindow:New({
+ name = "Log",
+ readOnly = true,
+ title = L.LogWindowTitle,
+ width = 600,
+ height = 400,
+ selectAllOnFocus = false,
+
+ -- Used in OnEvent when receiving BAGSHUI_LOG_UPDATE events.
+ allowUpdates = true,
+ })
+ Bagshui.components.LogWindow = LogWindow
+ Bagshui.environment.BsLogWindow = LogWindow
+
+ --- Open the Bagshui log window.
+ function LogWindow:Open()
+ self._super.Open(self, Bagshui:GetLogText())
+ end
+
+ --- Event handling.
+ ---@param event string Event name.
+ ---@param arg1 any Event argument.
+ function LogWindow:OnEvent(event, arg1)
+ -- During update, set allowUpdates to false to prevent stack overflow if messages are logged during the process.
+ if event == "BAGSHUI_LOG_UPDATE" and self.allowUpdates then
+ self.allowUpdates = false
+ self:SetText(Bagshui:GetLogText())
+ self.allowUpdates = true
+ return
+ end
+ end
+
+ --- Prepare log window.
+ function LogWindow:InitUi()
+ -- Calls ScrollableTextWindow:InitUi().
+ if self._super.InitUi(self) == false then
+ return
+ end
+
+ -- Add toolbar icons.
+ self.clearButton = self.ui:CreateIconButton({
+ name = "ClearLog",
+ texture = "Delete",
+ tooltipTitle = L.ClearLog,
+ parentFrame = self.uiFrame.bagshuiData.header,
+ anchorPoint = "RIGHT",
+ anchorToFrame = self.uiFrame.bagshuiData.closeButton,
+ anchorToPoint = "LEFT",
+ width = 16,
+ height = 16,
+ xOffset = -BsSkin.toolbarCloseButtonOffset,
+ onClick = function()
+ Bagshui:ClearLog()
+ self:SetText("")
+ end,
+ })
+
+ -- Re-anchor title to leftmost toolbar button so it doesn't run underneath.
+ self.uiFrame.bagshuiData.title:SetPoint("RIGHT", self.clearButton, "LEFT", -BsSkin.toolbarCloseButtonOffset, 0)
+
+ -- EditBox SHOULD scroll to the bottom when the log is updated.
+ self.editBox.bagshuiData.scrollToBottomOnTextSet = true
+ end
+
+ -- Register for log change event.
+ Bagshui:RegisterEvent("BAGSHUI_LOG_UPDATE", LogWindow)
+end)
diff --git a/Components/Menus.lua b/Components/Menus.lua
new file mode 100644
index 0000000..8a96929
--- /dev/null
+++ b/Components/Menus.lua
@@ -0,0 +1,1244 @@
+-- Bagshui Menus Class Prototype
+-- Exposes: Bagshui.prototypes.Menus
+--
+-- Makes WoW's native menus easier to use. To get around the 32-item limit, auto-split
+-- menus can be set up, which will (as the name implies) automatically split their
+-- contents into submenus once they go over the limit.
+-- Fair warning: The auto-split menu code isn't the cleanest or the best-documented.
+
+-- ======================================================
+-- REFERENCE: Menu item attributes
+--
+-- ------------------------------------------------------
+-- WoW native
+-- ------------------------------------------------------
+-- text = [STRING] -- The text of the button
+-- value = [ANYTHING] -- The value that UIDROPDOWNMENU_MENU_VALUE is set to when the button is clicked
+-- func = [function()] -- The function that is called when you click the button
+-- checked = [nil, 1] -- Check the button
+-- isTitle = [nil, 1] -- If it's a title the button is disabled and the font color is set to yellow
+-- disabled = [nil, 1] -- Disable the button and show an invisible button that still traps the mouseover event so menu doesn't time out
+-- hasArrow = [nil, 1] -- Show the expand arrow for multilevel menus
+-- hasColorSwatch = [nil, 1] -- Show color swatch or not, for color selection
+-- r = [1 - 255] -- Red color value of the color swatch
+-- g = [1 - 255] -- Green color value of the color swatch
+-- b = [1 - 255] -- Blue color value of the color swatch
+-- textR = [1 - 255] -- Red color value of the button text
+-- textG = [1 - 255] -- Green color value of the button text
+-- textB = [1 - 255] -- Blue color value of the button text
+-- swatchFunc = [function()] -- Function called by the color picker on color change
+-- hasOpacity = [nil, 1] -- Show the opacity slider on the color picker frame
+-- opacity = [0.0 - 1.0] -- Percentage of the opacity, 1.0 is fully shown, 0 is transparent
+-- opacityFunc = [function()] -- Function called by the opacity slider when you change its value
+-- cancelFunc = [function(previousValues)] -- Function called by the color picker when you click the cancel button (it takes the previous values as its argument)
+-- notClickable = [nil, 1] -- Disable the button and color the font white
+-- notCheckable = [nil, 1] -- Shrink the size of the buttons and don't display a check box
+-- owner = [Frame] -- Dropdown frame that "owns" the current dropdownlist
+-- keepShownOnClick = [nil, 1] -- Don't hide the dropdownlist after a button is clicked
+-- tooltipTitle = [nil, STRING] -- Title of the tooltip shown on mouseover
+-- tooltipText = [nil, STRING] -- Text of the tooltip shown on mouseover
+-- justifyH = [nil, "CENTER"] -- Justify button text
+-- arg1 = [ANYTHING] -- This is the first argument used by info.func
+-- arg2 = [ANYTHING] -- This is the second argument used by info.func
+-- textHeight = [NUMBER] -- font height for button text
+-- icon = [STRING] - Texture to use for icon
+-- tCoordLeft = [0.0, 1.0] -- Icon texture coordinates
+-- tCoordRight = [0.0, 1.0]
+-- tCoordTop = [0.0, 1.0]
+-- tCoordBottom = [0.0, 1.0]
+--
+-- ------------------------------------------------------
+-- Bagshui custom
+-- ------------------------------------------------------
+-- _bagshuiCheckedFunc -- function(): Return true/false to control value of `checked` property.
+-- _bagshuiDisabledFunc -- function(): Return true/false to control value of `disabled` property.
+-- _bagshuiFakeTitle -- boolean: Make the menu item *look* like a title but remain clickable.
+-- _bagshuiHide -- boolean: Menu item will not be displayed if true.
+-- _bagshuiHideFunc -- function(): Return true/false to control value of `_bagshuiHide` property.
+-- _bagshuiSettingName -- string: Name of setting to generate menu item for.
+-- _bagshuiTextureR -- number [1 - 255]: Red color value of `icon` texture.
+-- _bagshuiTextureG -- number [1 - 255]: Blue color value of `icon` texture.
+-- _bagshuiTextureB -- number [1 - 255]: Green color value of `icon` texture.
+-- _bagshuiTextureA -- number [0.0 - 1.0]: Alpha opacity value of `icon` texture.
+-- _bagshuiTextureSize ---@type number Alternate height/width for `icon` texture.
+-- _bagshuiTextureXOffset - number: Alternate X offset for `icon` texture.
+
+Bagshui:AddComponent(function()
+ -- Weird name to guarantee uniqueness within self.menuList.
+ local AUTO_SPLIT_MENU_LIST_TYPE = "***autoSplit***"
+
+ -- These are the auto-split menu properties that will be permitted. Anything else will generate an error.
+ -- 1 = required
+ -- 2 = optional
+ local AUTO_SPLIT_MENU_ALLOWED_PROPERTIES = {
+ defaultIdList = 1, -- any[]: IDs to be used to populate the menu if idList isn't provided by the menu item's value table.
+ sortFunc = 2, -- function: Function that will accept an array of IDs and sort them by the text that will be returned by nameFunc.
+ nameFunc = 2, -- function(id): Return the text that should be displayed in the menu.
+ tooltipTitleFunc = 2, -- function(id): Return the string that should be displayed in the tooltip title.
+ tooltipTextFunc = 2, -- function(id): Return the string that should be displayed in the tooltip.
+ parentMenuNameTrimFunc = 2, -- function(menuName): Return the string that should be used for the parent menu item that opens a submenu.
+ extraItems = 2, -- table: Additional static menu items to add to the end of the menu.
+ itemList = 2, -- table[]: Actual list of objects potentially contained in the menu. Passed to omitFunc so object properties can be looked up.
+ }
+
+ local Menus = {
+
+ -- Track the "value" parameters for the most recent menu levels that were opened.
+ -- Consumed during Menus:Refresh().
+ recentLevelValues = {},
+
+ -- Normal menus.
+ -- This needs to exist for the prototype because there will be an `AUTO_SPLIT_MENU_LIST_TYPE`
+ -- key added during Init(). Menu class instances will also get their own menuList tables.
+ menuList = {},
+
+ -- Auto-Split menus.
+ autoSplitMenuList = {},
+
+ -- Reusable temp table for storing lists of IDs used to generate auto-split menus.
+ autoSplitMenuItemIds = {},
+ }
+ Bagshui.prototypes.Menus = Menus
+
+ -- Initialize the Menus prototype.
+ function Menus:Init()
+ -- Add the auto-split menu type to the base Menus class.
+ self:AddMenu(AUTO_SPLIT_MENU_LIST_TYPE)
+
+ -- This frame and text are used to wrestle UIDropDownMenu_Refresh into doing what we want, which is simply to
+ -- refresh the menu and put all check marks in the correct state. However, it insists on doing a SetText() call,
+ -- so we need to give it a dummy frame and corresponding font string so it won't error.
+ local autoSplitMenuRefreshHackFrameName = "autoSplitMenuRefreshHackFrame"
+ self.autoSplitMenuRefreshHackFrame = _G.CreateFrame("Frame", autoSplitMenuRefreshHackFrameName)
+ -- The FontString must be named `Text` for this to work.
+ self.autoSplitMenuRefreshHackText = self.autoSplitMenuRefreshHackFrame:CreateFontString(
+ autoSplitMenuRefreshHackFrameName .. "Text",
+ nil,
+ "GameFontNormal"
+ )
+
+ --- Auto-split menu OnClick function.
+ --- Having a reusable function avoids generating hundreds of anonymous functions as we construct the menu tables.
+ --- This is stored on the prototype because it needs to be available to `PopulateAutoSplitMenuItems()`.
+ ---@param params any arg1 from the menu `func()` call, set by `PrepareAutoSplitMenu()`.
+ ---@param itemId any arg2 from the menu `func()` call, set by `PrepareAutoSplitMenu()`.
+ self._autoSplitMenuItem_Click = function(params, itemId)
+ -- Safeguard.
+ if not params then
+ return
+ end
+
+ -- Call provided function.
+ if type(params.func) == "function" then
+ params.func(params.objectId, itemId)
+ end
+
+ -- Refresh or close menus depending on what was configured.
+ if params.menuItem.keepShownOnClick then
+ -- Record the value of the clicked menu item on our hack frame so that UIDropDownMenu_Refresh knows what to do.
+ self.autoSplitMenuRefreshHackFrame.selectedValue = (not params.menuItem.notCheckable) and params.menuItem.value
+ or nil
+ -- Call UIDropDownMenu_Refresh on the next frame to put the check mark back if it was removed.
+ Bagshui:QueueEvent(_G.UIDropDownMenu_Refresh, nil, true, self.autoSplitMenuRefreshHackFrame)
+ else
+ Bagshui:CloseMenus()
+ end
+ end
+
+ --- Create an auto-split menu placeholder. (Used in multiple places in `Init()`).
+ ---@param menuTable table Table into which the placeholder should be inserted.
+ local function addAutoSplitMenuItem(menuTable)
+ table.insert(menuTable, {
+ text = "",
+ value = {
+ menuType = AUTO_SPLIT_MENU_LIST_TYPE,
+ value = "",
+ },
+ arg1 = {
+ objectId = "",
+ func = nil, -- Will be added/removed during PrepareAutoSplitMenu().
+ menuItem = nil,
+ },
+ hasArrow = false,
+ _bagshuiHide = true,
+ func = nil, -- Will be added/removed during PrepareAutoSplitMenu().
+ })
+ end
+
+ -- Build out the auto-split menu with placeholders for level 1 and 2.
+ for i = 1, _G.UIDROPDOWNMENU_MAXBUTTONS do
+ -- First level.
+ addAutoSplitMenuItem(self.menuList[AUTO_SPLIT_MENU_LIST_TYPE].levels[1])
+
+ -- Second level.
+ table.insert(self.menuList[AUTO_SPLIT_MENU_LIST_TYPE].levels[2], {})
+ for j = 1, _G.UIDROPDOWNMENU_MAXBUTTONS do
+ -- j is "unused" here because we're doing table.insert() j number of times.
+ addAutoSplitMenuItem(self.menuList[AUTO_SPLIT_MENU_LIST_TYPE].levels[2][i])
+ end
+ end
+ end
+
+ --- Create a new instance of the Menus class.
+ ---@param inventoryClassInstance table? Inventory class, used to call GenerateSettingsMenuItem() when an item has a _bagshuiSettingName property.
+ ---@return table menusClassInstance
+ function Menus:New(inventoryClassInstance)
+ -- Prepare new class object.
+ local menus = {
+ _super = Menus,
+ inventoryClassInstance = inventoryClassInstance,
+ -- Normal menus that are specific to this class instance.
+ menuList = {},
+ }
+
+ -- Set up the class object.
+ setmetatable(menus, self)
+ self.__index = self
+
+ -- menuList also needs to be metatable'd so it can get AutoSplit menus from the prototype.
+ setmetatable(menus.menuList, self.menuList)
+ self.menuList.__index = self.menuList
+
+ return menus
+ end
+
+ --- Register a standard menu.
+ ---@param menuName string Unique identifier for this menu. Will become the key in self.menuList.
+ ---@param level1 table[]? Array of menu item tables (see top of file for menu item attributes).
+ ---@param level2 table? Lists of ` = `. The value attribute from lower levels is used to link to the submenus on higher levels.
+ ---@param level3 table? Lists of ` = `. The value attribute from lower levels is used to link to the submenus on higher levels.
+ ---@param openMenuFunc function? Function to be called in lieu of Menus:ShowMenu(). Will receive arg1 and arg2 from Menus:OpenMenu(). If openMenuFunc returns false, the normal call to ShowMenu() will be prevented, allowing full control of the menu opening process.
+ function Menus:AddMenu(menuName, level1, level2, level3, openMenuFunc)
+ assert(menuName, "Menus:AddMenu(): menuName is required")
+
+ self.menuList[menuName] = {
+ levels = {
+ type(level1) == "table" and level1 or {},
+ type(level2) == "table" and level2 or {},
+ type(level3) == "table" and level3 or {},
+ },
+ openFunc = openMenuFunc,
+ }
+
+ for i = 1, 3 do
+ for _, submenu in pairs(self.menuList[menuName].levels[i]) do
+ if type(submenu) == "table" and submenu._initializeEmptyMenu then
+ self:InitializeEmptyMenu(submenu, submenu._initializeEmptyMenu)
+ submenu._initializeEmptyMenu = nil
+ end
+ end
+ end
+ end
+
+ --- Register a new auto-split menu.
+ ---@param autoSplitMenuType BS_AUTO_SPLIT_MENU_TYPE|string Unique identifier for this auto-split menu.
+ ---@param propsTable table Menu properties -- see `AUTO_SPLIT_MENU_ALLOWED_PROPERTIES` declaration for details.
+ function Menus:AddAutoSplitMenu(autoSplitMenuType, propsTable)
+ assert(type(autoSplitMenuType) == "string", "Menus:AddAutoSplitMenu(): autoSplitMenuType must be a string")
+ -- Ensure all required properties are present.
+ for prop, required in pairs(AUTO_SPLIT_MENU_ALLOWED_PROPERTIES) do
+ assert(
+ required ~= 1 or propsTable[prop],
+ "Menus:AddAutoSplitMenu(" .. autoSplitMenuType .. "): " .. prop .. " is missing from propsTable"
+ )
+ end
+ -- Reject any non-allowed properties.
+ for prop, _ in pairs(propsTable) do
+ assert(
+ AUTO_SPLIT_MENU_ALLOWED_PROPERTIES[prop],
+ "Menus:AddAutoSplitMenu(" .. autoSplitMenuType .. "): " .. prop .. " is not a valid property"
+ )
+ end
+
+ self.autoSplitMenuList[autoSplitMenuType] = propsTable
+ end
+
+ --- Open a menu of the given type.
+ --- This is structured in two parts because Blizzard's `ToggleDropDownMenu()` closes any visible menus on the first call,
+ --- leading to an awkward need to click twice to open a different menu if one is already open. The workaround for this
+ --- is to close menus before calling `ToggleDropDownMenu()`, then on the next frame update, show the new menu.
+ ---@param menuType string Identifier for a menu that has been registered via `AddMenu()`.
+ ---@param arg1 any First parameter for the `openMenuFunc` callback set up in `AddMenu()`.
+ ---@param arg2 any Second parameter for the `openMenuFunc` callback set up in `AddMenu()`.
+ ---@param anchorFrame table Frame to which the menu should be anchored.
+ ---@param xOffset number? X adjustment for menu anchoring.
+ ---@param yOffset number? Y adjustment for menu anchoring.
+ ---@param anchorPoint string? Point on the menu which should be attached to `anchorFrame`.
+ ---@param anchorToPoint string? Point on `anchorFrame` to which the menu should be attached.
+ function Menus:OpenMenu(menuType, arg1, arg2, anchorFrame, xOffset, yOffset, anchorPoint, anchorToPoint)
+ -- Store parameters for callback.
+ self._openMenu_MenuType = menuType
+ self._openMenu_Arg1 = arg1
+ self._openMenu_Arg2 = arg2
+ self._openMenu_AnchorFrame = anchorFrame
+ self._openMenu_XOffset = xOffset
+ self._openMenu_YOffset = yOffset
+ self._openMenu_AnchorPoint = anchorPoint
+ self._openMenu_AnchorToPoint = anchorToPoint
+
+ -- Close all open menus
+ Bagshui:CloseMenus(1, true)
+
+ -- Callback will fire on next frame update and consume parameters
+ Bagshui:QueueClassCallback(self, self.OpenMenuCallback)
+
+ -- Clear historical parameters.
+ self._openMenu_last_MenuType = nil
+ self._openMenu_last_Arg1 = nil
+ self._openMenu_last_Arg2 = nil
+ self._openMenu_last_UIParentXOffset = nil
+ self._openMenu_last_UIParentYOffset = nil
+ end
+
+ --- Helper function to open menus immediately after any currently visible menus are closed by OpenMenu
+ function Menus:OpenMenuCallback()
+ local doShowMenu = true
+
+ -- Invoke custom callback function if one exists
+ -- This function can block the normal automatic call to ShowMenu() by returning false
+ local openMenuFunc = self.menuList[self._openMenu_MenuType] and self.menuList[self._openMenu_MenuType].openFunc
+ if
+ type(openMenuFunc) == "function"
+ and openMenuFunc(self.menuList[self._openMenu_MenuType], self._openMenu_Arg1, self._openMenu_Arg2)
+ == false
+ then
+ doShowMenu = false
+ end
+
+ -- Automatically call ShowMenu(), if not stopped by openMenuFunc().
+ if doShowMenu then
+ self:ShowMenu(
+ self._openMenu_MenuType,
+ self._openMenu_AnchorFrame,
+ self._openMenu_XOffset,
+ self._openMenu_YOffset,
+ self._openMenu_AnchorPoint,
+ self._openMenu_AnchorToPoint
+ )
+ end
+
+ -- Reset parameters.
+ self._openMenu_MenuType = nil
+ self._openMenu_Arg1 = nil
+ self._openMenu_Arg2 = nil
+ self._openMenu_AnchorFrame = nil
+ self._openMenu_XOffset = nil
+ self._openMenu_YOffset = nil
+ self._openMenu_AnchorPoint = nil
+ self._openMenu_AnchorToPoint = nil
+ end
+
+ --- Actually display a menu of the given type.
+ --- This should ONLY be called by OpenMenuCallback or an OpenMenu override function.
+ ---@param menuType string Identifier for a menu that has been registered via `AddMenu()`.
+ ---@param anchorFrame table|nil Frame to which the menu should be anchored.
+ ---@param xOffset number? X adjustment for menu anchoring.
+ ---@param yOffset number? Y adjustment for menu anchoring.
+ ---@param anchorPoint string? Point on the menu which should be attached to `anchorFrame`.
+ ---@param anchorToPoint string? Point on `anchorFrame` to which the menu should be attached.
+ function Menus:ShowMenu(menuType, anchorFrame, xOffset, yOffset, anchorPoint, anchorToPoint)
+ -- Used by IsMenuOpen() when checking to see if an open menu is unique to this class instance.
+ Bagshui.menuFrame.bagshuiData.lastMenuTypeLoaded = menuType .. tostring(self)
+
+ -- Force re-initialization of menus to avoid the "Too many buttons in UIDropDownMenu:" error.
+ _G.UIDROPDOWNMENU_OPEN_MENU = nil
+
+ -- Reset menu properties to prevent spurious check marks from appearing (weird Blizzard issue).
+ Bagshui.menuFrame.selectedID = nil
+ Bagshui.menuFrame.selectedValue = nil
+ Bagshui.menuFrame.selectedName = nil
+ -- Hide any existing check marks; they'll be re-added automatically if needed.
+ for i = 1, _G.UIDROPDOWNMENU_MAXLEVELS do
+ for j = 1, _G.UIDROPDOWNMENU_MAXBUTTONS do
+ _G["DropDownList" .. i .. "Button" .. j]:UnlockHighlight()
+ _G["DropDownList" .. i .. "Button" .. j .. "Check"]:Hide()
+ end
+ end
+
+ -- Load the menu.
+ _G.UIDropDownMenu_Initialize(Bagshui.menuFrame, function(level)
+ self:LoadMenu(menuType, level)
+ end, "MENU")
+
+ -- Adjust anchoring if requested.
+ if anchorPoint then
+ Bagshui.menuFrame.bagshuiData.xOffset = xOffset
+ Bagshui.menuFrame.bagshuiData.yOffset = yOffset
+ Bagshui.menuFrame.bagshuiData.anchorPoint = anchorPoint
+ Bagshui.menuFrame.bagshuiData.anchorToFrame = anchorFrame
+ Bagshui.menuFrame.bagshuiData.anchorToPoint = anchorToPoint
+ _G.UIDropDownMenu_SetAnchor(xOffset, yOffset, Bagshui.menuFrame, anchorPoint, anchorFrame, anchorToPoint)
+
+ -- Force ToggleDropDownMenu() to use the points set by UIDropDownMenu_SetAnchor().
+ anchorFrame = nil
+ else
+ -- Reset everything so Bagshui:ToggleDropDownMenu() knows there's no special anchors.
+
+ Bagshui.menuFrame.xOffset = nil
+ Bagshui.menuFrame.yOffset = nil
+ Bagshui.menuFrame.point = nil
+ Bagshui.menuFrame.relativeTo = nil
+ Bagshui.menuFrame.relativePoint = nil
+ Bagshui.menuFrame.bagshuiData.xOffset = nil
+ Bagshui.menuFrame.bagshuiData.yOffset = nil
+ Bagshui.menuFrame.bagshuiData.anchorPoint = nil
+ Bagshui.menuFrame.bagshuiData.anchorToFrame = nil
+ Bagshui.menuFrame.bagshuiData.anchorToPoint = nil
+ end
+
+ -- Open the menu.
+ _G.ToggleDropDownMenu(1, nil, Bagshui.menuFrame, anchorFrame, xOffset, yOffset)
+
+ -- Record most recent parameters so level 1 menu can be reopened.
+ self._openMenu_last_MenuType = menuType
+ self._openMenu_last_Arg1 = self._openMenu_Arg1
+ self._openMenu_last_Arg2 = self._openMenu_Arg2
+ self._openMenu_last_UIParentXOffset = _G.DropDownList1:GetLeft()
+ self._openMenu_last_UIParentYOffset = _G.DropDownList1:GetTop()
+
+ _G.PlaySound("igMainMenuOption")
+ end
+
+ --- Load a menu of the given type and level from the `self.menuList` table.
+ --- Used as a callback from `UIDropDownMenu_Initialize()`.
+ ---@param menuType string Identifier for a menu that has been registered via `AddMenu()`.
+ ---@param level number Menu level, 1-3.
+ function Menus:LoadMenu(menuType, level)
+ level = level or 1
+
+ -- Figure out what menu items to load.
+ local menuItems
+ local menuValue = level > 1 and _G.UIDROPDOWNMENU_MENU_VALUE or nil
+ -- The menu table index we need to pull from can change depending on the menu being loaded.
+ local menuLevel = level
+
+ -- Override menuValue if we're refreshing to ensure the correct items are loaded.
+ if self.refresh then
+ menuValue = self.recentLevelValues[level]
+ end
+
+ -- Level 1 auto-split menus.
+ -- This replaces the ENTIRE level 1 menu with an auto-split menu.
+ -- To use this, the level 1 table of the menu must be an associative list of auto-split
+ -- menu parameters (including autoSplitMenuType) instead of of an array of tables.
+ if
+ level == 1
+ and self.menuList[menuType]
+ and self.menuList[menuType].levels[1]
+ and self.menuList[menuType].levels[1].autoSplitMenuType
+ then
+ self:PrepareAutoSplitMenu(self.menuList[menuType].levels[1])
+ menuType = AUTO_SPLIT_MENU_LIST_TYPE
+ end
+
+ -- Menu type overrides and auto-split menu loading.
+ -- If a menu item has a table as its value property and the table contains a
+ -- menuType entry, that menu will be loaded in lieu of the menuType parameter.
+ if type(menuValue) == "table" then
+ if menuValue.menuType then
+ -- Normal override.
+ menuType = menuValue.menuType
+ elseif menuValue.autoSplitMenuType then
+ -- This is an auto-split menu.
+ menuType = AUTO_SPLIT_MENU_LIST_TYPE
+ end
+
+ if menuType == AUTO_SPLIT_MENU_LIST_TYPE then
+ -- Auto-split menus typically need to pull from one level higher, because
+ -- they are most often loaded from a submenu, but that submenu usually
+ -- needs the top-level entry from the auto-split menu. Hopefully that
+ -- almost makes sense.
+ menuLevel = menuValue.menuLevel or menuLevel - 1
+ self:PrepareAutoSplitMenu(menuValue)
+ end
+
+ -- Pre-processing function (not currently used).
+ if menuValue.preloadFunction then
+ menuValue.preloadFunction(menuValue.preloadFunctionArg1, menuValue.preloadFunctionArg2)
+ end
+
+ menuValue = menuValue.value
+ end
+
+ -- Ensure menu is valid.
+ assert(self.menuList[menuType], "'" .. menuType .. "' menu not found in self.menuList!")
+
+ local menu = self.menuList[menuType]
+
+ -- When the level is higher than 1, it's a submenu.
+ -- Menu items that trigger submenus are expected to provide the key for their
+ -- items as the value of the item that opens the submenu, which gets stuffed
+ -- into UIDROPDOWNMENU_MENU_VALUE by ToggleDropDownMenu.
+ if menuLevel > 1 then
+ menuItems = menu.levels[menuLevel][menuValue]
+ else
+ -- Level 1.
+ menuItems = menu.levels[menuLevel]
+ end
+
+ -- Final validity check.
+ if not menuItems then
+ Bagshui:PrintError(
+ "Menu not found for type " .. menuType .. " level " .. tostring(menuLevel) .. " value " .. tostring(menuValue)
+ )
+ return
+ end
+
+ -- Add items to the menu.
+ for i, menuItem in ipairs(menuItems) do
+ -- Flag this as a Bagshui menu item.
+ menuItem._bagshui = true
+
+ -- Assume not checked (this probably isn't actually necessary?).
+ if menuItem.checked == nil then
+ menuItem.checked = false
+ end
+
+ -- Fake title formatting.
+ if menuItem._bagshuiFakeTitle then
+ menuItem.isTitle = false
+ menuItem.notCheckable = true
+ menuItem.textR = _G.NORMAL_FONT_COLOR.r
+ menuItem.textG = _G.NORMAL_FONT_COLOR.g
+ menuItem.textB = _G.NORMAL_FONT_COLOR.b
+ end
+
+ -- Ensure texCoords are present for icons to avoid weird scaling issues.
+ -- Blizzard's code doesn't make a call to SetTexCoord unless tCoordLeft is present,
+ -- so if the texCoords were changed by something else, they won't be reset by default.
+ if menuItem.icon and not menuItem.tCoordLeft then
+ menuItem.tCoordLeft = 0.0
+ menuItem.tCoordRight = 1.0
+ menuItem.tCoordTop = 0.0
+ menuItem.tCoordBottom = 1.0
+ end
+
+ -- Update static properties based on provided functions.
+ if type(menuItem._bagshuiCheckedFunc) == "function" then
+ menuItem.checked = menuItem._bagshuiCheckedFunc(menuItem.arg1)
+ end
+ if type(menuItem._bagshuiDisabledFunc) == "function" then
+ menuItem.disabled = menuItem._bagshuiDisabledFunc(menuItem.arg1)
+ end
+ if type(menuItem._bagshuiHideFunc) == "function" then
+ menuItem._bagshuiHide = menuItem._bagshuiHideFunc()
+ end
+
+ -- Ensure tooltip displays.
+ if menuItem.tooltipText and not menuItem.tooltipTitle then
+ menuItem.tooltipTitle = menuItem.text
+ end
+
+ -- Settings handling.
+ -- Any update to settings just needs to set the new value in self.settings and the Settings class
+ -- will take care of everything, including initiating a window update to reflect the change.
+ if menuItem._bagshuiSettingName and self.inventoryClassInstance then
+ self.inventoryClassInstance:GenerateSettingsMenuItem(menuItem, menu, menuLevel, i, menuType)
+ end
+
+ -- Put it on the the menu unless it's hidden.
+ if not menuItem._bagshuiHide then
+ _G.UIDropDownMenu_AddButton(menuItem, level)
+ end
+ end
+
+ -- Store value for use during refresh.
+ self.recentLevelValues[level] = menuValue
+ end
+
+ --- Wrapper for `Bagshui:IsMenuOpen()` to pass this class's specific identifiers.
+ ---@param menuType string? Identifier for a menu that has been registered via `AddMenu()`.
+ ---@return boolean
+ function Menus:IsMenuOpen(menuType)
+ return Bagshui:IsMenuOpen((menuType and (menuType .. tostring(self))))
+ end
+
+ --- Wrapper for `Bagshui:BlockAutoMenuClose()` to pass this class's specific identifiers.
+ ---@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 Menus:BlockAutoMenuClose(startLevel, endLevel, menuType)
+ Bagshui:BlockAutoMenuClose(startLevel, endLevel, (menuType and (menuType .. tostring(self))))
+ end
+
+ --- Redraw an open menu at the given level.
+ ---@param level number? Menu level to refresh. Defaults to _G.UIDROPDOWNMENU_MENU_LEVEL.
+ ---@param refreshParent boolean? Also refresh the next level up.
+ function Menus:Refresh(level, refreshParent)
+ level = (level or _G.UIDROPDOWNMENU_MENU_LEVEL)
+
+ -- Do refresh on next frame to ensure new values are picked up.
+ if not self.refresh then
+ Bagshui:QueueClassCallback(self, self.Refresh, nil, nil, level, refreshParent)
+
+ -- Set flag so that we know the callback has been queued and LoadMenu()
+ -- knows to pull from self.recentLevelValues.
+ self.refresh = true
+ return
+ end
+
+ -- Reset list properties.
+ local dropDownlist = _G["DropDownList" .. level]
+ if not dropDownlist then
+ return
+ end
+ dropDownlist.numButtons = 0
+ dropDownlist.maxWidth = 0
+
+ -- Hide buttons.
+ for i = 1, _G.UIDROPDOWNMENU_MAXBUTTONS, 1 do
+ local button = _G["DropDownList" .. level .. "Button" .. i]
+ if button then
+ button:Hide()
+ end
+ end
+
+ -- Reset frame and call initialization function to re-populate buttons.
+ Bagshui.menuFrame:SetHeight(_G.UIDROPDOWNMENU_BUTTON_HEIGHT * 2)
+ Bagshui.menuFrame.initialize(level)
+
+ -- Only refresh parent level if it's feasible.
+ if refreshParent and level > 1 then
+ self:Refresh(level - 1)
+ end
+
+ -- Reset flag.
+ self.refresh = false
+ end
+
+ --- Set the given property to the given value for all menu items.
+ ---@param menuTable table Menu item table on which the property should be set. Must be the parent table, not the menu level table.
+ ---@param property string Property to set.
+ ---@param value any Property value.
+ ---@param level number? Only set values on this level.
+ function Menus:SetPropertyOnAllMenuItems(menuTable, property, value, level)
+ if not level then
+ -- Set all levels-
+ for menuLevel = 1, 3 do
+ self:SetPropertyOnAllMenuItems(menuTable, property, value, menuLevel)
+ end
+ elseif level > 0 then
+ -- We have a level to set, so grab that level's table and recurse to do the actual setting.
+
+ if level == 1 then
+ -- Level 1 is just an array of menu items.
+ self:SetPropertyOnAllMenuItems(menuTable.levels[1], property, value, 0)
+ else
+ -- Level 2/3 are `{ = }`.
+ for _, menuEntries in pairs(menuTable.levels[level]) do
+ self:SetPropertyOnAllMenuItems(menuEntries, property, value, 0)
+ end
+ end
+ else
+ -- Level is 0, so just set properties.
+ for _, menuEntry in ipairs(menuTable) do
+ menuEntry[property] = value
+ end
+ end
+ end
+
+ --- Fill a menu table with empty, hidden (by default) entries.
+ ---@param menu table Level-specific menu table.
+ ---@param properties table Additional properties to set.
+ function Menus:InitializeEmptyMenu(menu, properties)
+ for i = 1, _G.UIDROPDOWNMENU_MAXBUTTONS do
+ local menuItem = {
+ _bagshuiHide = true,
+ value = i,
+ }
+ for key, value in pairs(properties) do
+ menuItem[key] = value
+ end
+ table.insert(menu, menuItem)
+ end
+ end
+
+ --- Set _bagshuiHide = true on menu items from `startPos` to `endPos`, inclusive.
+ ---@param menu table Level-specific menu table.
+ ---@param startPos number? First menu item to hide.
+ ---@param endPos number? Last menu item to hide.
+ function Menus:HideMenuItems(menu, startPos, endPos)
+ for i = startPos or 1, (endPos or _G.UIDROPDOWNMENU_MAXBUTTONS) do
+ if menu[i] then
+ menu[i]._bagshuiHide = true
+ end
+ end
+ end
+
+ --- Prepare to display an auto-split menu.
+ --- This is used for long lists of items so they are automatically divided into submenus,
+ --- which is necessary due to Vanilla's 32-item-per-menu limit.
+ ---@param menuValue table `value` property entry from the triggering menu item, which contains all properties necessary to configure the auto-split menu (see Auto-Split Menu Properties below).
+ function Menus:PrepareAutoSplitMenu(menuValue)
+ assert(type(menuValue) == "table", "Menus:PrepareAutoSplitMenu(): menuValue must be a table")
+ local autoSplitMenuType = menuValue.autoSplitMenuType
+ assert(
+ self.autoSplitMenuList[autoSplitMenuType],
+ "Menus:PrepareAutoSplitMenu(): Auto-Split menu type '" .. tostring(autoSplitMenuType) .. "' not found"
+ )
+
+ local autoSplitMenu = self.menuList[AUTO_SPLIT_MENU_LIST_TYPE]
+ local autoSplitMenuParams = self.autoSplitMenuList[autoSplitMenuType]
+
+ -- Auto-Split Menu Properties.
+
+ ---@type any Object or object ID **UPON WHICH** this auto-split menu is acting. Will be passed to functions as arg1.objectId.
+ --- For example, this could be the ID of the group to which a new category needs to be added.
+ local objectId = menuValue.objectId
+
+ ---@type any[]? Actual list of items potentially contained in the menu. Passed as the second parameter to omitFunc so item properties can be looked up.
+ local itemList = menuValue.itemList or autoSplitMenuParams.itemList
+
+ ---@type any[] List of item IDs with which to populate the menu.
+ local idList = menuValue.idList or autoSplitMenuParams.defaultIdList
+
+ ---@type any[]? List of item IDs which should be omitted from the menu.
+ local idsToOmit = menuValue.idsToOmit
+
+ ---@type any[]? List of item IDs which should be disabled on the menu.
+ local idsToDisable = menuValue.idsToDisable
+
+ ---@type any[]? List of item IDs which should be checked on the menu.
+ local idsToCheck = menuValue.idsToCheck
+
+ ---@type function? Sort the given array of IDs in-place by the text that will be returned by nameFunc.
+ ---```
+ --- ---@param idList table Array of item IDs
+ --- ---sortFunc(idList)
+ ---```
+ local sortFunc = menuValue.sortFunc or autoSplitMenuParams.sortFunc
+
+ ---@type function? Executed when the menu item is clicked. Invoked by `Menus._autoSplitMenuItem_Click()`.
+ ---```
+ --- ---@param arg1 table This is the `arg1` table set up in `addAutoSplitMenuItem()` and updated by `PopulateAutoSplitMenuItems()`.
+ --- ---@param arg2 any ID of the item the menu entry represents.
+ --- ---clickFunc(arg1, arg2)
+ ---```
+ local clickFunc = menuValue.func
+
+ ---@type function? Decide whether the menu entry for a given item should be checked.
+ ---```
+ --- ---@param itemId any ID of the item the menu entry represents.
+ --- ---@param menuItem table Actual menu table entry.
+ --- ---@param triggeringMenuValue table The `value` property of the menu item that triggered the auto-split menu.
+ --- ---@return boolean checked
+ --- ---checkFunc(itemId, menuItem, triggeringMenuValue)
+ ---```
+ local checkFunc = menuValue.checkFunc
+
+ ---@type function? Decide whether the menu entry for a given item should be disabled.
+ ---```
+ --- ---@param itemId any ID of the item the menu entry represents.
+ --- ---@param menuItem table Actual menu table entry.
+ --- ---@param triggeringMenuValue table The `value` property of the menu item that triggered the auto-split menu.
+ --- ---@return boolean disabled
+ --- ---checkFunc(itemId, menuItem, triggeringMenuValue)
+ ---```
+ local disableFunc = menuValue.disableFunc
+
+ ---@type function? Decide whether the menu entry for a given item should be omitted.
+ --- NOT CALLED unless `itemList` is provided.
+ ---```
+ --- ---@param itemId any ID of the item the menu entry represents.
+ --- ---@param menuItem table? `itemList`, if available.
+ --- ---@return boolean omit
+ --- ---omitFunc(itemId, itemList)
+ ---```
+ local omitFunc = menuValue.omitFunc
+
+ ---@type table? List of additional static menu items that should be added to the bottom of the menu.
+ --- 1/2: This one comes from the original configuration of the auto-split menu.
+ local baseExtraMenuitems = autoSplitMenuParams.extraItems
+
+ ---@type table? List of additional static menu items that should be added to the bottom of the menu.
+ --- 2/2: This one comes from the triggering menu item's `value.autoSplitMenuExtraItems` property.
+ local extraMenuitems = menuValue.autoSplitMenuExtraItems
+
+ ---@type number? When constructing the "N - Z" parent menu item, go back this number of items to determine the second entry (Z).
+ --- This is used to ensure that extra menu items don't mess up alphabetic ranges (the Rule Function menu for the Categories UI needs it).
+ local triggeringMenuEndRangeOmitIdCount = menuValue.triggeringMenuEndRangeOmitIdCount or 0
+
+ -- Prepare to build the menu.
+
+ local firstIndex, lastIndex, submenuLastItemIndex, submenuFirstItem, submenuLastItem, nextSubmenuFirstItem
+ local menuStartText, menuEndText, nextMenuStartText, nextMenuStartTextMinLength, prevNextMenuStartText, displayStartText, displayEndText
+ local menuItemNum = 1
+
+ -- The number of available submenu entries will be decreased by the number of extra menu items.
+ local numExtraItems = (type(baseExtraMenuitems) == "table" and table.getn(baseExtraMenuitems) or 0)
+ + (type(extraMenuitems) == "table" and table.getn(extraMenuitems) or 0)
+
+ -- Reset reusable tables.
+ BsUtil.TableClear(self.autoSplitMenuItemIds)
+ local idListSorted = self.autoSplitMenuItemIds
+
+ -- Add IDs not being skipped to the reusable storage table.
+ for _, id in ipairs(idList) do
+ if
+ -- idsStoOmit is an array, not an associative table, so we need to check contents by looping.
+ BsUtil.TableContainsValue(idsToOmit, id) == nil
+ -- Call omitFunc() if present.
+ and ((type(omitFunc) ~= "function" or type(itemList) ~= "table") or not omitFunc(id, itemList))
+ then
+ table.insert(idListSorted, id)
+ end
+ end
+
+ -- Ensure list is sorted correctly.
+ if type(sortFunc) == "function" then
+ sortFunc(idListSorted)
+ end
+
+ local idCount = table.getn(idListSorted)
+
+ -- We need this many submenus. Count of extra items is subtracted because they
+ -- are top-level and decrease the number of available buttons.
+ local submenuCount = math.ceil(idCount / (_G.UIDROPDOWNMENU_MAXBUTTONS - numExtraItems))
+
+ -- Decide whether to build submenus or toss everything at the first level.
+ if submenuCount > 1 then
+ -- We need submenus.
+
+ lastIndex = 0
+ for submenuNum = 1, submenuCount do
+ if menuItemNum > _G.UIDROPDOWNMENU_MAXBUTTONS then
+ break
+ end
+
+ firstIndex = lastIndex + 1
+ lastIndex = math.min((firstIndex + _G.UIDROPDOWNMENU_MAXBUTTONS - 1), idCount)
+ submenuLastItemIndex = lastIndex
+
+ -- Once we reach the last item, adjust the index of the item we're going to use
+ -- to determine the "Z" in "N - Z" (see `triggeringMenuEndRangeOmitIdCount` declaration).
+ if submenuNum == submenuCount then
+ submenuLastItemIndex = submenuLastItemIndex - triggeringMenuEndRangeOmitIdCount
+ end
+
+ -- Safeguard.
+ if firstIndex > idCount then
+ break
+ end
+
+ -- Figure out the triggering menu text by adding letters until they're different.
+
+ -- First, do the items that start and end this submenu.
+ submenuFirstItem = autoSplitMenuParams.nameFunc(idListSorted[firstIndex], menuValue)
+ submenuLastItem = autoSplitMenuParams.nameFunc(idListSorted[submenuLastItemIndex], menuValue)
+ menuStartText, menuEndText =
+ self:BuildAutoSplitSubmenuRangeTexts(submenuFirstItem, submenuLastItem, prevNextMenuStartText, nil)
+
+ -- Now we need to check the ending item against the first item of the next submenu to avoid having a situation like:
+ -- A - C >
+ -- C - N >
+ -- when what we want is:
+ -- A - Cl >
+ -- Co - N >
+
+ -- There's only a first item for the next submenu if we haven't run out of items.
+ nextSubmenuFirstItem = (lastIndex + 1 <= idCount)
+ and autoSplitMenuParams.nameFunc(idListSorted[lastIndex + 1], menuValue)
+ or nil
+
+ -- To decide whether any adjustment of the end text is necessary, grab the first
+ -- `` letters of the next submenu's first item for comparison.
+ -- `nextMenuStartText` is used during this loop and also captured into `prevNextMenuStartText`
+ -- for use in the next loop's call to `BuildCategorySubmenuRangeTexts()`.
+ nextMenuStartText = (nextSubmenuFirstItem ~= nil)
+ and string.sub(nextSubmenuFirstItem, 1, string.len(menuEndText))
+ or ""
+
+ -- Change the end text and/or the next menu's start text when the next
+ -- start text would be the same, or if there is a matching prefix.
+ nextMenuStartTextMinLength = 0
+ if string.len(nextMenuStartText) > 0 then
+ if nextMenuStartText == menuEndText then
+ -- They're exactly the same, so keep adding characters.
+ nextMenuStartTextMinLength = string.len(menuEndText)
+ elseif string.sub(nextMenuStartText, 1, -2) == string.sub(menuEndText, 1, -2) then
+ -- They have a shared prefix, so the next start text needs to be
+ -- at least the same length as the current end text.
+ nextMenuStartTextMinLength = string.len(menuEndText) - 1
+ else
+ nextMenuStartText = nil
+ end
+ end
+
+ -- Make adjustments.
+ if nextMenuStartTextMinLength > 0 then
+ menuEndText, nextMenuStartText =
+ self:BuildAutoSplitSubmenuRangeTexts(submenuLastItem, nextSubmenuFirstItem, nil, nextMenuStartTextMinLength)
+ else
+ -- When the next menu's first item has different text from the current menu's
+ -- last item, we won't need `prevNextMenuStartText`, so just nil this out here.
+ nextMenuStartText = nil
+ end
+
+ -- Menu item that will trigger the submenu.
+ local menuItem = autoSplitMenu.levels[1][menuItemNum] -- This has to use menuItemNum, NOT submenuNum.
+
+ displayStartText = menuStartText
+ displayEndText = menuEndText
+ if autoSplitMenuParams.parentMenuNameTrimFunc then
+ displayStartText = autoSplitMenuParams.parentMenuNameTrimFunc(menuStartText)
+ displayEndText = autoSplitMenuParams.parentMenuNameTrimFunc(menuEndText)
+ end
+
+ -- Base values.
+ menuItem.text = displayStartText .. (displayEndText ~= displayStartText and (" - " .. displayEndText) or "")
+ menuItem.isTitle = false
+ menuItem.tooltipTitle = menuValue.tooltipTitle
+ menuItem.tooltipText = menuValue.tooltipText
+ menuItem.disabled = false
+ menuItem.notCheckable = menuValue.notCheckable
+ menuItem.value.value = submenuNum -- This is the submenu to load.
+ menuItem.arg2 = nil -- self._autoSplitMenuItem_Click() will see arg2 is nil and not close the menu.
+ menuItem.hasArrow = 1
+ menuItem.func = nil
+ menuItem._bagshuiHide = false
+
+ -- These are needed to pass values through to the next level via LoadMenu().
+
+ menuItem.value.autoSplitMenuType = autoSplitMenuType
+ menuItem.value.autoSplitMenuExtraItems = extraMenuitems
+ menuItem.value.objectId = objectId
+ menuItem.value.itemList = itemList
+ menuItem.value.idList = idList
+ menuItem.value.idsToOmit = idsToOmit
+ menuItem.value.idsToDisable = idsToDisable
+ menuItem.value.idsToCheck = idsToCheck
+ menuItem.value.checkFunc = checkFunc
+ menuItem.value.disableFunc = disableFunc
+ menuItem.value.omitFunc = omitFunc
+ menuItem.value.func = clickFunc
+ menuItem.value.keepShownOnClick = menuValue.keepShownOnClick
+ menuItem.value.notCheckable = menuValue.notCheckable
+ menuItem.value.tooltipTitleFunc = menuValue.tooltipTitleFunc
+ menuItem.value.tooltipTextFunc = menuValue.tooltipTextFunc
+
+ -- Fill the submenu and place a check mark next to the parent menu
+ -- if any submenu items are checked.
+ menuItem.checked = self:PopulateAutoSplitMenuItems(
+ menuValue,
+ autoSplitMenuParams,
+ autoSplitMenu.levels[2][submenuNum],
+ idListSorted,
+ objectId,
+ firstIndex,
+ lastIndex,
+ idsToCheck,
+ idsToDisable,
+ clickFunc,
+ checkFunc,
+ 1
+ )
+
+ -- Prepare for the next loop.
+
+ prevNextMenuStartText = nextMenuStartText
+ menuItemNum = menuItemNum + 1
+ end -- Menu-building for loop.
+ else
+ -- Only one level; no submenus.
+ self:PopulateAutoSplitMenuItems(
+ menuValue,
+ autoSplitMenuParams,
+ autoSplitMenu.levels[1],
+ idListSorted,
+ objectId,
+ menuItemNum, -- firstIndex.
+ nil, -- lastIndex is nil to trigger use of the remaining list.
+ idsToCheck,
+ idsToDisable,
+ clickFunc,
+ checkFunc,
+ menuItemNum
+ )
+
+ menuItemNum = menuItemNum + idCount
+ end
+
+ -- Add "extra" items to bottom of menu.
+ if numExtraItems > 0 then
+ if type(extraMenuitems) == "table" and table.getn(extraMenuitems) > 0 then
+ self:PopulateAutoSplitMenuItems(
+ menuValue,
+ autoSplitMenuParams,
+ autoSplitMenu.levels[1],
+ extraMenuitems,
+ objectId,
+ 1,
+ numExtraItems,
+ idsToCheck,
+ idsToDisable,
+ clickFunc,
+ checkFunc,
+ menuItemNum
+ )
+ menuItemNum = menuItemNum + table.getn(extraMenuitems)
+ end
+ if type(baseExtraMenuitems) == "table" and table.getn(baseExtraMenuitems) > 0 then
+ self:PopulateAutoSplitMenuItems(
+ menuValue,
+ autoSplitMenuParams,
+ autoSplitMenu.levels[1],
+ baseExtraMenuitems,
+ objectId,
+ 1,
+ numExtraItems,
+ idsToCheck,
+ idsToDisable,
+ clickFunc,
+ checkFunc,
+ menuItemNum
+ )
+ menuItemNum = menuItemNum + table.getn(baseExtraMenuitems)
+ end
+ end
+
+ -- Menu was empty and nothing has been populated
+ if menuItemNum == 1 then
+ self:PopulateAutoSplitMenuItems(
+ menuValue,
+ autoSplitMenuParams,
+ autoSplitMenu.levels[1],
+ self.autoSplitMenuItemIds, -- Just need a table whose getn is 0.
+ objectId,
+ nil, -- firstIndex and
+ nil, -- lastIndex are nil to trigger use of the entire list.
+ idsToCheck,
+ idsToDisable,
+ clickFunc,
+ checkFunc,
+ menuItemNum
+ )
+ end
+
+ -- Hide any remaining buttons in the parent menu.
+ self:HideMenuItems(autoSplitMenu.levels[1], menuItemNum)
+ end
+
+ --- Populate the given auto-split menu with items.
+ ---
+ --- There is some inconsistency around how properties get picked up -- some come
+ --- from this function's arguments, others from `triggeringMenuValue`/`autoSplitMenuParams`.
+ --- That's not getting cleaned up at this point.
+ ---@param triggeringMenuValue table `value` property of the menu item that triggered this auto-split menu.
+ ---@param autoSplitMenuParams table Configuration of this auto-split menu.
+ ---@param autoSplitMenu table[] List of menu entries.
+ ---@param idList any[] List of item IDs with which to populate the menu.
+ ---@param objectId any Object or object ID **UPON WHICH** this auto-split menu is acting. Will be passed to functions as arg1.objectId.
+ ---@param firstIndex number? Start position within `idList`, inclusive.
+ ---@param lastIndex number? End position within `idList`, inclusive.
+ ---@param idsToCheck any?[] List of item IDs which should be checked on the menu.
+ ---@param idsToDisable any?[] List of item IDs which should be disabled on the menu.
+ ---@param clickFunc function? Executed when the menu item is clicked. Invoked by `Menus._autoSplitMenuItem_Click()`. See declaration in `PrepareAutoSplitMenu()` for details.
+ ---@param checkFunc function? Decide whether the menu entry for a given item should be checked. See declaration in `PrepareAutoSplitMenu()` for details.
+ ---@param menuItemStartNum number? Starting point within `autoSplitMenu` for menu entries.
+ ---@return boolean hasCheckedItems true if one or more menu items received a check mark.
+ function Menus:PopulateAutoSplitMenuItems(
+ triggeringMenuValue,
+ autoSplitMenuParams,
+ autoSplitMenu,
+ idList,
+ objectId,
+ firstIndex,
+ lastIndex,
+ idsToCheck,
+ idsToDisable,
+ clickFunc,
+ checkFunc,
+ menuItemStartNum
+ )
+ -- Keep track of the count so we can hide unused menu items.
+ local menuItemNum = menuItemStartNum or 1
+ local menuItemCount = menuItemNum - 1
+
+ -- Return value.
+ local hasCheckedItems = false
+
+ local itemId, itemName, menuItem, menuItemFunc
+ local nameFunc = triggeringMenuValue.nameFunc or autoSplitMenuParams.nameFunc
+ local disableFunc = triggeringMenuValue.disableFunc or autoSplitMenuParams.disableFunc
+ local tooltipTitleFunc = triggeringMenuValue.tooltipTitleFunc or autoSplitMenuParams.tooltipTitleFunc
+ local tooltipTextFunc = triggeringMenuValue.tooltipTextFunc or autoSplitMenuParams.tooltipTextFunc
+
+ -- Iterate through idList using the given start/end points.
+ for i = firstIndex or 1, lastIndex or table.getn(idList) do
+ -- Safeguard - don't overfill the menu.
+ if menuItemCount > _G.UIDROPDOWNMENU_MAXBUTTONS then
+ break
+ end
+
+ menuItem = autoSplitMenu[menuItemNum]
+
+ -- Reset/Set defaults.
+
+ menuItem.arg1.objectId = objectId
+ menuItem.arg1.menuItem = menuItem
+ menuItem.isTitle = false
+ menuItem.disabled = false
+ menuItem.hasArrow = false
+ menuItem.tooltipTitle = nil
+ menuItem.tooltipText = nil
+ menuItem.checked = false
+ menuItem.keepShownOnClick = triggeringMenuValue.keepShownOnClick
+ menuItem.notCheckable = triggeringMenuValue.notCheckable
+ menuItem._bagshuiHide = false
+
+ menuItemFunc = self._autoSplitMenuItem_Click
+
+ if type(idList[i]) == "table" then
+ -- When an item within the ID list is a table, assume it's an "extra" menu item.
+
+ -- Transfer all properties to the live menu item.
+ for menuItemKey, menuItemProperty in pairs(idList[i]) do
+ -- Need special handling for auto-split menu parameters in the value table.
+
+ if menuItemKey == "value" and type(menuItemProperty) == "table" then
+ for valueKey, valueSetting in pairs(menuItemProperty) do
+ -- value.func should be assigned to arg1.func so it passes through properly.
+ if valueKey == "func" then
+ menuItem.arg1.func = valueSetting
+ elseif valueKey == "tooltipTextFunc" and type(valueSetting) == "function" then
+ menuItem.tooltipText = valueSetting()
+ end
+ end
+ elseif menuItemKey == "func" then
+ -- Override _autoSplitMenuItem_Click() with a custom function.
+ menuItemFunc = menuItemProperty
+ else
+ -- Normal property assignment.
+ menuItem[menuItemKey] = menuItemProperty
+ end
+ end
+ else
+ itemId = idList[i]
+
+ -- Populate the menu.
+
+ -- Figure out the name, passing it through the revision function if one is available.
+ itemName = type(nameFunc) == "function" and nameFunc(itemId, triggeringMenuValue) or itemId
+ if type(triggeringMenuValue.nameRevisionFunc) == "function" then
+ itemName = triggeringMenuValue.nameRevisionFunc(itemId, itemName, triggeringMenuValue)
+ end
+ menuItem.text = itemName
+
+ menuItem.disabled = (BsUtil.TableContainsValue(idsToDisable, itemId) ~= nil)
+
+ menuItem.tooltipTitle = itemName
+ if type(autoSplitMenuParams.tooltipTitleFunc) == "function" then
+ menuItem.tooltipTitle = autoSplitMenuParams.tooltipTitleFunc(itemId, menuItem.tooltipTitle) or itemName
+ end
+ if type(triggeringMenuValue.tooltipTitleFunc) == "function" then
+ menuItem.tooltipTitle = triggeringMenuValue.tooltipTitleFunc(itemId, menuItem.tooltipTitle)
+ end
+
+ menuItem.tooltipText = nil
+ if type(autoSplitMenuParams.tooltipTextFunc) == "function" then
+ menuItem.tooltipText = autoSplitMenuParams.tooltipTextFunc(itemId, menuItem.tooltipText)
+ end
+ if type(triggeringMenuValue.tooltipTextFunc) == "function" then
+ menuItem.tooltipText = triggeringMenuValue.tooltipTextFunc(itemId, menuItem.tooltipText)
+ end
+
+ menuItem.arg1.func = clickFunc
+
+ menuItem.arg2 = itemId
+
+ -- Put a check mark next to the item when its id is in idsToCheck or checkFunc() returns true.
+ menuItem.checked = BsUtil.TableContainsValue(idsToCheck, itemId) ~= nil
+ or (type(checkFunc) == "function" and checkFunc(itemId, menuItem, triggeringMenuValue))
+
+ if menuItem.checked then
+ hasCheckedItems = true
+ end
+ end
+
+ -- Assign click function.
+ menuItem.func = menuItemFunc
+
+ -- Call disable function if available.
+ if disableFunc then
+ menuItem.disabled = disableFunc(itemId, menuItem, triggeringMenuValue)
+ end
+
+ -- Keep track of the count so we can hide extra menu items.
+ menuItemCount = menuItemCount + 1
+ menuItemNum = menuItemNum + 1
+ end
+
+ -- "(No Items Available)".
+ if menuItemCount == 0 then
+ menuItemNum = 1
+ menuItem = autoSplitMenu[menuItemNum]
+ menuItem.text = L.NoItemsAvailable
+ menuItem.disabled = true
+ menuItem.hasArrow = false
+ menuItem._bagshuiHide = false
+ menuItemNum = 2
+ end
+
+ -- Hide unused menu items.
+ self:HideMenuItems(autoSplitMenu, menuItemNum)
+
+ return hasCheckedItems
+ end
+
+ --- Determines the best naming for auto-split menu parent items by iterating until
+ --- the start and end texts are different. For example, given "Armor" and "Arrows",
+ --- "Arm" and "Arr" would be the return values.
+ ---@param startItemName string Text of the first item in the submenu.
+ ---@param endItemName string Text of the last item in the submenu.
+ ---@param previousStartText string? Text of the last item in the *previous* submenu.
+ ---@param textLengthStart number? Iteration for comparing `startItemName` to `endItemName` will begin at this length.
+ ---@return string|unknown startText
+ ---@return string|unknown endText
+ function Menus:BuildAutoSplitSubmenuRangeTexts(startItemName, endItemName, previousStartText, textLengthStart)
+ local submenuTextCharCount = textLengthStart or 0
+ local startText, endText
+ local tries = 0
+
+ repeat
+ tries = tries + 1
+ submenuTextCharCount = submenuTextCharCount + 1
+
+ -- The first entry needs to take into account what we figured out during the previous loop to avoid
+ -- incorrectly truncating it back to something smaller. Until the number of required characters for this
+ -- loop exceeds the length of the previous loop's "next" start text, stick to what we found in the previous loop.
+ startText = (previousStartText ~= nil and BsUtil.Utf8Len(previousStartText) >= submenuTextCharCount)
+ and previousStartText
+ or BsUtil.Utf8Sub(startItemName, 1, submenuTextCharCount)
+ endText = BsUtil.Utf8Sub(endItemName, 1, submenuTextCharCount)
+
+ -- Remove punctuation from ends of strings if it doesn't make them the same.
+ if startText ~= endText then
+ -- Don't strip punctuation if the string is ALL punctuation.
+ local startWithoutTrailingPunctuation = not string.find(startText, "^%p+$")
+ and string.gsub(startText, "%W+$", "")
+ or startText
+ local endWithoutTrailingPunctuation = not string.find(endText, "^%p+$") and string.gsub(endText, "%W+$", "")
+ or endText
+ if startWithoutTrailingPunctuation ~= endWithoutTrailingPunctuation then
+ startText = startWithoutTrailingPunctuation
+ endText = endWithoutTrailingPunctuation
+ end
+ end
+
+ until (
+ startText ~= endText
+ -- Ensure endText isn't just the beginning of startText
+ and not string.find(startText, "^" .. string.gsub(endText, "(%W)", "%%%1"))
+ -- Ensure items don't end with punctuation where possible
+ and (string.find(startText, "[^%p]$") or string.find(startText, "^%p+$"))
+ and (string.find(endText, "[^%p]$") or string.find(endText, "^%p+$"))
+ )
+ or (startText == startItemName and endText == endItemName)
+ -- Safeguard: Prevent an infinite loop.
+ or tries >= 500
+ return startText, endText
+ end
+
+ -- Perform initialization.
+ Menus:Init()
+end)
diff --git a/Components/ObjectList.lua b/Components/ObjectList.lua
new file mode 100644
index 0000000..5256c03
--- /dev/null
+++ b/Components/ObjectList.lua
@@ -0,0 +1,1223 @@
+-- Bagshui ObjectList Prototype
+-- Exposes: Bagshui.prototypes.ObjectList
+-- Raises:
+-- * BAGSHUI_[OBJECT_NAME]_UPDATE (i.e. BAGSHUI_CATEGORY_UPDATE)
+-- * BAGSHUI_[OBJECT_NAME_PLURAL]_LOADED (i.e. BAGSHUI_CATEGORIES_LOADED)
+--
+-- Base class for Categories, Sort Orders, etc. since they share a lot of concepts.
+
+Bagshui:AddComponent(function()
+ Bagshui:AddConstants({
+
+ -- Use this in object skeletons to blindly accept any table provided
+ -- as a property value without filtering. When this is used, the
+ -- subclass should perform its own validation. See Components\Profiles.lua
+ -- for an example of this in action.
+ BS_OBJECT_LIST_TEMPLATE_TABLE_WILDCARD = {},
+ })
+
+ local ObjectList = {}
+ Bagshui.prototypes.ObjectList = ObjectList
+
+ --- Create a new ObjectList instance.
+ --- ### `instanceProperties` Notes:
+ --- * The keys below are mandatory.
+ --- * Anything else in the table will be assigned to the final class instance
+ --- as well, so this can be used to override optional keys that are initialized
+ --- in the `objectList` table declared just after the `assert()`s.
+ ---```
+ ---{
+ --- ---@type string *Required* Key in BagshuiData where these objects will be stored.
+ --- dataStorageKey,
+ --- ---@type number? Current data version that can be used to determine when migration is required.
+ --- objectVersion,
+ --- ---@type function(data, oldVersion)? Take care of any migration actions that need to occur when object versions change. See below for example.
+ --- objectMigrationFunction,
+ --- ---@type string *Required* Localizable string for object name.
+ --- objectName
+ --- ---@type string *Required* Localizable string for plural object name.
+ --- objectNamePlural,
+ --- ---@type string *Required* Internal name of the object, used for localization. If not specified, will be derived from objectName by removing spaces.
+ --- objectType,
+ --- ---@type table *Required* Template for use with TableCopy() that specifies what is valid for the object. Can leverage BS_OBJECT_LIST_TEMPLATE_TABLE_WILDCARD.
+ --- objectSkeleton,
+ --- ---@type table *Required* Template for creating a new object.
+ --- objectTemplate,
+ --- ---@type table[]? Default objects that will be loaded into dataStorageKey.
+ --- defaults,
+ --- ---@type table[]? List of template objects that can be created by the object manager UI.
+ --- templates,
+ --- ---@type table If a property is missing from a default object, give it this value.
+ --- defaultObjectValues,
+ --- }
+ ---```
+ ---
+ ---### `objectMigrationFunction` example
+ ---```
+ --- ---@param data table Contents of the SavedVariables `dataStorageKey` for this ObjectList.
+ --- ---@param oldVersion number *Previous* version number before Bagshui was loaded this time.
+ --- function migrate(data, oldVersion)
+ --- if oldVersion < 2 then
+ --- -- Perform migration actions on object list data.
+ --- end
+ --- end
+ ---```
+ ---@param instanceProperties table List of properties for the new instance.
+ ---@return table objectList New instance.
+ function ObjectList:New(instanceProperties)
+ assert(type(instanceProperties.dataStorageKey) == "string", "instanceProperties.dataStorageKey must be a string")
+ assert(type(instanceProperties.objectName) == "string", "instanceProperties.objectName must be a string")
+ assert(
+ type(instanceProperties.objectNamePlural) == "string",
+ "instanceProperties.objectNamePlural must be a string"
+ )
+ assert(type(instanceProperties.objectSkeleton) == "table", "instanceProperties.objectSkeleton must be a table")
+ assert(type(instanceProperties.objectTemplate) == "table", "instanceProperties.objectTemplate must be a table")
+ assert(
+ type(instanceProperties.defaults) == "table" or not instanceProperties.defaults,
+ "instanceProperties.defaults must be a table"
+ )
+
+ local objectList = {
+ _super = ObjectList,
+
+ ---@type table
+ -- This is the ` = { Object }` table that is populated during Init().
+ list = nil,
+
+ ---@type table
+ -- Metadata about objects.
+ meta = {},
+
+ -- Keep track of names to assist in preventing duplicates.
+ defaultObjectNames = {},
+ customObjectNames = {},
+
+ -- Provide a base version.
+ objectVersion = instanceProperties.objectVersion or 1,
+
+ -- Derive objectType from objectName if needed.
+ objectType = string.gsub(instanceProperties.objectType or instanceProperties.objectName, "%s", ""),
+
+ -- Should everything in the defaults bet set to readOnly when LoadDefaults() is called?
+ defaultsReadOnly = true,
+ -- Should everything in the defaults bet set to hidden when LoadDefaults() is called?
+ defaultsHidden = false,
+
+ -- Prevent creation of new objects.
+ disableObjectCreation = false,
+ -- Prevent deletion of existing objects.
+ disableObjectDeletion = false,
+ -- Prevent editing of existing objects.
+ disableObjectEditing = false,
+ -- Prevent import/export.
+ disableObjectSharing = false,
+
+ ---@type function(objectId) -> boolean
+ -- Prevent creation of objects matching specific criteria.
+ disableObjectCreationFunc = nil,
+ ---@type function(objectId) -> boolean
+ -- Prevent deletion of objects matching specific criteria.
+ disableObjectDeletionFunc = nil,
+ ---@type function(objectId) -> boolean
+ -- Prevent editing of objects matching specific criteria.
+ disableObjectEditingFunc = nil,
+
+ -- Event that will trigger `ObjectList:Init()`.
+ initEvent = "BAGSHUI_ADDON_LOADED",
+
+ ---@type string?
+ -- Additional event that will trigger `ObjectList:RefreshUsage()`.
+ -- See `ObjectList:OnEvent()` for events that are default triggers.
+ refreshObjectUsageEvent = nil,
+
+ ---@type boolean?
+ -- Set false to ignore the default events that would usually trigger
+ -- a usage refresh. See `ObjectList:OnEvent()` for events that are default triggers.
+ defaultObjectUsageRefreshEventsActive = true,
+
+ -- Event that will be raised when objects change.
+ -- Example: BAGSHUI_CATEGORY_UPDATE
+ objectChangeEvent = "BAGSHUI_"
+ .. string.gsub(string.upper(instanceProperties.objectName), "%s", "_")
+ .. "_UPDATE",
+
+ -- Arrays of object IDs sorted by certain properties.
+ sortedIdLists = {
+ name = {},
+ builtin = {},
+ inUse = {},
+ },
+ -- Arrays of object IDs filtered by certain properties.
+ filteredIdLists = {
+ builtin = {},
+ custom = {},
+ },
+ -- This is the same as sortedIdLists but excluding any object with a hidden = true property.
+ sortedIdListsForManager = {
+ name = {},
+ builtin = {},
+ inUse = {},
+ },
+
+ ---@type string[]
+ -- List of object keys to delete in the exported version of an object.
+ -- Only top-level keys are currently supported.
+ exportRemoveKeys = {
+ "inUse",
+ "dateCreated",
+ "dateModified",
+ },
+ ---@type table
+ -- Object keys to ignore when determining whether an object that is about
+ -- to be imported is identical to an existing object. This will automatically
+ -- be updated to include everything in `exportRemoveKeys`.
+ importIgnoreKeys = {
+ name = true,
+ },
+
+ ---@type table
+ -- When an ID is requested for a new object but it hasn't been saved yet,
+ -- that ID is temporarily stored here so that we won't issue the same
+ -- ID multiple times.
+ reservedObjectIds = {},
+
+ ---@type table
+ -- ObjectManager instance that will be initialized when Open() is called.
+ -- DO NOT override it here -- use `:InitUi()` instead.
+ objectManager = nil,
+
+ -- Object change events won't be raised until `self.initialized == true`.
+ initialized = false,
+
+ ---@type table List of functions used for sorting by item properties.
+ --- Keys are item properties, strings are functions that accept two IDs and return
+ --- true if idA < idB. See `objectList.idListSortFunctions` setup below for examples.
+ --- Subclasses can add their own property-specific sort functions by leveraging the
+ --- `_idListSortFunctions_objectList` property just like the base functions.
+ idListSortFunctions = nil,
+
+ ---@type table
+ -- Table reference used to pass the object list into the `idListSortFunctions`.
+ _idListSortFunctions_objectList = nil,
+
+ -- Reusable table for GetUses().
+ _getUses_temp = {},
+
+ -- Should the object list be cleared at startup?
+ debugResetOnLoad = false and BS_DEBUG,
+ }
+
+ -- (Need to wait to declare these so we have the objectList instance.)
+ objectList.idListSortFunctions = {
+ name = function(idA, idB)
+ return string.lower(objectList._idListSortFunctions_objectList[idA].name or "")
+ < string.lower(objectList._idListSortFunctions_objectList[idB].name or "")
+ end,
+
+ builtin = function(idA, idB)
+ return (
+ (objectList._idListSortFunctions_objectList[idA].builtin and 1 or 0)
+ .. string.lower(objectList._idListSortFunctions_objectList[idA].name or "")
+ )
+ < (
+ (objectList._idListSortFunctions_objectList[idB].builtin and 1 or 0)
+ .. string.lower(objectList._idListSortFunctions_objectList[idB].name or "")
+ )
+ end,
+
+ inUse = function(idA, idB)
+ return (
+ (objectList._idListSortFunctions_objectList[idA].inUse and 1 or 0)
+ .. string.lower(objectList._idListSortFunctions_objectList[idA].name or "")
+ )
+ < (
+ (objectList._idListSortFunctions_objectList[idB].inUse and 1 or 0)
+ .. string.lower(objectList._idListSortFunctions_objectList[idB].name or "")
+ )
+ end,
+ }
+
+ --- Helper function that can be passed as a parameter to `table.sort()`.
+ ---@param idA any ID of first object to compare.
+ ---@param idB any ID of second object to compare.
+ ---@return boolean # true if idA < idB.
+ function objectList.idListSortWrapper(idA, idB)
+ -- Need a safeguard for sorting to avoid errors if an object doesn't exist
+ if not objectList._idListSortFunctions_objectList[idA] or not objectList._idListSortFunctions_objectList[idB] then
+ return false
+ end
+ if not objectList.idListSortFunctions[objectList._idListSortProperty] then
+ objectList._idListSortProperty = "name"
+ end
+ return objectList.idListSortFunctions[objectList._idListSortProperty](idA, idB)
+ end
+
+ -- Copy instance properties to final object.
+ for key, value in pairs(instanceProperties) do
+ objectList[key] = value
+ end
+
+ -- Ensure key properties are allowed by the object skeleton.
+ objectList.objectSkeleton.name = ""
+ objectList.objectSkeleton.builtin = false
+ objectList.objectSkeleton.readOnly = false
+ objectList.objectSkeleton.hidden = false
+ objectList.objectSkeleton.inUse = false
+
+ -- Set up the class object.
+ setmetatable(objectList, self)
+ self.__index = self
+
+ -- Prepare for Init().
+ Bagshui:RegisterEvent(objectList.initEvent, objectList)
+ if objectList.defaultObjectUsageRefreshEventsActive then
+ Bagshui:RegisterEvent("BAGSHUI_INVENTORY_EDIT_MODE_UPDATE", objectList)
+ Bagshui:RegisterEvent("BAGSHUI_SETTING_UPDATE", objectList)
+ end
+ if objectList.refreshObjectUsageEvent then
+ Bagshui:RegisterEvent(objectList.refreshObjectUsageEvent, objectList)
+ end
+
+ -- Register slash command handler (for example, /bagshui categories).
+ BsSlash:AddOpenCloseHandler(instanceProperties.objectNamePlural, objectList)
+
+ return objectList
+ end
+
+ --- Initialize object list after BagshuiData/SavedVariables are available.
+ ---@param subclassWillSetInitializedProperty boolean? true when the calling subclass needs to complete other tasks before `self.initialized` becomes true.
+ function ObjectList:Init(subclassWillSetInitializedProperty)
+ --Bagshui:PrintDebug("ObjectList:Init() -- " .. self.objectName)
+
+ -- Initialize ObjectList config in SavedVariables.
+ if _G.BagshuiData[self.dataStorageKey] == nil or self.debugResetOnLoad then
+ -- Bagshui:PrintDebug("Resetting " .. self.dataStorageKey .. " for " .. self.objectName)
+ _G.BagshuiData[self.dataStorageKey] = {}
+ Bagshui.objectVersions[self.dataStorageKey] = self.objectVersion
+ end
+ -- Reference to `dataStorageKey` in BagshuiData.
+ self.list = _G.BagshuiData[self.dataStorageKey]
+
+ -- Perform migration if available.
+ if type(self.objectMigrationFunction) == "function" then
+ self.objectMigrationFunction(self, (Bagshui.objectVersions[self.dataStorageKey] or 1))
+ end
+ -- Update object version so migration won't repeat.
+ Bagshui.objectVersions[self.dataStorageKey] = self.objectVersion
+
+ self:LoadDefaults(self.defaults)
+
+ -- Get the sorted object list initialized.
+ self:UpdateTrackingLists()
+
+ -- Save operations should now raise BAGSHUI__UPDATE.
+ if not subclassWillSetInitializedProperty then
+ self.initialized = true
+ end
+
+ -- Raise event for loading finished, for example, BAGSHUI_CATEGORIES_LOADED.
+ Bagshui:RaiseEvent("BAGSHUI_" .. string.upper(self.objectNamePlural) .. "_LOADED")
+ end
+
+ --- Event handling.
+ ---@param event string Event identifier.
+ ---@param arg1 any? Argument 1 from the event.
+ function ObjectList:OnEvent(event, arg1, arg2, arg3)
+ -- Bagshui:PrintDebug("ObjectList " .. tostring(self.objectType) .. " OnEvent " .. event .. " (prototype: " .. tostring(self == ObjectList) .. ")")
+
+ -- Don't call Init() on the prototype.
+ if event == self.initEvent and self ~= ObjectList then
+ if not self.initialized then
+ self:Init()
+ end
+ return true
+ end
+
+ -- Keep object usage information up to date when changes are made to inventory layouts or default profile settings.
+ if
+ self.defaultObjectUsageRefreshEventsActive
+ and (
+ event == "BAGSHUI_INVENTORY_EDIT_MODE_UPDATE"
+ or (
+ -- There is definitely a cleaner way to do this, but being able to disable
+ -- via defaultObjectUsageRefreshEventsActive = false is going to be good enough for now.
+event == "BAGSHUI_SETTING_UPDATE"
+ and (string.find(tostring(arg1), "^defaultProfile") or string.find(tostring(arg1), "^profile"))
+ )
+ )
+ or event == self.refreshObjectUsageEvent
+ then
+ -- Get usage information up to date.
+ self:RefreshUsage()
+ return
+ end
+ end
+
+ --- Call this to raise BAGSHUI__UPDATE.
+ ---@param objectId string|number? Object that has changed.
+ function ObjectList:ObjectsChanged(objectId)
+ -- Perform updates and send notifications only after startup
+ if self.initialized then
+ self:UpdateTrackingLists()
+ -- Event will be, for example, BAGSHUI_CATEGORY_UPDATE
+ Bagshui:RaiseEvent(self.objectChangeEvent, false, objectId)
+ end
+ end
+
+ --- Update usage information for all objects.
+ function ObjectList:RefreshUsage()
+ if self.GetUses ~= ObjectList.GetUses then
+ for id, _ in pairs(self.list) do
+ if not self.meta[id] then
+ self.meta[id] = {
+ usesLeft = {},
+ usesRight = {},
+ usesOfProfilesLeft = {},
+ usesOfProfilesRight = {},
+ }
+ end
+ self.list[id].inUse = self:GetUses(id, self.meta[id])
+ end
+ end
+ -- When usage information changes, sorting by the inUse property can change.
+ self:UpdateSortedObjectLists()
+ -- Refresh UI.
+ if self.objectManager then
+ self.objectManager:UpdateList()
+ end
+ end
+
+ -- Load default objects.
+ ---@param objectList table[] List of objects to load.
+ function ObjectList:LoadDefaults(objectList)
+ local foundDefaults = {}
+
+ if objectList then
+ for _, objectInfo in ipairs(objectList) do
+ -- Fill any missing default values.
+ if self.defaultObjectValues then
+ for key, defaultValue in pairs(self.defaultObjectValues) do
+ if not objectInfo[key] then
+ objectInfo[key] = defaultValue
+ end
+ end
+ end
+ -- Add flags.
+ objectInfo.builtin = true
+ if self.defaultsReadOnly then
+ objectInfo.readOnly = true
+ end
+ if self.defaultsHidden then
+ objectInfo.hidden = true
+ end
+ -- Save to self.list.
+ self:Save(objectInfo.id, objectInfo)
+ foundDefaults[objectInfo.id] = true
+ end
+ end
+
+ -- Remove any built-ins that no longer exist.
+ if not self.disableObjectDeletion then
+ for id, objectInfo in pairs(self.list) do
+ if
+ objectInfo.builtin
+ and not foundDefaults[id]
+ and not (type(self.disableObjectDeletionFunc) == "function" and self.disableObjectDeletionFunc(id))
+ then
+ self.list[id] = nil
+ end
+ end
+ end
+
+ -- Need to manually update here since automatic updates don't happen until after startup.
+ self:UpdateObjectNameLists()
+ end
+
+ --- Retrieve an object.
+ ---@param objectId string|number ID of object to retrieve.
+ ---@return table|nil object
+ function ObjectList:Get(objectId)
+ return self.list[objectId]
+ end
+
+ --- Determine whether an object is a builtin..
+ ---@param objectId string|number ID of object to check.
+ ---@return boolean
+ function ObjectList:IsBuiltIn(objectId)
+ return self.list[objectId] and (self.list[objectId].builtin and true) or false
+ end
+
+ --- Retrieve a copy of an object for exporting.
+ --- The return value MUST be a table with the fallowing keys:
+ --- ```
+ --- {
+ --- object =
+ --- dependencies = {
+ --- -- Yes, this should use the ACTUAL class as the key -- for example, BsCategories.
+ --- [] = {
+ ---
+ --- },
+ --- [] = {
+ ---
+ --- },
+ --- },
+ --- }
+ --- ```
+ --- *Optional* subclass method. When subclassing is required,
+ --- grab the return value of the superclass function and modify it,
+ --- along the lines of this pseudocode:
+ --- ```
+ --- function Subclass:Export(objectId)
+ --- local export = self._super.Export(self, objectId)
+ --- export.dependencies[DependencyClass.objectType] = {}
+ --- for do
+ --- table.insert(
+ --- export.dependencies[DependencyClass],
+ --- dependencyObjectId
+ --- )
+ --- end
+ --- end
+ --- ```
+ ---@param objectId string|number
+ ---@return table exportableData
+ function ObjectList:Export(objectId)
+ local object = nil
+ if self.list[objectId] and not self.list[objectId].builtin then
+ object = BsUtil.TableCopy(self.list[objectId])
+ for _, keyToRemove in ipairs(self.exportRemoveKeys) do
+ object[keyToRemove] = nil
+ end
+ end
+ local export = {
+ object = object,
+ dependencies = {},
+ }
+ return export
+ end
+
+ --- Import the given data as a new object.
+ --- *Optional* subclass method.
+ ---@param objectInfo table
+ ---@param dependencyMap table