Files
Bagshui/Components/Inventory.Layout.lua
OEVGandClaude Opus 5.5 03cd03c3e6 Restack moves items into matching specialty bags
FindSpecialtyBagMoves() pairs items in general-purpose bags with free
slots in the same inventory's specialty bags (C_Item.GetItemFamily on
item and bag; bags themselves excluded). Restack() queues those moves
first and then consolidates stacks. UpdateCache() refreshes
specialtyMovesAvailable when items changed, which enables the Restack
toolbar button. Restack tooltip text updated (enUS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwt5PZSpEDkXYzRuRhH
2026-11-02 14:41:44 +00:00

2814 lines
116 KiB
Lua

-- 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
-- Don't redraw while the window is being dragged. A full redraw (for example from a BAG_UPDATE
-- while looting in combat) resizes and re-lays out every button of a frame that is moving,
-- which shows up as a hitch. The update runs as soon as the window is dropped.
if self:IsDragInProgress() then
self.updateDeferredByDrag = true
self.updateDeferredByDragCascade = self.updateDeferredByDragCascade or cascade
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.cacheNeedsCategorize = false
self.lookupTablesWereRebuilt = false
-- We're updating!
self.windowUpdateBlocked = true
-- Before doing anything else, we need to know if we're online.
self:UpdateOnlineStatus()
-- Was a full redraw already requested before the cache update?
local windowUpdateWasNeeded = self.windowUpdateNeeded
-- 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
-- Only items locking/unlocking (picked up, or moving between bags and bank): refresh those
-- buttons so they turn gray and back, instead of redrawing the whole window.
if
self.cacheOnlyLockChanges
and not windowUpdateWasNeeded
and not self.forceResort
and not self.resortNeeded
and not self.lookupTablesStale
and not self.editMode
then
self:RefreshLockChangedItemButtons()
self.windowUpdateNeeded = false
self.windowUpdateBlocked = false
if update_cascadeInventory then
update_cascadeInventory:Update()
end
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.
-- A forced resort (Resort button, settings, Edit Mode) re-categorizes every item.
if self.forceResort then
self.fullCategorizeNeeded = true
end
local needsCategorizeAndSort = self.forceResort or self.resortNeeded
self:ManageDryRun(true) -- First call decides whether we're in dry run mode (`self.dryRun`).
self:UpdateLayoutLookupTables()
-- New category/group lookup tables mean every item's group may be different.
if self.lookupTablesWereRebuilt then
self.fullCategorizeNeeded = true
end
if self.cacheNeedsCategorize 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
--- Re-apply the look of item buttons whose item only locked or unlocked in the last cache update
--- (gray while locked, badges dimmed). Same as what a full redraw does for those buttons.
function Inventory:RefreshLockChangedItemButtons()
local changed = self.lockOnlyChangedItems
if not changed then
return
end
for _, button in ipairs(self.ui.buttons.itemSlots) do
local buttonInfo = button.bagshuiData
if buttonInfo and buttonInfo.item and changed[buttonInfo.item] and button:IsVisible() then
self.ui:AssignItemToItemButton(button, buttonInfo.item, buttonInfo.groupId)
end
end
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] = <reference to self.inventory[bagNum][slotNum]>`
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`: `{ <Group ID> = <Reference to group> }`
--- - `categoryIdsGroupedBySequence` is used to determine the order of execution for category matching:
--- ```
--- {
--- [<Sequence Num 1>] = {
--- [<Category ID 1>],
--- [<Category ID 2>],
--- },
--- [<Sequence Num 2>] = {
--- [<Category ID 3>],
--- },
--- }
--- ```
--- - `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[<sequence>][<categoryId].
self.categoryIdsGroupedBySequence[categoryDetails.sequence][categoryId] = groupId
-- Add to the flat category-to-group relationship table.
self.categoriesToGroups[categoryId] = groupId
-- This is a an active group.
self.activeGroups[groupId] = self.groups[groupId]
end
end
-- Sorted-order cache helpers, defined below SortGroups()'s cache tables.
local MarkGroupForResort, InvalidateAllSortedGroups
--- Assign categories and groups to all items in the inventory cache.
--- **Should not be called directly!** Use `Inventory:Update()` to coordinate the process.
---
--- Lookup tables managed by this function:
--- - `groupItems` stores the sorted list of items that belong to each group.
---
--- Also updates each item's `bagshuiGroupId` and `bagshuiCategoryId` properties.
function Inventory:CategorizeItems()
-- groupItems stores the list of items that belongs to each group.
-- Start clean each time we categorize and sort.
-- groupItems is a table of tables, so only wipe the 2nd level tables to keep the garbage collector happy.
for groupId, _ in pairs(self.groupItems) do
BsUtil.TableClear(self.groupItems[groupId])
end
-- Using groups instead of activeGroups here just to ensure every possibility is initialized.
for groupId, _ in pairs(self.groups) do
if not self.groupItems[groupId] then
self.groupItems[groupId] = {}
end
end
local defaultGroupId = self.categoriesToGroups[BsCategories.defaultCategory]
local groupId
-- Full pass (every item) or only items whose category may have changed?
-- See `Inventory:CategoryCacheStale()` and where `fullCategorizeNeeded` is set.
local fullPass = self.fullCategorizeNeeded
if fullPass then
self.categorizeEpoch = (self.categorizeEpoch or 0) + 1
self.fullCategorizeNeeded = false
self.lastFullCategorizeTime = _G.GetTime()
-- Every group gets re-sorted after a full pass (see SortGroups()).
InvalidateAllSortedGroups()
end
-- Perform the actual categorization.
for _, bagNum in ipairs(self.containerIds) do
-- Only process bags that have contents.
if self.containers[bagNum].numSlots > 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 whose category may have changed since last time. New and changed
-- items are always categorized so they never show up with an unknown category in the
-- Bagshui tooltip; unchanged items keep the category from the last pass.
if fullPass or self:CategoryCacheStale(self.inventory[bagNum][slotNum]) then
local previousGroupId = self.inventory[bagNum][slotNum].bagshuiGroupId
BsCategories:Categorize(
self.inventory[bagNum][slotNum],
nil, -- Categorization as any alternate character is not currently enabled.
self.sortedCategorySequenceNumbers,
self.categoryIdsGroupedBySequence
)
self:StoreCategorySignature(self.inventory[bagNum][slotNum])
-- The group this item left and the group it's in now both need re-sorting.
MarkGroupForResort(previousGroupId)
MarkGroupForResort(self.inventory[bagNum][slotNum].bagshuiGroupId)
end
-- 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
-- Sorted-order cache for groups (see SortGroups()). A group's cached order is reused when none of
-- its items were re-categorized since it was sorted (an item that changed in any way the sort can
-- see is re-categorized, see CategoryCacheStale()) and it still holds exactly the same items.
-- `groupSortVersions[groupId]` goes up whenever an item enters or leaves a group or changes;
-- `sortEpoch` goes up after every full categorization pass (rules, settings, sort orders...).
local groupSortVersions = {}
local sortEpoch = 0
-- { [groupItems table] = { list = {...}, set = { [item] = true }, count, version, epoch } }
local sortedGroupCache = setmetatable({}, { __mode = "k" })
MarkGroupForResort = function(groupId)
if groupId and groupId ~= "" then
groupSortVersions[groupId] = (groupSortVersions[groupId] or 0) + 1
end
end
InvalidateAllSortedGroups = function()
sortEpoch = sortEpoch + 1
end
--- Put a group's items back in their last sorted order if nothing relevant changed.
---@return boolean restored
local function RestoreSortedGroup(items, groupId)
local cached = sortedGroupCache[items]
local count = table.getn(items)
if
not cached
or cached.epoch ~= sortEpoch
or cached.version ~= (groupSortVersions[groupId] or 0)
or cached.count ~= count
then
return false
end
for i = 1, count do
if not cached.set[items[i]] then
return false
end
end
for i = 1, count do
items[i] = cached.list[i]
end
return true
end
--- Remember a group's sorted order.
local function StoreSortedGroup(items, groupId)
local cached = sortedGroupCache[items]
if not cached then
cached = { list = {}, set = {} }
sortedGroupCache[items] = cached
end
BsUtil.TableClear(cached.list)
BsUtil.TableClear(cached.set)
local count = table.getn(items)
for i = 1, count do
table.insert(cached.list, items[i])
cached.set[items[i]] = true
end
cached.count = count
cached.epoch = sortEpoch
cached.version = groupSortVersions[groupId] or 0
end
-- What each item's last categorization was based on: { [item] = signature }.
-- Weak keys so replaced item tables don't linger; kept out of the item tables so nothing extra
-- ends up in SavedVariables.
local categorySignatures = setmetatable({}, { __mode = "k" })
--- Could this item's category be different from its last categorization?
--- True when anything the category rules read from the item or its bag has changed, or when a
--- full pass has happened since (rules, profiles, settings, quests, gear, zone, window opened...).
---@param item table Inventory cache entry.
---@return boolean
function Inventory:CategoryCacheStale(item)
local sig = categorySignatures[item]
return not sig
or sig.inventory ~= self
or sig.epoch ~= self.categorizeEpoch
or sig.itemString ~= item.itemString
or sig.bagNum ~= item.bagNum
or sig.slotNum ~= item.slotNum
or sig.bagType ~= item.bagType
or sig.count ~= item.count
or sig.charges ~= item.charges
or sig.stockState ~= item.bagshuiStockState
or sig.tooltip ~= item.tooltip
or not item.bagshuiGroupId
or item.bagshuiGroupId == ""
end
--- Remember what the item's categorization was based on (see `Inventory:CategoryCacheStale()`).
---@param item table Inventory cache entry.
function Inventory:StoreCategorySignature(item)
local sig = categorySignatures[item]
if not sig then
sig = {}
categorySignatures[item] = sig
end
sig.inventory = self
sig.epoch = self.categorizeEpoch
sig.itemString = item.itemString
sig.bagNum = item.bagNum
sig.slotNum = item.slotNum
sig.bagType = item.bagType
sig.count = item.count
sig.charges = item.charges
sig.stockState = item.bagshuiStockState
sig.tooltip = item.tooltip
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
-- Groups whose items didn't change keep their previous sorted order (see RestoreSortedGroup()).
if not RestoreSortedGroup(self.groupItems[groupId], groupId) then
BsSortOrders:SortGroup(self.groupItems[groupId], groupConfig, self.settings.defaultSortOrder)
StoreSortedGroup(self.groupItems[groupId], groupId)
end
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
-- Items can go into a matching specialty bag.
or self.specialtyMovesAvailable
or (self.dockedInventory and self.dockedInventory.specialtyMovesAvailable)
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)