Files
Bagshui/Components/Inventory.lua
T
OEVGandClaude Opus 5.5 5f0c1c95d6 Re-categorize only changed items; per-frame work only on hovered button
- CategorizeItems() keeps each item's category unless something it was
  based on changed (item, bag/slot, bag type, count, charges, stock
  state, tooltip). A full pass runs on window open, forced resort,
  rebuilt lookup tables, character switch and any non-bag event.
- ActiveQuestItems: raise BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE only when the
  quest item list or counts changed, not after every QUEST_LOG_UPDATE.
- Item buttons: OnUpdate is set on mouse enter and removes itself after
  the mouse leaves; the base template OnUpdate is cleared. Stock badges
  are refreshed once a minute from the window's OnUpdate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwt5PZSpEDkXYzRuRhH
2026-11-01 20:57:18 +00:00

1087 lines
44 KiB
Lua

-- Bagshui Inventory Class Prototype
-- Exposes: Bagshui.prototypes.Inventory
-- Raises:
-- - BAGSHUI_INVENTORY_CACHE_UPDATE
-- - BAGSHUI_INVENTORY_EDIT_MODE_UPDATE
-- All inventory types (Bags, Bank, etc.) are instances of this class.
Bagshui:AddComponent(function()
Bagshui:AddConstants({
-- Control the order in which bag types are displayed in the free space tooltip
-- that appears when mousing over the free space display next to the bag bar.
BS_INVENTORY_CONTAINER_TYPE_ORDER = {
L["Bag"],
L["Ammo Pouch"],
L["Enchanting Bag"],
L["Herb Bag"],
L["Quiver"],
L["Soul Bag"],
},
-- Different bag types get different empty slot textures.
BS_INVENTORY_EMPTY_SLOT_TEXTURE = {
[L["Bag"]] = "ItemSlot\\EmptySlot",
[L["Ammo Pouch"]] = "ItemSlot\\EmptySlot-AmmoPouch",
[L["Enchanting Bag"]] = "ItemSlot\\EmptySlot-EnchantingBag",
[L["Herb Bag"]] = "ItemSlot\\EmptySlot-HerbBag",
[L["Quiver"]] = "ItemSlot\\EmptySlot-Quiver",
[L["Soul Bag"]] = "ItemSlot\\EmptySlot-SoulBag",
},
-- Properties listed here are appended when building the new class object instead of
-- overriding what's in the prototype. Obviously only works for tables.
-- - Keys must match case with class property names.
BS_INVENTORY_SUBCLASS_APPEND_PROPERTY = {
events = true,
apiFunctionsToHook = true,
},
-- Quality badges are set via these parameters for `SetTexCoord()` to crop UI-RaidTargetingIcons for finer
-- control than `SetRaidTargetIconTexture()` allows (mostly to make the purple diamond more square).
-- - `[Quality Number] = { left, right, top, bottom }`
BS_INVENTORY_QUALITY_BADGE_COORD = {
[2] = { 0.00, 0.25, 0.25, 0.5 }, -- White Crescent Moon
-- Green alternates
-- [2] = { 0.72, 1.00, 0.00, 0.26 }, -- Green Triangle
-- [2] = { 0.00, 0.25, 0.00, 0.25 }, -- Yellow Cross
[3] = { 0.235, 0.515, 0.235, 0.515 }, -- Blue Square
[4] = { 0.53, 0.74, 0.00, 0.26 }, -- Purple Diamond
[5] = { 0.24, 0.49, 0.00, 0.24 }, -- Orange Circle
[6] = { 0.00, 0.25, 0.00, 0.25 }, -- Yellow Cross
},
-- Set `[Quality Number] = true` if a badge requires recoloring.
-- This will cause it to be recolored to the standard item quality color via `SetVertexColor()`.
BS_INVENTORY_QUALITY_BADGE_RECOLOR = {
[2] = true, -- The moon is white, so turn it green.
},
-- Textures and cropping for item usable badges.
-- ```
-- USABLE_STATE = {
-- texture = "path",
-- texCoord = { left, right, top, bottom },
-- }
-- ```
-- Note: `texCoord` is **required**; use `{ 0, 1, 0, 1 }` for uncropped.
BS_INVENTORY_USABLE_BADGE = {
UNUSABLE = {
texture = "Interface\\TargetingFrame\\UI-RaidTargetingIcons",
texCoord = { 0.49, 0.761, 0.241, 0.511 }, -- Red X
},
KNOWN = {
-- There's no check mark in UI-RaidTargetingIcons, so we have to provide our own.
texture = "ItemSlot\\Check",
texCoord = { 0, 1, 0, 1 },
},
},
-- Lowest stock badge opacity allowed (percentage as decimal between 0 and 1).
BS_INVENTORY_STOCK_BADGE_MAX_FADE = 0.4,
-- Truncate long tooltips at the first empty line when the Bagshui Info tooltip
-- is displayed or in Edit Mode.
BS_INVENTORY_TRUNCATE_TOOLTIPS_INFO_MODE = true,
BS_INVENTORY_TRUNCATE_TOOLTIPS_EDIT_MODE = true,
-- Use these strings to build the full string that is stored in each item's tooltip property.
-- MUST be an array-type table where:
-- ```
-- [1] = <Left>
-- [2] = <Right>
-- ```
BS_INVENTORY_TOOLTIP_JOIN_CHARACTERS = {
Left = BS_NEWLINE,
Right = " ",
},
-- Enum used by `Visible()` (passed from `OpenCloseToggle()`) to call the correct UI visibility function.
-- Values must correspond to class function names.
---@enum BS_INVENTORY_UI_VISIBILITY_ACTION
BS_INVENTORY_UI_VISIBILITY_ACTION = {
OPEN = "Open",
CLOSE = "Close",
TOGGLE = "Toggle",
},
-- Inventory-specific object identifiers.
---@enum BS_INVENTORY_OBJECT_TYPE
BS_INVENTORY_OBJECT_TYPE = {
GROUP = "Group",
GROUP_MOVE_TARGET = "GroupMoveTarget",
CATEGORY = "Category",
ITEM = "Item",
},
-- Used in Edit Mode to determine whether a layout change is being made in
-- a horizontal or vertical direction.
---@enum BS_INVENTORY_LAYOUT_DIRECTION
BS_INVENTORY_LAYOUT_DIRECTION = {
ROW = "Row",
COLUMN = "Column",
},
-- Used to find the Hearthstone for the Hearthstone button.
BS_INVENTORY_HEARTHSTONE_ITEM_ID = 6948,
})
local Inventory = {}
Bagshui.prototypes.Inventory = Inventory
-- Instance of UI class with Inventory-specific modifications. Additions are made
-- in the various Inventory.Ui.*.lua files. Each instance of the Inventory class
-- then creates its own instance of InventoryUi in Inventory:InitUi().
Bagshui.prototypes.InventoryUi = Bagshui.prototypes.Ui:New()
--- Create new Inventory class instance.
---@param newPropsOrInventoryType BS_INVENTORY_TYPE|table Inventory type configured in Config\Inventory.lua or a table of Inventory class properties.
---@return table inventoryClassInstance
function Inventory:New(newPropsOrInventoryType)
local newProps = newPropsOrInventoryType --[[@as table]]
if type(newPropsOrInventoryType) == "string" then
newProps = Bagshui.config.Inventory[newPropsOrInventoryType]
assert(newProps, "Inventory:New() - " .. newPropsOrInventoryType .. " not found in Inventory config")
-- Transfer inventory type identifier to the instance properties.
newProps.inventoryType = newPropsOrInventoryType
end
assert(newProps.inventoryType, "Inventory:New() - property table must contain inventoryType")
-- Default class properties.
local classObj = {
_super = Inventory,
--#region MUST be filled in by subclasses. --------------------------------
---@type string A Bagshui BS_INVENTORY_TYPE.
inventoryType = nil,
---@type number How many item slot buttons to create during initialization.
-- More will automatically be created if needed; this is just the starting point.
initialItemSlotButtons = 160,
---@type number[] {start, end} inclusive range that controls which bag numbers
-- are included in this subclass' containerIds array.
containerIdRange = {},
---@type table<string,any> Handling instructions for the primary immutable container
-- belonging to this class (the Backpack, initial Bank bag, Keyring).
-- Items are optional unless noted.
-- ```
-- {
-- ---@type number id of the bag (required).
-- id = <ContainerId>,
-- ---@type string Name of the bag (required).
-- name = "Name",
-- ---@type string Texture to display (required).
-- texture = "Path\\To\\Texture",
-- ---@type string Parameter for GetBindingKey() that will return the key binding for this container.
-- bindingKey = "BINDINGKEY"
-- ---@type function Call this in lieu of GetContainerNumSlots() (will be passed a bagNum parameter).
-- numSlotsFunction = <function>,
-- }
-- ```
primaryContainer = {},
---@type function? Called as the second parameter to <tooltip>:SetInventoryItem()
--- and passed the item's slotNum. This is necessary because some containers'
--- contents can't be loaded into tooltips using SetBagItem().
getInventorySlotFunction = nil,
---@type string? Name of game function to call so other addons' tooltip hooks work.
--- Will be passed the current item slot button.
--- This is a string instead of the actual function so we can always be sure to
--- call the last link in the hook chain. If the function pointer was captured
--- at startup in Config\Inventory.lua then there's the risk of missing hooks.
itemSlotTooltipFunction = nil,
-- Bag button handling (none of these are applied to primaryContainer).
---@type string Name of XML template for bag buttons.
bagButtonTemplate = "BagshuiBagsContainerTemplate",
---@type number Add this to ContainerId when creating bag slots to avoid issues.
-- with conflicting frame IDs (relevant for Bags, not Bank).
bagButtonIdOffset = 0,
---@type string When creating bag buttons, use this as the name prefix instead.
-- of the usual CreateElementName. This is needed because the Blizzard code
-- we rely on for bag slot buttons parses information from the element's
-- name via `strsub(<name>, 10)`. See the bagSlotName reference in
-- `InventoryUi:CreateBagSlotButtons()` along with Config\Inventory.lua
-- for more details.
bagSlotNameFormat = "Bagshui__%d",
---@type number When creating bag buttons, add this to ContainerId to populate bagSlotNameFormat.
bagSlotNameNumberOffset = 0,
--#endregion MUST be filled in by subclasses.
--#region Additional subclass override properties. --------------------------------
---@type BS_INVENTORY_TYPE? to attach frame to.
dockTo = nil,
---@type BS_RULE_MATCH_TYPE[]? Inventory classes to use as temporary space when swapping bags.
bagSwappingSupplementalStorage = nil,
---@type boolean Should the special toolbar buttons be available?
clamButton = false,
hearthButton = false,
disenchantButton = false,
pickLockButton = false,
---@type string Default sound to play when window is opened.
openSound = "igMainMenuOpen",
---@type string Default sound to play when window is closed.
closeSound = "igMainMenuClose",
---@type table<string,boolean|number|string> Table of events to register.
-- Subclasses can add or remove events by providing their own Events table.
-- Keys are event names.
-- Values can be any of the below (note that some of the advanced functionality may not currently be used):
-- * true = Just handle the event normally.
-- * number = Queue the event to bae raised again with this delay and a BAGSHUI_ prefix.
-- * string = Call the function with this name, if it exists.
-- * false = Disable this event.
events = {
PLAYER_LOGIN = true,
PLAYER_ENTERING_WORLD = true,
BAG_UPDATE = true,
-- ClassicAPI: fires once after the game finishes a batch of BAG_UPDATEs (no payload).
-- Lets bag changes redraw as soon as the batch is done instead of after the 0.07s debounce.
BAG_UPDATE_DELAYED = (_G.C_Container ~= nil),
BAG_UPDATE_COOLDOWN = true,
ITEM_LOCKED = true, -- Doesn't seem to actually ever fire but let's register for it anyway.
ITEM_LOCK_CHANGED = true, -- Required for multiple reasons, including preventing items from staying gray if you attempt to place them in an incompatible container (ex. non-ammo in ammo bags).
MERCHANT_CLOSED = true, -- Clear pending sale item when leaving the merchant.
BAGSHUI_INVENTORY_INIT_RETRY = true,
BAGSHUI_INVENTORY_EDIT_MODE_UPDATE = true, -- Need to register for this so inventory classes that share the same Structure profile can stay in sync.
BAGSHUI_INVENTORY_SEARCH = true,
BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE = true,
BAGSHUI_INITIAL_INVENTORY_UPDATE = true,
BAGSHUI_CATEGORY_UPDATE = true,
BAGSHUI_CHARACTER_LEARNED_RECIPE = true,
BAGSHUI_CHARACTER_UPDATE = true,
BAGSHUI_CHARACTERDATA_UPDATE = true,
BAGSHUI_GAME_UPDATE = true,
BAGSHUI_PROFESSION_ITEM_UPDATE = true,
BAGSHUI_PROFILE_UPDATE = true,
BAGSHUI_SETTING_UPDATE = true,
BAGSHUI_SORTORDER_UPDATE = true,
BAGSHUI_EQUIPPED_HISTORY_UPDATE = true,
BAGSHUI_WINDOW_OPENED = true,
BAGSHUI_WINDOW_CLOSED = true,
},
---@type table<string,string> WoW API functions to hook and the corresponding class function to call.
-- The WoW API function name will be passed as the first parameter with any additional parameters after that.
apiFunctionsToHook = {
BagSlotButton_OnClick = "BagSlotButton_OnHook",
BagSlotButton_OnDrag = "BagSlotButton_OnHook",
TradeFrame_OnShow = "TradeFrame_OnShow",
UIErrorsFrame_OnEvent = "UIErrorsFrame_OnEvent", -- See Inventory.Actions.lua.
},
---@type table<string,string> See Inventory:GetHookSettingName() for details.
-- Keys are pattern strings to match against the API function name and
-- values are the setting name to return.
hookSettingTranslations = {},
---@type boolean When true, add entries to the Hooks setting menu for this class' bag numbers.
opensViaHooks = false,
---@type string The string that goes in front of key binding indexes to look them up.
keyBindingPrefix = nil,
---@type boolean Debugging options.
debug = false and BS_DEBUG,
clearItemCacheAtStartup = false and BS_DEBUG,
wipeSettingsOnLoad = false and BS_DEBUG,
--#endregion Additional subclass override properties.
--#region Runtime properties. --------------------------------
---@type boolean Should inventory changes be allowed?
online = true,
-- Container-related lookup tables.
---@type number[] Full list of bag numbers owned by this subclass.
-- * The primary container ID will always be at index 1.
-- * This is filled by code at the bottom of Config\Inventory.lua because other
-- configuration files depend on it.
-- * No reason to create a table here because it will be pulled from the config.
containerIds = nil,
---@type table<number, number> `{ <ContainerId> = <index in self.containerIds> }` for every ContainerId in self.containerIds -- used to quickly check whether a given container ID belongs to this class.
myContainerIds = {},
---@type table<number, number> `{ <InventoryId> = <ContainerId> }`
inventoryIdsToContainerIds = {},
---@type table<string, table> `{ <Container Type> = { available = <used slots>, total = <total slots> } }` -- Managed by UpdateBagBar().
containerSpace = {},
-- Partial stack tracking -- Managed by UpdateCache().
---@type table<number, number> `{ itemId = count of partial stacks }`
partialStacks = {},
-- "Shadow" cache of item stock states.
-- See comment above the declaration of shadowId in Inventory.Cache.lua.
shadowStockState = {},
shadowBagshuiDate = {},
-- Item move queue -- used for restacking and bag swapping.
queuedMoveSources = {},
queuedMoveTargets = {},
tempMoveTargets = {},
-- Available empty slots (excluding profession bags -- see `Inventory:SwapBag()` comments).
emptyGenericContainerSlots = {},
-- Tracking tables used in UpdateCache().
preUpdateItemCounts = {},
postUpdateItemCounts = {},
lastUpdateLockedContainers = {},
-- Category/group-related lookup tables -- See `UpdateLayoutLookupTables()` for descriptions of most of these.
-- This is stored in the `currentLayoutState` table because there is a parallel `proposedLayoutState`
-- table used for dry-run calculations (automatically created during `Init()`).
currentLayoutState = {
activeCategoryIds = {},
activeGroups = {},
categoriesToGroups = {},
categoryIdsGroupedBySequence = {},
categorySequenceNumbers = {},
groupItems = {},
groups = {},
sortedCategorySequenceNumbers = {},
},
-- Tracking tables and state variables for UpdateWindow() and friends.
-- Detailed descriptions typically can be found where each is first referenced.
actualGroupWidths = {},
emptySlotStacks = {}, -- Fake item entries used to create empty slot stacks in the UI
groupItemCounts = {},
groupsIdsToFrames = {},
groupWidthsInItems = {},
expandEmptySlotStacks = false,
lastExpandEmptySlotStacks = false,
hasSlotsWithStackingPrevented = false, -- Whether any item in the cache has the `_bagshuiPreventEmptySlotStack` property set to `true` (managed by `UpdateCache()`).
highlightItemsInContainerId = nil,
highlightItemsInContainerLocked = false,
highlightItemsContainerSlot = nil,
showHidden = false, -- Toggle display of hidden groups and items (like Hearthstone).
hasHiddenGroups = false, -- Whether any objects are hidden and the toolbar icon should appear.
multiplePartialStacks = false, -- Whether there are multiple partial stacks of the same item, meaning the restack toolbar icon should be enabled.
positioningTables = {},
enableResortIcon = false, -- Managed by `Inventory:CategorizeAndSort()` and consumed by `Inventory:UpdateToolbar()`.
hideItems = {},
hasHiddenItems = false,
hasChanges = false,
highlightChangesEnabled = false,
hasOpenables = false,
nextOpenableItemBagNum = nil,
nextOpenableItemSlotNum = nil,
nextOpenableItemSlotButton = nil,
-- Used to track changes that occur when bags are moved between slots (see `Bagshui:PickupInventoryItem()` for details).
pendingContainerChanges = {},
-- Update tracking.
-- Start by requiring an update of everything.
inventoryUpdateAllowed = false, -- This will be flipped to true once the BAGSHUI_INITIAL_INVENTORY_UPDATE event is raised.
initialInventoryUpdateNeeded = true, -- This starts out true so that on the very first cache update, everything will be rebuilt.
cacheUpdateNeeded = true,
resortNeeded = true,
windowUpdateNeeded = true,
updating = false,
forceCacheUpdate = false,
forceResort = false,
-- Dirty flags for pipeline short-circuiting -- allow Update() to skip stages when nothing changed.
cacheChanged = false,
cacheNeedsCategorize = false,
-- Next categorization must re-check every item (see `Inventory:CategoryCacheStale()`).
fullCategorizeNeeded = true,
lookupTablesStale = true,
lookupTablesWereRebuilt = false,
windowUpdateNeededByCategorize = false,
---@type string|nil Item search will occur when this is a string instead of nil.
searchText = nil,
---@type boolean Highlight mode - dim items whose stock state has not changed
highlightChanges = false,
---@type boolean Alter frame close behavior -- see UiFrame_OnShow().
dockingFrameVisibleOnLastOpen = false,
---@type boolean Make the bag utilization summary visible at all times - managed by UpdateWindow().
alwaysShowUsageSummary = false,
---@type table|nil Hearthstone cache entry tracking.
hearthstoneItemRef = nil,
-- Edit Mode state tracking -- see Components\Inventory.EditMode.lua.
---@type boolean
editMode = false,
---@type table<string, any>
editState = {
cursorItem = nil,
cursorItemType = nil,
cursorTooltip = nil,
highlightCategory = nil,
highlightItem = nil,
},
-- UI element storage.
ui = nil, -- Ui class instance that we also hang UI elements on.
uiFrame = nil,
---@type boolean When true, this indicates that on the initial cache update, items should NOT be
-- treated as new. This avoids the situation where everything receives the new item badge on a
-- clean install. It gets set to true if needed during `Inventory:Init()`.
freshCache = false,
---@type string When this is NOT an empty string, the error icon will appear in the top left of the window.
errorText = "",
-- Table pointers.
-- These are set during `Inventory:Init()` but are documented here for reference.
settings = nil, -- Settings class instance
inventory = nil, -- `Bagshui.characters[<characterId>][self.inventoryTypeSavedVars].inventory`
layout = nil, -- Active layout.
-- `containers` stores the following information about equipped/available bags (subclasses may add more):
-- ```
-- <ContainerId> = {
-- name = "<Name of bag>",
-- numSlots = <Number of slots>,
-- genericType = "<Bag Subclass or 'Bag'>", -- (A bag's "genericType" is the the item class returned by GetItemInfo for profession bags or the localized version of "Bag" for any other bag and the primary container)
-- isProfessionBag = <true/false>,
-- slotsFilled = <Number of slots filled>,
-- texture = "<Bag texture>",
-- type = "<Container type>",
-- usable = <true/false>, -- OPTIONAL value. Will disable the slot in the "Equip Bag" item menu if `false`.
-- }
-- ```
containers = nil, -- `Bagshui.characters[<characterId>][self.inventoryTypeSavedVars].containers`
profiles = {}, -- Pointers to active profiles, mostly so that the Settings class can access them easily.
-- Used by `Inventory:Init()` to avoid initializing multiple times.
initialized = false,
--#endregion Runtime properties.
}
-- Append/overwrite additional properties for class instance.
if newProps ~= nil then
for key, value in pairs(newProps) do
-- Some properties need to be appended instead of overwriting.
if BS_INVENTORY_SUBCLASS_APPEND_PROPERTY[key] then
for subKey, subVal in pairs(value) do
classObj[key][subKey] = subVal
end
else
classObj[key] = value
end
end
end
-- We'll need the lowercase version of the inventory type for SavedVariables and the localized version for the UI.
classObj.inventoryTypeSavedVars = BsUtil.LowercaseFirstLetter(classObj.inventoryType)
classObj.inventoryTypeLocalized = L[classObj.inventoryType]
-- Build the list of containerIds belonging to this class along with the reverse inventory ID (slot) mapping.
for index = 1, table.getn(classObj.containerIds) do
local containerId = classObj.containerIds[index]
classObj.myContainerIds[containerId] = index
-- Starting inventory ID mapping at index 2 because primary containers are never inventory slots into which bags can be equipped.
if index > 1 then
classObj.inventoryIdsToContainerIds[_G.ContainerIDToInventoryID(containerId)] = containerId
end
end
-- Store docked inventory relationship.
if classObj.dockTo and Bagshui.components[classObj.dockTo] then
Bagshui.dockedInventories[classObj.dockTo] = classObj.inventoryType
classObj.dockedToInventory = Bagshui.components[classObj.dockTo]
Bagshui.components[classObj.dockTo].dockedInventory = classObj
end
-- Make the class work.
setmetatable(classObj, self)
self.__index = self
-- Register events.
for event, eventAction in pairs(classObj.events) do
if eventAction ~= false then
Bagshui:RegisterEvent(event, classObj)
-- Events with number actions get queued for a re-raise as BAGSHUI_<event>,
-- so we need to register for that version of the event too.
if type(eventAction) == "number" then
Bagshui:RegisterEvent("BAGSHUI_" .. event, classObj)
end
end
end
-- Register slash command handler with additional verbs.
BsSlash:AddOpenCloseHandler(classObj.inventoryType, classObj, {
L.Settings,
L.ResetPosition,
L.Lock,
L.Unlock,
}, function(tokens)
if BsUtil.MatchLocalizedOrNon(tokens[2], "settings") then
classObj:Open()
classObj.menus:OpenMenu("Settings")
return true
end
if BsUtil.MatchLocalizedOrNon(tokens[2], "resetposition") then
classObj:Open()
classObj:RescueWindow(true)
return true
end
if BsUtil.MatchLocalizedOrNon(tokens[2], "lock") or BsUtil.MatchLocalizedOrNon(tokens[2], "unlock") then
classObj.settings.windowLocked = BsUtil.MatchLocalizedOrNon(tokens[2], "lock")
return true
end
end)
-- Expose as `Bagshui.<inventoryType>`.
Bagshui.components[classObj.inventoryType] = classObj
return classObj
end
--- Perform initialization tasks.
function Inventory:Init()
if self.initialized then
return
end
-- Ensure config is initialized for current player.
if Bagshui.currentCharacterData[self.inventoryTypeSavedVars] == nil then
Bagshui.currentCharacterData[self.inventoryTypeSavedVars] = {
neverOnline = true,
}
self.freshCache = true
end
-- Prepare settings (docked classes use settings of the class they're docked to).
self.settings = (
self.dockTo and Bagshui.components[self.dockTo].settings
or Bagshui.prototypes.Settings:New(
self,
self.debugSettingsOnLoad -- Wipe settings on load if configured.
)
)
-- Default to current character at startup.
self:SetCharacter(Bagshui.currentCharacterId)
-- Convenience properties for current character that can be referenced
-- regardless of what the active character is.
self.currentCharacterInventory =
Bagshui.characters[Bagshui.currentCharacterId][self.inventoryTypeSavedVars].inventory
-- Activate profiles.
for _, profileType in pairs(BS_PROFILE_TYPE) do
self:SetProfile(self.settings["profile" .. profileType], profileType, true)
end
-- Prepare dry-run lookup tables and set initial lookup table pointers.
self.proposedLayoutState = {}
for key, _ in pairs(self.currentLayoutState) do
self.proposedLayoutState[key] = {}
self[key] = self.currentLayoutState[key]
end
-- Initialize settings.
self.settings:SetDefaults(false, BS_SETTING_SCOPE.INVENTORY)
self.settings:InitCache()
-- Clean the inventory cache at startup (not usually needed but can be enabled).
if self.clearItemCacheAtStartup then
for index, bagNum in ipairs(self.containerIds) do
self.inventory[bagNum] = {}
end
self.freshCache = true
end
-- Set up WoW API function hooks.
self.hooks = Bagshui.prototypes.Hooks:New(self.apiFunctionsToHook, self)
-- Build the GUI.
self:InitUi()
self:InitMenus()
-- Ensure lookup tables are correct from the start.
self:UpdateLayoutLookupTables()
-- Initialize the frame docked to this one, if present.
if self.dockedInventory then
self.dockedInventory:Init()
end
-- We're done.
self.initialized = true
end
--- Calling this function means that an update is needed, but if other update-triggering
--- events arrive within the delay period, go ahead and allow them to reset the delay.
--- This allows us to minimize our updates while still staying responsive.
---@param delay number? Delay before update in seconds.
---@param cascade boolean? Parameter for `Inventory:Update()`.
function Inventory:QueueUpdate(delay, cascade)
-- Don't push the default delay too low on this or we'll end up performing updates too quickly
-- during things like moving bags between slots, and stock states will be lost.
Bagshui:QueueClassCallback(self, self.Update, delay or 0.07, false, cascade)
end
-- Events that only change bag contents or lock state. Items affected by them are re-categorized
-- individually (see `Inventory:CategoryCacheStale()`); every other event triggers a full pass.
local CATEGORY_CACHE_SAFE_EVENTS = {
BAG_UPDATE = true,
BAG_UPDATE_DELAYED = true,
BAG_UPDATE_COOLDOWN = true,
ITEM_LOCKED = true,
ITEM_LOCK_CHANGED = true,
MERCHANT_CLOSED = true,
BAGSHUI_INVENTORY_SEARCH = true,
BAGSHUI_WINDOW_OPENED = true,
BAGSHUI_WINDOW_CLOSED = true,
}
--- Event handling.
---@param event string Event identifier.
---@param arg1 any? Argument 1 from the event.
---@param arg2 any? Argument 2 from the event.
function Inventory:OnEvent(event, arg1, arg2)
-- self:PrintDebug("OnEvent(): " .. event .. " // " .. tostring(arg1) .. " // " .. tostring(arg2))
-- BAG_UPDATE_DELAYED: the batch of bag changes is complete, so redraw on the next frame.
-- Ignored while a bag is being swapped between slots; that keeps the original 0.07s debounce
-- so the redraw waits until the whole swap has settled (see the BAG_UPDATE handling below).
-- Handled before `self.lastEvent` is set so the ITEM_LOCK_CHANGED check in UpdateCache()
-- still sees the event that actually caused the update.
if event == "BAG_UPDATE_DELAYED" then
if self.bagUpdatePending and not Bagshui.pickedUpBagSlotNum and not Bagshui.putDownBagSlotNum then
self.bagUpdatePending = false
self:QueueUpdate(0)
end
return
end
-- Anything other than plain bag/lock activity (quests, gear, character, zone/group, settings,
-- profiles, categories...) can change which category an unchanged item belongs to, so the
-- next categorization re-checks every item.
if not CATEGORY_CACHE_SAFE_EVENTS[event] then
self.fullCategorizeNeeded = true
end
-- Store the name of this event so it can be checked when updating the inventory
-- cache to see if the cache update should be delayed (see self.lastEvent check
-- near the top of Inventory:UpdateCache()).
self.lastEvent = event
-- Initialization.
-- Triggering off both PLAYER_LOGIN and PLAYER_ENTERING_WORLD because there seem
-- to be occasional issues with PLAYER_LOGIN not firing in 1.12.
if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then
-- Don't initialize if we're docked to another frame -- that frame will initialize us.
if not self.dockTo then
-- It's safe to call `Inventory:Init()` multiple times since it will
-- just return immediately if it has already executed successfully.
self:Init()
end
-- Queue initial inventory cache update so long as it hasn't been done yet.
-- PLAYER_ENTERING_WORLD also fires when zoning, so we avoid repeating it.
if self.initialInventoryUpdateNeeded and event == "PLAYER_ENTERING_WORLD" then
-- The delay is added because if we try to update immediately, GetItemInfo() may return nil.
Bagshui:QueueEvent("BAGSHUI_INITIAL_INVENTORY_UPDATE", 2)
end
return
end
if not self.initialized then
return
end
-- BAG_UPDATE: Don't do anything if arg1 is for a bag not handled by this class.
if event == "BAG_UPDATE" and not self.myContainerIds[arg1] then
return
end
-- BAG_UPDATE with BAG_UPDATE_DELAYED available (ClassicAPI): note the change and let
-- BAG_UPDATE_DELAYED trigger the redraw. During a bag swap, fall through to the normal
-- 0.07s debounce instead.
if
event == "BAG_UPDATE"
and self.events.BAG_UPDATE_DELAYED
and not Bagshui.pickedUpBagSlotNum
and not Bagshui.putDownBagSlotNum
then
self.cacheUpdateNeeded = true
self.bagUpdatePending = true
-- Safety net in case BAG_UPDATE_DELAYED never arrives. `noReset` = true so this
-- never pushes back an update that is already queued sooner.
Bagshui:QueueClassCallback(self, self.Update, 0.5, true)
return
end
-- MERCHANT_CLOSED: Clear pending sale item.
-- We also clear this when the window is opened but this is here just to be safe.
if event == "MERCHANT_CLOSED" then
self:ClearItemPendingSale()
return
end
-- When a character learns a new recipe, we have to re-cache all item tooltips
-- so learned indicators will be correct.
if event == "BAGSHUI_CHARACTER_LEARNED_RECIPE" then
self.forceFullCacheUpdate = true
self.windowUpdateNeeded = true
self:QueueUpdate()
return
end
-- Active quest / character changes / equipped gear / profession items.
-- Putting this early since it will be very common.
if
event == "BAGSHUI_ACTIVE_QUEST_ITEM_UPDATE"
or event == "BAGSHUI_CHARACTER_UPDATE"
or event == "BAGSHUI_EQUIPPED_HISTORY_UPDATE"
or event == "BAGSHUI_GAME_UPDATE"
or event == "BAGSHUI_PROFESSION_ITEM_UPDATE"
then
self.cacheUpdateNeeded = true
self.windowUpdateNeeded = true
self.resortNeeded = true -- Light up the resort icon if the window is open.
-- DO NOT force a resort here or things will move around when the inventory
-- window is open and one of the change events is raised.
self:QueueUpdate()
return
end
-- Toolbar updates.
if
-- Window changes should update the toolbar since icon highlights need to
-- be locked for inventories and catalog.
event == "BAGSHUI_WINDOW_OPENED"
or event == "BAGSHUI_WINDOW_CLOSED"
-- Refresh toolbar state when alts are deleted.
or event == "BAGSHUI_CHARACTERDATA_UPDATE"
then
self:UpdateToolbar()
return
end
-- When a character learns a new recipe, we have to re-cache all item tooltips
-- so learned indicators will be correct.
if event == "BAGSHUI_INVENTORY_SEARCH" then
if self:Visible() and arg1 ~= self.inventoryType then
self.searchTextSetFromEvent = true
if string.len(arg2 or "") > 0 then
self.ui.buttons.toolbar.search:Hide()
self.ui.frames.searchBox:Show()
self.ui.frames.searchBox:SetText(arg2)
else
self:ClearSearch()
self.ui.buttons.toolbar.search:Show()
self.ui.frames.searchBox:Hide()
end
end
return
end
-- Special action handling - see description of the Events table in Inventory:New() for details.
local eventAction = self.events[event]
-- Re-queue the event with a delay if configured.
if type(eventAction) == "number" then
Bagshui:QueueEvent("BAGSHUI_" .. event, eventAction, false, arg1, arg2)
return
end
-- Call the event function if it exists.
-- If the function returns false, don't continue processing.
local eventFunction = self[eventAction]
if type(eventFunction) == "function" then
if eventFunction(self) == false then
return
end
end
-- Actually perform initial inventory cache update.
if event == "BAGSHUI_INITIAL_INVENTORY_UPDATE" then
self.inventoryUpdateAllowed = true
self.cacheUpdateNeeded = true
self.forceCacheUpdate = true
self.forceResort = true
self:QueueUpdate()
return
end
-- Settings changes should trigger a window update with optional cache update.
-- arg1 = Setting name.
-- arg2 = Setting info table as defined in Config\Settings.lua.
if event == "BAGSHUI_SETTING_UPDATE" then
-- If any of the "inventory*OnChange" options are set, an update is needed.
if
arg2
and (arg2.inventoryCacheUpdateOnChange or arg2.inventoryResortOnChange or arg2.inventoryWindowUpdateOnChange)
then
-- Reset header/footer show/hide double-click state when the corresponding setting is changed.
if (arg1 == "showHeader" or arg1 == "showFooter") and not self.ignoreNextSettingChange then
self.headerWasHiddenOnDoubleClick = false
self.footerWasHiddenOnDoubleClick = false
end
self.cacheUpdateNeeded = arg2.inventoryCacheUpdateOnChange or self.cacheUpdateNeeded or false
self.forceResort = self.forceResort or arg2.inventoryResortOnChange or false
self.lookupTablesStale = self.lookupTablesStale or (arg2.inventoryResortOnChange and true) or false
self.windowUpdateNeeded = true
self:QueueUpdate(0.005)
end
-- Even if no update was needed, we don't need anything else to happen.
return
end
-- Keep layouts of inventories that share the same Structure profile in sync.
if event == "BAGSHUI_INVENTORY_EDIT_MODE_UPDATE" then
-- arg1 = Raising class.
-- arg2 = forceResort.
if arg1 ~= self and arg1.settings.profileStructure == self.settings.profileStructure then
self:EditModeWindowUpdate(arg2, false, true)
end
return
end
-- Fall back to another profile if the active one is deleted.
if event == "BAGSHUI_PROFILE_UPDATE" then
for _, profileType in pairs(BS_PROFILE_TYPE) do
-- Trigger a profile update when one of the active profiles has changed.
if self.settings["profile" .. profileType] == arg1 then
self:SetProfile(arg1, profileType)
if profileType == BS_PROFILE_TYPE.STRUCTURE then
self.lookupTablesStale = true
self.forceResort = true -- Required to avoid issues during UpdateWindow() since all groups may need to be reassessed.
end
self.windowUpdateNeeded = true
self:QueueUpdate(0.05)
end
end
return
end
-- Re-sort and re-categorize when fundamental objects change.
if event == "BAGSHUI_CATEGORY_UPDATE" or event == "BAGSHUI_SORTORDER_UPDATE" then
self.lookupTablesStale = true
self.forceResort = true
self.windowUpdateNeeded = true
self:QueueUpdate()
return
end
-- BAG_UPDATE_COOLDOWN: refresh cooldown animations on visible item buttons directly without
-- triggering a full pipeline update. Before the performance optimizations, the full update
-- path ran unconditionally and kept cooldowns current as a side effect. The early-exit added
-- for no-op BAG_UPDATE events now suppresses that path when nothing in the cache has changed,
-- so we need a dedicated lightweight refresh here.
if event == "BAG_UPDATE_COOLDOWN" then
if self:Visible() then
self:UpdateItemSlotCooldowns()
end
return
end
-- Lock/unlock events need a cache update, but not when a container has
-- been picked up.
if event == "ITEM_LOCK_CHANGED" and not Bagshui.pickedUpBagSlotNum and not Bagshui.putDownBagSlotNum then
self.forceCacheUpdate = true
end
-- Assume any other event that gets this far may require a cache update.
self.cacheUpdateNeeded = true
-- If we get this far, it's a normal event that should just trigger inventory and window updates.
self:QueueUpdate()
end
--- Conditions that determine whether the inventory is online.
function Inventory:UpdateOnlineStatus()
self.online = true
-- Can only be online if the active character is the one currently logged in.
if self.activeCharacterId ~= Bagshui.currentCharacterId then
self.online = false
end
end
local setCharacterConvenienceProps = {
"containers",
"inventory",
}
--- Change the active character for this inventory class.
--- Alters the properties listed in `setCharacterConvenienceProps`
--- to point to the given character's data storage.
--- Also updates the class `activeCharacterId` property.
---@param characterId any
function Inventory:SetCharacter(characterId)
-- A different character's inventory: re-check every item's category.
self.fullCategorizeNeeded = true
local characterData = Bagshui.characters[characterId]
if not characterData and Bagshui.sharedCharacters[characterId] then
-- Character from another account: bring in its full inventory from the shared file first.
local loaded, reason = false, nil
if BsCatalog then
loaded, reason = BsCatalog:LoadSharedInventory(characterId)
end
if loaded then
characterData = Bagshui.sharedCharacters[characterId]
else
Bagshui:PrintWarning("Can't show " .. characterId .. ": " .. tostring(reason))
return
end
end
if not characterData then
Bagshui:PrintWarning(characterId .. " is not a known character")
return
end
-- No change.
if characterId == self.activeCharacterId then
return
end
-- Used for online checks.
self.activeCharacterId = characterId
if not self._characterMenuActiveCharacter then
-- self._characterMenuActiveCharacter is assigned to the Character menu's idsToCheck
-- property, so this will place a check mark next to the active character.
self._characterMenuActiveCharacter = {}
end
BsUtil.TableClear(self._characterMenuActiveCharacter)
table.insert(self._characterMenuActiveCharacter, self.activeCharacterId)
-- Point convenience properties to character's SavedVariables.
for _, propName in ipairs(setCharacterConvenienceProps) do
if characterData[self.inventoryTypeSavedVars] == nil then
characterData[self.inventoryTypeSavedVars] = {}
end
if characterData[self.inventoryTypeSavedVars][propName] == nil then
characterData[self.inventoryTypeSavedVars][propName] = {}
end
self[propName] = characterData[self.inventoryTypeSavedVars][propName]
end
-- Initialize container info tables if needed.
for _, bagNum in ipairs(self.containerIds) do
if self.containers[bagNum] == nil or (self.containers[bagNum] and not self.containers[bagNum].numSlots) then
self.containers[bagNum] = {
numSlots = 0,
}
end
-- Need to call this here to make sure there aren't errors when Bank is
-- brought up offline before visiting a banker.
self:InitializeEmptySlotStackTracking(self.containers[bagNum])
end
-- Remove any bag slot highlighting since bags are going to change.
self.highlightItemsInContainerId = nil
self.highlightItemsInContainerLocked = nil
self.forceResort = true
self:QueueUpdate()
-- Update character of docked inventory class to match.
if self.initialized and self.dockedInventory then
self.dockedInventory:SetCharacter(characterId)
end
end
--- Given a profile ID and type, activate it for use if possible. Uses fallback
--- logic in the Profile class' `GetUsableProfile()` function if the desired profile
--- isn't available.
---@param desiredProfileId string|number ID of profile to activate.
---@param profileType BS_PROFILE_TYPE Type for which this profile should be used.
---@param startup boolean? Changing the value of the profile* settings triggers a call to this function, so this needs to be true during startup to prevent repeated calls.
---@return string|nil # ID of selected profile, if any.
function Inventory:SetProfile(desiredProfileId, profileType, startup)
-- Prevent double-call during initialization from settings change.
if not self.initialized and not startup then
return
end
-- Ensure the desired profile is actually valid.
-- GetUsableProfile() takes care of figuring out what profile to use if there's an issue.
-- It also handles first login logic when desiredProfileId will be nil.
local profile, profileId = BsProfiles:GetUsableProfile(desiredProfileId, profileType)
-- Store a reference to the profile so the Settings class can update it.
self.profiles[profileType] = profile[BsUtil.LowercaseFirstLetter(profileType)]
-- Assign the Structure profile's layout to the correct convenience property.
if profileType == BS_PROFILE_TYPE.STRUCTURE then
if self.dockTo then
-- Docked inventory is stored in its own table.
self.layout = profile.structure.docked[self.inventoryType].layout
else
self.layout = profile.structure.primary.layout
end
end
-- Store the selected profile in settings.
if not self.dockTo then
self.settings["profile" .. profileType] = profileId
-- Ensure the profile has all default values filled in and reset the settings cache.
-- Only do this if already initialized. Otherwise it'll happen multiple times at startup.
-- `Init()` takes care of these steps at startup.
if self.initialized then
self.settings:SetDefaults(false, BS_SETTING_SCOPE.INVENTORY)
self.settings:InitCache()
end
end
-- Update docked inventory to use the same profile.
if not self.dockTo and self.dockedInventory then
self.dockedInventory:SetProfile(profileId, profileType)
end
return profileId
end
--- Enable self:PrintDebug() for Inventory classes that prefixes the output with the
--- inventory type and enables control of debug output at the class instance level.
function Inventory:PrintDebug(msg, r, g, b)
if self.debug then
Bagshui:PrintDebug(msg, self.inventoryType, r, g, b)
end
end
--- Call the same function for every Inventory class instance.
---@param functionName string
function Bagshui:CallInventoryClassFunctionForAll(functionName, arg1, arg2, arg3)
if self.prototypes.Inventory[functionName] then
for _, inventoryType in pairs(BS_INVENTORY_TYPE) do
self.components[inventoryType][functionName](self.components[inventoryType], arg1, arg2, arg3)
end
end
end
--- Trigger `Resort()` for every every Inventory class instance.
--- Used by key bindings.
function Bagshui:ResortAll()
self:CallInventoryClassFunctionForAll("Resort")
end
--- Trigger `Restack()` for every every Inventory class instance.
--- Used by key bindings.
function Bagshui:RestackAll()
self:CallInventoryClassFunctionForAll("Restack")
end
end)