commit 05df49ff606551668bd2e7d1d5a8861296e06429 Author: Gitea Date: Fri Nov 13 14:13:12 2020 -0500 initial commit diff --git a/ChangeLog.md b/ChangeLog.md new file mode 100644 index 0000000..82a2d14 --- /dev/null +++ b/ChangeLog.md @@ -0,0 +1,13 @@ +## v4.10.14 Changes + +* Fixed tooltip error when hovering over favorite searches +* Fixed error on base group UI after removing a group +* Fixed TSM taking a long time on logout for some users +* [Retail] Added additional bonusIds +* [Retail] Added light/rich illusion dust conversion +* [Retail] Updated ink trading for Shadowlands +* [Retail] Fixed error when syncing professions between accounts +* [Retail] Fixed error when creating profession groups +* [Retail] Fixed issue with "/disenchant" search filter + +[Known Issues](http://support.tradeskillmaster.com/display/KB/TSM4+Currently+Known+Issues) diff --git a/Core/API.lua b/Core/API.lua new file mode 100644 index 0000000..ea8f606 --- /dev/null +++ b/Core/API.lua @@ -0,0 +1,420 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Public TSM API functions +-- @module TSM_API + +local _, TSM = ... +local Money = TSM.Include("Util.Money") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local Inventory = TSM.Include("Service.Inventory") +TSM_API = {} +local private = {} + + + +-- ============================================================================ +-- UI +-- ============================================================================ + +--- Checks if a TSM UI is currently visible. +-- @within UI +-- @tparam string uiName A string which represents the UI ("AUCTION", "CRAFTING", "MAILING", or "VENDORING") +-- @treturn boolean Whether or not the TSM UI is visible +function TSM_API.IsUIVisible(uiName) + private.CheckCallMethod(uiName) + if uiName == "AUCTION" then + return TSM.UI.AuctionUI.IsVisible() + elseif uiName == "CRAFTING" then + return TSM.UI.CraftingUI.IsVisible() + elseif uiName == "MAILING" then + return TSM.UI.MailingUI.IsVisible() + elseif uiName == "VENDORING" then + return TSM.UI.VendoringUI.IsVisible() + else + error("Invalid uiName: "..tostring(uiName), 2) + end +end + + + +-- ============================================================================ +-- Groups +-- ============================================================================ + +--- Gets a current list of TSM group paths. +-- @within Group +-- @tparam table result A table to store the result in +-- @treturn table The passed table, populated with group paths +function TSM_API.GetGroupPaths(result) + private.CheckCallMethod(result) + if type(result) ~= "table" then + error("Invalid 'result' argument type (must be a table): "..tostring(result), 2) + end + for _, groupPath in TSM.Groups.GroupIterator() do + tinsert(result, groupPath) + end + return result +end + +--- Formats a TSM group path into a human-readable form +-- @within Group +-- @tparam string path The group path to be formatted +-- @treturn string The formatted group path +function TSM_API.FormatGroupPath(path) + private.CheckCallMethod(path) + if type(path) ~= "string" then + error("Invalid 'path' argument type (must be a string): "..tostring(path), 2) + elseif path == "" then + error("Invalid 'path' argument (empty string)", 2) + end + return TSM.Groups.Path.Format(path) +end + +--- Splits a TSM group path into its parent path and group name components. +-- @within Group +-- @tparam string path The group path to be split +-- @treturn string The path of the parent group or nil if the specified path has no parent +-- @treturn string The name of the group +function TSM_API.SplitGroupPath(path) + private.CheckCallMethod(path) + if type(path) ~= "string" then + error("Invalid 'path' argument type (must be a string): "..tostring(path), 2) + elseif path == "" then + error("Invalid 'path' argument (empty string)", 2) + end + local parentPath, groupName = TSM.Groups.Path.Split(path) + if parentPath == TSM.CONST.ROOT_GROUP_PATH then + parentPath = nil + end + return parentPath, groupName +end + +--- Gets the path to the group which a specific item is in. +-- @within Group +-- @tparam string itemString The TSM item string to get the group path of +-- @treturn string The path to the group which the item is in, or nil if it's not in a group +function TSM_API.GetGroupPathByItem(itemString) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + local path = TSM.Groups.GetPathByItem(itemString) + return path ~= TSM.CONST.ROOT_GROUP_PATH and path or nil +end + + + +-- ============================================================================ +-- Profiles +-- ============================================================================ + +--- Gets a current list of TSM profiles. +-- @within Profile +-- @tparam table result A table to store the result in +-- @treturn table The passed table, populated with group paths +function TSM_API.GetProfiles(result) + private.CheckCallMethod(result) + for _, profileName in TSM.db:ProfileIterator() do + tinsert(result, profileName) + end + return result +end + +--- Gets the active TSM profile. +-- @within Profile +-- @treturn string The name of the currently active profile +function TSM_API.GetActiveProfile() + return TSM.db:GetCurrentProfile() +end + +--- Sets the active TSM profile. +-- @within Profile +-- @tparam string profile The name of the profile to make active +function TSM_API.SetActiveProfile(profile) + private.CheckCallMethod(profile) + if type(profile) ~= "string" then + error("Invalid 'profile' argument type (must be a string): "..tostring(profile), 2) + elseif not TSM.db:ProfileExists(profile) then + error("Profile does not exist: "..profile, 2) + elseif profile == TSM.db:GetCurrentProfile() then + error("Profile is already active: "..profile, 2) + end + return TSM.db:SetProfile(profile) +end + + + +-- ============================================================================ +-- Prices +-- ============================================================================ + +--- Gets a list of price source keys which can be used in TSM custom prices. +-- @within Price +-- @tparam table result A table to store the result in +-- @treturn table The passed table, populated with price source keys +function TSM_API.GetPriceSourceKeys(result) + private.CheckCallMethod(result) + if type(result) ~= "table" then + error("Invalid 'result' argument type (must be a table): "..tostring(result), 2) + end + for key in CustomPrice.Iterator() do + tinsert(result, key) + end + return result +end + +--- Gets the localized description of a given price source key. +-- @within Price +-- @tparam string key The price source key +-- @treturn string The localized description +function TSM_API.GetPriceSourceDescription(key) + private.CheckCallMethod(key) + if type(key) ~= "string" then + error("Invalid 'key' argument type (must be a string): "..tostring(key), 2) + end + local result = CustomPrice.GetDescription(key) + if not result then + error("Unknown price source key: "..tostring(key), 2) + end + return result +end + +--- Gets whether or not a custom price string is valid. +-- @within Price +-- @tparam string customPriceStr The custom price string +-- @treturn boolean Whether or not the custom price is valid +-- @treturn string The (localized) error message or nil if the custom price was valid +function TSM_API.IsCustomPriceValid(customPriceStr) + private.CheckCallMethod(customPriceStr) + if type(customPriceStr) ~= "string" then + error("Invalid 'customPriceStr' argument type (must be a string): "..tostring(customPriceStr), 2) + end + return CustomPrice.Validate(customPriceStr) +end + +--- Evalulates a custom price string or price source key for a given item +-- @within Price +-- @tparam string customPriceStr The custom price string or price source key to get the value of +-- @tparam string itemString The TSM item string to get the value for +-- @treturn number The value in copper or nil if the custom price string is not valid +-- @treturn string The (localized) error message if the custom price string is not valid or nil if it is valid +function TSM_API.GetCustomPriceValue(customPriceStr, itemString) + private.CheckCallMethod(customPriceStr) + if type(customPriceStr) ~= "string" then + error("Invalid 'customPriceStr' argument type (must be a string): "..tostring(customPriceStr), 2) + end + itemString = private.ValidateTSMItemString(itemString) + return CustomPrice.GetValue(customPriceStr, itemString) +end + + + +-- ============================================================================ +-- Money +-- ============================================================================ + +--- Converts a money value to a formatted, human-readable string. +-- @within Money +-- @tparam number value The money value in copper to be converted +-- @treturn string The formatted money string +function TSM_API.FormatMoneyString(value) + private.CheckCallMethod(value) + if type(value) ~= "number" then + error("Invalid 'value' argument type (must be a number): "..tostring(value), 2) + end + local result = Money.ToString(value) + assert(result) + return result +end + +--- Converts a formatted, human-readable money string to a value. +-- @within Money +-- @tparam string str The formatted money string +-- @treturn number The money value in copper +function TSM_API.ParseMoneyString(str) + private.CheckCallMethod(str) + if type(str) ~= "string" then + error("Invalid 'str' argument type (must be a string): "..tostring(str), 2) + end + local result = Money.FromString(str) + assert(result) + return result +end + + + +-- ============================================================================ +-- Item +-- ============================================================================ + +--- Converts an item to a TSM item string. +-- @within Item +-- @tparam string item Either an item link, TSM item string, or WoW item string +-- @treturn string The TSM item string or nil if the specified item could not be converted +function TSM_API.ToItemString(item) + private.CheckCallMethod(item) + if type(item) ~= "string" then + error("Invalid 'item' argument type (must be a string): "..tostring(item), 2) + end + return ItemString.Get(item) +end + +--- Gets an item's name from a given TSM item string. +-- @within Item +-- @tparam string itemString The TSM item string +-- @treturn string The name of the item or nil if it couldn't be determined +function TSM_API.GetItemName(itemString) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + return ItemInfo.GetName(itemString) +end + +--- Gets an item link from a given TSM item string. +-- @within Item +-- @tparam string itemString The TSM item string +-- @treturn string The item link or an "[Unknown Item]" link +function TSM_API.GetItemLink(itemString) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + local result = ItemInfo.GetLink(itemString) + assert(result) + return result +end + + + +-- ============================================================================ +-- Inventory +-- ============================================================================ + +--- Gets the quantity of an item in a character's bags. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @tparam ?string character The character to get data for (defaults to the current character if not set) +-- @tparam ?string factionrealm The factionrealm to get data for (defaults to the current factionrealm if not set) +-- @treturn number The quantity of the specified item +function TSM_API.GetBagQuantity(itemString, character, factionrealm) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + assert(character == nil or type(character) == "string") + assert(factionrealm == nil or type(factionrealm) == "string") + return Inventory.GetBagQuantity(itemString, character, factionrealm) +end + +--- Gets the quantity of an item in a character's bank. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @tparam ?string character The character to get data for (defaults to the current character if not set) +-- @tparam ?string factionrealm The factionrealm to get data for (defaults to the current factionrealm if not set) +-- @treturn number The quantity of the specified item +function TSM_API.GetBankQuantity(itemString, character, factionrealm) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + assert(character == nil or type(character) == "string") + assert(factionrealm == nil or type(factionrealm) == "string") + return Inventory.GetBankQuantity(itemString, character, factionrealm) +end + +--- Gets the quantity of an item in a character's reagent bank. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @tparam ?string character The character to get data for (defaults to the current character if not set) +-- @tparam ?string factionrealm The factionrealm to get data for (defaults to the current factionrealm if not set) +-- @treturn number The quantity of the specified item +function TSM_API.GetReagentBankQuantity(itemString, character, factionrealm) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + assert(character == nil or type(character) == "string") + assert(factionrealm == nil or type(factionrealm) == "string") + return Inventory.GetReagentBankQuantity(itemString, character, factionrealm) +end + +--- Gets the quantity of an item posted to the auction house by a character. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @tparam ?string character The character to get data for (defaults to the current character if not set) +-- @tparam ?string factionrealm The factionrealm to get data for (defaults to the current factionrealm if not set) +-- @treturn number The quantity of the specified item +function TSM_API.GetAuctionQuantity(itemString, character, factionrealm) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + assert(character == nil or type(character) == "string") + assert(factionrealm == nil or type(factionrealm) == "string") + return Inventory.GetAuctionQuantity(itemString, character, factionrealm) +end + +--- Gets the quantity of an item in a character's mailbox. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @tparam ?string character The character to get data for (defaults to the current character if not set) +-- @tparam ?string factionrealm The factionrealm to get data for (defaults to the current factionrealm if not set) +-- @treturn number The quantity of the specified item +function TSM_API.GetMailQuantity(itemString, character, factionrealm) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + assert(character == nil or type(character) == "string") + assert(factionrealm == nil or type(factionrealm) == "string") + return Inventory.GetMailQuantity(itemString, character, factionrealm) +end + +--- Gets the quantity of an item in a guild's bank. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @tparam ?string guild The guild to get data for (defaults to the current character's guild if not set) +-- @treturn number The quantity of the specified item +function TSM_API.GetGuildQuantity(itemString, guild) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + assert(guild == nil or type(guild) == "string") + return Inventory.GetGuildQuantity(itemString, guild) +end + +--- Get some total quantities for an item. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @treturn number The total quantity the current player has (bags, bank, reagent bank, and mail) +-- @treturn number The total quantity alt characters have (bags, bank, reagent bank, and mail) +-- @treturn number The total quantity the current player has on the auction house +-- @treturn number The total quantity alt characters have on the auction house +function TSM_API.GetPlayerTotals(itemString) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + return Inventory.GetPlayerTotals(itemString) +end + +--- Get the total number of items in all tracked guild banks. +-- @within Inventory +-- @tparam string itemString The TSM item string (note that inventory data is tracked per base item) +-- @treturn number The total quantity in all tracked guild banks +function TSM_API.GetGuildTotal(itemString) + private.CheckCallMethod(itemString) + itemString = private.ValidateTSMItemString(itemString) + return Inventory.GetGuildTotal(itemString) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ValidateTSMItemString(itemString) + if type(itemString) ~= "string" or not strmatch(itemString, "[ip]:%d+") then + error("Invalid 'itemString' argument type (must be a TSM item string): "..tostring(itemString), 3) + end + local newItemString = ItemString.Get(itemString) + if not newItemString then + error("Invalid TSM itemString: "..itemString, 3) + end + return newItemString +end + +function private.CheckCallMethod(firstArg) + if firstArg == TSM_API then + error("Invalid usage of colon operator to call TSM_API function", 3) + end +end diff --git a/Core/Const/__init.lua b/Core/Const/__init.lua new file mode 100644 index 0000000..e4fc563 --- /dev/null +++ b/Core/Const/__init.lua @@ -0,0 +1,20 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +TSM.CONST = {} + +-- Miscellaneous constants which should never change +TSM.CONST.OPERATION_SEP = "\001" +TSM.CONST.GROUP_SEP = "`" +TSM.CONST.ROOT_GROUP_PATH = "" +TSM.CONST.TOOLTIP_SEP = "\001" +TSM.CONST.MIN_BONUS_ID_ITEM_LEVEL = 200 +TSM.CONST.AUCTION_DURATIONS = { + not TSM.IsWowClassic() and AUCTION_DURATION_ONE or gsub(AUCTION_DURATION_ONE, "12", "2"), + not TSM.IsWowClassic() and AUCTION_DURATION_TWO or gsub(AUCTION_DURATION_TWO, "24", "8"), + not TSM.IsWowClassic() and AUCTION_DURATION_THREE or gsub(AUCTION_DURATION_THREE, "48", "24"), +} diff --git a/Core/Development/Core.lua b/Core/Development/Core.lua new file mode 100644 index 0000000..4461867 --- /dev/null +++ b/Core/Development/Core.lua @@ -0,0 +1,18 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +-- only create the TSMDEV table if we're in a dev or test environment +local version = GetAddOnMetadata("TradeSkillMaster", "Version") +if not strmatch(version, "^@tsm%-project%-version@$") and version ~= "v4.99.99" then + return +end + +TSMDEV = {} + +function TSMDEV.Dump(value) + LoadAddOn("Blizzard_DebugTools") + DevTools_Dump(value) +end diff --git a/Core/Development/Profiling.lua b/Core/Development/Profiling.lua new file mode 100644 index 0000000..9ba590e --- /dev/null +++ b/Core/Development/Profiling.lua @@ -0,0 +1,163 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +if not TSMDEV then return end + +local _, TSM = ... +TSMDEV.Profiling = {} +local Profiling = TSMDEV.Profiling +local Math = TSM.Include("Util.Math") +local private = { + startTime = nil, + nodes = {}, + nodeRuns = {}, + nodeStart = {}, + nodeTotal = {}, + nodeMaxContext = {}, + nodeMaxTime = {}, + nodeParent = {}, + nodeStack = {}, +} +local NODE_PATH_SEP = "`" + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +--- Starts profiling. +function Profiling.Start() + assert(not private.startTime, "Profiling already started") + private.startTime = debugprofilestop() +end + +--- Starts profiling of a node. +-- Profiling must have been started for this to have any effect. +-- @tparam string node The name of the profiling node +function Profiling.StartNode(node) + if not private.startTime then + -- profiling is not running + return + end + local nodeStackLen = #private.nodeStack + local parentNode = nodeStackLen > 0 and table.concat(private.nodeStack, NODE_PATH_SEP) or nil + private.nodeStack[nodeStackLen + 1] = node + node = table.concat(private.nodeStack, NODE_PATH_SEP) + if private.nodeStart[node] then + error("Node already started", 2) + end + if not private.nodeTotal[node] then + tinsert(private.nodes, node) + private.nodeTotal[node] = 0 + private.nodeRuns[node] = 0 + private.nodeMaxContext[node] = nil + private.nodeMaxTime[node] = 0 + private.nodeParent[node] = parentNode + elseif private.nodeParent[node] ~= parentNode then + error("Node changed parents", 2) + end + private.nodeStart[node] = debugprofilestop() +end + +--- Ends profiling of a node. +-- Profiling of this node must have been started for this to have any effect. +-- @tparam string node The name of the profiling node +-- @param[opt] arg An extra argument which is printed if this invocation represents the max duration for the node +function Profiling.EndNode(node, arg) + if not private.startTime then + -- profiling is not running + return + end + local endTime = debugprofilestop() + local nodeStackLen = #private.nodeStack + if node ~= private.nodeStack[nodeStackLen] then + error("Node isn't at the top of the stack", 2) + end + node = table.concat(private.nodeStack, NODE_PATH_SEP) + if not private.nodeStart[node] then + error("Node hasn't been started", 2) + end + private.nodeStack[nodeStackLen] = nil + local nodeTime = endTime - private.nodeStart[node] + private.nodeRuns[node] = private.nodeRuns[node] + 1 + private.nodeTotal[node] = private.nodeTotal[node] + nodeTime + private.nodeStart[node] = nil + if nodeTime > private.nodeMaxTime[node] then + private.nodeMaxContext[node] = arg + private.nodeMaxTime[node] = nodeTime + end +end + +--- Ends profiling and prints the results to chat. +-- @tparam[opt=0] number minTotalTime The minimum total time to print the profiling info +function Profiling.End(minTotalTime) + if not private.startTime then + -- profiling is not running + return + end + local totalTime = debugprofilestop() - private.startTime + if totalTime > (minTotalTime or 0) then + print(format("Total: %.03f", Math.Round(totalTime, 0.001))) + for _, node in ipairs(private.nodes) do + local parentNode = private.nodeParent[node] + local parentTotalTime = nil + if parentNode then + parentTotalTime = private.nodeTotal[parentNode] + else + parentTotalTime = totalTime + end + local nodeTotalTime = Math.Round(private.nodeTotal[node], 0.001) + local pctTime = Math.Round(nodeTotalTime * 100 / parentTotalTime) + local nodeRuns = private.nodeRuns[node] + local nodeMaxContext = private.nodeMaxContext[node] + local level = private.GetLevel(node) + local name = strmatch(node, NODE_PATH_SEP.."?([^"..NODE_PATH_SEP.."]+)$") + if nodeMaxContext ~= nil then + local nodeMaxTime = private.nodeMaxTime[node] + print(format("%s%s | %d%% | %.03f | %d | %.03f | %s", strrep(" ", level), name, pctTime, nodeTotalTime, nodeRuns, nodeMaxTime, tostring(nodeMaxContext))) + else + print(format("%s%s | %d%% | %.03f | %d", strrep(" ", level), name, pctTime, nodeTotalTime, nodeRuns)) + end + end + end + private.startTime = nil + wipe(private.nodes) + wipe(private.nodeRuns) + wipe(private.nodeStart) + wipe(private.nodeTotal) + wipe(private.nodeMaxContext) + wipe(private.nodeMaxTime) +end + +--- Checks whether or not we're currently profiling. +-- @treturn boolean Whether or not we're currently profiling. +function Profiling.IsActive() + return private.startTime and true or false +end + +--- Gets the total memory used by TSM. +-- @treturn number The amount of memory being used in bytes +function Profiling.GetMemoryUsage() + collectgarbage() + UpdateAddOnMemoryUsage("TradeSkillMaster") + return GetAddOnMemoryUsage("TradeSkillMaster") * 1024 +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetLevel(node) + local level = 0 + while node do + level = level + 1 + node = private.nodeParent[node] + end + return level +end diff --git a/Core/Lib/Addon.lua b/Core/Lib/Addon.lua new file mode 100644 index 0000000..5fd6998 --- /dev/null +++ b/Core/Lib/Addon.lua @@ -0,0 +1,204 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local TSM_NAME, TSM = ... +local Analytics = TSM.Include("Util.Analytics") +local Event = TSM.Include("Util.Event") +local Log = TSM.Include("Util.Log") +local LibTSMClass = TSM.Include("LibTSMClass") +local private = { + eventFrames = {}, + initializeQueue = {}, + enableQueue = {}, + disableQueue = {}, + totalInitializeTime = 0, + totalEnableTime = 0, +} +local TIME_WARNING_THRESHOLD_MS = 20 +local MAX_TIME_PER_EVENT_MS = 12000 +local NUM_EVENT_FRAMES = 10 + + + +-- ============================================================================ +-- Event Handling +-- ============================================================================ + +function private.DoInitialize() + local eventStartTime = debugprofilestop() + while #private.initializeQueue > 0 and debugprofilestop() < (eventStartTime + MAX_TIME_PER_EVENT_MS) do + local addon = tremove(private.initializeQueue, 1) + if addon.OnInitialize then + local addonStartTime = debugprofilestop() + addon.OnInitialize() + local addonTimeTaken = debugprofilestop() - addonStartTime + if addonTimeTaken > TIME_WARNING_THRESHOLD_MS then + Log.Warn("OnInitialize (%s) took %0.2fms", addon, addonTimeTaken) + end + end + tinsert(private.enableQueue, addon) + end + if private.totalInitializeTime == 0 then + for _, path, moduleLoadTime, settingsLoadTime in TSM.ModuleInfoIterator() do + if moduleLoadTime > TIME_WARNING_THRESHOLD_MS then + Log.Warn("Loading module %s took %0.2fms", path, moduleLoadTime) + end + if settingsLoadTime > TIME_WARNING_THRESHOLD_MS then + Log.Warn("Loading settings for %s took %0.2fms", path, settingsLoadTime) + end + end + end + private.totalInitializeTime = private.totalInitializeTime + debugprofilestop() - eventStartTime + return #private.initializeQueue == 0 +end + +function private.DoEnable() + local eventStartTime = debugprofilestop() + while #private.enableQueue > 0 and debugprofilestop() < (eventStartTime + MAX_TIME_PER_EVENT_MS) do + local addon = tremove(private.enableQueue, 1) + if addon.OnEnable then + local addonStartTime = debugprofilestop() + addon.OnEnable() + local addonTimeTaken = debugprofilestop() - addonStartTime + if addonTimeTaken > TIME_WARNING_THRESHOLD_MS then + Log.Warn("OnEnable (%s) took %0.2fms", addon, addonTimeTaken) + end + end + tinsert(private.disableQueue, addon) + end + if private.totalEnableTime == 0 then + for _, path, _, _, gameDataLoadTime in TSM.ModuleInfoIterator() do + if (gameDataLoadTime or 0) > TIME_WARNING_THRESHOLD_MS then + Log.Warn("Loading game data for %s took %0.2fms", path, gameDataLoadTime) + end + end + end + private.totalEnableTime = private.totalEnableTime + debugprofilestop() - eventStartTime + return #private.enableQueue == 0 +end + +function private.PlayerLogoutHandler() + private.OnDisableHelper() + wipe(private.disableQueue) +end + +function private.OnDisableHelper() + local disableStartTime = debugprofilestop() + for _, addon in ipairs(private.disableQueue) do + -- defer the main TSM.OnDisable() call to the very end + if addon.OnDisable and addon ~= TSM then + local startTime = debugprofilestop() + addon.OnDisable() + local timeTaken = debugprofilestop() - startTime + if timeTaken > TIME_WARNING_THRESHOLD_MS then + Log.Warn("OnDisable (%s) took %0.2fms", addon, timeTaken) + end + end + end + local totalDisableTime = debugprofilestop() - disableStartTime + Analytics.Action("ADDON_DISABLE", floor(totalDisableTime)) + if TSM.OnDisable then + TSM.OnDisable() + end +end + +do + -- Blizzard did something silly in 8.1 where scripts time throw an error after 19 seconds, but nothing prevents us + -- from just splitting the processing across multiple script handlers, so we do that here. + local function EventHandler(self, event, arg) + if event == "ADDON_LOADED" and arg == "TradeSkillMaster" then + if private.DoInitialize() then + -- we're done + for _, frame in ipairs(private.eventFrames) do + frame:UnregisterEvent(event) + end + Analytics.Action("ADDON_INITIALIZE", floor(private.totalInitializeTime)) + elseif self == private.eventFrames[#private.eventFrames] then + error("Ran out of event frames to initialize TSM") + end + elseif event == "PLAYER_LOGIN" then + if private.DoEnable() then + -- we're done + for _, frame in ipairs(private.eventFrames) do + frame:UnregisterEvent(event) + end + Analytics.Action("ADDON_ENABLE", floor(private.totalEnableTime)) + elseif self == private.eventFrames[#private.eventFrames] then + error("Ran out of event frames to enable TSM") + end + end + end + for _ = 1, NUM_EVENT_FRAMES do + local frame = CreateFrame("Frame") + frame:SetScript("OnEvent", EventHandler) + frame:RegisterEvent("ADDON_LOADED") + frame:RegisterEvent("PLAYER_LOGIN") + tinsert(private.eventFrames, frame) + end + Event.Register("PLAYER_LOGOUT", private.PlayerLogoutHandler) +end + + + +-- ============================================================================ +-- AddonPackage Class +-- ============================================================================ + +local AddonPackage = LibTSMClass.DefineClass("AddonPackage") + +function AddonPackage.__init(self, name) + self.name = name + tinsert(private.initializeQueue, self) +end + +function AddonPackage.__tostring(self) + return self.name +end + +function AddonPackage.NewPackage(self, name) + local package = AddonPackage(name) + assert(not self[name]) + self[name] = package + return package +end + + + +-- ============================================================================ +-- Addon Class +-- ============================================================================ + +local Addon = LibTSMClass.DefineClass("Addon", AddonPackage) + +function Addon.__init(self, name) + self.__super:__init(name) +end + + + +-- ============================================================================ +-- Initialization Code +-- ============================================================================ + +do + LibTSMClass.ConstructWithTable(TSM, Addon, TSM_NAME) +end + + + +-- ============================================================================ +-- Module Functions (Debug Only) +-- ============================================================================ + +function TSM.AddonTestLogout() + private.OnDisableHelper() + TSM.DebugLogout() + for _, path, _, _, _, moduleUnloadTime in TSM.ModuleInfoIterator() do + if moduleUnloadTime > TIME_WARNING_THRESHOLD_MS then + Log.Warn("Unloading %s took %0.2fms", path, moduleUnloadTime) + end + end +end diff --git a/Core/Lib/Exporter.lua b/Core/Lib/Exporter.lua new file mode 100644 index 0000000..a5714b9 --- /dev/null +++ b/Core/Lib/Exporter.lua @@ -0,0 +1,188 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ExporterModule = TSM:NewPackage("Exporter") +local TempTable = TSM.Include("Util.TempTable") +local LibAceSerializer = LibStub("AceSerializer-3.0") +local Exporter = TSM.Include("LibTSMClass").DefineClass("Exporter") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function ExporterModule.New() + return Exporter() +end + + + +-- ============================================================================ +-- Class definition +-- ============================================================================ + +function Exporter.__init(self) + self.options = { + ["includeAttachedOperations"] = true + } + self.groups = {} + self.operations = {} + self.groupOperations = {} + self.operationsBlacklist = {} + self.groupTargets = {} + for _, module in TSM.Operations.ModuleIterator() do + self.groupOperations[module] = {} + self.operationsBlacklist[module] = {} + self.operations[module] = {} + end +end + +--- Blacklist the given operation from being included with the export +-- @tparam self the exporter +-- @tparam module the operation belongs to +-- @tparam name of the operation +function Exporter.BlacklistOperation(self, module, name) + self.operationsBlacklist[module][name] = true +end + +--- Reset the selected groups and drop the cached copies of the operations +-- @tparam self the exporter +function Exporter.ResetSelection(self) + wipe(self.groups) + wipe(self.groupOperations) + for _, module in TSM.Operations.ModuleIterator() do + wipe(self.operations[module]) + end +end + +--- Add the path to the current selected groups +-- @tparam self the exporter +-- @tparam string path the group to add +function Exporter.SelectGroup(self, path) + if path ~= TSM.CONST.ROOT_GROUP_PATH then + tinsert(self.groups, path) + for _, module in TSM.Operations.ModuleIterator() do + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator(module, path) do + if not self.operationsBlacklist[module][operationName] then + self.operations[module][operationName] = operationSettings + end + end + end + end +end + +--- Finishes bookkeeping when the group selection changes +-- @tparam self the exporter +function Exporter.FinalizeGroupSelections(self) + TSM.Groups.SortGroupList(self.groups) + self:_SetupGroupTargets() + for _, path in ipairs(self.groups) do + self:_SaveGroupOperations(path) + end +end + +--- gets a string that is the exported groups with the selected options +-- @tparam self the exporter +function Exporter.GetExportString(self) + local items = {} + local selectedGroups = {} + + for _, group in ipairs(self.groups) do + selectedGroups[group] = true + self:_SaveGroupOperations(group) + end + + self:_SaveItems(selectedGroups, items) + + local groupExport = table.concat(items, ",") + if not self.options.includeAttachedOperations then + return groupExport + end + return LibAceSerializer:Serialize({groupExport=groupExport, groupOperations=self.groupOperations, operations=self.operations}) +end + +function Exporter._SaveGroupOperations(self, group) + if not self.options.includeAttachedOperations then + return + end + local relPath = self.groupTargets[group] + self.groupOperations[relPath] = TSM.db.profile.userData.groups[group] + for _, moduleName in TSM.Operations.ModuleIterator() do + local operationInfo = self.groupOperations[relPath][moduleName] + for _, operationName in ipairs(operationInfo) do + local data = CopyTable(TSM.Operations.GetSettings(moduleName, operationName)) + data.ignorePlayer = nil + data.ignoreFactionrealm = nil + data.relationships = nil + self.operations[moduleName] = self.operations[moduleName] or {} + self.operations[moduleName][operationName] = data + end + end +end + +function Exporter._SaveItems(self, selectedGroups, saveItems) + local temp = TempTable.Acquire() + + for _, itemString, groupPath in TSM.Groups.ItemIterator() do + if selectedGroups[groupPath] then + tinsert(temp, itemString) + end + end + + sort(temp, private.GroupsThenItemsSortFunc) + + local currentPath = "" + for _, itemString in pairs(temp) do + local rawPath = TSM.Groups.GetPathByItem(itemString) + local relPath = self.groupTargets[rawPath] + if relPath ~= currentPath then + tinsert(saveItems, "group:"..relPath) + currentPath = relPath + end + tinsert(saveItems, itemString) + end + TempTable.Release(temp) +end + +function Exporter._SetupGroupTargets(self) + wipe(self.groupTargets) + if #self.groups < 1 then + return + end + local knownRoots = {} + for _, groupPath in ipairs(self.groups) do + local root, leaf = TSM.Groups.Path.Split(groupPath) + leaf = gsub(leaf, ",", TSM.CONST.GROUP_SEP..TSM.CONST.GROUP_SEP) + if knownRoots[root] then + self.groupTargets[groupPath] = leaf + else + if self.groupTargets[root] then + self.groupTargets[groupPath] = TSM.Groups.Path.Join(self.groupTargets[root], leaf) + else + knownRoots[root] = true + self.groupTargets[groupPath] = leaf + end + end + end +end + + + +-- ============================================================================ +-- Private Functions +-- ============================================================================ + +function private.GroupsThenItemsSortFunc(a, b) + local groupA = strlower(gsub(TSM.Groups.GetPathByItem(a), TSM.CONST.GROUP_SEP, "\001")) + local groupB = strlower(gsub(TSM.Groups.GetPathByItem(b), TSM.CONST.GROUP_SEP, "\001")) + if groupA == groupB then + return a < b + end + return groupA < groupB +end diff --git a/Core/Service/Accounting/Auctions.lua b/Core/Service/Accounting/Auctions.lua new file mode 100644 index 0000000..a3ad33b --- /dev/null +++ b/Core/Service/Accounting/Auctions.lua @@ -0,0 +1,235 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctions = TSM.Accounting:NewPackage("Auctions") +local Database = TSM.Include("Util.Database") +local CSV = TSM.Include("Util.CSV") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local CustomPrice = TSM.Include("Service.CustomPrice") +local private = { + db = nil, + numExpiresQuery = nil, + dataChanged = false, + statsQuery = nil, + statsTemp = {}, +} +local COMBINE_TIME_THRESHOLD = 300 -- group expenses within 5 minutes together +local REMOVE_OLD_THRESHOLD = 180 * 24 * 60 * 60 -- remove records over 6 months old +local SECONDS_PER_DAY = 24 * 60 * 60 +local CSV_KEYS = { "itemString", "stackSize", "quantity", "player", "time" } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctions.OnInitialize() + private.db = Database.NewSchema("ACCOUNTING_AUCTIONS") + :AddStringField("baseItemString") + :AddStringField("type") + :AddStringField("itemString") + :AddNumberField("stackSize") + :AddNumberField("quantity") + :AddStringField("player") + :AddNumberField("time") + :AddNumberField("saveTime") + :AddIndex("baseItemString") + :AddIndex("time") + :Commit() + private.numExpiresQuery = private.db:NewQuery() + :Select("quantity") + :Equal("type", "expire") + :Equal("baseItemString", Database.BoundQueryParam()) + :GreaterThanOrEqual("time", Database.BoundQueryParam()) + private.statsQuery = private.db:NewQuery() + :Select("type", "quantity") + :Equal("baseItemString", Database.BoundQueryParam()) + :GreaterThanOrEqual("time", Database.BoundQueryParam()) + + private.db:BulkInsertStart() + private.LoadData("cancel", TSM.db.realm.internalData.csvCancelled, TSM.db.realm.internalData.saveTimeCancels) + private.LoadData("expire", TSM.db.realm.internalData.csvExpired, TSM.db.realm.internalData.saveTimeExpires) + private.db:BulkInsertEnd() + CustomPrice.OnSourceChange("NumExpires") +end + +function Auctions.OnDisable() + if not private.dataChanged then + -- nothing changed, so no need to save + return + end + local cancelSaveTimes, expireSaveTimes = {}, {} + local cancelEncodeContext = CSV.EncodeStart(CSV_KEYS) + local expireEncodeContext = CSV.EncodeStart(CSV_KEYS) + -- order by time to speed up loading + local query = private.db:NewQuery() + :Select("type", "itemString", "stackSize", "quantity", "player", "time", "saveTime") + :OrderBy("time", true) + for _, recordType, itemString, stackSize, quantity, player, timestamp, saveTime in query:Iterator() do + local saveTimes, encodeContext = nil, nil + if recordType == "cancel" then + saveTimes = cancelSaveTimes + encodeContext = cancelEncodeContext + elseif recordType == "expire" then + saveTimes = expireSaveTimes + encodeContext = expireEncodeContext + else + error("Invalid recordType: "..tostring(recordType)) + end + -- add the save time + tinsert(saveTimes, saveTime ~= 0 and saveTime or time()) + -- add to our list of CSV lines + CSV.EncodeAddRowDataRaw(encodeContext, itemString, stackSize, quantity, player, timestamp) + end + query:Release() + TSM.db.realm.internalData.csvCancelled = CSV.EncodeEnd(cancelEncodeContext) + TSM.db.realm.internalData.saveTimeCancels = table.concat(cancelSaveTimes, ",") + TSM.db.realm.internalData.csvExpired = CSV.EncodeEnd(expireEncodeContext) + TSM.db.realm.internalData.saveTimeExpires = table.concat(expireSaveTimes, ",") +end + +function Auctions.InsertCancel(itemString, stackSize, timestamp) + private.InsertRecord("cancel", itemString, stackSize, timestamp) +end + +function Auctions.InsertExpire(itemString, stackSize, timestamp) + private.InsertRecord("expire", itemString, stackSize, timestamp) +end + +function Auctions.GetStats(itemString, minTime) + private.statsQuery:BindParams(ItemString.GetBase(itemString), minTime or 0) + wipe(private.statsTemp) + private.statsQuery:GroupedSum("type", "quantity", private.statsTemp) + local cancel = private.statsTemp.cancel or 0 + local expire = private.statsTemp.expire or 0 + local total = cancel + expire + return cancel, expire, total +end + +function Auctions.GetNumExpires(itemString, minTime) + private.numExpiresQuery:BindParams(ItemString.GetBase(itemString), minTime or 0) + local num = 0 + for _, quantity in private.numExpiresQuery:Iterator() do + num = num + quantity + end + return num +end + +function Auctions.GetNumExpiresSinceSale(itemString) + return Auctions.GetNumExpires(itemString, TSM.Accounting.Transactions.GetLastSaleTime(itemString)) +end + +function Auctions.CreateQuery() + return private.db:NewQuery() +end + +function Auctions.RemoveOldData(days) + private.dataChanged = true + private.db:SetQueryUpdatesPaused(true) + local numRecords = private.db:NewQuery() + :LessThan("time", time() - days * SECONDS_PER_DAY) + :DeleteAndRelease() + private.db:SetQueryUpdatesPaused(false) + CustomPrice.OnSourceChange("NumExpires") + return numRecords +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.LoadData(recordType, csvRecords, csvSaveTimes) + local saveTimes = String.SafeSplit(csvSaveTimes, ",") + if not saveTimes then + return + end + + local decodeContext = CSV.DecodeStart(csvRecords, CSV_KEYS) + if not decodeContext then + Log.Err("Failed to decode %s records", recordType) + private.dataChanged = true + return + end + + local removeTime = time() - REMOVE_OLD_THRESHOLD + local index = 1 + local prevTimestamp = 0 + for itemString, stackSize, quantity, player, timestamp in CSV.DecodeIterator(decodeContext) do + itemString = ItemString.Get(itemString) + local baseItemString = ItemString.GetBaseFast(itemString) + local saveTime = tonumber(saveTimes[index]) + stackSize = tonumber(stackSize) + quantity = tonumber(quantity) + timestamp = tonumber(timestamp) + if itemString and baseItemString and stackSize and quantity and timestamp and saveTime and timestamp > removeTime then + local newTimestamp = floor(timestamp) + if newTimestamp ~= timestamp then + -- make sure all timestamps are stored as integers + private.dataChanged = true + timestamp = newTimestamp + end + if timestamp < prevTimestamp then + -- not ordered by timestamp + private.dataChanged = true + end + prevTimestamp = timestamp + private.db:BulkInsertNewRowFast8(baseItemString, recordType, itemString, stackSize, quantity, player, timestamp, saveTime) + else + private.dataChanged = true + end + index = index + 1 + end + + if not CSV.DecodeEnd(decodeContext) then + Log.Err("Failed to decode %s records", recordType) + private.dataChanged = true + end + + CustomPrice.OnSourceChange("NumExpires") +end + +function private.InsertRecord(recordType, itemString, stackSize, timestamp) + private.dataChanged = true + assert(itemString and stackSize and stackSize > 0 and timestamp) + timestamp = floor(timestamp) + local baseItemString = ItemString.GetBase(itemString) + local matchingRow = private.db:NewQuery() + :Equal("type", recordType) + :Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + :Equal("stackSize", stackSize) + :Equal("player", UnitName("player")) + :GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD) + :LessThan("time", timestamp + COMBINE_TIME_THRESHOLD) + :Equal("saveTime", 0) + :GetFirstResultAndRelease() + if matchingRow then + matchingRow:SetField("quantity", matchingRow:GetField("quantity") + stackSize) + matchingRow:Update() + matchingRow:Release() + else + private.db:NewRow() + :SetField("baseItemString", baseItemString) + :SetField("type", recordType) + :SetField("itemString", itemString) + :SetField("stackSize", stackSize) + :SetField("quantity", stackSize) + :SetField("player", UnitName("player")) + :SetField("time", timestamp) + :SetField("saveTime", 0) + :Create() + end + + if recordType == "expire" then + CustomPrice.OnSourceChange("NumExpires", itemString) + end +end diff --git a/Core/Service/Accounting/Core.lua b/Core/Service/Accounting/Core.lua new file mode 100644 index 0000000..22ba06d --- /dev/null +++ b/Core/Service/Accounting/Core.lua @@ -0,0 +1,54 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Accounting = TSM:NewPackage("Accounting") +local Math = TSM.Include("Util.Math") +local private = { + characterGuildTemp = {}, +} +local SECONDS_PER_DAY = 24 * 60 * 60 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Accounting.GetSummaryQuery(timeFilterStart, timeFilterEnd, ignoredCharacters) + local query = TSM.Accounting.Transactions.CreateQuery() + :Select("type", "itemString", "price", "quantity", "time") + if timeFilterStart then + query:GreaterThan("time", timeFilterStart) + end + if timeFilterEnd then + query:LessThan("time", timeFilterEnd) + end + if ignoredCharacters then + wipe(private.characterGuildTemp) + for characterGuild in pairs(ignoredCharacters) do + local character, realm = strmatch(characterGuild, "^(.+) %- .+ %- (.+)$") + if character and realm == GetRealmName() then + private.characterGuildTemp[character] = true + end + end + query:NotInTable("player", private.characterGuildTemp) + end + return query +end + +function Accounting.GetSaleRate(itemString) + -- since auction data only goes back 180 days, limit the sales to that same time range + local _, totalSaleNum = TSM.Accounting.Transactions.GetSaleStats(itemString, 180 * SECONDS_PER_DAY) + if not totalSaleNum then + return nil + end + local _, _, totalFailed = TSM.Accounting.Auctions.GetStats(itemString) + if not totalFailed then + return nil + end + return Math.Round(totalSaleNum / (totalSaleNum + totalFailed), 0.01) +end diff --git a/Core/Service/Accounting/Garrison.lua b/Core/Service/Accounting/Garrison.lua new file mode 100644 index 0000000..1cb039f --- /dev/null +++ b/Core/Service/Accounting/Garrison.lua @@ -0,0 +1,56 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Garrison = TSM.Accounting:NewPackage("Garrison") +local Event = TSM.Include("Util.Event") +local private = {} +local GOLD_TRAIT_ID = 256 -- traitId for the treasure hunter trait which increases gold from missions + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Garrison.OnInitialize() + if not TSM.IsWowClassic() then + Event.Register("GARRISON_MISSION_COMPLETE_RESPONSE", private.MissionComplete) + end +end + + + +-- ============================================================================ +-- Misson Reward Tracking +-- ============================================================================ + +function private.MissionComplete(_, missionId) + local moneyAward = 0 + local info = C_Garrison.GetBasicMissionInfo(missionId) + if not info then + return + end + local rewards = info.rewards or info.overMaxRewards + for _, reward in pairs(rewards) do + if reward.title == GARRISON_REWARD_MONEY and reward.currencyID == 0 then + moneyAward = moneyAward + reward.quantity + end + end + if moneyAward > 0 then + -- check for followers which give bonus gold + local multiplier = 1 + for _, followerId in ipairs(info.followers) do + for _, trait in ipairs(C_Garrison.GetFollowerAbilities(followerId)) do + if trait.id == GOLD_TRAIT_ID then + multiplier = multiplier + 1 + end + end + end + moneyAward = moneyAward * multiplier + TSM.Accounting.Money.InsertGarrisonIncome(moneyAward) + end +end diff --git a/Core/Service/Accounting/GoldTracker.lua b/Core/Service/Accounting/GoldTracker.lua new file mode 100644 index 0000000..2763579 --- /dev/null +++ b/Core/Service/Accounting/GoldTracker.lua @@ -0,0 +1,291 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local GoldTracker = TSM.Accounting:NewPackage("GoldTracker") +local Event = TSM.Include("Util.Event") +local Delay = TSM.Include("Util.Delay") +local CSV = TSM.Include("Util.CSV") +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local Table = TSM.Include("Util.Table") +local TempTable = TSM.Include("Util.TempTable") +local Settings = TSM.Include("Service.Settings") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local private = { + truncateGoldLog = {}, + characterGoldLog = {}, + guildGoldLog = {}, + currentCharacterKey = nil, + playerLogCount = 0, + searchValueTemp = {}, +} +local CSV_KEYS = { "minute", "copper" } +local CHARACTER_KEY_SEP = " - " +local SECONDS_PER_MIN = 60 +local SECONDS_PER_DAY = SECONDS_PER_MIN * 60 * 24 +local MAX_COPPER_VALUE = 10 * 1000 * 1000 * COPPER_PER_GOLD - 1 +local ERRONEOUS_ZERO_THRESHOLD = 5 * 1000 * COPPER_PER_GOLD + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function GoldTracker.OnInitialize() + if not TSM.IsWowClassic() then + Event.Register("GUILDBANKFRAME_OPENED", private.GuildLogGold) + Event.Register("GUILDBANK_UPDATE_MONEY", private.GuildLogGold) + end + Event.Register("PLAYER_MONEY", private.PlayerLogGold) + + -- get a list of known characters / guilds + local validCharacterGuilds = TempTable.Acquire() + for _, character in Settings.CharacterByFactionrealmIterator() do + validCharacterGuilds[character..CHARACTER_KEY_SEP..UnitFactionGroup("player")..CHARACTER_KEY_SEP..GetRealmName()] = true + local guild = TSM.db.factionrealm.internalData.characterGuilds[character] + if guild then + validCharacterGuilds[guild] = true + end + end + + -- load the gold log data + for realm in TSM.db:GetConnectedRealmIterator("realm") do + for factionrealm in TSM.db:FactionrealmByRealmIterator(realm) do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + local data = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character, factionrealm), "internalData", "goldLog") + if data then + local lastUpdate = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character, factionrealm), "internalData", "goldLogLastUpdate") or 0 + local characterKey = character..CHARACTER_KEY_SEP..factionrealm + private.LoadCharacterGoldLog(characterKey, data, validCharacterGuilds, lastUpdate) + end + end + local guildData = TSM.db:Get("factionrealm", factionrealm, "internalData", "guildGoldLog") + if guildData then + for guild, data in pairs(guildData) do + local entries = {} + local decodeContext = CSV.DecodeStart(data, CSV_KEYS) + if decodeContext then + for minute, copper in CSV.DecodeIterator(decodeContext) do + tinsert(entries, { minute = tonumber(minute), copper = tonumber(copper) }) + end + CSV.DecodeEnd(decodeContext) + end + private.guildGoldLog[guild] = entries + local lastEntryTime = #entries > 0 and entries[#entries].minute * SECONDS_PER_MIN or math.huge + local lastUpdate = TSM.db:Get("factionrealm", factionrealm, "internalData", "guildGoldLogLastUpdate") + if not validCharacterGuilds[guild] and max(lastEntryTime, lastUpdate and lastUpdate[guild] or 0) < time() - 30 * SECONDS_PER_DAY then + -- this guild may not be valid and the last entry is over 30 days old, so truncate the data + private.truncateGoldLog[guild] = lastEntryTime + end + end + end + end + end + TempTable.Release(validCharacterGuilds) + private.currentCharacterKey = UnitName("player")..CHARACTER_KEY_SEP..UnitFactionGroup("player")..CHARACTER_KEY_SEP..GetRealmName() + assert(private.characterGoldLog[private.currentCharacterKey]) +end + +function GoldTracker.OnEnable() + -- Log the current player gold (need to wait for OnEnable, otherwise GetMoney() returns 0 when first logging in) + private.PlayerLogGold() +end + +function GoldTracker.OnDisable() + private.PlayerLogGold() + TSM.db.sync.internalData.goldLog = CSV.Encode(CSV_KEYS, private.characterGoldLog[private.currentCharacterKey]) + TSM.db.sync.internalData.goldLogLastUpdate = private.characterGoldLog[private.currentCharacterKey].lastUpdate + local guild = PlayerInfo.GetPlayerGuild(UnitName("player")) + if guild and private.guildGoldLog[guild] then + TSM.db.factionrealm.internalData.guildGoldLog[guild] = CSV.Encode(CSV_KEYS, private.guildGoldLog[guild]) + TSM.db.factionrealm.internalData.guildGoldLogLastUpdate[guild] = private.guildGoldLog[guild].lastUpdate + end +end + +function GoldTracker.CharacterGuildIterator() + return private.CharacterGuildIteratorHelper +end + +function GoldTracker.GetGoldAtTime(timestamp, ignoredCharactersGuilds) + local value = 0 + for character, logEntries in pairs(private.characterGoldLog) do + if #logEntries > 0 and not ignoredCharactersGuilds[character] and (private.truncateGoldLog[character] or math.huge) > timestamp then + value = value + private.GetValueAtTime(logEntries, timestamp) + end + end + for guild, logEntries in pairs(private.guildGoldLog) do + if #logEntries > 0 and not ignoredCharactersGuilds[guild] and (private.truncateGoldLog[guild] or math.huge) > timestamp then + value = value + private.GetValueAtTime(logEntries, timestamp) + end + end + return value +end + +function GoldTracker.GetGraphTimeRange(ignoredCharactersGuilds) + local minTime = Math.Floor(time(), SECONDS_PER_MIN) + for character, logEntries in pairs(private.characterGoldLog) do + if #logEntries > 0 and not ignoredCharactersGuilds[character] then + minTime = min(minTime, logEntries[1].minute * SECONDS_PER_MIN) + end + end + for guild, logEntries in pairs(private.guildGoldLog) do + if #logEntries > 0 and not ignoredCharactersGuilds[guild] then + minTime = min(minTime, logEntries[1].minute * SECONDS_PER_MIN) + end + end + return minTime, Math.Floor(time(), SECONDS_PER_MIN), SECONDS_PER_MIN +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.LoadCharacterGoldLog(characterKey, data, validCharacterGuilds, lastUpdate) + assert(not private.characterGoldLog[characterKey]) + local decodeContext = CSV.DecodeStart(data, CSV_KEYS) + if not decodeContext then + Log.Err("Failed to decode (%s, %d)", characterKey, #data) + private.characterGoldLog[characterKey] = {} + return + end + + local entries = {} + for minute, copper in CSV.DecodeIterator(decodeContext) do + tinsert(entries, { minute = tonumber(minute), copper = tonumber(copper) }) + end + CSV.DecodeEnd(decodeContext) + + -- clean up any erroneous 0 entries, entries which are too high, and duplicate entries + local didChange = true + while didChange do + didChange = false + for i = #entries - 1, 2, -1 do + local prevValue = entries[i-1].copper + local value = entries[i].copper + local nextValue = entries[i+1].copper + if prevValue > ERRONEOUS_ZERO_THRESHOLD and value == 0 and nextValue > ERRONEOUS_ZERO_THRESHOLD then + -- this is likely an erroneous 0 value + didChange = true + tremove(entries, i) + end + end + for i = #entries, 2, -1 do + local prevValue = entries[i-1].copper + local value = entries[i].copper + if prevValue == value or value > MAX_COPPER_VALUE then + -- this is either a duplicate or invalid value + didChange = true + tremove(entries, i) + end + end + end + + private.characterGoldLog[characterKey] = entries + local lastEntryTime = #entries > 0 and entries[#entries].minute * SECONDS_PER_MIN or math.huge + if not validCharacterGuilds[characterKey] and max(lastEntryTime, lastUpdate) < time() - 30 * SECONDS_PER_DAY then + -- this character may not be valid and the last entry is over 30 days old, so truncate the data + private.truncateGoldLog[characterKey] = lastEntryTime + end +end + +function private.UpdateGoldLog(goldLog, copper) + copper = Math.Round(copper, COPPER_PER_GOLD * (TSM.IsWowClassic() and 1 or 1000)) + local currentMinute = floor(time() / SECONDS_PER_MIN) + local prevRecord = goldLog[#goldLog] + + -- store the last update time + goldLog.lastUpdate = time() + + if prevRecord and copper == prevRecord.copper then + -- amount of gold hasn't changed, so nothing to do + return + elseif prevRecord and prevRecord.minute == currentMinute then + -- gold has changed and the previous record is for the current minute so just modify it + prevRecord.copper = copper + else + -- amount of gold changed and we're in a new minute, so insert a new record + while prevRecord and prevRecord.minute > currentMinute - 1 do + -- their clock may have changed - just delete everything that's too recent + tremove(goldLog) + prevRecord = goldLog[#goldLog] + end + tinsert(goldLog, { + minute = currentMinute, + copper = copper + }) + end +end + +function private.GuildLogGold() + local guildName = GetGuildInfo("player") + local isGuildLeader = IsGuildLeader() + if guildName and not isGuildLeader then + -- check if our alt is the guild leader + for i = 1, GetNumGuildMembers() do + local name, _, rankIndex = GetGuildRosterInfo(i) + if name and rankIndex == 0 and PlayerInfo.IsPlayer(gsub(name, "%-", " - "), true) then + isGuildLeader = true + end + end + end + if guildName and isGuildLeader then + if not private.guildGoldLog[guildName] then + private.guildGoldLog[guildName] = {} + end + private.UpdateGoldLog(private.guildGoldLog[guildName], GetGuildBankMoney()) + end +end + +function private.PlayerLogGold() + -- GetMoney sometimes returns 0 for a while after login, so keep trying for 30 seconds before recording a 0 + local money = GetMoney() + if money == 0 and private.playerLogCount < 30 then + private.playerLogCount = private.playerLogCount + 1 + Delay.AfterTime(1, private.PlayerLogGold) + return + end + private.playerLogCount = 0 + private.UpdateGoldLog(private.characterGoldLog[private.currentCharacterKey], money) + TSM.db.sync.internalData.money = money +end + +function private.GetValueAtTime(logEntries, timestamp) + local minute = floor(timestamp / SECONDS_PER_MIN) + if logEntries[1].minute > minute then + -- timestamp is before we had any data + return 0 + end + private.searchValueTemp.minute = minute + local index, insertIndex = Table.BinarySearch(logEntries, private.searchValueTemp, private.GetEntryMinute) + -- if we didn't find an exact match, the index is the previous one (compared to the insert index) + -- as that point's gold value is true up until the next point + index = index or (insertIndex - 1) + return logEntries[index].copper +end + +function private.GetEntryMinute(entry) + return entry.minute +end + +function private.CharacterGuildIteratorHelper(_, lastKey) + local result, isGuild = nil, nil + if not lastKey or private.characterGoldLog[lastKey] then + result = next(private.characterGoldLog, lastKey) + isGuild = false + if not result then + lastKey = nil + end + end + if not result then + result = next(private.guildGoldLog, lastKey) + isGuild = result and true or false + end + return result, isGuild +end diff --git a/Core/Service/Accounting/Mail.lua b/Core/Service/Accounting/Mail.lua new file mode 100644 index 0000000..6f785a1 --- /dev/null +++ b/Core/Service/Accounting/Mail.lua @@ -0,0 +1,387 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Mail = TSM.Accounting:NewPackage("Mail") +local Event = TSM.Include("Util.Event") +local Delay = TSM.Include("Util.Delay") +local String = TSM.Include("Util.String") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local InventoryInfo = TSM.Include("Service.InventoryInfo") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local Inventory = TSM.Include("Service.Inventory") +local private = { + hooks = {}, +} +local SECONDS_PER_DAY = 24 * 60 * 60 +local EXPIRED_MATCH_TEXT = AUCTION_EXPIRED_MAIL_SUBJECT:gsub("%%s", "") +local CANCELLED_MATCH_TEXT = AUCTION_REMOVED_MAIL_SUBJECT:gsub("%%s", "") +local OUTBID_MATCH_TEXT = AUCTION_OUTBID_MAIL_SUBJECT:gsub("%%s", "(.+)") + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Mail.OnInitialize() + Event.Register("MAIL_SHOW", function() Delay.AfterTime("ACCOUNTING_GET_SELLERS", 0.1, private.RequestSellerInfo, 0.1) end) + Event.Register("MAIL_CLOSED", function() Delay.Cancel("ACCOUNTING_GET_SELLERS") end) + -- hook certain mail functions + private.hooks.TakeInboxItem = TakeInboxItem + TakeInboxItem = function(...) + Mail:ScanCollectedMail("TakeInboxItem", 1, ...) + end + private.hooks.TakeInboxMoney = TakeInboxMoney + TakeInboxMoney = function(...) + Mail:ScanCollectedMail("TakeInboxMoney", 1, ...) + end + private.hooks.AutoLootMailItem = AutoLootMailItem + AutoLootMailItem = function(...) + Mail:ScanCollectedMail("AutoLootMailItem", 1, ...) + end + private.hooks.SendMail = SendMail + SendMail = private.CheckSendMail +end + + + +-- ============================================================================ +-- Inbox Functions +-- ============================================================================ + +function private.RequestSellerInfo() + local isDone = true + for i = 1, GetInboxNumItems() do + local invoiceType, _, seller = GetInboxInvoiceInfo(i) + if invoiceType and seller == "" then + isDone = false + end + end + if isDone and GetInboxNumItems() > 0 then + Delay.Cancel("ACCOUNTING_GET_SELLERS") + end +end + +function private.CanLootMailIndex(index, copper) + local currentMoney = GetMoney() + assert(currentMoney <= MAXIMUM_BID_PRICE) + -- check if this would put them over the gold cap + if currentMoney + copper > MAXIMUM_BID_PRICE then return end + local _, _, _, _, _, _, _, itemCount = GetInboxHeaderInfo(index) + if not itemCount or itemCount == 0 then return true end + for j = 1, ATTACHMENTS_MAX_RECEIVE do + -- TODO: prevent items that you can't loot because of internal mail error + if CalculateTotalNumberOfFreeBagSlots() <= 0 then + return + end + local link = GetInboxItemLink(index, j) + local itemString = ItemString.Get(link) + local _, _, _, count = GetInboxItem(index, j) + local quantity = count or 0 + local maxUnique = private.GetInboxMaxUnique(index, j) + -- dont record unique items that we can't loot + local playerQty = Inventory.GetBagQuantity(itemString) + Inventory.GetBankQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString) + if maxUnique > 0 and maxUnique < playerQty + quantity then + return + end + if itemString then + for bag = 0, NUM_BAG_SLOTS do + if InventoryInfo.ItemWillGoInBag(link, bag) then + for slot = 1, GetContainerNumSlots(bag) do + local iString = ItemString.Get(GetContainerItemLink(bag, slot)) + if iString == itemString then + local _, stackSize = GetContainerItemInfo(bag, slot) + local maxStackSize = ItemInfo.GetMaxStack(itemString) or 1 + if (maxStackSize - stackSize) >= quantity then + return true + end + elseif not iString then + return true + end + end + end + end + end + end +end + +function private.GetInboxMaxUnique(index, num) + if not num then + num = 1 + end + + if not TSMScanTooltip then + CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate") + end + + TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE") + TSMScanTooltip:ClearLines() + + local _, speciesId = TSMScanTooltip:SetInboxItem(index, num) + if (speciesId or 0) > 0 then + return 0 + else + for id = 2, TSMScanTooltip:NumLines() do + local text = private.GetTooltipText(_G["TSMScanTooltipTextLeft"..id]) + if text then + if text == ITEM_UNIQUE then + return 1 + else + local match = text and strmatch(text, "^"..ITEM_UNIQUE.." %((%d+)%)$") + if match then + return tonumber(match) + end + end + end + end + end + + return 0 +end + +function private.GetTooltipText(text) + local textStr = strtrim(text and text:GetText() or "") + if textStr == "" then return end + + return textStr +end + +-- scans the mail that the player just attempted to collected (Pre-Hook) +function Mail:ScanCollectedMail(oFunc, attempt, index, subIndex) + local invoiceType, itemName, buyer, bid, _, _, ahcut, _, _, _, quantity = GetInboxInvoiceInfo(index) + buyer = buyer or (invoiceType == "buyer" and AUCTION_HOUSE_MAIL_MULTIPLE_SELLERS or AUCTION_HOUSE_MAIL_MULTIPLE_BUYERS) + local _, stationeryIcon, sender, subject, money, codAmount, daysLeft = GetInboxHeaderInfo(index) + if not subject then return end + if attempt > 2 then + if buyer == "" then + buyer = "?" + elseif sender == "" then + sender = "?" + end + end + + local success = false + if invoiceType == "seller" and buyer and buyer ~= "" then -- AH Sales + local saleTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY) + local itemString = ItemInfo.ItemNameToItemString(itemName) + if not itemString or itemString == ItemString.GetUnknown() then + itemString = AuctionTracking.GetSaleHintItemString(itemName, quantity, bid) + end + if private.CanLootMailIndex(index, (bid - ahcut)) then + if itemString then + local copper = floor((bid - ahcut) / quantity + 0.5) + TSM.Accounting.Transactions.InsertAuctionSale(itemString, quantity, copper, buyer, saleTime) + end + success = true + end + elseif invoiceType == "buyer" and buyer and buyer ~= "" then -- AH Buys + local copper = floor(bid / quantity + 0.5) + if not TSM.IsWowClassic() then + if subIndex then + quantity = select(4, GetInboxItem(index, subIndex)) + else + quantity = 0 + for i = 1, ATTACHMENTS_MAX do + quantity = quantity + (select(4, GetInboxItem(index, i)) or 0) + end + end + end + local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1) + local itemString = ItemString.Get(link) + if itemString and private.CanLootMailIndex(index, 0) then + local buyTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY) + TSM.Accounting.Transactions.InsertAuctionBuy(itemString, quantity, copper, buyer, buyTime) + success = true + end + elseif codAmount > 0 then -- COD Buys (only if all attachments are same item) + local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1) + local itemString = ItemString.Get(link) + if itemString and sender then + local name = ItemInfo.GetName(link) + local total = 0 + local stacks = 0 + local ignore = false + for i = 1, ATTACHMENTS_MAX_RECEIVE do + local nameCheck, _, _, count = GetInboxItem(index, i) + if nameCheck and count then + if nameCheck == name then + total = total + count + stacks = stacks + 1 + else + ignore = true + end + end + end + + if total ~= 0 and not ignore and private.CanLootMailIndex(index, codAmount) then + local copper = floor(codAmount / total + 0.5) + local buyTime = (time() + (daysLeft - 3) * SECONDS_PER_DAY) + local maxStack = ItemInfo.GetMaxStack(link) + for _ = 1, stacks do + local stackSize = (total >= maxStack) and maxStack or total + TSM.Accounting.Transactions.InsertCODBuy(itemString, stackSize, copper, sender, buyTime) + total = total - stackSize + if total <= 0 then + break + end + end + end + success = true + end + elseif money > 0 and invoiceType ~= "seller" and not strfind(subject, OUTBID_MATCH_TEXT) then + local str = nil + if GetLocale() == "deDE" then + str = gsub(subject, gsub(COD_PAYMENT, String.Escape("%1$s"), ""), "") + else + str = gsub(subject, gsub(COD_PAYMENT, String.Escape("%s"), ""), "") + end + local saleTime = (time() + (daysLeft - 31) * SECONDS_PER_DAY) + if sender and private.CanLootMailIndex(index, money) then + if str and strfind(str, "TSM$") then -- payment for a COD the player sent + local codName = strtrim(strmatch(str, "([^%(]+)")) + local qty = strmatch(str, "%(([0-9]+)%)") + qty = tonumber(qty) + local itemString = ItemInfo.ItemNameToItemString(codName) + if itemString then + local copper = floor(money / qty + 0.5) + local maxStack = ItemInfo.GetMaxStack(itemString) or 1 + local stacks = ceil(qty / maxStack) + + for _ = 1, stacks do + local stackSize = (qty >= maxStack) and maxStack or qty + TSM.Accounting.Transactions.InsertCODSale(itemString, stackSize, copper, sender, saleTime) + qty = qty - stackSize + if qty <= 0 then + break + end + end + end + else -- record a money transfer + TSM.Accounting.Money.InsertMoneyTransferIncome(money, sender, saleTime) + end + success = true + end + elseif strfind(subject, EXPIRED_MATCH_TEXT) then -- expired auction + local expiredTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY) + local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1) + local _, _, _, count = GetInboxItem(index, subIndex or 1) + if TSM.IsWowClassic() then + quantity = count or 0 + else + if subIndex then + quantity = select(4, GetInboxItem(index, subIndex)) + else + quantity = 0 + for i = 1, ATTACHMENTS_MAX do + quantity = quantity + (select(4, GetInboxItem(index, i)) or 0) + end + end + end + local itemString = ItemString.Get(link) + if private.CanLootMailIndex(index, 0) and itemString and quantity then + TSM.Accounting.Auctions.InsertExpire(itemString, quantity, expiredTime) + success = true + end + elseif strfind(subject, CANCELLED_MATCH_TEXT) then -- cancelled auction + local cancelledTime = (time() + (daysLeft - 30) * SECONDS_PER_DAY) + local link = (subIndex or 1) == 1 and private.GetFirstInboxItemLink(index) or GetInboxItemLink(index, subIndex or 1) + local _, _, _, count = GetInboxItem(index, subIndex or 1) + if TSM.IsWowClassic() then + quantity = count or 0 + else + if subIndex then + quantity = select(4, GetInboxItem(index, subIndex)) + else + quantity = 0 + for i = 1, ATTACHMENTS_MAX do + quantity = quantity + (select(4, GetInboxItem(index, i)) or 0) + end + end + end + local itemString = ItemString.Get(link) + if private.CanLootMailIndex(index, 0) and itemString and quantity then + TSM.Accounting.Auctions.InsertCancel(itemString, quantity, cancelledTime) + success = true + end + end + + if success then + private.hooks[oFunc](index, subIndex) + elseif (not stationeryIcon or (invoiceType and (not buyer or buyer == ""))) and attempt <= 5 then + Delay.AfterTime("accountingHookDelay", 0.2, function() Mail:ScanCollectedMail(oFunc, attempt + 1, index, subIndex) end) + elseif attempt > 5 then + private.hooks[oFunc](index, subIndex) + else + private.hooks[oFunc](index, subIndex) + end +end + + + +-- ============================================================================ +-- Sending Functions +-- ============================================================================ + +-- scans the mail that the player just attempted to send (Pre-Hook) to see if COD +function private.CheckSendMail(destination, currentSubject, ...) + local codAmount = GetSendMailCOD() + local moneyAmount = GetSendMailMoney() + local mailCost = GetSendMailPrice() + local subject + local total = 0 + local ignore = false + + if codAmount ~= 0 then + for i = 1, 12 do + local itemName, _, _, count = GetSendMailItem(i) + if itemName and count then + if not subject then + subject = itemName + end + + if subject == itemName then + total = total + count + else + ignore = true + end + end + end + else + ignore = true + end + + if moneyAmount > 0 then + -- add a record for the money transfer + TSM.Accounting.Money.InsertMoneyTransferExpense(moneyAmount, destination) + mailCost = mailCost - moneyAmount + end + TSM.Accounting.Money.InsertPostageExpense(mailCost, destination) + + if not ignore then + private.hooks.SendMail(destination, subject .. " (" .. total .. ") TSM", ...) + else + private.hooks.SendMail(destination, currentSubject, ...) + end +end + +function private.GetFirstInboxItemLink(index) + if not TSMAccountingMailTooltip then + CreateFrame("GameTooltip", "TSMAccountingMailTooltip", UIParent, "GameTooltipTemplate") + end + TSMAccountingMailTooltip:SetOwner(UIParent, "ANCHOR_NONE") + TSMAccountingMailTooltip:ClearLines() + local _, speciesId, level, breedQuality, maxHealth, power, speed = TSMAccountingMailTooltip:SetInboxItem(index) + local link = nil + if (speciesId or 0) > 0 then + link = ItemInfo.GetLink(strjoin(":", "p", speciesId, level, breedQuality, maxHealth, power, speed)) + else + link = GetInboxItemLink(index, 1) + end + TSMAccountingMailTooltip:Hide() + return link +end diff --git a/Core/Service/Accounting/Merchant.lua b/Core/Service/Accounting/Merchant.lua new file mode 100644 index 0000000..71eff3a --- /dev/null +++ b/Core/Service/Accounting/Merchant.lua @@ -0,0 +1,133 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Merchant = TSM.Accounting:NewPackage("Merchant") +local Event = TSM.Include("Util.Event") +local Math = TSM.Include("Util.Math") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + repairMoney = 0, + couldRepair = nil, + repairCost = 0, + pendingSales = { + itemString = {}, + quantity = {}, + copper = {}, + insertTime = {}, + }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Merchant.OnInitialize() + Event.Register("MERCHANT_SHOW", private.SetupRepairCost) + Event.Register("BAG_UPDATE_DELAYED", private.OnMerchantUpdate) + Event.Register("UPDATE_INVENTORY_DURABILITY", private.AddRepairCosts) + Event.Register("MERCHANT_CLOSED", private.OnMerchantClosed) + hooksecurefunc("UseContainerItem", private.CheckMerchantSale) + hooksecurefunc("BuyMerchantItem", private.OnMerchantBuy) + hooksecurefunc("BuybackItem", private.OnMerchantBuyback) +end + + + +-- ============================================================================ +-- Repair Cost Tracking +-- ============================================================================ + +function private.SetupRepairCost() + private.repairMoney = GetMoney() + private.couldRepair = CanMerchantRepair() + -- if merchant can repair set up variables so we can track repairs + if private.couldRepair then + private.repairCost = GetRepairAllCost() + end +end + +function private.OnMerchantUpdate() + -- Could have bought something before or after repair + private.repairMoney = GetMoney() + -- log any pending sales + for i, insertTime in ipairs(private.pendingSales.insertTime) do + if GetTime() - insertTime < 5 then + TSM.Accounting.Transactions.InsertVendorSale(private.pendingSales.itemString[i], private.pendingSales.quantity[i], private.pendingSales.copper[i]) + end + end + wipe(private.pendingSales.itemString) + wipe(private.pendingSales.quantity) + wipe(private.pendingSales.copper) + wipe(private.pendingSales.insertTime) +end + +function private.AddRepairCosts() + if private.couldRepair and private.repairCost > 0 then + local cash = GetMoney() + if private.repairMoney > cash then + -- this is probably a repair bill + local cost = private.repairMoney - cash + TSM.Accounting.Money.InsertRepairBillExpense(cost) + -- reset money as this might have been a single item repair + private.repairMoney = cash + -- reset the repair cost for the next repair + private.repairCost = GetRepairAllCost() + end + end +end + +function private.OnMerchantClosed() + private.couldRepair = nil + private.repairCost = 0 +end + + +-- ============================================================================ +-- Merchant Purchases / Sales Tracking +-- ============================================================================ + +function private.CheckMerchantSale(bag, slot, onSelf) + -- check if we are trying to sell something to a vendor + if (not MerchantFrame:IsShown() and not TSM.UI.VendoringUI.IsVisible()) or onSelf then + return + end + + local itemString = ItemString.Get(GetContainerItemLink(bag, slot)) + local _, quantity = GetContainerItemInfo(bag, slot) + local copper = ItemInfo.GetVendorSell(itemString) + if not itemString or not quantity or not copper then + return + end + tinsert(private.pendingSales.itemString, itemString) + tinsert(private.pendingSales.quantity, quantity) + tinsert(private.pendingSales.copper, copper) + tinsert(private.pendingSales.insertTime, GetTime()) +end + +function private.OnMerchantBuy(index, quantity) + local _, _, price, batchQuantity = GetMerchantItemInfo(index) + local itemString = ItemString.Get(GetMerchantItemLink(index)) + if not itemString or not price or price <= 0 then + return + end + quantity = quantity or batchQuantity + local copper = Math.Round(price / batchQuantity) + TSM.Accounting.Transactions.InsertVendorBuy(itemString, quantity, copper) +end + +function private.OnMerchantBuyback(index) + local _, _, price, quantity = GetBuybackItemInfo(index) + local itemString = ItemString.Get(GetBuybackItemLink(index)) + if not itemString or not price or price <= 0 then + return + end + local copper = Math.Round(price / quantity) + TSM.Accounting.Transactions.InsertVendorBuy(itemString, quantity, copper) +end diff --git a/Core/Service/Accounting/Money.lua b/Core/Service/Accounting/Money.lua new file mode 100644 index 0000000..86df2f7 --- /dev/null +++ b/Core/Service/Accounting/Money.lua @@ -0,0 +1,171 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Money = TSM.Accounting:NewPackage("Money") +local Database = TSM.Include("Util.Database") +local CSV = TSM.Include("Util.CSV") +local Log = TSM.Include("Util.Log") +local private = { + db = nil, + dataChanged = false, +} +local CSV_KEYS = { "type", "amount", "otherPlayer", "player", "time" } +local COMBINE_TIME_THRESHOLD = 300 -- group expenses within 5 minutes together +local SECONDS_PER_DAY = 24 * 60 * 60 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Money.OnInitialize() + private.db = Database.NewSchema("ACCOUNTING_MONEY") + :AddStringField("recordType") + :AddStringField("type") + :AddNumberField("amount") + :AddStringField("otherPlayer") + :AddStringField("player") + :AddNumberField("time") + :AddIndex("recordType") + :Commit() + private.db:BulkInsertStart() + private.LoadData("expense", TSM.db.realm.internalData.csvExpense) + private.LoadData("income", TSM.db.realm.internalData.csvIncome) + private.db:BulkInsertEnd() +end + +function Money.OnDisable() + if not private.dataChanged then + -- nothing changed, so just keep the previous saved values + return + end + TSM.db.realm.internalData.csvExpense = private.SaveData("expense") + TSM.db.realm.internalData.csvIncome = private.SaveData("income") +end + +function Money.InsertMoneyTransferExpense(amount, destination) + private.InsertRecord("expense", "Money Transfer", amount, destination, time()) +end + +function Money.InsertPostageExpense(amount, destination) + private.InsertRecord("expense", "Postage", amount, destination, time()) +end + +function Money.InsertRepairBillExpense(amount) + private.InsertRecord("expense", "Repair Bill", amount, "Merchant", time()) +end + +function Money.InsertMoneyTransferIncome(amount, source, timestamp) + private.InsertRecord("income", "Money Transfer", amount, source, timestamp) +end + +function Money.InsertGarrisonIncome(amount) + private.InsertRecord("income", "Garrison", amount, "Mission", time()) +end + +function Money.CreateQuery() + return private.db:NewQuery() +end + +function Money.CharacterIterator(recordType) + return private.db:NewQuery() + :Equal("recordType", recordType) + :Distinct("player") + :Select("player") + :IteratorAndRelease() +end + +function Money.RemoveOldData(days) + private.dataChanged = true + local query = private.db:NewQuery() + :LessThan("time", time() - days * SECONDS_PER_DAY) + local numRecords = 0 + private.db:SetQueryUpdatesPaused(true) + for _, row in query:Iterator() do + private.db:DeleteRow(row) + numRecords = numRecords + 1 + end + query:Release() + private.db:SetQueryUpdatesPaused(false) + return numRecords +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.LoadData(recordType, csvRecords) + local decodeContext = CSV.DecodeStart(csvRecords, CSV_KEYS) + if not decodeContext then + Log.Err("Failed to decode %s records", recordType) + private.dataChanged = true + return + end + + for type, amount, otherPlayer, player, timestamp in CSV.DecodeIterator(decodeContext) do + amount = tonumber(amount) + timestamp = tonumber(timestamp) + if amount and timestamp then + local newTimestamp = floor(timestamp) + if newTimestamp ~= timestamp then + -- make sure all timestamps are stored as integers + timestamp = newTimestamp + private.dataChanged = true + end + private.db:BulkInsertNewRowFast6(recordType, type, amount, otherPlayer, player, timestamp) + else + private.dataChanged = true + end + end + + if not CSV.DecodeEnd(decodeContext) then + Log.Err("Failed to decode %s records", recordType) + private.dataChanged = true + end +end + +function private.SaveData(recordType) + local query = private.db:NewQuery() + :Equal("recordType", recordType) + local encodeContext = CSV.EncodeStart(CSV_KEYS) + for _, row in query:Iterator() do + CSV.EncodeAddRowData(encodeContext, row) + end + query:Release() + return CSV.EncodeEnd(encodeContext) +end + +function private.InsertRecord(recordType, type, amount, otherPlayer, timestamp) + private.dataChanged = true + assert(type and amount and amount > 0 and otherPlayer and timestamp) + timestamp = floor(timestamp) + local matchingRow = private.db:NewQuery() + :Equal("recordType", recordType) + :Equal("type", type) + :Equal("otherPlayer", otherPlayer) + :Equal("player", UnitName("player")) + :GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD) + :LessThan("time", timestamp + COMBINE_TIME_THRESHOLD) + :GetFirstResultAndRelease() + if matchingRow then + matchingRow:SetField("amount", matchingRow:GetField("amount") + amount) + matchingRow:Update() + matchingRow:Release() + else + private.db:NewRow() + :SetField("recordType", recordType) + :SetField("type", type) + :SetField("amount", amount) + :SetField("otherPlayer", otherPlayer) + :SetField("player", UnitName("player")) + :SetField("time", timestamp) + :Create() + end +end diff --git a/Core/Service/Accounting/Sync.lua b/Core/Service/Accounting/Sync.lua new file mode 100644 index 0000000..59fc1c7 --- /dev/null +++ b/Core/Service/Accounting/Sync.lua @@ -0,0 +1,287 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local AccountingSync = TSM.Accounting:NewPackage("Sync") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Log = TSM.Include("Util.Log") +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local Sync = TSM.Include("Service.Sync") +local private = { + accountLookup = {}, + accountStatus = {}, + pendingChunks = {}, + dataTemp = {}, +} +local CHANGE_NOTIFICATION_DELAY = 5 +local RETRY_DELAY = 5 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function AccountingSync.OnInitialize() + Sync.RegisterConnectionChangedCallback(private.ConnectionChangedHandler) + Sync.RegisterRPC("ACCOUNTING_GET_PLAYER_HASH", private.RPCGetPlayerHash) + Sync.RegisterRPC("ACCOUNTING_GET_PLAYER_CHUNKS", private.RPCGetPlayerChunks) + Sync.RegisterRPC("ACCOUNTING_GET_PLAYER_DATA", private.RPCGetData) + Sync.RegisterRPC("ACCOUNTING_CHANGE_NOTIFICATION", private.RPCChangeNotification) +end + +function AccountingSync.GetStatus(account) + local status = private.accountStatus[account] + if not status then + return Theme.GetFeedbackColor("RED"):ColorText(L["Not Connected"]) + elseif status == "GET_PLAYER_HASH" or status == "GET_PLAYER_CHUNKS" or status == "GET_PLAYER_DATA" or status == "RETRY" then + return Theme.GetFeedbackColor("YELLOW"):ColorText(L["Updating"]) + elseif status == "SYNCED" then + return Theme.GetFeedbackColor("GREEN"):ColorText(L["Up to date"]) + else + error("Invalid status: "..tostring(status)) + end +end + +function AccountingSync.OnTransactionsChanged() + Delay.AfterTime("ACCOUNTING_SYNC_CHANGE", CHANGE_NOTIFICATION_DELAY, private.NotifyChange) +end + + + +-- ============================================================================ +-- RPC Functions and Result Handlers +-- ============================================================================ + +function private.GetPlayerHash(player) + local account = private.accountLookup[player] + private.accountStatus[account] = "GET_PLAYER_HASH" + TSM.Accounting.Transactions.PrepareSyncHashes(player) + Sync.CallRPC("ACCOUNTING_GET_PLAYER_HASH", player, private.RPCGetPlayerHashResultHandler) +end + +function private.RPCGetPlayerHash() + local player = UnitName("player") + return player, TSM.Accounting.Transactions.GetSyncHash(player) +end + +function private.RPCGetPlayerHashResultHandler(player, hash) + local account = player and private.accountLookup[player] + if not account then + -- request timed out, so try again + Log.Warn("Getting player hash timed out") + private.QueueRetriesByStatus("GET_PLAYER_HASH") + return + elseif not hash then + -- the hash isn't ready yet, so try again + Log.Warn("Sync player hash not ready yet") + private.QueueRetryByPlayer(player) + return + end + if private.accountStatus[account] == "RETRY" then + -- There is a race condition where if we tried to issue GET_PLAYER_HASH for two players and one times out, + -- we would also queue a retry for the other one, so handle that here. + private.accountStatus[account] = "GET_PLAYER_HASH" + end + assert(private.accountStatus[account] == "GET_PLAYER_HASH") + + local currentHash = TSM.Accounting.Transactions.GetSyncHash(player) + if not currentHash then + -- don't have our hash yet, so try again + Log.Warn("Current player hash not ready yet") + private.QueueRetryByPlayer(player) + return + end + + if hash ~= currentHash then + Log.Info("Need updated transactions data from %s (%s, %s)", player, hash, currentHash) + private.GetPlayerChunks(player) + else + Log.Info("Transactions data for %s already up to date (%s, %s)", player, hash, currentHash) + private.accountStatus[account] = "SYNCED" + end +end + +function private.GetPlayerChunks(player) + local account = private.accountLookup[player] + private.accountStatus[account] = "GET_PLAYER_CHUNKS" + Sync.CallRPC("ACCOUNTING_GET_PLAYER_CHUNKS", player, private.RPCGetPlayerChunksResultHandler) +end + +function private.RPCGetPlayerChunks() + local player = UnitName("player") + return player, TSM.Accounting.Transactions.GetSyncHashByDay(player) +end + +function private.RPCGetPlayerChunksResultHandler(player, chunks) + local account = player and private.accountLookup[player] + if not account then + -- request timed out, so try again from the start + Log.Warn("Getting chunks timed out") + private.QueueRetriesByStatus("GET_PLAYER_CHUNKS") + return + elseif not chunks then + -- the hashes have been invalidated, so try again from the start + Log.Warn("Sync player chunks not ready yet") + private.QueueRetryByPlayer(player) + return + end + assert(private.accountStatus[account] == "GET_PLAYER_CHUNKS") + + local currentChunks = TSM.Accounting.Transactions.GetSyncHashByDay(player) + if not currentChunks then + -- our hashes have been invalidated, so try again from the start + Log.Warn("Local hashes are invalid") + private.QueueRetryByPlayer(player) + return + end + for day in pairs(currentChunks) do + if not chunks[day] then + -- remove day which no longer exists + TSM.Accounting.Transactions.RemovePlayerDay(player, day) + end + end + + -- queue up all the pending chunks + private.pendingChunks[player] = private.pendingChunks[player] or TempTable.Acquire() + wipe(private.pendingChunks[player]) + for day, hash in pairs(chunks) do + if currentChunks[day] ~= hash then + tinsert(private.pendingChunks[player], day) + end + end + + local requestDay = private.GetNextPendingChunk(player) + if requestDay then + Log.Info("Requesting transactions data (%s, %s, %s, %s)", player, requestDay, tostring(currentChunks[requestDay]), chunks[requestDay]) + private.GetPlayerData(player, requestDay) + else + Log.Info("All chunks are up to date (%s)", player) + private.accountStatus[account] = "SYNCED" + end +end + +function private.GetPlayerData(player, requestDay) + local account = private.accountLookup[player] + private.accountStatus[account] = "GET_PLAYER_DATA" + Sync.CallRPC("ACCOUNTING_GET_PLAYER_DATA", player, private.RPCGetDataResultHandler, requestDay) +end + +function private.RPCGetData(day) + local player = UnitName("player") + wipe(private.dataTemp) + TSM.Accounting.Transactions.GetSyncData(player, day, private.dataTemp) + return player, day, private.dataTemp +end + +function private.RPCGetDataResultHandler(player, day, data) + local account = player and private.accountLookup[player] + if not account then + -- request timed out, so try again from the start + Log.Warn("Getting transactions data timed out") + private.QueueRetriesByStatus("GET_PLAYER_DATA") + return + elseif #data % 9 ~= 0 then + -- invalid data - just silently give up + Log.Warn("Got invalid transactions data") + return + end + assert(private.accountStatus[account] == "GET_PLAYER_DATA") + + Log.Info("Received transactions data (%s, %s, %s)", player, day, #data) + TSM.Accounting.Transactions.HandleSyncedData(player, day, data) + + local requestDay = private.GetNextPendingChunk(player) + if requestDay then + -- request the next chunk + Log.Info("Requesting transactions data (%s, %s)", player, requestDay) + private.GetPlayerData(player, requestDay) + else + -- request chunks again to check for other chunks we need to sync + private.GetPlayerChunks(player) + end +end + +function private.RPCChangeNotification(player) + if private.accountStatus[private.accountLookup[player]] == "SYNCED" then + -- request the player hash + Log.Info("Got change notification - requesting player hash") + private.GetPlayerHash(player) + else + Log.Info("Got change notification - dropping (%s)", tostring(private.accountStatus[private.accountLookup[player]])) + end +end + +function private.RPCChangeNotificationResultHandler() + -- nop +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ConnectionChangedHandler(account, player, connected) + if connected then + private.accountLookup[player] = account + private.GetPlayerHash(player) + else + private.accountLookup[player] = nil + private.accountStatus[account] = nil + if private.pendingChunks[player] then + TempTable.Release(private.pendingChunks[player]) + private.pendingChunks[player] = nil + end + end +end + +function private.GetNextPendingChunk(player) + if not private.pendingChunks[player] then + return nil + end + local result = tremove(private.pendingChunks[player]) + if not result then + TempTable.Release(private.pendingChunks[player]) + private.pendingChunks[player] = nil + end + return result +end + +function private.QueueRetriesByStatus(statusFilter) + for player, account in pairs(private.accountLookup) do + if private.accountStatus[account] == statusFilter then + private.QueueRetryByPlayer(player) + end + end +end + +function private.QueueRetryByPlayer(player) + local account = private.accountLookup[player] + Log.Info("Retrying (%s, %s, %s)", player, account, private.accountStatus[account]) + private.accountStatus[account] = "RETRY" + Delay.AfterTime(RETRY_DELAY, private.RetryGetPlayerHashRPC) +end + +function private.RetryGetPlayerHashRPC() + for player, account in pairs(private.accountLookup) do + if private.accountStatus[account] == "RETRY" then + private.GetPlayerHash(player) + end + end +end + +function private.NotifyChange() + for player, account in pairs(private.accountLookup) do + if private.accountStatus[account] == "SYNCED" then + -- notify the other account that our data has changed and request the other account's latest hash ourselves + private.GetPlayerHash(player) + Sync.CallRPC("ACCOUNTING_CHANGE_NOTIFICATION", player, private.RPCChangeNotificationResultHandler, UnitName("player")) + end + end +end diff --git a/Core/Service/Accounting/Trade.lua b/Core/Service/Accounting/Trade.lua new file mode 100644 index 0000000..6eb80e3 --- /dev/null +++ b/Core/Service/Accounting/Trade.lua @@ -0,0 +1,165 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Trade = TSM.Accounting:NewPackage("Trade") +local L = TSM.Include("Locale").GetTable() +local Event = TSM.Include("Util.Event") +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local ItemString = TSM.Include("Util.ItemString") +local Wow = TSM.Include("Util.Wow") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + tradeInfo = nil, + popupContext = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Trade.OnInitialize() + Event.Register("TRADE_ACCEPT_UPDATE", private.OnAcceptUpdate) + Event.Register("UI_INFO_MESSAGE", private.OnChatMsg) +end + + + +-- ============================================================================ +-- Trade Functions +-- ============================================================================ + +function private.OnAcceptUpdate(_, player, target) + if (player == 1 or target == 1) and not (GetTradePlayerItemLink(7) or GetTradeTargetItemLink(7)) then + -- update tradeInfo + private.tradeInfo = { player = {}, target = {} } + private.tradeInfo.player.money = tonumber(GetPlayerTradeMoney()) + private.tradeInfo.target.money = tonumber(GetTargetTradeMoney()) + private.tradeInfo.target.name = UnitName("NPC") + + for i = 1, 6 do + local targetLink = GetTradeTargetItemLink(i) + local _, _, targetCount = GetTradeTargetItemInfo(i) + if targetLink then + tinsert(private.tradeInfo.target, { itemString = ItemString.Get(targetLink), count = targetCount }) + end + + local playerLink = GetTradePlayerItemLink(i) + local _, _, playerCount = GetTradePlayerItemInfo(i) + if playerLink then + tinsert(private.tradeInfo.player, { itemString = ItemString.Get(playerLink), count = playerCount }) + end + end + else + private.tradeInfo = nil + end +end + +function private.OnChatMsg(_, msg) + if not TSM.db.global.accountingOptions.trackTrades then + return + end + if msg == LE_GAME_ERR_TRADE_COMPLETE and private.tradeInfo then + -- trade went through + local tradeType, itemString, count, money = nil, nil, nil, nil + if private.tradeInfo.player.money > 0 and #private.tradeInfo.player == 0 and private.tradeInfo.target.money == 0 and #private.tradeInfo.target > 0 then + -- player bought items + for i = 1, #private.tradeInfo.target do + local data = private.tradeInfo.target[i] + if not itemString then + itemString = data.itemString + count = data.count + elseif itemString == data.itemString then + count = count + data.count + else + return + end + end + tradeType = "buy" + money = private.tradeInfo.player.money + elseif private.tradeInfo.player.money == 0 and #private.tradeInfo.player > 0 and private.tradeInfo.target.money > 0 and #private.tradeInfo.target == 0 then + -- player sold items + for i = 1, #private.tradeInfo.player do + local data = private.tradeInfo.player[i] + if not itemString then + itemString = data.itemString + count = data.count + elseif itemString == data.itemString then + count = count + data.count + else + return + end + end + tradeType = "sale" + money = private.tradeInfo.target.money + end + + if not tradeType or not itemString or not count then + return + end + local insertInfo = TempTable.Acquire() + insertInfo.type = tradeType + insertInfo.itemString = itemString + insertInfo.price = money / count + insertInfo.count = count + insertInfo.name = private.tradeInfo.target.name + local gotText, gaveText = nil, nil + if tradeType == "buy" then + gotText = format("%sx%d", ItemInfo.GetLink(itemString), count) + gaveText = Money.ToString(money) + elseif tradeType == "sale" then + gaveText = format("%sx%d", ItemInfo.GetLink(itemString), count) + gotText = Money.ToString(money) + else + error("Invalid tradeType: "..tostring(tradeType)) + end + + if TSM.db.global.accountingOptions.autoTrackTrades then + private.DoInsert(insertInfo) + TempTable.Release(insertInfo) + else + if private.popupContext then + -- popup already visible so ignore this + TempTable.Release(insertInfo) + return + end + private.popupContext = insertInfo + if not StaticPopupDialogs["TSMAccountingOnTrade"] then + StaticPopupDialogs["TSMAccountingOnTrade"] = { + button1 = YES, + button2 = NO, + timeout = 0, + whileDead = true, + hideOnEscape = true, + OnAccept = function() + private.DoInsert(private.popupContext) + TempTable.Release(private.popupContext) + private.popupContext = nil + end, + OnCancel = function() + TempTable.Release(private.popupContext) + private.popupContext = nil + end, + } + end + StaticPopupDialogs["TSMAccountingOnTrade"].text = format(L["TSM detected that you just traded %s to %s in return for %s. Would you like Accounting to store a record of this trade?"], gaveText, insertInfo.name, gotText) + Wow.ShowStaticPopupDialog("TSMAccountingOnTrade") + end + end +end + +function private.DoInsert(info) + if info.type == "sale" then + TSM.Accounting.Transactions.InsertTradeSale(info.itemString, info.count, info.price, info.name) + elseif info.type == "buy" then + TSM.Accounting.Transactions.InsertTradeBuy(info.itemString, info.count, info.price, info.name) + else + error("Unknown type: "..tostring(info.type)) + end +end diff --git a/Core/Service/Accounting/Transactions.lua b/Core/Service/Accounting/Transactions.lua new file mode 100644 index 0000000..18c1d24 --- /dev/null +++ b/Core/Service/Accounting/Transactions.lua @@ -0,0 +1,825 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Transactions = TSM.Accounting:NewPackage("Transactions") +local L = TSM.Include("Locale").GetTable() +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local CSV = TSM.Include("Util.CSV") +local Math = TSM.Include("Util.Math") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local Table = TSM.Include("Util.Table") +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local CustomPrice = TSM.Include("Service.CustomPrice") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Inventory = TSM.Include("Service.Inventory") +local Settings = TSM.Include("Service.Settings") +local Threading = TSM.Include("Service.Threading") +local private = { + db = nil, + dbSummary = nil, + dataChanged = false, + baseStatsQuery = nil, + statsQuery = nil, + baseStatsMinTimeQuery = nil, + statsMinTimeQuery = nil, + syncHashesThread = nil, + isSyncHashesThreadRunning = false, + syncHashDayCache = {}, + syncHashDayCacheIsInvalid = {}, + pendingSyncHashCharacters = {}, +} +local OLD_CSV_KEYS = { + sale = { "itemString", "stackSize", "quantity", "price", "buyer", "player", "time", "source" }, + buy = { "itemString", "stackSize", "quantity", "price", "seller", "player", "time", "source" }, +} +local CSV_KEYS = { "itemString", "stackSize", "quantity", "price", "otherPlayer", "player", "time", "source" } +local COMBINE_TIME_THRESHOLD = 300 -- group transactions within 5 minutes together +local MAX_CSV_RECORDS = 55000 -- the max number of records we can store without WoW corrupting the SV file +local TRIMMED_CSV_RECORDS = 50000 -- how many records to trim to if we're over the limit (so we don't trim every time) +local SECONDS_PER_DAY = 24 * 60 * 60 +local SYNC_FIELDS = { "type", "itemString", "stackSize", "quantity", "price", "otherPlayer", "time", "source", "saveTime" } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Transactions.OnInitialize() + if TSM.db.realm.internalData.accountingTrimmed.sales then + Log.PrintfUser(L["%sIMPORTANT:|r When Accounting data was last saved for this realm, it was too big for WoW to handle, so old data was automatically trimmed in order to avoid corruption of the saved variables. The last %s of sale data has been preserved."], Theme.GetFeedbackColor("RED"):GetTextColorPrefix(), SecondsToTime(time() - TSM.db.realm.internalData.accountingTrimmed.sales)) + TSM.db.realm.internalData.accountingTrimmed.sales = nil + end + if TSM.db.realm.internalData.accountingTrimmed.buys then + Log.PrintfUser(L["%sIMPORTANT:|r When Accounting data was last saved for this realm, it was too big for WoW to handle, so old data was automatically trimmed in order to avoid corruption of the saved variables. The last %s of purchase data has been preserved."], Theme.GetFeedbackColor("RED"):GetTextColorPrefix(), SecondsToTime(time() - TSM.db.realm.internalData.accountingTrimmed.buys)) + TSM.db.realm.internalData.accountingTrimmed.buys = nil + end + + private.db = Database.NewSchema("TRANSACTIONS_LOG") + :AddStringField("baseItemString") + :AddStringField("type") + :AddStringField("itemString") + :AddNumberField("stackSize") + :AddNumberField("quantity") + :AddNumberField("price") + :AddStringField("otherPlayer") + :AddStringField("player") + :AddNumberField("time") + :AddStringField("source") + :AddNumberField("saveTime") + :AddIndex("baseItemString") + :AddIndex("time") + :Commit() + private.db:BulkInsertStart() + private.LoadData("sale", TSM.db.realm.internalData.csvSales, TSM.db.realm.internalData.saveTimeSales) + private.LoadData("buy", TSM.db.realm.internalData.csvBuys, TSM.db.realm.internalData.saveTimeBuys) + private.db:BulkInsertEnd() + private.dbSummary = Database.NewSchema("TRANSACTIONS_SUMMARY") + :AddUniqueStringField("itemString") + :AddNumberField("sold") + :AddNumberField("avgSellPrice") + :AddNumberField("bought") + :AddNumberField("avgBuyPrice") + :AddNumberField("avgProfit") + :AddNumberField("totalProfit") + :AddNumberField("profitPct") + :Commit() + private.baseStatsQuery = private.db:NewQuery() + :Select("quantity", "price") + :Equal("type", Database.BoundQueryParam()) + :Equal("baseItemString", Database.BoundQueryParam()) + :NotEqual("source", "Vendor") + private.statsQuery = private.db:NewQuery() + :Select("quantity", "price") + :Equal("type", Database.BoundQueryParam()) + :Equal("baseItemString", Database.BoundQueryParam()) + :Equal("itemString", Database.BoundQueryParam()) + :NotEqual("source", "Vendor") + private.baseStatsMinTimeQuery = private.db:NewQuery() + :Select("quantity", "price") + :Equal("type", Database.BoundQueryParam()) + :Equal("baseItemString", Database.BoundQueryParam()) + :GreaterThanOrEqual("time", Database.BoundQueryParam()) + :NotEqual("source", "Vendor") + private.statsMinTimeQuery = private.db:NewQuery() + :Select("quantity", "price") + :Equal("type", Database.BoundQueryParam()) + :Equal("baseItemString", Database.BoundQueryParam()) + :Equal("itemString", Database.BoundQueryParam()) + :GreaterThanOrEqual("time", Database.BoundQueryParam()) + :NotEqual("source", "Vendor") + private.syncHashesThread = Threading.New("TRANSACTIONS_SYNC_HASHES", private.SyncHashesThread) + + Inventory.RegisterCallback(private.InventoryCallback) +end + +function Transactions.OnDisable() + if not private.dataChanged then + -- nothing changed, so just keep the previous saved values + return + end + TSM.db.realm.internalData.csvSales, TSM.db.realm.internalData.saveTimeSales, TSM.db.realm.internalData.accountingTrimmed.sales = private.SaveData("sale") + TSM.db.realm.internalData.csvBuys, TSM.db.realm.internalData.saveTimeBuys, TSM.db.realm.internalData.accountingTrimmed.buys = private.SaveData("buy") +end + +function Transactions.InsertAuctionSale(itemString, stackSize, price, buyer, timestamp) + private.InsertRecord("sale", itemString, "Auction", stackSize, price, buyer, timestamp) +end + +function Transactions.InsertAuctionBuy(itemString, stackSize, price, seller, timestamp) + private.InsertRecord("buy", itemString, "Auction", stackSize, price, seller, timestamp) +end + +function Transactions.InsertCODSale(itemString, stackSize, price, buyer, timestamp) + private.InsertRecord("sale", itemString, "COD", stackSize, price, buyer, timestamp) +end + +function Transactions.InsertCODBuy(itemString, stackSize, price, seller, timestamp) + private.InsertRecord("buy", itemString, "COD", stackSize, price, seller, timestamp) +end + +function Transactions.InsertTradeSale(itemString, stackSize, price, buyer) + private.InsertRecord("sale", itemString, "Trade", stackSize, price, buyer, time()) +end + +function Transactions.InsertTradeBuy(itemString, stackSize, price, seller) + private.InsertRecord("buy", itemString, "Trade", stackSize, price, seller, time()) +end + +function Transactions.InsertVendorSale(itemString, stackSize, price) + private.InsertRecord("sale", itemString, "Vendor", stackSize, price, "Merchant", time()) +end + +function Transactions.InsertVendorBuy(itemString, stackSize, price) + private.InsertRecord("buy", itemString, "Vendor", stackSize, price, "Merchant", time()) +end + +function Transactions.CreateQuery() + return private.db:NewQuery() +end + +function Transactions.RemoveOldData(days) + private.dataChanged = true + private.db:SetQueryUpdatesPaused(true) + local numRecords = private.db:NewQuery() + :LessThan("time", time() - days * SECONDS_PER_DAY) + :DeleteAndRelease() + private.db:SetQueryUpdatesPaused(false) + private.OnItemRecordsChanged("sale") + private.OnItemRecordsChanged("buy") + TSM.Accounting.Sync.OnTransactionsChanged() + return numRecords +end + +function Transactions.GetSaleStats(itemString, minTime) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = nil + if minTime then + if isBaseItemString then + query = private.baseStatsMinTimeQuery:BindParams("sale", baseItemString, minTime) + else + query = private.statsMinTimeQuery:BindParams("sale", baseItemString, itemString, minTime) + end + else + if isBaseItemString then + query = private.baseStatsQuery:BindParams("sale", baseItemString) + else + query = private.statsQuery:BindParams("sale", baseItemString, itemString) + end + end + query:ResetOrderBy() + local totalPrice = query:SumOfProduct("quantity", "price") + local totalNum = query:Sum("quantity") + if not totalNum or totalNum == 0 then + return + end + return totalPrice, totalNum +end + +function Transactions.GetBuyStats(itemString, isSmart) + local baseItemString = ItemString.GetBaseFast(itemString) + local isBaseItemString = itemString == baseItemString + + local query = nil + if isBaseItemString then + query = private.baseStatsQuery:BindParams("buy", baseItemString) + else + query = private.statsQuery:BindParams("buy", baseItemString, itemString) + end + query:ResetOrderBy() + + if isSmart then + local totalQuantity = CustomPrice.GetItemPrice(itemString, "NumInventory") or 0 + if totalQuantity == 0 then + return nil, nil + end + query:OrderBy("time", false) + + local remainingSmartQuantity = totalQuantity + local priceSum, quantitySum = 0, 0 + for _, quantity, price in query:Iterator() do + if remainingSmartQuantity > 0 then + quantity = min(remainingSmartQuantity, quantity) + remainingSmartQuantity = remainingSmartQuantity - quantity + priceSum = priceSum + price * quantity + quantitySum = quantitySum + quantity + end + end + if priceSum == 0 then + return nil, nil + end + return priceSum, quantitySum + else + local quantitySum = query:Sum("quantity") + if not quantitySum then + return nil, nil + end + local priceSum = query:SumOfProduct("quantity", "price") + if priceSum == 0 then + return nil, nil + end + return priceSum, quantitySum + end +end + +function Transactions.GetMaxSalePrice(itemString) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("price") + :Equal("type", "sale") + :NotEqual("source", "Vendor") + :OrderBy("price", false) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + return query:GetFirstResultAndRelease() +end + +function Transactions.GetMaxBuyPrice(itemString) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("price") + :Equal("type", "buy") + :NotEqual("source", "Vendor") + :OrderBy("price", false) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + return query:GetFirstResultAndRelease() +end + +function Transactions.GetMinSalePrice(itemString) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("price") + :Equal("type", "sale") + :NotEqual("source", "Vendor") + :OrderBy("price", true) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + return query:GetFirstResultAndRelease() +end + +function Transactions.GetMinBuyPrice(itemString) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("price") + :Equal("type", "buy") + :NotEqual("source", "Vendor") + :OrderBy("price", true) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + return query:GetFirstResultAndRelease() +end + +function Transactions.GetAverageSalePrice(itemString) + local totalPrice, totalNum = Transactions.GetSaleStats(itemString) + if not totalPrice or totalPrice == 0 then + return + end + return Math.Round(totalPrice / totalNum), totalNum +end + +function Transactions.GetAverageBuyPrice(itemString, isSmart) + local totalPrice, totalNum = Transactions.GetBuyStats(itemString, isSmart) + return totalPrice and Math.Round(totalPrice / totalNum) or nil +end + +function Transactions.GetLastSaleTime(itemString) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("time") + :Equal("type", "sale") + :NotEqual("source", "Vendor") + :OrderBy("time", false) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + return query:GetFirstResultAndRelease() +end + +function Transactions.GetLastBuyTime(itemString) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery():Select("time") + :Equal("type", "buy") + :NotEqual("source", "Vendor") + :OrderBy("time", false) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + return query:GetFirstResultAndRelease() +end + +function Transactions.GetQuantity(itemString, timeFilter, typeFilter) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Equal("type", typeFilter) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + if timeFilter then + query:GreaterThan("time", time() - timeFilter) + end + local sum = query:Sum("quantity") or 0 + query:Release() + return sum +end + +function Transactions.GetAveragePrice(itemString, timeFilter, typeFilter) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("price", "quantity") + :Equal("type", typeFilter) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + if timeFilter then + query:GreaterThan("time", time() - timeFilter) + end + local avgPrice = 0 + local totalQuantity = 0 + for _, price, quantity in query:IteratorAndRelease() do + avgPrice = avgPrice + price * quantity + totalQuantity = totalQuantity + quantity + end + return Math.Round(avgPrice / totalQuantity) +end + +function Transactions.GetTotalPrice(itemString, timeFilter, typeFilter) + local baseItemString = ItemString.GetBase(itemString) + local isBaseItemString = itemString == baseItemString + local query = private.db:NewQuery() + :Select("price", "quantity") + :Equal("type", typeFilter) + if isBaseItemString then + query:Equal("baseItemString", itemString) + else + query:Equal("baseItemString", baseItemString) + :Equal("itemString", itemString) + end + if timeFilter then + query:GreaterThan("time", time() - timeFilter) + end + local sumPrice = query:SumOfProduct("price", "quantity") or 0 + query:Release() + return sumPrice +end + +function Transactions.CreateSummaryQuery() + return private.dbSummary:NewQuery() +end + +function Transactions.UpdateSummaryData(groupFilter, searchFilter, typeFilter, characterFilter, minTime) + local totalSold = TempTable.Acquire() + local totalSellPrice = TempTable.Acquire() + local totalBought = TempTable.Acquire() + local totalBoughtPrice = TempTable.Acquire() + + local items = private.db:NewQuery() + :Select("itemString", "price", "quantity", "type") + :LeftJoin(TSM.Groups.GetItemDBForJoin(), "itemString") + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + + if groupFilter then + items:InTable("groupPath", groupFilter) + end + if searchFilter then + items:Matches("name", String.Escape(searchFilter)) + end + if typeFilter then + items:InTable("source", typeFilter) + end + if characterFilter then + items:InTable("player", characterFilter) + end + if minTime then + items:GreaterThan("time", minTime) + end + + for _, itemString, price, quantity, recordType in items:IteratorAndRelease() do + if not totalSold[itemString] then + totalSold[itemString] = 0 + totalSellPrice[itemString] = 0 + totalBought[itemString] = 0 + totalBoughtPrice[itemString] = 0 + end + + if recordType == "sale" then + totalSold[itemString] = totalSold[itemString] + quantity + totalSellPrice[itemString] = totalSellPrice[itemString] + price * quantity + elseif recordType == "buy" then + totalBought[itemString] = totalBought[itemString] + quantity + totalBoughtPrice[itemString] = totalBoughtPrice[itemString] + price * quantity + else + error("Invalid recordType: "..tostring(recordType)) + end + end + + private.dbSummary:TruncateAndBulkInsertStart() + for itemString, sold in pairs(totalSold) do + if sold > 0 and totalBought[itemString] > 0 then + local totalAvgSellPrice = totalSellPrice[itemString] / totalSold[itemString] + local totalAvgBuyPrice = totalBoughtPrice[itemString] / totalBought[itemString] + local profit = totalAvgSellPrice - totalAvgBuyPrice + local totalProfit = profit * min(totalSold[itemString], totalBought[itemString]) + local profitPct = Math.Round(profit * 100 / totalAvgBuyPrice) + private.dbSummary:BulkInsertNewRow(itemString, sold, totalAvgSellPrice, totalBought[itemString], totalAvgBuyPrice, profit, totalProfit, profitPct) + end + end + private.dbSummary:BulkInsertEnd() + + TempTable.Release(totalSold) + TempTable.Release(totalSellPrice) + TempTable.Release(totalBought) + TempTable.Release(totalBoughtPrice) +end + +function Transactions.GetCharacters(characters) + private.db:NewQuery() + :Distinct("player") + :Select("player") + :AsTable(characters) + :Release() + return characters +end + +function Transactions.CanDeleteByUUID(uuid) + return Settings.IsCurrentAccountOwner(private.db:GetRowFieldByUUID(uuid, "player")) +end + +function Transactions.RemoveRowByUUID(uuid) + local recordType = private.db:GetRowFieldByUUID(uuid, "type") + local itemString = private.db:GetRowFieldByUUID(uuid, "itemString") + local player = private.db:GetRowFieldByUUID(uuid, "player") + private.db:DeleteRowByUUID(uuid) + if private.syncHashDayCache[player] then + private.syncHashDayCacheIsInvalid[player] = true + end + private.dataChanged = true + private.OnItemRecordsChanged(recordType, itemString) + TSM.Accounting.Sync.OnTransactionsChanged() +end + +function Transactions.PrepareSyncHashes(player) + tinsert(private.pendingSyncHashCharacters, player) + if not private.isSyncHashesThreadRunning then + private.isSyncHashesThreadRunning = true + Threading.Start(private.syncHashesThread) + end +end + +function Transactions.GetSyncHash(player) + local hashesByDay = Transactions.GetSyncHashByDay(player) + if not hashesByDay then + return + end + return Math.CalculateHash(hashesByDay) +end + +function Transactions.GetSyncHashByDay(player) + if not private.syncHashDayCache[player] or private.syncHashDayCacheIsInvalid[player] then + return + end + return private.syncHashDayCache[player] +end + +function Transactions.GetSyncData(player, day, result) + local query = private.db:NewQuery() + :Equal("player", player) + :GreaterThanOrEqual("time", day * SECONDS_PER_DAY) + :LessThan("time", (day + 1) * SECONDS_PER_DAY) + for _, row in query:Iterator() do + Table.Append(result, row:GetFields(unpack(SYNC_FIELDS))) + end + query:Release() +end + +function Transactions.RemovePlayerDay(player, day) + private.dataChanged = true + private.db:SetQueryUpdatesPaused(true) + local query = private.db:NewQuery() + :Equal("player", player) + :GreaterThanOrEqual("time", day * SECONDS_PER_DAY) + :LessThan("time", (day + 1) * SECONDS_PER_DAY) + for _, uuid in query:UUIDIterator() do + private.db:DeleteRowByUUID(uuid) + end + query:Release() + if private.syncHashDayCache[player] then + private.syncHashDayCacheIsInvalid[player] = true + end + private.db:SetQueryUpdatesPaused(false) + private.OnItemRecordsChanged("sale") + private.OnItemRecordsChanged("buy") +end + +function Transactions.HandleSyncedData(player, day, data) + assert(#data % 9 == 0) + private.dataChanged = true + private.db:SetQueryUpdatesPaused(true) + + -- remove any prior data for the day + local query = private.db:NewQuery() + :Equal("player", player) + :GreaterThanOrEqual("time", day * SECONDS_PER_DAY) + :LessThan("time", (day + 1) * SECONDS_PER_DAY) + for _, uuid in query:UUIDIterator() do + private.db:DeleteRowByUUID(uuid) + end + query:Release() + if private.syncHashDayCache[player] then + private.syncHashDayCacheIsInvalid[player] = true + end + + -- insert the new data + private.db:BulkInsertStart() + for i = 1, #data, 9 do + private.BulkInsertNewRowHelper(player, unpack(data, i, i + 8)) + end + private.db:BulkInsertEnd() + + private.db:SetQueryUpdatesPaused(false) + private.OnItemRecordsChanged("sale") + private.OnItemRecordsChanged("buy") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.LoadData(recordType, csvRecords, csvSaveTimes) + local saveTimes = String.SafeSplit(csvSaveTimes, ",") + + local decodeContext = CSV.DecodeStart(csvRecords, OLD_CSV_KEYS[recordType]) or CSV.DecodeStart(csvRecords, CSV_KEYS) + if not decodeContext then + Log.Err("Failed to decode %s records", recordType) + private.dataChanged = true + return + end + + local saveTimeIndex = 1 + for itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source in CSV.DecodeIterator(decodeContext) do + local saveTime = 0 + if saveTimes and source == "Auction" then + saveTime = tonumber(saveTimes[saveTimeIndex]) + saveTimeIndex = saveTimeIndex + 1 + end + private.BulkInsertNewRowHelper(player, recordType, itemString, stackSize, quantity, price, otherPlayer, timestamp, source, saveTime) + end + + if not CSV.DecodeEnd(decodeContext) then + Log.Err("Failed to decode %s records", recordType) + private.dataChanged = true + end + + private.OnItemRecordsChanged(recordType) +end + +function private.BulkInsertNewRowHelper(player, recordType, itemString, stackSize, quantity, price, otherPlayer, timestamp, source, saveTime) + itemString = ItemString.Get(itemString) + local baseItemString = ItemString.GetBaseFast(itemString) + stackSize = tonumber(stackSize) + quantity = tonumber(quantity) + price = tonumber(price) + timestamp = tonumber(timestamp) + if itemString and stackSize and quantity and price and otherPlayer and player and timestamp and source then + local newTimestamp = floor(timestamp) + if newTimestamp ~= timestamp then + -- make sure all timestamps are stored as integers + private.dataChanged = true + timestamp = newTimestamp + end + local newPrice = floor(price) + if newPrice ~= price then + -- make sure all prices are stored as integers + private.dataChanged = true + price = newPrice + end + private.db:BulkInsertNewRowFast11(baseItemString, recordType, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source, saveTime) + else + private.dataChanged = true + end +end + +function private.SaveData(recordType) + local numRecords = private.db:NewQuery() + :Equal("type", recordType) + :CountAndRelease() + if numRecords > MAX_CSV_RECORDS then + local query = private.db:NewQuery() + :Equal("type", recordType) + :OrderBy("time", false) + local count = 0 + local saveTimes = {} + local shouldTrim = query:Count() > MAX_CSV_RECORDS + local lastTime = nil + local encodeContext = CSV.EncodeStart(CSV_KEYS) + for _, row in query:Iterator() do + if not shouldTrim or count <= TRIMMED_CSV_RECORDS then + -- add the save time + local saveTime = row:GetField("saveTime") + saveTime = saveTime ~= 0 and saveTime or time() + if row:GetField("source") == "Auction" then + tinsert(saveTimes, saveTime) + end + -- update the time we're trimming to + if shouldTrim then + lastTime = row:GetField("time") + end + -- add to our list of CSV lines + CSV.EncodeAddRowData(encodeContext, row) + end + count = count + 1 + end + query:Release() + return CSV.EncodeEnd(encodeContext), table.concat(saveTimes, ","), lastTime + else + local saveTimes = {} + local encodeContext = CSV.EncodeStart(CSV_KEYS) + for _, _, rowRecordType, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source, saveTime in private.db:RawIterator() do + if rowRecordType == recordType then + -- add the save time + if source == "Auction" then + tinsert(saveTimes, saveTime ~= 0 and saveTime or time()) + end + -- add to our list of CSV lines + CSV.EncodeAddRowDataRaw(encodeContext, itemString, stackSize, quantity, price, otherPlayer, player, timestamp, source) + end + end + return CSV.EncodeEnd(encodeContext), table.concat(saveTimes, ","), nil + end +end + +function private.InsertRecord(recordType, itemString, source, stackSize, price, otherPlayer, timestamp) + private.dataChanged = true + assert(itemString and source and stackSize and price and otherPlayer and timestamp) + timestamp = floor(timestamp) + local baseItemString = ItemString.GetBase(itemString) + local player = UnitName("player") + local matchingRow = private.db:NewQuery() + :Equal("type", recordType) + :Equal("itemString", itemString) + :Equal("baseItemString", baseItemString) + :Equal("stackSize", stackSize) + :Equal("source", source) + :Equal("price", price) + :Equal("player", player) + :Equal("otherPlayer", otherPlayer) + :GreaterThan("time", timestamp - COMBINE_TIME_THRESHOLD) + :LessThan("time", timestamp + COMBINE_TIME_THRESHOLD) + :Equal("saveTime", 0) + :GetFirstResultAndRelease() + if matchingRow then + matchingRow:SetField("quantity", matchingRow:GetField("quantity") + stackSize) + matchingRow:Update() + matchingRow:Release() + else + private.db:NewRow() + :SetField("type", recordType) + :SetField("itemString", itemString) + :SetField("baseItemString", baseItemString) + :SetField("stackSize", stackSize) + :SetField("quantity", stackSize) + :SetField("price", price) + :SetField("otherPlayer", otherPlayer) + :SetField("player", player) + :SetField("time", timestamp) + :SetField("source", source) + :SetField("saveTime", 0) + :Create() + end + if private.syncHashDayCache[player] then + private.syncHashDayCacheIsInvalid[player] = true + end + + private.OnItemRecordsChanged(recordType, itemString) + TSM.Accounting.Sync.OnTransactionsChanged() +end + +function private.OnItemRecordsChanged(recordType, itemString) + if recordType == "sale" then + CustomPrice.OnSourceChange("AvgSell", itemString) + CustomPrice.OnSourceChange("MaxSell", itemString) + CustomPrice.OnSourceChange("MinSell", itemString) + CustomPrice.OnSourceChange("NumExpires", itemString) + elseif recordType == "buy" then + CustomPrice.OnSourceChange("AvgBuy", itemString) + CustomPrice.OnSourceChange("MaxBuy", itemString) + CustomPrice.OnSourceChange("MinBuy", itemString) + else + error("Invalid recordType: "..tostring(recordType)) + end +end + +function private.SyncHashesThread(otherPlayer) + private.CalculateSyncHashesThreaded(UnitName("player")) + while #private.pendingSyncHashCharacters > 0 do + local player = tremove(private.pendingSyncHashCharacters, 1) + private.CalculateSyncHashesThreaded(player) + end + private.isSyncHashesThreadRunning = false +end + +function private.CalculateSyncHashesThreaded(player) + if private.syncHashDayCache[player] and not private.syncHashDayCacheIsInvalid[player] then + Log.Info("Sync hashes for player (%s) are already up to date", player) + return + end + private.syncHashDayCache[player] = private.syncHashDayCache[player] or {} + local result = private.syncHashDayCache[player] + wipe(result) + private.syncHashDayCacheIsInvalid[player] = true + while true do + local aborted = false + local query = private.db:NewQuery() + :Equal("player", player) + :OrderBy("time", false) + :OrderBy("itemString", true) + Threading.GuardDatabaseQuery(query) + for _, row in query:Iterator(true) do + local rowHash = row:CalculateHash(SYNC_FIELDS) + local day = floor(row:GetField("time") / SECONDS_PER_DAY) + result[day] = Math.CalculateHash(rowHash, result[day]) + Threading.Yield() + if query:IsIteratorAborted() then + Log.Warn("Iterator was aborted for player (%s), will retry", player) + aborted = true + end + end + Threading.UnguardDatabaseQuery(query) + query:Release() + if not aborted then + break + end + end + private.syncHashDayCacheIsInvalid[player] = nil + Log.Info("Updated sync hashes for player (%s)", player) +end + +function private.InventoryCallback() + CustomPrice.OnSourceChange("SmartAvgBuy") +end diff --git a/Core/Service/AuctionDB/Core.lua b/Core/Service/AuctionDB/Core.lua new file mode 100644 index 0000000..9c224cf --- /dev/null +++ b/Core/Service/AuctionDB/Core.lua @@ -0,0 +1,571 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local AuctionDB = TSM:NewPackage("AuctionDB") +local L = TSM.Include("Locale").GetTable() +local Event = TSM.Include("Util.Event") +local CSV = TSM.Include("Util.CSV") +local Table = TSM.Include("Util.Table") +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local Wow = TSM.Include("Util.Wow") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local AuctionScan = TSM.Include("Service.AuctionScan") +local private = { + region = nil, + realmAppData = { + scanTime = nil, + data = {}, + itemOffset = {}, + fieldOffset = {}, + numFields = nil, + }, + regionData = nil, + regionUpdateTime = nil, + scanRealmData = {}, + scanRealmTime = nil, + scanThreadId = nil, + ahOpen = false, + didScan = false, + auctionScan = nil, + isScanning = false, +} +local CSV_KEYS = { "itemString", "minBuyout", "marketValue", "numAuctions", "quantity", "lastScan" } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function AuctionDB.OnInitialize() + private.scanThreadId = Threading.New("AUCTIONDB_SCAN", private.ScanThread) + Threading.SetCallback(private.scanThreadId, private.ScanThreadCleanup) + Event.Register("AUCTION_HOUSE_SHOW", private.OnAuctionHouseShow) + Event.Register("AUCTION_HOUSE_CLOSED", private.OnAuctionHouseClosed) +end + +function AuctionDB.OnEnable() + private.region = TSM.GetRegion() + + local realmAppData = nil + local appData = TSMAPI.AppHelper and TSMAPI.AppHelper:FetchData("AUCTIONDB_MARKET_DATA") -- get app data from TSM_AppHelper if it's installed + if appData then + for _, info in ipairs(appData) do + local realm, data = unpack(info) + local downloadTime = "?" + -- try switching around "Classic-[US|EU]" to match the addon's "[US|EU]-Classic" format for classic region data + if realm == private.region or gsub(realm, "Classic-%-([A-Z]+)", "%1-Classic") == private.region then + private.regionData, private.regionUpdateTime = private.LoadRegionAppData(data) + downloadTime = SecondsToTime(time() - private.regionUpdateTime).." ago" + elseif TSMAPI.AppHelper:IsCurrentRealm(realm) then + realmAppData = private.ProcessRealmAppData(data) + downloadTime = SecondsToTime(time() - realmAppData.downloadTime).." ago" + end + Log.Info("Got AppData for %s (isCurrent=%s, %s)", realm, tostring(TSMAPI.AppHelper:IsCurrentRealm(realm)), downloadTime) + end + end + + -- check if we can load realm data from the app + if realmAppData then + private.realmAppData.scanTime = realmAppData.downloadTime + for i = 2, #realmAppData.fields do + private.realmAppData.fieldOffset[realmAppData.fields[i]] = i - 1 + end + private.realmAppData.numFields = #realmAppData.fields - 1 + local numRawFields = #realmAppData.fields + local nextItmeOffset, nextDataOffset = 0, 1 + for _, data in ipairs(realmAppData.data) do + for i = 1, numRawFields do + local value = data[i] + if i == 1 then + -- item string must be the first field + local itemString = nil + if type(value) == "number" then + itemString = "i:"..value + else + itemString = gsub(value, ":0:", "::") + end + itemString = ItemString.Get(itemString) + private.realmAppData.itemOffset[itemString] = nextItmeOffset + nextItmeOffset = nextItmeOffset + 1 + else + private.realmAppData.data[nextDataOffset] = value + nextDataOffset = nextDataOffset + 1 + end + end + end + end + + for itemString in pairs(private.realmAppData.itemOffset) do + ItemInfo.FetchInfo(itemString) + end + if TSM.db.factionrealm.internalData.auctionDBScanTime > 0 then + private.LoadSVRealmData() + end + if not private.realmAppData.numFields and not next(private.scanRealmData) then + Log.PrintfUser(L["TSM doesn't currently have any AuctionDB pricing data for your realm. We recommend you download the TSM Desktop Application from %s to automatically update your AuctionDB data (and auto-backup your TSM settings)."], Log.ColorUserAccentText("https://tradeskillmaster.com")) + end + + CustomPrice.OnSourceChange("DBMarket") + CustomPrice.OnSourceChange("DBMinBuyout") + CustomPrice.OnSourceChange("DBHistorical") + CustomPrice.OnSourceChange("DBRegionMinBuyoutAvg") + CustomPrice.OnSourceChange("DBRegionMarketAvg") + CustomPrice.OnSourceChange("DBRegionHistorical") + CustomPrice.OnSourceChange("DBRegionSaleAvg") + CustomPrice.OnSourceChange("DBRegionSaleRate") + CustomPrice.OnSourceChange("DBRegionSoldPerDay") + collectgarbage() +end + +function AuctionDB.OnDisable() + if not private.didScan then + return + end + + local encodeContext = CSV.EncodeStart(CSV_KEYS) + for itemString, data in pairs(private.scanRealmData) do + CSV.EncodeAddRowDataRaw(encodeContext, itemString, data.minBuyout, data.marketValue, data.numAuctions, data.quantity, data.lastScan) + end + TSM.db.factionrealm.internalData.csvAuctionDBScan = CSV.EncodeEnd(encodeContext) + TSM.db.factionrealm.internalData.auctionDBScanHash = Math.CalculateHash(TSM.db.factionrealm.internalData.csvAuctionDBScan) +end + +function AuctionDB.GetAppDataUpdateTimes() + return private.realmAppData.scanTime or 0, private.regionUpdateTime or 0 +end + +function AuctionDB.GetLastCompleteScanTime() + local result = private.didScan and (private.scanRealmTime or 0) or (private.realmAppData.scanTime or 0) + return result ~= 0 and result or nil +end + +function AuctionDB.LastScanIteratorThreaded() + local itemNumAuctions = Threading.AcquireSafeTempTable() + local itemMinBuyout = Threading.AcquireSafeTempTable() + local baseItems = Threading.AcquireSafeTempTable() + + local lastScanTime = AuctionDB.GetLastCompleteScanTime() + for itemString, data in pairs(private.didScan and private.scanRealmData or private.realmAppData.itemOffset) do + if not private.didScan or data.lastScan >= lastScanTime then + itemString = ItemString.Get(itemString) + local baseItemString = ItemString.GetBaseFast(itemString) + if baseItemString ~= itemString then + baseItems[baseItemString] = true + end + local numAuctions, minBuyout = nil, nil + if private.didScan then + numAuctions = data.numAuctions + minBuyout = data.minBuyout + else + numAuctions = private.realmAppData.data[data * private.realmAppData.numFields + private.realmAppData.fieldOffset.numAuctions] + minBuyout = private.realmAppData.data[data * private.realmAppData.numFields + private.realmAppData.fieldOffset.minBuyout] + end + itemNumAuctions[itemString] = (itemNumAuctions[itemString] or 0) + numAuctions + if minBuyout and minBuyout > 0 then + itemMinBuyout[itemString] = min(itemMinBuyout[itemString] or math.huge, minBuyout) + end + end + Threading.Yield() + end + + -- remove the base items since they would be double-counted with the specific variants + for itemString in pairs(baseItems) do + itemNumAuctions[itemString] = nil + itemMinBuyout[itemString] = nil + end + Threading.ReleaseSafeTempTable(baseItems) + + -- convert the remaining items into a list + local itemList = Threading.AcquireSafeTempTable() + itemList.numAuctions = itemNumAuctions + itemList.minBuyout = itemMinBuyout + for itemString in pairs(itemNumAuctions) do + tinsert(itemList, itemString) + end + return Table.Iterator(itemList, private.LastScanIteratorHelper, itemList, private.LastScanIteratorCleanup) +end + +function AuctionDB.GetRealmItemData(itemString, key) + local realmData = nil + if private.didScan and (key == "minBuyout" or key == "numAuctions" or key == "lastScan") then + -- always use scanRealmData for minBuyout/numAuctions/lastScan if we've done a scan + realmData = private.scanRealmData + elseif private.realmAppData.numFields then + -- use app data + return private.GetRealmAppItemDataHelper(private.realmAppData, key, itemString) + else + realmData = private.scanRealmData + end + return private.GetItemDataHelper(realmData, key, itemString) +end + +function AuctionDB.GetRegionItemData(itemString, key) + return private.GetRegionItemDataHelper(private.regionData, key, itemString) +end + +function AuctionDB.GetRegionSaleInfo(itemString, key) + -- need to divide the result by 100 + local result = private.GetRegionItemDataHelper(private.regionData, key, itemString) + return result and (result / 100) or nil +end + +function AuctionDB.RunScan() + if private.isScanning then + return + end + if not private.ahOpen then + Log.PrintUser(L["ERROR: The auction house must be open in order to do a scan."]) + return + end + local canScan, canGetAllScan = CanSendAuctionQuery() + if not canScan then + Log.PrintUser(L["ERROR: The AH is currently busy with another scan. Please try again once that scan has completed."]) + return + elseif not canGetAllScan then + Log.PrintUser(L["ERROR: A full AH scan has recently been performed and is on cooldown. Log out to reset this cooldown."]) + return + end + if not TSM.UI.AuctionUI.StartingScan("FULL_SCAN") then + return + end + Log.PrintUser(L["Starting full AH scan. Please note that this scan may cause your game client to lag or crash. This scan generally takes 1-2 minutes."]) + Threading.Start(private.scanThreadId) + private.isScanning = true +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread() + assert(not private.auctionScan) + + -- run the scan + local auctionScan = AuctionScan.GetManager() + :SetResolveSellers(false) + private.auctionScan = auctionScan + local query = auctionScan:NewQuery() + :SetGetAll(true) + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["Failed to run full AH scan."]) + return + end + + -- process the results + Log.PrintfUser(L["Processing scan results..."]) + wipe(private.scanRealmData) + private.scanRealmTime = time() + TSM.db.factionrealm.internalData.auctionDBScanTime = time() + TSM.db.factionrealm.internalData.csvAuctionDBScan = "" + local numScannedAuctions = 0 + local subRows = Threading.AcquireSafeTempTable() + local subRowSortValue = Threading.AcquireSafeTempTable() + local itemBuyouts = Threading.AcquireSafeTempTable() + for baseItemString, row in query:BrowseResultsIterator() do + wipe(subRows) + wipe(subRowSortValue) + for _, subRow in row:SubRowIterator() do + local _, itemBuyout = subRow:GetBuyouts() + tinsert(subRows, subRow) + subRowSortValue[subRow] = itemBuyout + end + Table.SortWithValueLookup(subRows, subRowSortValue, false, true) + + wipe(itemBuyouts) + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity, numAuctions = subRow:GetQuantities() + numScannedAuctions = numScannedAuctions + numAuctions + for _ = 1, numAuctions do + private.ProcessScanResultItem(baseItemString, itemBuyout, quantity) + end + if itemBuyout > 0 then + for _ = 1, quantity * numAuctions do + tinsert(itemBuyouts, itemBuyout) + end + end + end + + local data = private.scanRealmData[baseItemString] + data.marketValue = private.CalculateItemMarketValue(itemBuyouts, data.quantity) + assert(data.minBuyout == 0 or data.marketValue >= data.minBuyout) + Threading.Yield() + end + Threading.ReleaseSafeTempTable(subRows) + Threading.ReleaseSafeTempTable(subRowSortValue) + Threading.ReleaseSafeTempTable(itemBuyouts) + Threading.Yield() + + collectgarbage() + Log.PrintfUser(L["Completed full AH scan (%d auctions)!"], numScannedAuctions) + private.didScan = true + CustomPrice.OnSourceChange("DBMinBuyout") +end + +function private.ScanThreadCleanup() + private.isScanning = false + if private.auctionScan then + private.auctionScan:Release() + private.auctionScan = nil + end + TSM.UI.AuctionUI.EndedScan("FULL_SCAN") +end + +function private.ProcessScanResultItem(itemString, itemBuyout, stackSize) + private.scanRealmData[itemString] = private.scanRealmData[itemString] or { numAuctions = 0, quantity = 0, minBuyout = 0 } + local data = private.scanRealmData[itemString] + data.lastScan = time() + if itemBuyout > 0 then + data.minBuyout = min(data.minBuyout > 0 and data.minBuyout or math.huge, itemBuyout) + data.quantity = data.quantity + stackSize + end + data.numAuctions = data.numAuctions + 1 +end + +function private.CalculateItemMarketValue(itemBuyouts, quantity) + assert(#itemBuyouts == quantity) + if quantity == 0 then + return 0 + end + + -- calculate the average of the lowest 15-30% of auctions + local total, num = 0, 0 + local lowBucketNum = max(floor(quantity * 0.15), 1) + local midBucketNum = max(floor(quantity * 0.30), 1) + local prevItemBuyout = 0 + for i = 1, midBucketNum do + local itemBuyout = itemBuyouts[i] + if num < lowBucketNum or itemBuyout < prevItemBuyout * 1.2 then + num = num + 1 + total = total + itemBuyout + end + prevItemBuyout = itemBuyout + end + local avg = total / num + + -- calculate the stdev of the auctions we used in the average + local stdev = nil + if num > 1 then + local stdevSum = 0 + for i = 1, num do + local itemBuyout = itemBuyouts[i] + stdevSum = stdevSum + (itemBuyout - avg) ^ 2 + end + stdev = sqrt(stdevSum / (num - 1)) + else + stdev = 0 + end + + -- calculate the market value as the average of all data within 1.5 stdev of our previous average + local minItemBuyout = avg - stdev * 1.5 + local maxItemBuyout = avg + stdev * 1.5 + local avgTotal, avgCount = 0, 0 + for i = 1, num do + local itemBuyout = itemBuyouts[i] + if itemBuyout >= minItemBuyout and itemBuyout <= maxItemBuyout then + avgTotal = avgTotal + itemBuyout + avgCount = avgCount + 1 + end + end + return avgTotal > 0 and floor(avgTotal / avgCount) or 0 +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.LoadSVRealmData() + local decodeContext = CSV.DecodeStart(TSM.db.factionrealm.internalData.csvAuctionDBScan, CSV_KEYS) + if not decodeContext then + Log.Err("Failed to decode records") + return + end + for itemString, minBuyout, marketValue, numAuctions, quantity, lastScan in CSV.DecodeIterator(decodeContext) do + private.scanRealmData[itemString] = { + minBuyout = tonumber(minBuyout), + marketValue = tonumber(marketValue), + numAuctions = tonumber(numAuctions), + quantity = tonumber(quantity), + lastScan = tonumber(lastScan), + } + end + if not CSV.DecodeEnd(decodeContext) then + Log.Err("Failed to decode records") + end + private.scanRealmTime = TSM.db.factionrealm.internalData.auctionDBScanTime +end + +function private.ProcessRealmAppData(rawData) + if #rawData < 3500000 then + -- we can safely just use loadstring() for strings below 3.5M + return assert(loadstring(rawData)()) + end + -- load the data in chunks + local leader, itemData, trailer = strmatch(rawData, "^(.+)data={({.+})}(.+)$") + local resultData = {} + local chunkStart, chunkEnd, nextChunkStart = 1, nil, nil + while chunkStart do + chunkEnd, nextChunkStart = strfind(itemData, "},{", chunkStart + 3400000) + local chunkData = assert(loadstring("return {"..strsub(itemData, chunkStart, chunkEnd).."}")()) + for _, data in ipairs(chunkData) do + tinsert(resultData, data) + end + chunkStart = nextChunkStart + end + __AUCTIONDB_IMPORT_TEMP = resultData + local result = assert(loadstring(leader.."data=__AUCTIONDB_IMPORT_TEMP"..trailer)()) + __AUCTIONDB_IMPORT_TEMP = nil + return result +end + +function private.LoadRegionAppData(appData) + local metaDataEndIndex, dataStartIndex = strfind(appData, ",data={") + local itemData = strsub(appData, dataStartIndex + 1, -3) + local metaDataStr = strsub(appData, 1, metaDataEndIndex - 1).."}" + local metaData = assert(loadstring(metaDataStr))() + local result = { fieldLookup = {}, itemLookup = {} } + for i, field in ipairs(metaData.fields) do + result.fieldLookup[field] = i + end + + for itemString, otherData in gmatch(itemData, "{([^,]+),([^}]+)}") do + if tonumber(itemString) then + itemString = "i:"..itemString + else + itemString = gsub(strsub(itemString, 2, -2), ":0:", "::") + end + result.itemLookup[itemString] = otherData + end + + return result, metaData.downloadTime +end + +function private.LastScanIteratorHelper(index, itemString, tbl) + return index, itemString, tbl.numAuctions[itemString], tbl.minBuyout[itemString] +end + +function private.LastScanIteratorCleanup(tbl) + Threading.ReleaseSafeTempTable(tbl.numAuctions) + Threading.ReleaseSafeTempTable(tbl.minBuyout) + Threading.ReleaseSafeTempTable(tbl) +end + +function private.GetItemDataHelper(tbl, key, itemString) + if not itemString or not tbl then + return nil + end + itemString = ItemString.Filter(itemString) + local value = nil + if not tbl[itemString] and not strmatch(itemString, "^[ip]:[0-9]+$") then + -- for items with random enchants or for pets, get data for the base item + itemString = private.GetBaseItemHelper(itemString) + end + if not itemString or not tbl[itemString] then + return nil + end + value = tbl[itemString][key] + return (value or 0) > 0 and value or nil +end + +function private.GetRegionItemDataHelper(tbl, key, itemString) + if not itemString or not tbl then + return nil + end + itemString = ItemString.Filter(itemString) + local fieldIndex = tbl.fieldLookup[key] - 1 + assert(fieldIndex and fieldIndex > 0) + local data = tbl.itemLookup[itemString] + if not data and not strmatch(itemString, "^[ip]:[0-9]+$") then + -- for items with random enchants or for pets, get data for the base item + itemString = private.GetBaseItemHelper(itemString) + itemString = ItemString.GetBase(itemString) + if not itemString then + return nil + end + data = tbl.itemLookup[itemString] + end + if type(data) == "string" then + local tblData = {strsplit(",", data)} + for i = 1, #tblData do + tblData[i] = tonumber(tblData[i]) + end + tbl.itemLookup[itemString] = tblData + data = tblData + end + if not data then + return nil + end + local value = data[fieldIndex] + return (value or 0) > 0 and value or nil +end + +function private.GetRealmAppItemDataHelper(appData, key, itemString) + if not itemString or not appData.numFields then + return nil + elseif key == "lastScan" then + return appData.scanTime + end + itemString = ItemString.Filter(itemString) + if not appData.itemOffset[itemString] and not strmatch(itemString, "^[ip]:[0-9]+$") then + -- for items with random enchants or for pets, get data for the base item + itemString = private.GetBaseItemHelper(itemString) + if not itemString then + return nil + end + end + if not appData.itemOffset[itemString] then + return nil + end + local value = appData.data[appData.itemOffset[itemString] * appData.numFields + appData.fieldOffset[key]] + return (value or 0) > 0 and value or nil +end + +function private.GetBaseItemHelper(itemString) + local quality = ItemInfo.GetQuality(itemString) + local itemLevel = ItemInfo.GetItemLevel(itemString) + local classId = ItemInfo.GetClassId(itemString) + if quality and quality >= 2 and itemLevel and itemLevel >= TSM.CONST.MIN_BONUS_ID_ITEM_LEVEL and (classId == LE_ITEM_CLASS_WEAPON or classId == LE_ITEM_CLASS_ARMOR) then + if strmatch(itemString, "^i:[0-9]+:[0-9%-]*:") then + return nil + end + end + return ItemString.GetBaseFast(itemString) +end + +function private.OnAuctionHouseShow() + private.ahOpen = true + if not TSM.IsWowClassic() or not select(2, CanSendAuctionQuery()) then + return + elseif (AuctionDB.GetLastCompleteScanTime() or 0) > time() - 60 * 60 * 2 then + -- the most recent scan is from the past 2 hours + return + elseif (TSM.db.factionrealm.internalData.auctionDBScanTime or 0) > time() - 60 * 60 * 24 then + -- this user has contributed a scan within the past 24 hours + return + end + StaticPopupDialogs["TSM_AUCTIONDB_SCAN"] = StaticPopupDialogs["TSM_AUCTIONDB_SCAN"] or { + text = L["TSM does not have recent AuctionDB data. Would you like to run a full AH scan?"], + button1 = YES, + button2 = NO, + timeout = 0, + OnAccept = AuctionDB.RunScan, + } + Wow.ShowStaticPopupDialog("TSM_AUCTIONDB_SCAN") +end + +function private.OnAuctionHouseClosed() + private.ahOpen = false +end diff --git a/Core/Service/Auctioning/CancelScan.lua b/Core/Service/Auctioning/CancelScan.lua new file mode 100644 index 0000000..352f5f4 --- /dev/null +++ b/Core/Service/Auctioning/CancelScan.lua @@ -0,0 +1,461 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CancelScan = TSM.Auctioning:NewPackage("CancelScan") +local L = TSM.Include("Locale").GetTable() +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local Log = TSM.Include("Util.Log") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper") +local private = { + scanThreadId = nil, + queueDB = nil, + itemList = {}, + usedAuctionIndex = {}, + subRowsTemp = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function CancelScan.OnInitialize() + -- initialize thread + private.scanThreadId = Threading.New("CANCEL_SCAN", private.ScanThread) + private.queueDB = Database.NewSchema("AUCTIONING_CANCEL_QUEUE") + :AddNumberField("auctionId") + :AddStringField("itemString") + :AddStringField("operationName") + :AddNumberField("bid") + :AddNumberField("buyout") + :AddNumberField("itemBid") + :AddNumberField("itemBuyout") + :AddNumberField("stackSize") + :AddNumberField("duration") + :AddNumberField("numStacks") + :AddNumberField("numProcessed") + :AddNumberField("numConfirmed") + :AddNumberField("numFailed") + :AddIndex("auctionId") + :AddIndex("itemString") + :Commit() +end + +function CancelScan.Prepare() + return private.scanThreadId +end + +function CancelScan.GetCurrentRow() + return private.queueDB:NewQuery() + :Custom(private.NextProcessRowQueryHelper) + :OrderBy("auctionId", false) + :GetFirstResultAndRelease() +end + +function CancelScan.GetStatus() + return TSM.Auctioning.Util.GetQueueStatus(private.queueDB:NewQuery()) +end + +function CancelScan.DoProcess() + local cancelRow = CancelScan.GetCurrentRow() + local cancelItemString = cancelRow:GetField("itemString") + local query = AuctionTracking.CreateQueryUnsoldItem(cancelItemString) + :Equal("stackSize", cancelRow:GetField("stackSize")) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", cancelItemString) + :Custom(private.ProcessQueryHelper, cancelRow) + :OrderBy("auctionId", false) + :Select("auctionId", "autoBaseItemString", "currentBid", "buyout") + if not TSM.db.global.auctioningOptions.cancelWithBid then + query:Equal("highBidder", "") + end + local auctionId, itemString, currentBid, buyout = query:GetFirstResultAndRelease() + if auctionId then + local result = nil + if TSM.IsWowClassic() then + private.usedAuctionIndex[itemString..buyout..currentBid..auctionId] = true + CancelAuction(auctionId) + result = true + else + private.usedAuctionIndex[auctionId] = true + result = AuctionHouseWrapper.CancelAuction(auctionId) + end + local isRowDone = cancelRow:GetField("numProcessed") + 1 == cancelRow:GetField("numStacks") + cancelRow:SetField("numProcessed", cancelRow:GetField("numProcessed") + 1) + :Update() + cancelRow:Release() + if result and isRowDone then + -- update the log + TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "CANCELLED") + end + return result, false + end + + -- we couldn't find this item, so mark this cancel as failed and we'll try again later + cancelRow:SetField("numProcessed", cancelRow:GetField("numProcessed") + 1) + :Update() + cancelRow:Release() + return false, false +end + +function CancelScan.DoSkip() + local cancelRow = CancelScan.GetCurrentRow() + local auctionId = cancelRow:GetField("auctionId") + cancelRow:SetField("numProcessed", cancelRow:GetField("numProcessed") + 1) + :SetField("numConfirmed", cancelRow:GetField("numConfirmed") + 1) + :Update() + cancelRow:Release() + -- update the log + TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "SKIPPED") +end + +function CancelScan.HandleConfirm(success, canRetry) + local confirmRow = private.queueDB:NewQuery() + :Custom(private.ConfirmRowQueryHelper) + :OrderBy("auctionId", true) + :GetFirstResultAndRelease() + if not confirmRow then + -- we may have cancelled something outside of TSM + return + end + + if canRetry then + assert(not success) + confirmRow:SetField("numFailed", confirmRow:GetField("numFailed") + 1) + end + confirmRow:SetField("numConfirmed", confirmRow:GetField("numConfirmed") + 1) + :Update() + confirmRow:Release() +end + +function CancelScan.PrepareFailedCancels() + wipe(private.usedAuctionIndex) + private.queueDB:SetQueryUpdatesPaused(true) + local query = private.queueDB:NewQuery() + :GreaterThan("numFailed", 0) + for _, row in query:Iterator() do + local numFailed, numProcessed, numConfirmed = row:GetFields("numFailed", "numProcessed", "numConfirmed") + assert(numProcessed >= numFailed and numConfirmed >= numFailed) + row:SetField("numFailed", 0) + :SetField("numProcessed", numProcessed - numFailed) + :SetField("numConfirmed", numConfirmed - numFailed) + :Update() + end + query:Release() + private.queueDB:SetQueryUpdatesPaused(false) +end + +function CancelScan.Reset() + private.queueDB:Truncate() + wipe(private.usedAuctionIndex) +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan, groupList) + auctionScan:SetScript("OnQueryDone", private.AuctionScanOnQueryDone) + + -- generate the list of items we want to scan for + wipe(private.itemList) + local processedItems = TempTable.Acquire() + local query = AuctionTracking.CreateQueryUnsold() + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Select("autoBaseItemString") + if not TSM.db.global.auctioningOptions.cancelWithBid then + query:Equal("highBidder", "") + end + for _, itemString in query:Iterator() do + if not processedItems[itemString] and private.CanCancelItem(itemString, groupList) then + tinsert(private.itemList, itemString) + end + processedItems[itemString] = true + end + query:Release() + TempTable.Release(processedItems) + + if #private.itemList == 0 then + return + end + TSM.Auctioning.SavedSearches.RecordSearch(groupList, "cancelGroups") + + -- run the scan + auctionScan:AddItemListQueriesThreaded(private.itemList) + for _, query2 in auctionScan:QueryIterator() do + query2:AddCustomFilter(private.QueryBuyoutFilter) + end + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.CanCancelItem(itemString, groupList) + local groupPath = TSM.Groups.GetPathByItem(itemString) + if not groupPath or not tContains(groupList, groupPath) then + return false + end + + local hasValidOperation, hasInvalidOperation = false, false + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do + local isValid = private.IsOperationValid(itemString, operationName, operationSettings) + if isValid == true then + hasValidOperation = true + elseif isValid == false then + hasInvalidOperation = true + else + -- we are ignoring this operation + assert(isValid == nil, "Invalid return value") + end + end + return hasValidOperation and not hasInvalidOperation, itemString +end + +function private.IsOperationValid(itemString, operationName, operationSettings) + if not operationSettings.cancelUndercut and not operationSettings.cancelRepost then + -- canceling is disabled, so ignore this operation + TSM.Auctioning.Log.AddEntry(itemString, operationName, "cancelDisabled", "", 0, 0) + return nil + end + + local errMsg = nil + local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) + local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString) + local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString) + local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) + local cancelRepostThreshold = TSM.Auctioning.Util.GetPrice("cancelRepostThreshold", operationSettings, itemString) + if not minPrice then + errMsg = format(L["Did not cancel %s because your minimum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.minPrice) + elseif not maxPrice then + errMsg = format(L["Did not cancel %s because your maximum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice) + elseif not normalPrice then + errMsg = format(L["Did not cancel %s because your normal price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice) + elseif operationSettings.cancelRepost and not cancelRepostThreshold then + errMsg = format(L["Did not cancel %s because your cancel to repost threshold (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.cancelRepostThreshold) + elseif not undercut then + errMsg = format(L["Did not cancel %s because your undercut (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.undercut) + elseif maxPrice < minPrice then + errMsg = format(L["Did not cancel %s because your maximum price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice, operationSettings.minPrice) + elseif normalPrice < minPrice then + errMsg = format(L["Did not cancel %s because your normal price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice, operationSettings.minPrice) + end + + if errMsg then + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintUser(errMsg) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, 0) + return false + else + return true + end +end + +function private.QueryBuyoutFilter(_, row) + local _, itemBuyout, minItemBuyout = row:GetBuyouts() + return (itemBuyout and itemBuyout == 0) or (minItemBuyout and minItemBuyout == 0) +end + +function private.AuctionScanOnQueryDone(_, query) + TSM.Auctioning.Log.SetQueryUpdatesPaused(true) + for itemString in query:ItemIterator() do + local groupPath = TSM.Groups.GetPathByItem(itemString) + if groupPath then + local auctionsDBQuery = AuctionTracking.CreateQueryUnsoldItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :OrderBy("auctionId", false) + for _, auctionsDBRow in auctionsDBQuery:IteratorAndRelease() do + private.GenerateCancels(auctionsDBRow, itemString, groupPath, query) + end + else + Log.Warn("Item removed from group since start of scan: %s", itemString) + end + end + TSM.Auctioning.Log.SetQueryUpdatesPaused(false) +end + +function private.GenerateCancels(auctionsDBRow, itemString, groupPath, query) + local isHandled = false + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do + if not isHandled and private.IsOperationValid(itemString, operationName, operationSettings) then + assert(not next(private.subRowsTemp)) + TSM.Auctioning.Util.GetFilteredSubRows(query, itemString, operationSettings, private.subRowsTemp) + local handled, logReason, itemBuyout, seller, auctionId = private.GenerateCancel(auctionsDBRow, itemString, operationName, operationSettings, private.subRowsTemp) + wipe(private.subRowsTemp) + if logReason then + seller = seller or "" + auctionId = auctionId or 0 + TSM.Auctioning.Log.AddEntry(itemString, operationName, logReason, seller, itemBuyout, auctionId) + end + isHandled = isHandled or handled + end + end +end + +function private.GenerateCancel(auctionsDBRow, itemString, operationName, operationSettings, subRows) + local auctionId, stackSize, currentBid, buyout, highBidder, duration = auctionsDBRow:GetFields("auctionId", "stackSize", "currentBid", "buyout", "highBidder", "duration") + local itemBuyout = TSM.IsWowClassic() and floor(buyout / stackSize) or buyout + local itemBid = TSM.IsWowClassic() and floor(currentBid / stackSize) or currentBid + if TSM.IsWowClassic() and operationSettings.matchStackSize and stackSize ~= TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString) then + return false + elseif not TSM.db.global.auctioningOptions.cancelWithBid and highBidder ~= "" then + -- Don't cancel an auction if it has a bid and we're set to not cancel those + return true, "cancelBid", itemBuyout, nil, auctionId + elseif not TSM.IsWowClassic() and C_AuctionHouse.GetCancelCost(auctionId) > GetMoney() then + return true, "cancelNoMoney", itemBuyout, nil, auctionId + end + + local lowestAuction = TempTable.Acquire() + if not TSM.Auctioning.Util.GetLowestAuction(subRows, itemString, operationSettings, lowestAuction) then + TempTable.Release(lowestAuction) + lowestAuction = nil + end + local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) + local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString) + local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString) + local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString) + local cancelRepostThreshold = TSM.Auctioning.Util.GetPrice("cancelRepostThreshold", operationSettings, itemString) + local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) + local aboveMax = TSM.Auctioning.Util.GetPrice("aboveMax", operationSettings, itemString) + + if not lowestAuction then + -- all auctions which are posted (including ours) have been ignored, so check if we should cancel to repost higher + if operationSettings.cancelRepost and normalPrice - itemBuyout > cancelRepostThreshold then + private.AddToQueue(itemString, operationName, itemBid, itemBuyout, stackSize, duration, auctionId) + return true, "cancelRepost", itemBuyout, nil, auctionId + else + return false, "cancelNotUndercut", itemBuyout + end + elseif lowestAuction.hasInvalidSeller then + Log.PrintfUser(L["The seller name of the lowest auction for %s was not given by the server. Skipping this item."], ItemInfo.GetLink(itemString)) + TempTable.Release(lowestAuction) + return false, "invalidSeller", itemBuyout + end + + local shouldCancel, logReason = false, nil + local playerLowestItemBuyout, playerLowestAuctionId = TSM.Auctioning.Util.GetPlayerLowestBuyout(subRows, itemString, operationSettings) + local secondLowestBuyout = TSM.Auctioning.Util.GetNextLowestItemBuyout(subRows, itemString, lowestAuction, operationSettings) + local nonPlayerLowestAuctionId = not TSM.IsWowClassic() and playerLowestItemBuyout and TSM.Auctioning.Util.GetLowestNonPlayerAuctionId(subRows, itemString, operationSettings, playerLowestItemBuyout) + if itemBuyout < minPrice and not lowestAuction.isBlacklist then + -- this auction is below the min price + if operationSettings.cancelRepost and resetPrice and itemBuyout < (resetPrice - cancelRepostThreshold) then + -- canceling to post at reset price + shouldCancel = true + logReason = "cancelReset" + else + logReason = "cancelBelowMin" + end + elseif lowestAuction.buyout < minPrice and not lowestAuction.isBlacklist then + -- lowest buyout is below min price, so do nothing + logReason = "cancelBelowMin" + elseif operationSettings.cancelUndercut and playerLowestItemBuyout and ((itemBuyout - undercut) > playerLowestItemBuyout or (not TSM.IsWowClassic() and (itemBuyout - undercut) == playerLowestItemBuyout and auctionId ~= playerLowestAuctionId and auctionId < (nonPlayerLowestAuctionId or 0))) then + -- we've undercut this auction + shouldCancel = true + logReason = "cancelPlayerUndercut" + elseif TSM.Auctioning.Util.IsPlayerOnlySeller(subRows, itemString, operationSettings) then + -- we are the only auction + if operationSettings.cancelRepost and (normalPrice - itemBuyout) > cancelRepostThreshold then + -- we can repost higher + shouldCancel = true + logReason = "cancelRepost" + else + logReason = "cancelAtNormal" + end + elseif lowestAuction.isPlayer and secondLowestBuyout and secondLowestBuyout > maxPrice then + -- we are posted at the aboveMax price with no competition under our max price + if operationSettings.cancelRepost and operationSettings.aboveMax ~= "none" and (aboveMax - itemBuyout) > cancelRepostThreshold then + -- we can repost higher + shouldCancel = true + logReason = "cancelRepost" + else + logReason = "cancelAtAboveMax" + end + elseif lowestAuction.isPlayer then + -- we are the loewst auction + if operationSettings.cancelRepost and secondLowestBuyout and ((secondLowestBuyout - undercut) - lowestAuction.buyout) > cancelRepostThreshold then + -- we can repost higher + shouldCancel = true + logReason = "cancelRepost" + else + logReason = "cancelNotUndercut" + end + elseif not operationSettings.cancelUndercut then + -- we're undercut but not canceling undercut auctions + elseif lowestAuction.isWhitelist and itemBuyout == lowestAuction.buyout then + -- at whitelisted player price + logReason = "cancelAtWhitelist" + elseif not lowestAuction.isWhitelist then + -- we've been undercut by somebody not on our whitelist + shouldCancel = true + logReason = "cancelUndercut" + elseif itemBuyout ~= lowestAuction.buyout or itemBid ~= lowestAuction.bid then + -- somebody on our whitelist undercut us (or their bid is lower) + shouldCancel = true + logReason = "cancelWhitelistUndercut" + else + error("Should not get here") + end + + local seller = lowestAuction.seller + TempTable.Release(lowestAuction) + if shouldCancel then + private.AddToQueue(itemString, operationName, itemBid, itemBuyout, stackSize, duration, auctionId) + end + return shouldCancel, logReason, itemBuyout, seller, shouldCancel and auctionId or nil +end + +function private.AddToQueue(itemString, operationName, itemBid, itemBuyout, stackSize, duration, auctionId) + private.queueDB:NewRow() + :SetField("auctionId", auctionId) + :SetField("itemString", itemString) + :SetField("operationName", operationName) + :SetField("bid", itemBid * stackSize) + :SetField("buyout", itemBuyout * stackSize) + :SetField("itemBid", itemBid) + :SetField("itemBuyout", itemBuyout) + :SetField("stackSize", stackSize) + :SetField("duration", duration) + :SetField("numStacks", 1) + :SetField("numProcessed", 0) + :SetField("numConfirmed", 0) + :SetField("numFailed", 0) + :Create() +end + +function private.ProcessQueryHelper(row, cancelRow) + if TSM.IsWowClassic() then + local auctionId, itemString, stackSize, currentBid, buyout = row:GetFields("auctionId", "autoBaseItemString", "stackSize", "currentBid", "buyout") + local itemBid = floor(currentBid / stackSize) + local itemBuyout = floor(buyout / stackSize) + return not private.usedAuctionIndex[itemString..buyout..currentBid..auctionId] and cancelRow:GetField("itemBid") == itemBid and cancelRow:GetField("itemBuyout") == itemBuyout + else + local auctionId = row:GetField("auctionId") + return not private.usedAuctionIndex[auctionId] and cancelRow:GetField("auctionId") == auctionId + end +end + +function private.ConfirmRowQueryHelper(row) + return row:GetField("numConfirmed") < row:GetField("numProcessed") +end + +function private.NextProcessRowQueryHelper(row) + return row:GetField("numProcessed") < row:GetField("numStacks") +end diff --git a/Core/Service/Auctioning/Core.lua b/Core/Service/Auctioning/Core.lua new file mode 100644 index 0000000..7cda759 --- /dev/null +++ b/Core/Service/Auctioning/Core.lua @@ -0,0 +1,8 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +TSM:NewPackage("Auctioning") diff --git a/Core/Service/Auctioning/Log.lua b/Core/Service/Auctioning/Log.lua new file mode 100644 index 0000000..ea67e66 --- /dev/null +++ b/Core/Service/Auctioning/Log.lua @@ -0,0 +1,142 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Log = TSM.Auctioning:NewPackage("Log") +local L = TSM.Include("Locale").GetTable() +local Database = TSM.Include("Util.Database") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + db = nil, +} +local REASON_INFO = { + -- general + invalidItemGroup = { color = "RED", str = L["Item/Group is invalid (see chat)."] }, + invalidSeller = { color = "RED", str = L["Invalid seller data returned by server."] }, + -- post scan + postDisabled = { color = "ORANGE", str = L["Posting disabled."] }, + postNotEnough = { color = "ORANGE", str = L["Not enough items in bags."] }, + postMaxExpires = { color = "ORANGE", str = L["Above max expires."] }, + postBelowMin = { color = "ORANGE", str = L["Cheapest auction below min price."] }, + postTooMany = { color = "BLUE", str = L["Maximum amount already posted."] }, + postNormal = { color = "GREEN", str = L["Posting at normal price."] }, + postResetMin = { color = "GREEN", str = L["Below min price. Posting at min."] }, + postResetMax = { color = "GREEN", str = L["Below min price. Posting at max."] }, + postResetNormal = { color = "GREEN", str = L["Below min price. Posting at normal."] }, + postAboveMaxMin = { color = "GREEN", str = L["Above max price. Posting at min."] }, + postAboveMaxMax = { color = "GREEN", str = L["Above max price. Posting at max."] }, + postAboveMaxNormal = { color = "GREEN", str = L["Above max price. Posting at normal."] }, + postAboveMaxNoPost = { color = "ORANGE", str = L["Above max price. Not posting."] }, + postUndercut = { color = "GREEN", str = L["Undercutting competition."] }, + postPlayer = { color = "GREEN", str = L["Posting at your current price."] }, + postWhitelist = { color = "GREEN", str = L["Posting at whitelisted player's price."] }, + postWhitelistNoPost = { color = "ORANGE", str = L["Lowest auction by whitelisted player."] }, + postBlacklist = { color = "GREEN", str = L["Undercutting blacklisted player."] }, + -- cancel scan + cancelDisabled = { color = "ORANGE", str = L["Canceling disabled."] }, + cancelNotUndercut = { color = "GREEN", str = L["Your auction has not been undercut."] }, + cancelBid = { color = "BLUE", str = L["Auction has been bid on."] }, + cancelNoMoney = { color = "BLUE", str = L["Not enough money to cancel."] }, + cancelKeepPosted = { color = "BLUE", str = L["Keeping undercut auctions posted."] }, + cancelBelowMin = { color = "ORANGE", str = L["Not canceling auction below min price."] }, + cancelAtReset = { color = "GREEN", str = L["Not canceling auction at reset price."] }, + cancelAtNormal = { color = "GREEN", str = L["At normal price and not undercut."] }, + cancelAtAboveMax = { color = "GREEN", str = L["At above max price and not undercut."] }, + cancelAtWhitelist = { color = "GREEN", str = L["Posted at whitelisted player's price."] }, + cancelUndercut = { color = "RED", str = L["You've been undercut."] }, + cancelRepost = { color = "BLUE", str = L["Canceling to repost at higher price."] }, + cancelReset = { color = "BLUE", str = L["Canceling to repost at reset price."] }, + cancelWhitelistUndercut = { color = "RED", str = L["Undercut by whitelisted player."] }, + cancelPlayerUndercut = { color = "BLUE", str = L["Canceling auction you've undercut."] }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Log.OnInitialize() + private.db = Database.NewSchema("AUCTIONING_LOG") + :AddNumberField("index") + :AddStringField("itemString") + :AddStringField("seller") + :AddNumberField("buyout") + :AddStringField("operation") + :AddStringField("reasonStr") + :AddStringField("reasonKey") + :AddStringField("state") + :AddIndex("index") + :Commit() +end + +function Log.Truncate() + private.db:Truncate() +end + +function Log.CreateQuery() + return private.db:NewQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :OrderBy("index", true) +end + +function Log.UpdateRowByIndex(index, field, value) + local row = private.db:NewQuery() + :Equal("index", index) + :GetFirstResultAndRelease() + + if field == "state" then + assert(value == "POSTED" or value == "CANCELLED" or value == "SKIPPED") + if not row then + return + end + end + + row:SetField(field, value) + :Update() + + row:Release() +end + +function Log.SetQueryUpdatesPaused(paused) + private.db:SetQueryUpdatesPaused(paused) +end + +function Log.AddEntry(itemString, operationName, reasonKey, seller, buyout, index) + private.db:NewRow() + :SetField("itemString", itemString) + :SetField("seller", seller) + :SetField("buyout", buyout) + :SetField("operation", operationName) + :SetField("reasonStr", REASON_INFO[reasonKey].str) + :SetField("reasonKey", reasonKey) + :SetField("index", index) + :SetField("state", "PENDING") + :Create() +end + +function Log.GetColorFromReasonKey(reasonKey) + return Theme.GetFeedbackColor(REASON_INFO[reasonKey].color) +end + +function Log.GetInfoStr(row) + local state, reasonKey = row:GetFields("state", "reasonKey") + local reasonInfo = REASON_INFO[reasonKey] + local color = nil + if state == "PENDING" then + return Theme.GetFeedbackColor(reasonInfo.color):ColorText(reasonInfo.str) + elseif state == "POSTED" then + return Theme.GetColor("INDICATOR"):ColorText(L["Posted:"]).." "..reasonInfo.str + elseif state == "CANCELLED" then + return Theme.GetColor("INDICATOR"):ColorText(L["Cancelled:"]).." "..reasonInfo.str + elseif state == "SKIPPED" then + return Theme.GetColor("INDICATOR"):ColorText(L["Skipped:"]).." "..reasonInfo.str + else + error("Invalid state: "..tostring(state)) + end + return color:ColorText(reasonInfo.str) +end diff --git a/Core/Service/Auctioning/PostScan.lua b/Core/Service/Auctioning/PostScan.lua new file mode 100644 index 0000000..61577bd --- /dev/null +++ b/Core/Service/Auctioning/PostScan.lua @@ -0,0 +1,965 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local PostScan = TSM.Auctioning:NewPackage("PostScan") +local L = TSM.Include("Locale").GetTable() +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local SlotId = TSM.Include("Util.SlotId") +local Delay = TSM.Include("Util.Delay") +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local Event = TSM.Include("Util.Event") +local ItemString = TSM.Include("Util.ItemString") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper") +local private = { + scanThreadId = nil, + queueDB = nil, + nextQueueIndex = 1, + bagDB = nil, + itemList = {}, + operationDB = nil, + debugLog = {}, + itemLocation = ItemLocation:CreateEmpty(), + subRowsTemp = {}, + groupsQuery = nil, --luacheck: ignore 1004 - just stored for GC reasons + operationsQuery = nil, --luacheck: ignore 1004 - just stored for GC reasons + isAHOpen = false, +} +local RESET_REASON_LOOKUP = { + minPrice = "postResetMin", + maxPrice = "postResetMax", + normalPrice = "postResetNormal" +} +local ABOVE_MAX_REASON_LOOKUP = { + minPrice = "postAboveMaxMin", + maxPrice = "postAboveMaxMax", + normalPrice = "postAboveMaxNormal", + none = "postAboveMaxNoPost" +} +local MAX_COMMODITY_STACKS_PER_AUCTION = 40 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function PostScan.OnInitialize() + BagTracking.RegisterCallback(private.UpdateOperationDB) + Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler) + Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler) + private.operationDB = Database.NewSchema("AUCTIONING_OPERATIONS") + :AddUniqueStringField("autoBaseItemString") + :AddStringField("firstOperation") + :Commit() + private.scanThreadId = Threading.New("POST_SCAN", private.ScanThread) + private.queueDB = Database.NewSchema("AUCTIONING_POST_QUEUE") + :AddNumberField("auctionId") + :AddStringField("itemString") + :AddStringField("operationName") + :AddNumberField("bid") + :AddNumberField("buyout") + :AddNumberField("itemBuyout") + :AddNumberField("stackSize") + :AddNumberField("numStacks") + :AddNumberField("postTime") + :AddNumberField("numProcessed") + :AddNumberField("numConfirmed") + :AddNumberField("numFailed") + :AddIndex("auctionId") + :AddIndex("itemString") + :Commit() + -- We maintain our own bag database rather than using the one in BagTracking since we need to be able to remove items + -- as they are posted, without waiting for bag update events, and control when our DB updates. + private.bagDB = Database.NewSchema("AUCTIONING_POST_BAGS") + :AddStringField("itemString") + :AddNumberField("bag") + :AddNumberField("slot") + :AddNumberField("quantity") + :AddUniqueNumberField("slotId") + :AddIndex("itemString") + :AddIndex("slotId") + :Commit() + -- create a groups and operations query just to register for updates + private.groupsQuery = TSM.Groups.CreateQuery() + :SetUpdateCallback(private.OnGroupsOperationsChanged) + private.operationsQuery = TSM.Operations.CreateQuery() + :SetUpdateCallback(private.OnGroupsOperationsChanged) +end + +function PostScan.CreateBagsQuery() + return BagTracking.CreateQueryBagsAuctionable() + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Distinct("autoBaseItemString") + :LeftJoin(private.operationDB, "autoBaseItemString") + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :OrderBy("name", true) +end + +function PostScan.Prepare() + return private.scanThreadId +end + +function PostScan.GetCurrentRow() + return private.queueDB:NewQuery() + :Custom(private.NextProcessRowQueryHelper) + :OrderBy("auctionId", true) + :GetFirstResultAndRelease() +end + +function PostScan.GetStatus() + return TSM.Auctioning.Util.GetQueueStatus(private.queueDB:NewQuery()) +end + +function PostScan.DoProcess() + local result, noRetry = nil, false + local postRow = PostScan.GetCurrentRow() + local itemString, stackSize, bid, buyout, itemBuyout, postTime = postRow:GetFields("itemString", "stackSize", "bid", "buyout", "itemBuyout", "postTime") + local bag, slot = private.GetPostBagSlot(itemString, stackSize) + if bag then + local _, bagQuantity = GetContainerItemInfo(bag, slot) + Log.Info("Posting %s x %d from %d,%d (%d)", itemString, stackSize, bag, slot, bagQuantity or -1) + if TSM.IsWowClassic() then + -- need to set the duration in the default UI to avoid Blizzard errors + AuctionFrameAuctions.duration = postTime + ClearCursor() + PickupContainerItem(bag, slot) + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + PostAuction(bid, buyout, postTime, stackSize, 1) + ClearCursor() + result = true + else + bid = Math.Round(bid / stackSize, COPPER_PER_SILVER) + buyout = Math.Round(buyout / stackSize, COPPER_PER_SILVER) + itemBuyout = Math.Round(itemBuyout, COPPER_PER_SILVER) + private.itemLocation:Clear() + private.itemLocation:SetBagAndSlot(bag, slot) + local commodityStatus = C_AuctionHouse.GetItemCommodityStatus(private.itemLocation) + if commodityStatus == Enum.ItemCommodityStatus.Item then + result = AuctionHouseWrapper.PostItem(private.itemLocation, postTime, stackSize, bid < buyout and bid or nil, buyout) + elseif commodityStatus == Enum.ItemCommodityStatus.Commodity then + result = AuctionHouseWrapper.PostCommodity(private.itemLocation, postTime, stackSize, itemBuyout) + else + error("Unknown commodity status: "..tostring(itemString)) + end + if not result then + Log.Err("Failed to post (%s, %s, %s)", itemString, bag, slot) + end + end + else + -- we couldn't find this item, so mark this post as failed and we'll try again later + result = false + noRetry = slot + if noRetry then + Log.PrintfUser(L["Failed to post %sx%d as the item no longer exists in your bags."], ItemInfo.GetLink(itemString), stackSize) + end + end + if result then + private.DebugLogInsert(itemString, "Posting %d from %d, %d", stackSize, bag, slot) + if postRow:GetField("numProcessed") + 1 == postRow:GetField("numStacks") then + -- update the log + local auctionId = postRow:GetField("auctionId") + TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "POSTED") + end + end + postRow:SetField("numProcessed", postRow:GetField("numProcessed") + 1) + :Update() + postRow:Release() + return result, noRetry +end + +function PostScan.DoSkip() + local postRow = PostScan.GetCurrentRow() + local auctionId = postRow:GetField("auctionId") + local numStacks = postRow:GetField("numStacks") + postRow:SetField("numProcessed", numStacks) + :SetField("numConfirmed", numStacks) + :Update() + postRow:Release() + -- update the log + TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "SKIPPED") +end + +function PostScan.HandleConfirm(success, canRetry) + if not success then + ClearCursor() + end + + local confirmRow = private.queueDB:NewQuery() + :Custom(private.ConfirmRowQueryHelper) + :OrderBy("auctionId", true) + :GetFirstResultAndRelease() + if not confirmRow then + -- we may have posted something outside of TSM + return + end + + private.DebugLogInsert(confirmRow:GetField("itemString"), "HandleConfirm(success=%s) x %d", tostring(success), confirmRow:GetField("stackSize")) + if canRetry then + assert(not success) + confirmRow:SetField("numFailed", confirmRow:GetField("numFailed") + 1) + end + confirmRow:SetField("numConfirmed", confirmRow:GetField("numConfirmed") + 1) + :Update() + confirmRow:Release() +end + +function PostScan.PrepareFailedPosts() + private.queueDB:SetQueryUpdatesPaused(true) + local query = private.queueDB:NewQuery() + :GreaterThan("numFailed", 0) + :OrderBy("auctionId", true) + for _, row in query:Iterator() do + local numFailed, numProcessed, numConfirmed = row:GetFields("numFailed", "numProcessed", "numConfirmed") + assert(numProcessed >= numFailed and numConfirmed >= numFailed) + private.DebugLogInsert(row:GetField("itemString"), "Preparing failed (%d, %d, %d)", numFailed, numProcessed, numConfirmed) + row:SetField("numFailed", 0) + :SetField("numProcessed", numProcessed - numFailed) + :SetField("numConfirmed", numConfirmed - numFailed) + :Update() + end + query:Release() + private.queueDB:SetQueryUpdatesPaused(false) + private.UpdateBagDB() +end + +function PostScan.Reset() + private.queueDB:Truncate() + private.nextQueueIndex = 1 + private.bagDB:Truncate() +end + +function PostScan.ChangePostDetail(field, value) + local postRow = PostScan.GetCurrentRow() + local isCommodity = ItemInfo.IsCommodity(postRow:GetField("itemString")) + if field == "bid" then + assert(not isCommodity) + value = min(max(value, 1), postRow:GetField("buyout")) + elseif field == "buyout" then + if not isCommodity and value < postRow:GetField("bid") then + postRow:SetField("bid", value) + end + TSM.Auctioning.Log.UpdateRowByIndex(postRow:GetField("auctionId"), field, value) + end + postRow:SetField((field == "buyout" and isCommodity) and "itemBuyout" or field, value) + :Update() + postRow:Release() +end + + + +-- ============================================================================ +-- Private Helper Functions (General) +-- ============================================================================ + +function private.AuctionHouseShowHandler() + private.isAHOpen = true + private.UpdateOperationDB() +end + +function private.AuctionHouseClosedHandler() + private.isAHOpen = false +end + +function private.OnGroupsOperationsChanged() + Delay.AfterFrame("POST_GROUP_OPERATIONS_CHANGED", 1, private.UpdateOperationDB) +end + +function private.UpdateOperationDB() + if not private.isAHOpen then + return + end + private.operationDB:TruncateAndBulkInsertStart() + local query = BagTracking.CreateQueryBagsAuctionable() + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Select("autoBaseItemString") + :Distinct("autoBaseItemString") + for _, itemString in query:Iterator() do + local firstOperation = TSM.Operations.GetFirstOperationByItem("Auctioning", itemString) + if firstOperation then + private.operationDB:BulkInsertNewRow(itemString, firstOperation) + end + end + query:Release() + private.operationDB:BulkInsertEnd() +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan, scanContext) + wipe(private.debugLog) + auctionScan:SetScript("OnQueryDone", private.AuctionScanOnQueryDone) + private.UpdateBagDB() + + -- get the state of the player's bags + local bagCounts = TempTable.Acquire() + local bagQuery = private.bagDB:NewQuery() + :Select("itemString", "quantity") + for _, itemString, quantity in bagQuery:Iterator() do + bagCounts[itemString] = (bagCounts[itemString] or 0) + quantity + end + bagQuery:Release() + + -- generate the list of items we want to scan for + wipe(private.itemList) + for itemString, numHave in pairs(bagCounts) do + private.DebugLogInsert(itemString, "Scan thread has %d", numHave) + local groupPath = TSM.Groups.GetPathByItem(itemString) + local contextFilter = scanContext.isItems and itemString or groupPath + if groupPath and tContains(scanContext, contextFilter) and private.CanPostItem(itemString, groupPath, numHave) then + tinsert(private.itemList, itemString) + end + end + TempTable.Release(bagCounts) + if #private.itemList == 0 then + return + end + -- record this search + TSM.Auctioning.SavedSearches.RecordSearch(scanContext, scanContext.isItems and "postItems" or "postGroups") + + -- run the scan + auctionScan:AddItemListQueriesThreaded(private.itemList) + for _, query in auctionScan:QueryIterator() do + query:SetIsBrowseDoneFunction(private.QueryIsBrowseDoneFunction) + query:AddCustomFilter(private.QueryBuyoutFilter) + end + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) + end +end + + + +-- ============================================================================ +-- Private Helper Functions for Scanning +-- ============================================================================ + +function private.UpdateBagDB() + private.bagDB:TruncateAndBulkInsertStart() + local query = BagTracking.CreateQueryBagsAuctionable() + :OrderBy("slotId", true) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Select("slotId", "bag", "slot", "autoBaseItemString", "quantity") + for _, slotId, bag, slot, itemString, quantity in query:Iterator() do + private.DebugLogInsert(itemString, "Updating bag DB with %d in %d, %d", quantity, bag, slot) + private.bagDB:BulkInsertNewRow(itemString, bag, slot, quantity, slotId) + end + query:Release() + private.bagDB:BulkInsertEnd() +end + +function private.CanPostItem(itemString, groupPath, numHave) + local hasValidOperation, hasInvalidOperation = false, false + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do + local isValid, numUsed = private.IsOperationValid(itemString, numHave, operationName, operationSettings) + if isValid == true then + assert(numUsed and numUsed > 0) + numHave = numHave - numUsed + hasValidOperation = true + elseif isValid == false then + hasInvalidOperation = true + else + -- we are ignoring this operation + assert(isValid == nil, "Invalid return value") + end + end + + return hasValidOperation and not hasInvalidOperation +end + +function private.IsOperationValid(itemString, num, operationName, operationSettings) + local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString) + if not postCap then + -- invalid postCap setting + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintfUser(L["Did not post %s because your post cap (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.postCap) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) + return nil + elseif postCap == 0 then + -- posting is disabled, so ignore this operation + TSM.Auctioning.Log.AddEntry(itemString, operationName, "postDisabled", "", 0, math.huge) + return nil + end + + local stackSize = nil + local minPostQuantity = nil + if not TSM.IsWowClassic() then + minPostQuantity = 1 + else + -- check the stack size + stackSize = TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString) + if not stackSize then + -- invalid stackSize setting + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintfUser(L["Did not post %s because your stack size (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.stackSize) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) + return nil + end + local maxStackSize = ItemInfo.GetMaxStack(itemString) + minPostQuantity = operationSettings.stackSizeIsCap and 1 or stackSize + if not maxStackSize then + -- couldn't lookup item info for this item (shouldn't happen) + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintfUser(L["Did not post %s because Blizzard didn't provide all necessary information for it. Try again later."], ItemInfo.GetLink(itemString)) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) + return false + elseif maxStackSize < minPostQuantity then + -- invalid stack size + return nil + end + end + + -- check that we have enough to post + local keepQuantity = TSM.Auctioning.Util.GetPrice("keepQuantity", operationSettings, itemString) + if not keepQuantity then + -- invalid keepQuantity setting + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintfUser(L["Did not post %s because your keep quantity (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.keepQuantity) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) + return nil + end + num = num - keepQuantity + if num < minPostQuantity then + -- not enough items to post for this operation + TSM.Auctioning.Log.AddEntry(itemString, operationName, "postNotEnough", "", 0, math.huge) + return nil + end + + -- check the max expires + local maxExpires = TSM.Auctioning.Util.GetPrice("maxExpires", operationSettings, itemString) + if not maxExpires then + -- invalid maxExpires setting + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintfUser(L["Did not post %s because your max expires (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxExpires) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) + return nil + end + if maxExpires > 0 then + local numExpires = TSM.Accounting.Auctions.GetNumExpiresSinceSale(itemString) + if numExpires and numExpires > maxExpires then + -- too many expires, so ignore this operation + TSM.Auctioning.Log.AddEntry(itemString, operationName, "postMaxExpires", "", 0, math.huge) + return nil + end + end + + local errMsg = nil + local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) + local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString) + local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString) + local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) + if not minPrice then + errMsg = format(L["Did not post %s because your minimum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.minPrice) + elseif not maxPrice then + errMsg = format(L["Did not post %s because your maximum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice) + elseif not normalPrice then + errMsg = format(L["Did not post %s because your normal price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice) + elseif not undercut then + errMsg = format(L["Did not post %s because your undercut (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.undercut) + elseif normalPrice < minPrice then + errMsg = format(L["Did not post %s because your normal price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice, operationSettings.minPrice) + elseif maxPrice < minPrice then + errMsg = format(L["Did not post %s because your maximum price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice, operationSettings.minPrice) + end + + if errMsg then + if not TSM.db.global.auctioningOptions.disableInvalidMsg then + Log.PrintUser(errMsg) + end + TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) + return false + else + local vendorSellPrice = ItemInfo.GetVendorSell(itemString) or 0 + if vendorSellPrice > 0 and minPrice <= vendorSellPrice / 0.95 then + -- just a warning, not an error + Log.PrintfUser(L["WARNING: Your minimum price for %s is below its vendorsell price (with AH cut taken into account). Consider raising your minimum price, or vendoring the item."], ItemInfo.GetLink(itemString)) + end + return true, (TSM.IsWowClassic() and stackSize or 1) * postCap + end +end + +function private.QueryBuyoutFilter(_, row) + local _, itemBuyout, minItemBuyout = row:GetBuyouts() + return (itemBuyout and itemBuyout == 0) or (minItemBuyout and minItemBuyout == 0) +end + +function private.QueryIsBrowseDoneFunction(query) + if not TSM.IsWowClassic() then + return false + end + local isDone = true + for itemString in query:ItemIterator() do + isDone = isDone and private.QueryIsBrowseDoneForItem(query, itemString) + end + return isDone +end + +function private.QueryIsBrowseDoneForItem(query, itemString) + local groupPath = TSM.Groups.GetPathByItem(itemString) + if not groupPath then + return true + end + local isFilterDone = true + for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do + if isFilterDone then + local numBuyouts, minItemBuyout, maxItemBuyout = 0, nil, nil + for _, subRow in query:ItemSubRowIterator(itemString) do + local _, itemBuyout = subRow:GetBuyouts() + local timeLeft = subRow:GetListingInfo() + if itemBuyout > 0 and timeLeft > operationSettings.ignoreLowDuration then + numBuyouts = numBuyouts + 1 + minItemBuyout = min(minItemBuyout or math.huge, itemBuyout) + maxItemBuyout = max(maxItemBuyout or 0, itemBuyout) + end + end + if numBuyouts <= 1 then + -- there is only one distinct item buyout, so can't stop yet + isFilterDone = false + else + local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) + local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) + if not minPrice or not undercut then + -- the min price or undercut is not valid, so just keep scanning + isFilterDone = false + elseif minItemBuyout - undercut <= minPrice then + local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString) + if operationSettings.priceReset == "ignore" or (resetPrice and maxItemBuyout <= resetPrice) then + -- we need to keep scanning to handle the reset price (always keep scanning for "ignore") + isFilterDone = false + end + end + end + end + end + return isFilterDone +end + +function private.AuctionScanOnQueryDone(_, query) + for itemString in query:ItemIterator() do + local groupPath = TSM.Groups.GetPathByItem(itemString) + if groupPath then + local numHave = 0 + local bagQuery = private.bagDB:NewQuery() + :Select("quantity", "bag", "slot") + :Equal("itemString", itemString) + for _, quantity, bag, slot in bagQuery:Iterator() do + numHave = numHave + quantity + private.DebugLogInsert(itemString, "Filter done and have %d in %d, %d", numHave, bag, slot) + end + bagQuery:Release() + + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do + if private.IsOperationValid(itemString, numHave, operationName, operationSettings) then + local keepQuantity = TSM.Auctioning.Util.GetPrice("keepQuantity", operationSettings, itemString) + assert(keepQuantity) + local operationNumHave = numHave - keepQuantity + if operationNumHave > 0 then + assert(not next(private.subRowsTemp)) + TSM.Auctioning.Util.GetFilteredSubRows(query, itemString, operationSettings, private.subRowsTemp) + local reason, numUsed, itemBuyout, seller, auctionId = private.GeneratePosts(itemString, operationName, operationSettings, operationNumHave, private.subRowsTemp) + wipe(private.subRowsTemp) + numHave = numHave - (numUsed or 0) + seller = seller or "" + auctionId = auctionId or math.huge + TSM.Auctioning.Log.AddEntry(itemString, operationName, reason, seller, itemBuyout or 0, auctionId) + end + end + end + assert(numHave >= 0) + else + Log.Warn("Item removed from group since start of scan: %s", itemString) + end + end +end + +function private.GeneratePosts(itemString, operationName, operationSettings, numHave, subRows) + if numHave == 0 then + return "postNotEnough" + end + + local perAuction, maxCanPost = nil, nil + local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString) + if not TSM.IsWowClassic() then + perAuction = min(postCap, numHave) + maxCanPost = 1 + else + local stackSize = TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString) + local maxStackSize = ItemInfo.GetMaxStack(itemString) + if stackSize > maxStackSize and not operationSettings.stackSizeIsCap then + return "postNotEnough" + end + + perAuction = min(stackSize, maxStackSize) + maxCanPost = min(floor(numHave / perAuction), postCap) + if maxCanPost == 0 then + if operationSettings.stackSizeIsCap then + perAuction = numHave + maxCanPost = 1 + else + -- not enough for single post + return "postNotEnough" + end + end + end + + local lowestAuction = TempTable.Acquire() + if not TSM.Auctioning.Util.GetLowestAuction(subRows, itemString, operationSettings, lowestAuction) then + TempTable.Release(lowestAuction) + lowestAuction = nil + end + local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) + local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString) + local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString) + local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) + local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString) + local aboveMax = TSM.Auctioning.Util.GetPrice("aboveMax", operationSettings, itemString) + + local reason, bid, buyout, seller, activeAuctions = nil, nil, nil, nil, 0 + if not lowestAuction then + -- post as many as we can at the normal price + reason = "postNormal" + buyout = normalPrice + elseif lowestAuction.hasInvalidSeller then + -- we didn't get all the necessary seller info + Log.PrintfUser(L["The seller name of the lowest auction for %s was not given by the server. Skipping this item."], ItemInfo.GetLink(itemString)) + TempTable.Release(lowestAuction) + return "invalidSeller" + elseif lowestAuction.isBlacklist and lowestAuction.isPlayer then + Log.PrintfUser(L["Did not post %s because you or one of your alts (%s) is on the blacklist which is not allowed. Remove this character from your blacklist."], ItemInfo.GetLink(itemString), lowestAuction.seller) + TempTable.Release(lowestAuction) + return "invalidItemGroup" + elseif lowestAuction.isBlacklist and lowestAuction.isWhitelist then + Log.PrintfUser(L["Did not post %s because the owner of the lowest auction (%s) is on both the blacklist and whitelist which is not allowed. Adjust your settings to correct this issue."], ItemInfo.GetLink(itemString), lowestAuction.seller) + TempTable.Release(lowestAuction) + return "invalidItemGroup" + elseif lowestAuction.buyout - undercut < minPrice then + seller = lowestAuction.seller + if resetPrice then + -- lowest is below the min price, but there is a reset price + assert(RESET_REASON_LOOKUP[operationSettings.priceReset], "Unexpected 'below minimum price' setting: "..tostring(operationSettings.priceReset)) + reason = RESET_REASON_LOOKUP[operationSettings.priceReset] + buyout = resetPrice + bid = max(bid or buyout * operationSettings.bidPercent, minPrice) + activeAuctions = TSM.Auctioning.Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, floor(bid), buyout, perAuction) + elseif lowestAuction.isBlacklist then + -- undercut the blacklisted player + reason = "postBlacklist" + buyout = lowestAuction.buyout - undercut + else + -- don't post this item + TempTable.Release(lowestAuction) + return "postBelowMin", nil, nil, seller + end + elseif lowestAuction.isPlayer or (lowestAuction.isWhitelist and TSM.db.global.auctioningOptions.matchWhitelist) then + -- we (or a whitelisted play we should match) are lowest, so match the current price and post as many as we can + activeAuctions = TSM.Auctioning.Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, lowestAuction.bid, lowestAuction.buyout, perAuction) + if lowestAuction.isPlayer then + reason = "postPlayer" + else + reason = "postWhitelist" + end + bid = lowestAuction.bid + buyout = lowestAuction.buyout + seller = lowestAuction.seller + elseif lowestAuction.isWhitelist then + -- don't undercut a whitelisted player + seller = lowestAuction.seller + TempTable.Release(lowestAuction) + return "postWhitelistNoPost", nil, nil, seller + elseif (lowestAuction.buyout - undercut) > maxPrice then + -- we'd be posting above the max price, so resort to the aboveMax setting + seller = lowestAuction.seller + if operationSettings.aboveMax == "none" then + TempTable.Release(lowestAuction) + return "postAboveMaxNoPost", nil, nil, seller + end + assert(ABOVE_MAX_REASON_LOOKUP[operationSettings.aboveMax], "Unexpected 'above max price' setting: "..tostring(operationSettings.aboveMax)) + reason = ABOVE_MAX_REASON_LOOKUP[operationSettings.aboveMax] + buyout = aboveMax + else + -- we just need to do a normal undercut of the lowest auction + reason = "postUndercut" + buyout = lowestAuction.buyout - undercut + seller = lowestAuction.seller + end + if reason == "postBlacklist" then + bid = bid or buyout * operationSettings.bidPercent + else + buyout = max(buyout, minPrice) + bid = max(bid or buyout * operationSettings.bidPercent, minPrice) + end + if lowestAuction then + TempTable.Release(lowestAuction) + end + if TSM.IsWowClassic() then + bid = floor(bid) + else + bid = max(Math.Round(bid, COPPER_PER_SILVER), COPPER_PER_SILVER) + buyout = max(Math.Round(buyout, COPPER_PER_SILVER), COPPER_PER_SILVER) + end + + bid = min(bid, TSM.IsWowClassic() and MAXIMUM_BID_PRICE or MAXIMUM_BID_PRICE - 99) + buyout = min(buyout, TSM.IsWowClassic() and MAXIMUM_BID_PRICE or MAXIMUM_BID_PRICE - 99) + + -- check if we can't post anymore + local queueQuery = private.queueDB:NewQuery() + :Select("numStacks") + :Equal("itemString", itemString) + :Equal("stackSize", perAuction) + :Equal("itemBuyout", buyout) + for _, numStacks in queueQuery:Iterator() do + activeAuctions = activeAuctions + numStacks + end + queueQuery:Release() + if TSM.IsWowClassic() then + maxCanPost = min(postCap - activeAuctions, maxCanPost) + else + perAuction = min(postCap - activeAuctions, perAuction) + end + if maxCanPost <= 0 or perAuction <= 0 then + return "postTooMany" + end + + if TSM.IsWowClassic() and (bid * perAuction > MAXIMUM_BID_PRICE or buyout * perAuction > MAXIMUM_BID_PRICE) then + Log.PrintfUser(L["The buyout price for %s would be above the maximum allowed price. Skipping this item."], ItemInfo.GetLink(itemString)) + return "invalidItemGroup" + end + + -- insert the posts into our DB + local auctionId = private.nextQueueIndex + local postTime = operationSettings.duration + local extraStack = 0 + if TSM.IsWowClassic() then + private.AddToQueue(itemString, operationName, bid, buyout, perAuction, maxCanPost, postTime) + -- check if we can post an extra partial stack + extraStack = (maxCanPost < postCap and operationSettings.stackSizeIsCap and (numHave % perAuction)) or 0 + else + assert(maxCanPost == 1) + if ItemInfo.IsCommodity(itemString) then + local maxPerAuction = ItemInfo.GetMaxStack(itemString) * MAX_COMMODITY_STACKS_PER_AUCTION + maxCanPost = floor(perAuction / maxPerAuction) + -- check if we can post an extra partial stack + extraStack = perAuction % maxPerAuction + perAuction = min(perAuction, maxPerAuction) + else + -- post non-commodities as single stacks + maxCanPost = perAuction + perAuction = 1 + end + assert(maxCanPost > 0 or extraStack > 0) + if maxCanPost > 0 then + private.AddToQueue(itemString, operationName, bid, buyout, perAuction, maxCanPost, postTime) + end + end + if extraStack > 0 then + private.AddToQueue(itemString, operationName, bid, buyout, extraStack, 1, postTime) + end + return reason, (perAuction * maxCanPost) + extraStack, buyout, seller, auctionId +end + +function private.AddToQueue(itemString, operationName, itemBid, itemBuyout, stackSize, numStacks, postTime) + private.DebugLogInsert(itemString, "Queued %d stacks of %d", stackSize, numStacks) + private.queueDB:NewRow() + :SetField("auctionId", private.nextQueueIndex) + :SetField("itemString", itemString) + :SetField("operationName", operationName) + :SetField("bid", itemBid * stackSize) + :SetField("buyout", itemBuyout * stackSize) + :SetField("itemBuyout", itemBuyout) + :SetField("stackSize", stackSize) + :SetField("numStacks", numStacks) + :SetField("postTime", postTime) + :SetField("numProcessed", 0) + :SetField("numConfirmed", 0) + :SetField("numFailed", 0) + :Create() + private.nextQueueIndex = private.nextQueueIndex + 1 +end + + + +-- ============================================================================ +-- Private Helper Functions for Posting +-- ============================================================================ + +function private.GetPostBagSlot(itemString, quantity) + -- start with the slot which is closest to the desired stack size + local bag, slot = private.bagDB:NewQuery() + :Select("bag", "slot") + :Equal("itemString", itemString) + :GreaterThanOrEqual("quantity", quantity) + :OrderBy("quantity", true) + :GetFirstResultAndRelease() + if not bag then + bag, slot = private.bagDB:NewQuery() + :Select("bag", "slot") + :Equal("itemString", itemString) + :LessThanOrEqual("quantity", quantity) + :OrderBy("quantity", false) + :GetFirstResultAndRelease() + end + if not bag or not slot then + -- this item was likely removed from the player's bags, so just give up + Log.Err("Failed to find initial bag / slot (%s, %d)", itemString, quantity) + return nil, true + end + local removeContext = TempTable.Acquire() + bag, slot = private.ItemBagSlotHelper(itemString, bag, slot, quantity, removeContext) + + local bagItemString = ItemString.Get(GetContainerItemLink(bag, slot)) + if not bagItemString or TSM.Groups.TranslateItemString(bagItemString) ~= itemString then + -- something changed with the player's bags so we can't post the item right now + TempTable.Release(removeContext) + private.DebugLogInsert(itemString, "Bags changed") + return nil, nil + end + local _, _, _, quality = GetContainerItemInfo(bag, slot) + assert(quality) + if quality == -1 then + -- the game client doesn't have item info cached for this item, so we can't post it yet + TempTable.Release(removeContext) + private.DebugLogInsert(itemString, "No item info") + return nil, nil + end + for slotId, removeQuantity in pairs(removeContext) do + private.RemoveBagQuantity(slotId, removeQuantity) + end + TempTable.Release(removeContext) + private.DebugLogInsert(itemString, "GetPostBagSlot(%d) -> %d, %d", quantity, bag, slot) + return bag, slot +end + +function private.ItemBagSlotHelper(itemString, bag, slot, quantity, removeContext) + local slotId = SlotId.Join(bag, slot) + + -- try to post completely from the selected slot + local found = private.bagDB:NewQuery() + :Select("slotId") + :Equal("slotId", slotId) + :GreaterThanOrEqual("quantity", quantity) + :GetFirstResultAndRelease() + if found then + removeContext[slotId] = quantity + return bag, slot + end + + -- try to find a stack at a lower slot which has enough to post from + local foundSlotId, foundBag, foundSlot = private.bagDB:NewQuery() + :Select("slotId", "bag", "slot") + :Equal("itemString", itemString) + :LessThan("slotId", slotId) + :GreaterThanOrEqual("quantity", quantity) + :OrderBy("slotId", true) + :GetFirstResultAndRelease() + if foundSlotId then + removeContext[foundSlotId] = quantity + return foundBag, foundSlot + end + + -- try to post using the selected slot and the lower slots + local selectedQuantity = private.bagDB:NewQuery() + :Select("quantity") + :Equal("slotId", slotId) + :GetFirstResultAndRelease() + local query = private.bagDB:NewQuery() + :Select("slotId", "quantity") + :Equal("itemString", itemString) + :LessThan("slotId", slotId) + :OrderBy("slotId", true) + local numNeeded = quantity - selectedQuantity + local numUsed = 0 + local usedSlotIds = TempTable.Acquire() + for _, rowSlotId, rowQuantity in query:Iterator() do + if numNeeded ~= numUsed then + numUsed = min(numUsed + rowQuantity, numNeeded) + tinsert(usedSlotIds, rowSlotId) + end + end + query:Release() + if numNeeded == numUsed then + removeContext[slotId] = selectedQuantity + for _, rowSlotId in TempTable.Iterator(usedSlotIds) do + local rowQuantity = private.bagDB:GetUniqueRowField("slotId", rowSlotId, "quantity") + local rowNumUsed = min(numUsed, rowQuantity) + numUsed = numUsed - rowNumUsed + removeContext[rowSlotId] = (removeContext[rowSlotId] or 0) + rowNumUsed + end + return bag, slot + else + TempTable.Release(usedSlotIds) + end + + -- try posting from the next highest slot + local rowBag, rowSlot = private.bagDB:NewQuery() + :Select("bag", "slot") + :Equal("itemString", itemString) + :GreaterThan("slotId", slotId) + :OrderBy("slotId", true) + :GetFirstResultAndRelease() + if not rowBag or not rowSlot then + private.ErrorForItem(itemString, "Failed to find next highest bag / slot") + end + return private.ItemBagSlotHelper(itemString, rowBag, rowSlot, quantity, removeContext) +end + +function private.RemoveBagQuantity(slotId, quantity) + local row = private.bagDB:GetUniqueRow("slotId", slotId) + local remainingQuantity = row:GetField("quantity") - quantity + private.DebugLogInsert(row:GetField("itemString"), "Removing %d (%d remain) from %d", quantity, remainingQuantity, slotId) + if remainingQuantity > 0 then + row:SetField("quantity", remainingQuantity) + :Update() + else + assert(remainingQuantity == 0) + private.bagDB:DeleteRow(row) + end + row:Release() +end + +function private.ConfirmRowQueryHelper(row) + return row:GetField("numConfirmed") < row:GetField("numProcessed") +end + +function private.NextProcessRowQueryHelper(row) + return row:GetField("numProcessed") < row:GetField("numStacks") +end + +function private.DebugLogInsert(itemString, ...) + tinsert(private.debugLog, itemString) + tinsert(private.debugLog, format(...)) +end + +function private.ErrorForItem(itemString, errorStr) + for i = 1, #private.debugLog, 2 do + if private.debugLog[i] == itemString then + Log.Info(private.debugLog[i + 1]) + end + end + Log.Info("Bag state:") + for b = 0, NUM_BAG_SLOTS do + for s = 1, GetContainerNumSlots(b) do + if ItemString.GetBase(GetContainerItemLink(b, s)) == itemString then + local _, q = GetContainerItemInfo(b, s) + Log.Info("%d in %d, %d", q, b, s) + end + end + end + error(errorStr, 2) +end diff --git a/Core/Service/Auctioning/SavedSearches.lua b/Core/Service/Auctioning/SavedSearches.lua new file mode 100644 index 0000000..15bfd47 --- /dev/null +++ b/Core/Service/Auctioning/SavedSearches.lua @@ -0,0 +1,204 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local SavedSearches = TSM.Auctioning:NewPackage("SavedSearches") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local String = TSM.Include("Util.String") +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local Settings = TSM.Include("Service.Settings") +local private = { + settings = nil, + db = nil, +} +local FILTER_SEP = "\001" +local MAX_RECENT_SEARCHES = 500 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function SavedSearches.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "userData", "savedAuctioningSearches") + + -- remove duplicates + local seen = TempTable.Acquire() + for i = #private.settings.savedAuctioningSearches.filters, 1, -1 do + local filter = private.settings.savedAuctioningSearches.filters[i] + if seen[filter] then + tremove(private.settings.savedAuctioningSearches.filters, i) + tremove(private.settings.savedAuctioningSearches.searchTypes, i) + private.settings.savedAuctioningSearches.name[filter] = nil + private.settings.savedAuctioningSearches.isFavorite[filter] = nil + else + seen[filter] = true + end + end + TempTable.Release(seen) + + -- remove old recent searches + local remainingRecentSearches = MAX_RECENT_SEARCHES + local numRemoved = 0 + for i = #private.settings.savedAuctioningSearches.filters, 1, -1 do + local filter = private.settings.savedAuctioningSearches.filters + if not private.settings.savedAuctioningSearches.isFavorite[filter] then + if remainingRecentSearches > 0 then + remainingRecentSearches = remainingRecentSearches - 1 + else + tremove(private.settings.savedAuctioningSearches.filters, i) + tremove(private.settings.savedAuctioningSearches.searchTypes, i) + private.settings.savedAuctioningSearches.name[filter] = nil + private.settings.savedAuctioningSearches.isFavorite[filter] = nil + numRemoved = numRemoved + 1 + end + end + end + if numRemoved > 0 then + Log.Info("Removed %d old recent searches", numRemoved) + end + + private.db = Database.NewSchema("AUCTIONING_SAVED_SEARCHES") + :AddUniqueNumberField("index") + :AddBooleanField("isFavorite") + :AddStringField("searchType") + :AddStringField("filter") + :AddStringField("name") + :AddIndex("index") + :Commit() + private.RebuildDB() +end + +function SavedSearches.CreateRecentSearchesQuery() + return private.db:NewQuery() + :OrderBy("index", false) +end + +function SavedSearches.CreateFavoriteSearchesQuery() + return private.db:NewQuery() + :Equal("isFavorite", true) + :OrderBy("name", true) +end + +function SavedSearches.SetSearchIsFavorite(dbRow, isFavorite) + local filter = dbRow:GetField("filter") + private.settings.savedAuctioningSearches.isFavorite[filter] = isFavorite or nil + dbRow:SetField("isFavorite", isFavorite) + :Update() +end + +function SavedSearches.RenameSearch(dbRow, newName) + local filter = dbRow:GetField("filter") + private.settings.savedAuctioningSearches.name[filter] = newName + dbRow:SetField("name", newName) + :Update() +end + +function SavedSearches.DeleteSearch(dbRow) + local index, filter = dbRow:GetFields("index", "filter") + tremove(private.settings.savedAuctioningSearches.filters, index) + tremove(private.settings.savedAuctioningSearches.searchTypes, index) + private.settings.savedAuctioningSearches.name[filter] = nil + private.settings.savedAuctioningSearches.isFavorite[filter] = nil + private.RebuildDB() +end + +function SavedSearches.RecordSearch(searchList, searchType) + assert(searchType == "postItems" or searchType == "postGroups" or searchType == "cancelGroups") + local filter = table.concat(searchList, FILTER_SEP) + for i, existingFilter in ipairs(private.settings.savedAuctioningSearches.filters) do + local existingSearchType = private.settings.savedAuctioningSearches.searchTypes[i] + if filter == existingFilter and searchType == existingSearchType then + -- move this to the end of the list and rebuild the DB + -- insert the existing filter so we don't need to update the isFavorite and name tables + tremove(private.settings.savedAuctioningSearches.filters, i) + tinsert(private.settings.savedAuctioningSearches.filters, existingFilter) + tremove(private.settings.savedAuctioningSearches.searchTypes, i) + tinsert(private.settings.savedAuctioningSearches.searchTypes, existingSearchType) + private.RebuildDB() + return + end + end + + -- didn't find an existing entry, so add a new one + tinsert(private.settings.savedAuctioningSearches.filters, filter) + tinsert(private.settings.savedAuctioningSearches.searchTypes, searchType) + assert(#private.settings.savedAuctioningSearches.filters == #private.settings.savedAuctioningSearches.searchTypes) + private.db:NewRow() + :SetField("index", #private.settings.savedAuctioningSearches.filters) + :SetField("isFavorite", false) + :SetField("searchType", searchType) + :SetField("filter", filter) + :SetField("name", private.GetSearchName(filter, searchType)) + :Create() +end + +function SavedSearches.FiltersToTable(dbRow, tbl) + String.SafeSplit(dbRow:GetField("filter"), FILTER_SEP, tbl) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RebuildDB() + assert(#private.settings.savedAuctioningSearches.filters == #private.settings.savedAuctioningSearches.searchTypes) + private.db:TruncateAndBulkInsertStart() + for index, filter in ipairs(private.settings.savedAuctioningSearches.filters) do + local searchType = private.settings.savedAuctioningSearches.searchTypes[index] + assert(searchType == "postItems" or searchType == "postGroups" or searchType == "cancelGroups") + local name = private.settings.savedAuctioningSearches.name[filter] or private.GetSearchName(filter, searchType) + local isFavorite = private.settings.savedAuctioningSearches.isFavorite[filter] and true or false + private.db:BulkInsertNewRow(index, isFavorite, searchType, filter, name) + end + private.db:BulkInsertEnd() +end + +function private.GetSearchName(filter, searchType) + local filters = TempTable.Acquire() + local searchTypeStr, numFiltersStr = nil, nil + if filter == "" or string.sub(filter, 1, 1) == FILTER_SEP then + tinsert(filters, L["Base Group"]) + end + if searchType == "postGroups" or searchType == "cancelGroups" then + for groupPath in gmatch(filter, "[^"..FILTER_SEP.."]+") do + local groupName = TSM.Groups.Path.GetName(groupPath) + local level = select('#', strsplit(TSM.CONST.GROUP_SEP, groupPath)) + local color = Theme.GetGroupColor(level) + tinsert(filters, color:ColorText(groupName)) + end + searchTypeStr = searchType == "postGroups" and L["Post Scan"] or L["Cancel Scan"] + numFiltersStr = #filters == 1 and L["1 Group"] or format(L["%d Groups"], #filters) + elseif searchType == "postItems" then + local numItems = 0 + for itemString in gmatch(filter, "[^"..FILTER_SEP.."]+") do + numItems = numItems + 1 + local coloredName = TSM.UI.GetColoredItemName(itemString) + if coloredName then + tinsert(filters, coloredName) + end + end + searchTypeStr = L["Post Scan"] + numFiltersStr = numItems == 1 and L["1 Item"] or format(L["%d Items"], numItems) + else + error("Unknown searchType: "..tostring(searchType)) + end + local groupList = nil + if #filters > 10 then + groupList = table.concat(filters, ", ", 1, 10)..",..." + TempTable.Release(filters) + else + groupList = strjoin(", ", TempTable.UnpackAndRelease(filters)) + end + return format("%s (%s): %s", searchTypeStr, numFiltersStr, groupList) +end diff --git a/Core/Service/Auctioning/Util.lua b/Core/Service/Auctioning/Util.lua new file mode 100644 index 0000000..06b273e --- /dev/null +++ b/Core/Service/Auctioning/Util.lua @@ -0,0 +1,326 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Util = TSM.Auctioning:NewPackage("Util") +local TempTable = TSM.Include("Util.TempTable") +local Vararg = TSM.Include("Util.Vararg") +local String = TSM.Include("Util.String") +local Math = TSM.Include("Util.Math") +local CustomPrice = TSM.Include("Service.CustomPrice") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local private = { + priceCache = {}, +} +local INVALID_PRICE = {} +local VALID_PRICE_KEYS = { + minPrice = true, + normalPrice = true, + maxPrice = true, + undercut = true, + cancelRepostThreshold = true, + priceReset = true, + aboveMax = true, + postCap = true, + stackSize = true, + keepQuantity = true, + maxExpires = true, +} +local IS_GOLD_PRICE_KEY = { + minPrice = true, + normalPrice = true, + maxPrice = true, + undercut = TSM.IsWowClassic(), + priceReset = true, + aboveMax = true, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Util.GetPrice(key, operation, itemString) + assert(VALID_PRICE_KEYS[key]) + local cacheKey = key..tostring(operation)..itemString + if private.priceCache.updateTime ~= GetTime() then + wipe(private.priceCache) + private.priceCache.updateTime = GetTime() + end + if not private.priceCache[cacheKey] then + local value = nil + if key == "aboveMax" or key == "priceReset" then + -- redirect to the selected price (if applicable) + local priceKey = operation[key] + if VALID_PRICE_KEYS[priceKey] then + value = Util.GetPrice(priceKey, operation, itemString) + end + else + value = CustomPrice.GetValue(operation[key], itemString, not IS_GOLD_PRICE_KEY[key]) + end + if not TSM.IsWowClassic() and IS_GOLD_PRICE_KEY[key] then + value = value and Math.Ceil(value, COPPER_PER_SILVER) or nil + else + value = value and Math.Round(value) or nil + end + local minValue, maxValue = TSM.Operations.Auctioning.GetMinMaxValues(key) + private.priceCache[cacheKey] = (value and value >= minValue and value <= maxValue) and value or INVALID_PRICE + end + if private.priceCache[cacheKey] == INVALID_PRICE then + return nil + end + return private.priceCache[cacheKey] +end + +function Util.GetLowestAuction(subRows, itemString, operationSettings, resultTbl) + if not TSM.IsWowClassic() then + local foundLowest = false + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft = subRow:GetListingInfo() + if not foundLowest and not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) then + local ownerStr = subRow:GetOwnerInfo() + local _, auctionId = subRow:GetListingInfo() + local _, itemMinBid = subRow:GetBidInfo() + local firstSeller = strsplit(",", ownerStr) + resultTbl.buyout = itemBuyout + resultTbl.bid = itemMinBid + resultTbl.seller = firstSeller + resultTbl.auctionId = auctionId + resultTbl.isWhitelist = TSM.db.factionrealm.auctioningOptions.whitelist[strlower(firstSeller)] and true or false + resultTbl.isPlayer = PlayerInfo.IsPlayer(firstSeller, true, true, true) + if not subRow:HasOwners() then + resultTbl.hasInvalidSeller = true + end + foundLowest = true + end + end + return foundLowest + else + local hasInvalidSeller = nil + local ignoreWhitelist = nil + local lowestItemBuyout = nil + local lowestAuction = nil + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft = subRow:GetListingInfo() + if not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) then + assert(itemBuyout and itemBuyout > 0) + lowestItemBuyout = lowestItemBuyout or itemBuyout + if itemBuyout == lowestItemBuyout then + local ownerStr = subRow:GetOwnerInfo() + local _, auctionId = subRow:GetListingInfo() + local _, itemMinBid = subRow:GetBidInfo() + local temp = TempTable.Acquire() + temp.buyout = itemBuyout + temp.bid = itemMinBid + temp.seller = ownerStr + temp.auctionId = auctionId + temp.isWhitelist = TSM.db.factionrealm.auctioningOptions.whitelist[strlower(ownerStr)] and true or false + temp.isPlayer = PlayerInfo.IsPlayer(ownerStr, true, true, true) + if not temp.isWhitelist and not temp.isPlayer then + -- there is a non-whitelisted competitor, so we don't care if a whitelisted competitor also posts at this price + ignoreWhitelist = true + end + if not subRow:HasOwners() and next(TSM.db.factionrealm.auctioningOptions.whitelist) then + hasInvalidSeller = true + end + if operationSettings.blacklist then + for _, player in Vararg.Iterator(strsplit(",", operationSettings.blacklist)) do + if String.SeparatedContains(strlower(ownerStr), ",", strlower(strtrim(player))) then + temp.isBlacklist = true + end + end + end + if not lowestAuction then + lowestAuction = temp + elseif private.LowestAuctionCompare(temp, lowestAuction) then + TempTable.Release(lowestAuction) + lowestAuction = temp + else + TempTable.Release(temp) + end + end + end + end + if not lowestAuction then + return false + end + for k, v in pairs(lowestAuction) do + resultTbl[k] = v + end + TempTable.Release(lowestAuction) + if resultTbl.isWhitelist and ignoreWhitelist then + resultTbl.isWhitelist = false + end + resultTbl.hasInvalidSeller = hasInvalidSeller + return true + end +end + +function Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, findBid, findBuyout, findStackSize) + local playerQuantity = 0 + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft = subRow:GetListingInfo() + if not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) then + local _, itemMinBid = subRow:GetBidInfo() + if itemMinBid == findBid and itemBuyout == findBuyout and (not TSM.IsWowClassic() or quantity == findStackSize) then + local count = private.GetPlayerAuctionCount(subRow) + if not TSM.IsWowClassic() and count == 0 and playerQuantity > 0 then + -- there's another player's auction after ours, so stop counting + break + end + playerQuantity = playerQuantity + count + end + end + end + return playerQuantity +end + +function Util.GetPlayerLowestBuyout(subRows, itemString, operationSettings) + local lowestItemBuyout, lowestItemAuctionId = nil, nil + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft, auctionId = subRow:GetListingInfo() + if not lowestItemBuyout and not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) and private.GetPlayerAuctionCount(subRow) > 0 then + lowestItemBuyout = itemBuyout + lowestItemAuctionId = auctionId + end + end + return lowestItemBuyout, lowestItemAuctionId +end + +function Util.GetLowestNonPlayerAuctionId(subRows, itemString, operationSettings, lowestItemBuyout) + local lowestItemAuctionId = nil + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft, auctionId = subRow:GetListingInfo() + if not lowestItemAuctionId and not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) and private.GetPlayerAuctionCount(subRow) == 0 and itemBuyout == lowestItemBuyout then + lowestItemAuctionId = auctionId + end + end + return lowestItemAuctionId +end + +function Util.IsPlayerOnlySeller(subRows, itemString, operationSettings) + local isOnly = true + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft = subRow:GetListingInfo() + if isOnly and not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) and private.GetPlayerAuctionCount(subRow) < (TSM.IsWowClassic() and 1 or quantity) then + isOnly = false + end + end + return isOnly +end + +function Util.GetNextLowestItemBuyout(subRows, itemString, lowestAuction, operationSettings) + local nextLowestItemBuyout = nil + for _, subRow in ipairs(subRows) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft, auctionId = subRow:GetListingInfo() + local isLower = itemBuyout > lowestAuction.buyout or (itemBuyout == lowestAuction.buyout and auctionId < lowestAuction.auctionId) + if not nextLowestItemBuyout and not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) and isLower then + nextLowestItemBuyout = itemBuyout + end + end + return nextLowestItemBuyout +end + +function Util.GetQueueStatus(query) + local numProcessed, numConfirmed, numFailed, totalNum = 0, 0, 0, 0 + query:OrderBy("auctionId", true) + for _, row in query:Iterator() do + local rowNumStacks, rowNumProcessed, rowNumConfirmed, rowNumFailed = row:GetFields("numStacks", "numProcessed", "numConfirmed", "numFailed") + totalNum = totalNum + rowNumStacks + numProcessed = numProcessed + rowNumProcessed + numConfirmed = numConfirmed + rowNumConfirmed + numFailed = numFailed + rowNumFailed + end + query:Release() + return numProcessed, numConfirmed, numFailed, totalNum +end + +function Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) + if timeLeft <= operationSettings.ignoreLowDuration then + -- ignoring low duration + return true + elseif TSM.IsWowClassic() and operationSettings.matchStackSize and quantity ~= Util.GetPrice("stackSize", operationSettings, itemString) then + -- matching stack size + return true + elseif operationSettings.priceReset == "ignore" then + local minPrice = Util.GetPrice("minPrice", operationSettings, itemString) + local undercut = Util.GetPrice("undercut", operationSettings, itemString) + if minPrice and itemBuyout - undercut < minPrice then + -- ignoring auctions below threshold + return true + end + end + return false +end + +function Util.GetFilteredSubRows(query, itemString, operationSettings, result) + for _, subRow in query:ItemSubRowIterator(itemString) do + local _, itemBuyout = subRow:GetBuyouts() + local quantity = subRow:GetQuantities() + local timeLeft = subRow:GetListingInfo() + if not Util.IsFiltered(itemString, operationSettings, itemBuyout, quantity, timeLeft) then + tinsert(result, subRow) + end + end + sort(result, private.SubRowSortHelper) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SubRowSortHelper(a, b) + local _, aItemBuyout = a:GetBuyouts() + local _, bItemBuyout = b:GetBuyouts() + if aItemBuyout ~= bItemBuyout then + return aItemBuyout < bItemBuyout + end + local _, aAuctionId = a:GetListingInfo() + local _, bAuctionId = b:GetListingInfo() + return aAuctionId > bAuctionId +end + +function private.LowestAuctionCompare(a, b) + if a.isBlacklist ~= b.isBlacklist then + return a.isBlacklist + end + if a.isWhitelist ~= b.isWhitelist then + return a.isWhitelist + end + if a.auctionId ~= b.auctionId then + return a.auctionId > b.auctionId + end + if a.isPlayer ~= b.isPlayer then + return b.isPlayer + end + return tostring(a) < tostring(b) +end + +function private.GetPlayerAuctionCount(subRow) + local ownerStr, numOwnerItems = subRow:GetOwnerInfo() + if TSM.IsWowClassic() then + return PlayerInfo.IsPlayer(ownerStr, true, true, true) and select(2, subRow:GetQuantities()) or 0 + else + return numOwnerItems + end +end diff --git a/Core/Service/Banking/Auctioning.lua b/Core/Service/Banking/Auctioning.lua new file mode 100644 index 0000000..5ec2f80 --- /dev/null +++ b/Core/Service/Banking/Auctioning.lua @@ -0,0 +1,123 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctioning = TSM.Banking:NewPackage("Auctioning") +local TempTable = TSM.Include("Util.TempTable") +local BagTracking = TSM.Include("Service.BagTracking") +local Inventory = TSM.Include("Service.Inventory") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctioning.MoveGroupsToBank(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromBags(items, groups, private.GroupsGetNumToMoveToBank) + TSM.Banking.MoveToBank(items, callback) + TempTable.Release(items) +end + +function Auctioning.PostCapToBags(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromOpenBank(items, groups, private.GetNumToMoveToBags) + TSM.Banking.MoveToBag(items, callback) + TempTable.Release(items) +end + +function Auctioning.ShortfallToBags(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromOpenBank(items, groups, private.GetNumToMoveToBags, true) + TSM.Banking.MoveToBag(items, callback) + TempTable.Release(items) +end + +function Auctioning.MaxExpiresToBank(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromBags(items, groups, private.MaxExpiresGetNumToMoveToBank) + TSM.Banking.MoveToBank(items, callback) + TempTable.Release(items) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GroupsGetNumToMoveToBank(itemString, numHave) + -- move everything + return numHave +end + +function private.GetNumToMoveToBags(itemString, numHave, includeAH) + local totalNumToMove = 0 + local numAvailable = numHave + local numInBags = BagTracking.CreateQueryBagsItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :SumAndRelease("quantity") or 0 + if includeAH then + numInBags = numInBags + select(3, Inventory.GetPlayerTotals(itemString)) + Inventory.GetMailQuantity(itemString) + end + + for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", TSM.Groups.GetPathByItem(itemString)) do + local maxExpires = TSM.Auctioning.Util.GetPrice("maxExpires", operationSettings, itemString) + local operationHasExpired = false + if maxExpires and maxExpires > 0 then + local numExpires = TSM.Accounting.Auctions.GetNumExpiresSinceSale(itemString) + if numExpires and numExpires > maxExpires then + operationHasExpired = true + end + end + + local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString) + local stackSize = (TSM.IsWowClassic() and TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString)) or (not TSM.IsWowClassic() and 1) + if not operationHasExpired and postCap and stackSize then + local numNeeded = stackSize * postCap + if numInBags > numNeeded then + -- we can satisfy this operation from the bags + numInBags = numInBags - numNeeded + numNeeded = 0 + elseif numInBags > 0 then + -- we can partially satisfy this operation from the bags + numNeeded = numNeeded - numInBags + numInBags = 0 + end + + local numToMove = min(numAvailable, numNeeded) + if numToMove > 0 then + numAvailable = numAvailable - numToMove + totalNumToMove = totalNumToMove + numToMove + end + end + end + + return totalNumToMove +end + +function private.MaxExpiresGetNumToMoveToBank(itemString, numHave) + local numToKeepInBags = 0 + for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", TSM.Groups.GetPathByItem(itemString)) do + local maxExpires = TSM.Auctioning.Util.GetPrice("maxExpires", operationSettings, itemString) + local operationHasExpired = false + if maxExpires and maxExpires > 0 then + local numExpires = TSM.Accounting.Auctions.GetNumExpiresSinceSale(itemString) + if numExpires and numExpires > maxExpires then + operationHasExpired = true + end + end + local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString) + local stackSize = (TSM.IsWowClassic() and TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString)) or (not TSM.IsWowClassic() and 1) + if not operationHasExpired and postCap and stackSize then + numToKeepInBags = numToKeepInBags + stackSize * postCap + end + end + return max(numHave - numToKeepInBags, 0) +end diff --git a/Core/Service/Banking/Core.lua b/Core/Service/Banking/Core.lua new file mode 100644 index 0000000..9d63d15 --- /dev/null +++ b/Core/Service/Banking/Core.lua @@ -0,0 +1,321 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Banking = TSM:NewPackage("Banking") +local Event = TSM.Include("Util.Event") +local TempTable = TSM.Include("Util.TempTable") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + moveThread = nil, + moveItems = {}, + restoreItems = {}, + restoreFrame = nil, + callback = nil, + openFrame = nil, + frameCallbacks = {}, +} +local MOVE_WAIT_TIMEOUT = 2 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Banking.OnInitialize() + private.moveThread = Threading.New("BANKING_MOVE", private.MoveThread) + + Event.Register("BANKFRAME_OPENED", private.BankOpened) + Event.Register("BANKFRAME_CLOSED", private.BankClosed) + if not TSM.IsWowClassic() then + Event.Register("GUILDBANKFRAME_OPENED", private.GuildBankOpened) + Event.Register("GUILDBANKFRAME_CLOSED", private.GuildBankClosed) + end +end + +function Banking.RegisterFrameCallback(callback) + tinsert(private.frameCallbacks, callback) +end + +function Banking.IsGuildBankOpen() + return private.openFrame == "GUILD_BANK" +end + +function Banking.IsBankOpen() + return private.openFrame == "BANK" +end + +function Banking.MoveToBag(items, callback) + assert(private.openFrame) + local context = Banking.IsGuildBankOpen() and Banking.MoveContext.GetGuildBankToBag() or Banking.MoveContext.GetBankToBag() + private.StartMove(items, context, callback) +end + +function Banking.MoveToBank(items, callback) + assert(private.openFrame) + local context = Banking.IsGuildBankOpen() and Banking.MoveContext.GetBagToGuildBank() or Banking.MoveContext.GetBagToBank() + private.StartMove(items, context, callback) +end + +function Banking.EmptyBags(callback) + assert(private.openFrame) + local items = TempTable.Acquire() + for _, _, _, itemString, quantity in Banking.Util.BagIterator(false) do + items[itemString] = (items[itemString] or 0) + quantity + end + wipe(private.restoreItems) + private.restoreFrame = private.openFrame + private.callback = callback + local context = Banking.IsGuildBankOpen() and Banking.MoveContext.GetBagToGuildBank() or Banking.MoveContext.GetBagToBank() + private.StartMove(items, context, private.EmptyBagsThreadCallbackWrapper) + TempTable.Release(items) +end + +function Banking.RestoreBags(callback) + assert(private.openFrame) + assert(Banking.CanRestoreBags()) + private.callback = callback + local context = Banking.IsGuildBankOpen() and Banking.MoveContext.GetGuildBankToBag() or Banking.MoveContext.GetBankToBag() + private.StartMove(private.restoreItems, context, private.RestoreBagsThreadCallbackWrapper) +end + +function Banking.CanRestoreBags() + assert(private.openFrame) + return private.openFrame == private.restoreFrame +end + +function Banking.PutByFilter(filterStr) + if not private.openFrame then + return + end + local filterItemString = ItemString.Get(filterStr) + filterStr = String.Escape(strlower(filterStr)) + + local items = TempTable.Acquire() + for _, _, _, itemString, quantity in Banking.Util.BagIterator(false) do + items[itemString] = (items[itemString] or 0) + quantity + end + + for itemString in pairs(items) do + if not private.MatchesFilter(itemString, filterStr, filterItemString) then + -- remove this item + items[itemString] = nil + end + end + + Banking.MoveToBank(items, private.GetPutCallback) + TempTable.Release(items) +end + +function Banking.GetByFilter(filterStr) + if not private.openFrame then + return + end + local filterItemString = ItemString.Get(filterStr) + filterStr = String.Escape(strlower(filterStr)) + + local items = TempTable.Acquire() + for _, _, _, itemString, quantity in Banking.Util.OpenBankIterator(false) do + items[itemString] = (items[itemString] or 0) + quantity + end + + for itemString in pairs(items) do + if not private.MatchesFilter(itemString, filterStr, filterItemString) then + -- remove this item + items[itemString] = nil + end + end + + Banking.MoveToBag(items, private.GetPutCallback) + TempTable.Release(items) +end + + + +-- ============================================================================ +-- Threads +-- ============================================================================ + +function private.MoveThread(context, callback) + local numMoves = 0 + local emptySlotIds = Threading.AcquireSafeTempTable() + context:GetEmptySlotsThreaded(emptySlotIds) + local slotIds = Threading.AcquireSafeTempTable() + local slotItemString = Threading.AcquireSafeTempTable() + local slotMoveQuantity = Threading.AcquireSafeTempTable() + local slotEndQuantity = Threading.AcquireSafeTempTable() + for itemString, numQueued in pairs(private.moveItems) do + for _, slotId, quantity in context:SlotIdIterator(itemString) do + if numQueued > 0 then + -- find a suitable empty slot + local targetSlotId = context:GetTargetSlotId(itemString, emptySlotIds) + if targetSlotId then + assert(not slotIds[slotId]) + slotIds[slotId] = targetSlotId + slotItemString[slotId] = itemString + slotMoveQuantity[slotId] = min(quantity, numQueued) + slotEndQuantity[slotId] = max(quantity - numQueued, 0) + numQueued = numQueued - slotMoveQuantity[slotId] + numMoves = numMoves + 1 + else + Log.Err("No target slot") + end + end + end + if numQueued > 0 then + Log.Err("No slots with item (%s)", itemString) + end + end + + local numDone = 0 + while next(slotIds) do + local movedSlotId = nil + -- do all the pending moves + for slotId, targetSlotId in pairs(slotIds) do + context:MoveSlot(slotId, targetSlotId, slotMoveQuantity[slotId]) + Threading.Yield() + if private.openFrame == "GUILD_BANK" then + movedSlotId = slotId + break + end + end + + -- wait for at least one to finish or the timeout to elapse + local didMove = false + local timeout = GetTime() + MOVE_WAIT_TIMEOUT + while not didMove and GetTime() < timeout do + -- check which moves are done + for slotId in pairs(slotIds) do + if private.openFrame ~= "GUILD_BANK" or slotId == movedSlotId then + if context:GetSlotQuantity(slotId) <= slotEndQuantity[slotId] then + didMove = true + slotIds[slotId] = nil + numDone = numDone + 1 + callback("MOVED", slotItemString[slotId], slotMoveQuantity[slotId]) + end + if didMove and slotId == movedSlotId then + break + end + Threading.Yield() + end + end + if didMove then + callback("PROGRESS", numDone / numMoves) + end + Threading.Yield(true) + end + end + + if private.openFrame == "GUILD_BANK" then + QueryGuildBankTab(GetCurrentGuildBankTab()) + end + + Threading.ReleaseSafeTempTable(slotIds) + Threading.ReleaseSafeTempTable(slotItemString) + Threading.ReleaseSafeTempTable(slotMoveQuantity) + Threading.ReleaseSafeTempTable(slotEndQuantity) + Threading.ReleaseSafeTempTable(emptySlotIds) + callback("DONE") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.BankOpened() + if private.openFrame == "BANK" then + return + end + assert(not private.openFrame) + private.openFrame = "BANK" + for _, callback in ipairs(private.frameCallbacks) do + callback(private.openFrame) + end +end + +function private.GuildBankOpened() + if private.openFrame == "GUILD_BANK" then + return + end + assert(not private.openFrame) + private.openFrame = "GUILD_BANK" + for _, callback in ipairs(private.frameCallbacks) do + callback(private.openFrame) + end +end + +function private.BankClosed() + if not private.openFrame then + return + end + private.openFrame = nil + private.StopMove() + for _, callback in ipairs(private.frameCallbacks) do + callback(private.openFrame) + end +end + +function private.GuildBankClosed() + if not private.openFrame then + return + end + private.openFrame = nil + private.StopMove() + for _, callback in ipairs(private.frameCallbacks) do + callback(private.openFrame) + end +end + +function private.StartMove(items, context, callback) + private.StopMove() + wipe(private.moveItems) + for itemString, quantity in pairs(items) do + private.moveItems[itemString] = quantity + end + Threading.Start(private.moveThread, context, callback) +end + +function private.StopMove() + Threading.Kill(private.moveThread) +end + +function private.EmptyBagsThreadCallbackWrapper(event, ...) + if event == "MOVED" then + local itemString, numMoved = ... + private.restoreItems[itemString] = (private.restoreItems[itemString] or 0) + numMoved + elseif event == "DONE" then + if not next(private.restoreItems) then + private.restoreFrame = private.openFrame + end + end + private.callback(event, ...) +end + +function private.RestoreBagsThreadCallbackWrapper(event, ...) + if event == "DONE" then + wipe(private.restoreItems) + private.restoreFrame = nil + end + private.callback(event, ...) +end + +function private.GetPutCallback(event) + if event == "DONE" then + Log.PrintUser(DONE) + end +end + +function private.MatchesFilter(itemString, filterStr, filterItemString) + local name = strlower(ItemInfo.GetName(itemString) or "") + return strmatch(ItemString.GetBase(itemString), filterStr) or strmatch(name, filterStr) or (filterItemString and itemString == filterItemString) +end diff --git a/Core/Service/Banking/Mailing.lua b/Core/Service/Banking/Mailing.lua new file mode 100644 index 0000000..4a276e1 --- /dev/null +++ b/Core/Service/Banking/Mailing.lua @@ -0,0 +1,105 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Mailing = TSM.Banking:NewPackage("Mailing") +local TempTable = TSM.Include("Util.TempTable") +local Inventory = TSM.Include("Service.Inventory") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Mailing.MoveGroupsToBank(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromBags(items, groups, private.GroupsGetNumToMoveToBank) + TSM.Banking.MoveToBank(items, callback) + TempTable.Release(items) +end + +function Mailing.NongroupToBank(callback) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateItemsFromBags(items, private.NongroupGetNumToBank) + TSM.Banking.MoveToBank(items, callback) + TempTable.Release(items) +end + +function Mailing.TargetShortfallToBags(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromOpenBank(items, groups, private.TargetShortfallGetNumToBags) + TSM.Banking.MoveToBag(items, callback) + TempTable.Release(items) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GroupsGetNumToMoveToBank(itemString, numHave) + -- move everything + return numHave +end + +function private.NongroupGetNumToBank(itemString, numHave) + local hasOperations = false + for _ in TSM.Operations.GroupOperationIterator("Mailing", TSM.Groups.GetPathByItem(itemString)) do + hasOperations = true + end + return not hasOperations and numHave or 0 +end + +function private.TargetShortfallGetNumToBags(itemString, numHave) + local totalNumToSend = 0 + for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Mailing", TSM.Groups.GetPathByItem(itemString)) do + local numAvailable = numHave - operationSettings.keepQty + local numToSend = 0 + if numAvailable > 0 then + if operationSettings.maxQtyEnabled then + if operationSettings.restock then + local targetQty = private.GetTargetQuantity(operationSettings.target, itemString, operationSettings.restockSources) + if PlayerInfo.IsPlayer(operationSettings.target) and targetQty <= operationSettings.maxQty then + numToSend = numAvailable + else + numToSend = min(numAvailable, operationSettings.maxQty - targetQty) + end + if PlayerInfo.IsPlayer(operationSettings.target) then + -- if using restock and target == player ensure that subsequent operations don't take reserved bag inventory + numHave = numHave - max((numAvailable - (targetQty - operationSettings.maxQty)), 0) + end + else + numToSend = min(numAvailable, operationSettings.maxQty) + end + else + numToSend = numAvailable + end + end + totalNumToSend = totalNumToSend + numToSend + numHave = numHave - numToSend + end + return totalNumToSend +end + +function private.GetTargetQuantity(player, itemString, sources) + if player then + player = strtrim(strmatch(player, "^[^-]+")) + end + local num = Inventory.GetBagQuantity(itemString, player) + Inventory.GetMailQuantity(itemString, player) + Inventory.GetAuctionQuantity(itemString, player) + if sources then + if sources.guild then + num = num + Inventory.GetGuildQuantity(itemString, PlayerInfo.GetPlayerGuild(player)) + end + if sources.bank then + num = num + Inventory.GetBankQuantity(itemString, player) + Inventory.GetReagentBankQuantity(itemString, player) + end + end + return num +end diff --git a/Core/Service/Banking/MoveContext.lua b/Core/Service/Banking/MoveContext.lua new file mode 100644 index 0000000..03cb1f6 --- /dev/null +++ b/Core/Service/Banking/MoveContext.lua @@ -0,0 +1,292 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local MoveContext = TSM.Banking:NewPackage("MoveContext") +local Table = TSM.Include("Util.Table") +local SlotId = TSM.Include("Util.SlotId") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local InventoryInfo = TSM.Include("Service.InventoryInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local GuildTracking = TSM.Include("Service.GuildTracking") +local private = { + bagToBank = nil, + bankToBag = nil, + bagToGuildBank = nil, + guildBankToBag = nil, +} +-- don't use MAX_GUILDBANK_SLOTS_PER_TAB since it isn't available right away +local GUILD_BANK_TAB_SLOTS = 98 + + + +-- ============================================================================ +-- BaseMoveContext Class +-- ============================================================================ + +local BaseMoveContext = TSM.Include("LibTSMClass").DefineClass("BaseMoveContext", nil, "ABSTRACT") + + + +-- ============================================================================ +-- BagToBankMoveContext Class +-- ============================================================================ + +local BagToBankMoveContext = TSM.Include("LibTSMClass").DefineClass("BagToBankMoveContext", BaseMoveContext) + +function BagToBankMoveContext.MoveSlot(self, fromSlotId, toSlotId, quantity) + local fromBag, fromSlot = SlotId.Split(fromSlotId) + SplitContainerItem(fromBag, fromSlot, quantity) + if GetCursorInfo() == "item" then + PickupContainerItem(SlotId.Split(toSlotId)) + end + ClearCursor() +end + +function BagToBankMoveContext.GetSlotQuantity(self, slotId) + return private.BagBankGetSlotQuantity(slotId) +end + +function BagToBankMoveContext.SlotIdIterator(self, itemString) + return private.BagSlotIdIterator(itemString) +end + +function BagToBankMoveContext.GetEmptySlotsThreaded(self, emptySlotIds) + local sortValue = Threading.AcquireSafeTempTable() + if not TSM.IsWowClassic() then + private.GetEmptySlotsHelper(REAGENTBANK_CONTAINER, emptySlotIds, sortValue) + end + private.GetEmptySlotsHelper(BANK_CONTAINER, emptySlotIds, sortValue) + for bag = NUM_BAG_SLOTS + 1, NUM_BAG_SLOTS + NUM_BANKBAGSLOTS do + private.GetEmptySlotsHelper(bag, emptySlotIds, sortValue) + end + Table.SortWithValueLookup(emptySlotIds, sortValue) + Threading.ReleaseSafeTempTable(sortValue) +end + +function BagToBankMoveContext.GetTargetSlotId(self, itemString, emptySlotIds) + return private.BagBankGetTargetSlotId(itemString, emptySlotIds) +end + + + +-- ============================================================================ +-- BankToBagMoveContext Class +-- ============================================================================ + +local BankToBagMoveContext = TSM.Include("LibTSMClass").DefineClass("BankToBagMoveContext", BaseMoveContext) + +function BankToBagMoveContext.MoveSlot(self, fromSlotId, toSlotId, quantity) + local fromBag, fromSlot = SlotId.Split(fromSlotId) + SplitContainerItem(fromBag, fromSlot, quantity) + if GetCursorInfo() == "item" then + PickupContainerItem(SlotId.Split(toSlotId)) + end + ClearCursor() +end + +function BankToBagMoveContext.GetSlotQuantity(self, slotId) + return private.BagBankGetSlotQuantity(slotId) +end + +function BankToBagMoveContext.SlotIdIterator(self, itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + return BagTracking.CreateQueryBankItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :Select("slotId", "quantity") + :IteratorAndRelease() +end + +function BankToBagMoveContext.GetEmptySlotsThreaded(self, emptySlotIds) + private.BagGetEmptySlotsThreaded(emptySlotIds) +end + +function BankToBagMoveContext.GetTargetSlotId(self, itemString, emptySlotIds) + return private.BagBankGetTargetSlotId(itemString, emptySlotIds) +end + + + +-- ============================================================================ +-- BagToGuildBankMoveContext Class +-- ============================================================================ + +local BagToGuildBankMoveContext = TSM.Include("LibTSMClass").DefineClass("BagToGuildBankMoveContext", BaseMoveContext) + +function BagToGuildBankMoveContext.MoveSlot(self, fromSlotId, toSlotId, quantity) + local fromBag, fromSlot = SlotId.Split(fromSlotId) + SplitContainerItem(fromBag, fromSlot, quantity) + if GetCursorInfo() == "item" then + PickupGuildBankItem(SlotId.Split(toSlotId)) + end + ClearCursor() +end + +function BagToGuildBankMoveContext.GetSlotQuantity(self, slotId) + return private.BagBankGetSlotQuantity(slotId) +end + +function BagToGuildBankMoveContext.SlotIdIterator(self, itemString) + return private.BagSlotIdIterator(itemString) +end + +function BagToGuildBankMoveContext.GetEmptySlotsThreaded(self, emptySlotIds) + local currentTab = GetCurrentGuildBankTab() + local _, _, _, _, numWithdrawals = GetGuildBankTabInfo(currentTab) + if numWithdrawals == -1 or numWithdrawals >= GUILD_BANK_TAB_SLOTS then + for slot = 1, GUILD_BANK_TAB_SLOTS do + if not GetGuildBankItemInfo(currentTab, slot) then + tinsert(emptySlotIds, SlotId.Join(currentTab, slot)) + end + end + end + for tab = 1, GetNumGuildBankTabs() do + if tab ~= currentTab then + -- only use tabs which we have at least enough withdrawals to withdraw every slot + _, _, _, _, numWithdrawals = GetGuildBankTabInfo(tab) + if numWithdrawals == -1 or numWithdrawals >= GUILD_BANK_TAB_SLOTS then + for slot = 1, GUILD_BANK_TAB_SLOTS do + if not GetGuildBankItemInfo(tab, slot) then + tinsert(emptySlotIds, SlotId.Join(tab, slot)) + end + end + end + end + end +end + +function BagToGuildBankMoveContext.GetTargetSlotId(self, itemString, emptySlotIds) + return tremove(emptySlotIds, 1) +end + + + +-- ============================================================================ +-- GuildBankToBagMoveContext Class +-- ============================================================================ + +local GuildBankToBagMoveContext = TSM.Include("LibTSMClass").DefineClass("GuildBankToBagMoveContext", BaseMoveContext) + +function GuildBankToBagMoveContext.MoveSlot(self, fromSlotId, toSlotId, quantity) + local fromTab, fromSlot = SlotId.Split(fromSlotId) + SplitGuildBankItem(fromTab, fromSlot, quantity) + if GetCursorInfo() == "item" then + PickupContainerItem(SlotId.Split(toSlotId)) + end + ClearCursor() +end + +function GuildBankToBagMoveContext.GetSlotQuantity(self, slotId) + local tab, slot = SlotId.Split(slotId) + QueryGuildBankTab(tab) + local _, quantity = GetGuildBankItemInfo(tab, slot) + return quantity or 0 +end + +function GuildBankToBagMoveContext.SlotIdIterator(self, itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + return GuildTracking.CreateQueryItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :Select("slotId", "quantity") + :IteratorAndRelease() +end + +function GuildBankToBagMoveContext.GetEmptySlotsThreaded(self, emptySlotIds) + private.BagGetEmptySlotsThreaded(emptySlotIds) +end + +function GuildBankToBagMoveContext.GetTargetSlotId(self, itemString, emptySlotIds) + return private.BagBankGetTargetSlotId(itemString, emptySlotIds) +end + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function MoveContext.GetBagToBank() + private.bagToBank = private.bagToBank or BagToBankMoveContext() + return private.bagToBank +end + +function MoveContext.GetBankToBag() + private.bankToBag = private.bankToBag or BankToBagMoveContext() + return private.bankToBag +end + +function MoveContext.GetBagToGuildBank() + private.bagToGuildBank = private.bagToGuildBank or BagToGuildBankMoveContext() + return private.bagToGuildBank +end + +function MoveContext.GetGuildBankToBag() + private.guildBankToBag = private.guildBankToBag or GuildBankToBagMoveContext() + return private.guildBankToBag +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.BagBankGetSlotQuantity(slotId) + local _, quantity = GetContainerItemInfo(SlotId.Split(slotId)) + return quantity or 0 +end + +function private.BagSlotIdIterator(itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + local query = BagTracking.CreateQueryBagsItem(itemString) + :Select("slotId", "quantity") + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + if TSM.Banking.IsGuildBankOpen() then + query:Equal("isBoA", false) + query:Equal("isBoP", false) + end + return query:IteratorAndRelease() +end + +function private.BagGetEmptySlotsThreaded(emptySlotIds) + local sortValue = Threading.AcquireSafeTempTable() + for bag = BACKPACK_CONTAINER, NUM_BAG_SLOTS do + private.GetEmptySlotsHelper(bag, emptySlotIds, sortValue) + end + Table.SortWithValueLookup(emptySlotIds, sortValue) + Threading.ReleaseSafeTempTable(sortValue) +end + +function private.GetEmptySlotsHelper(bag, emptySlotIds, sortValue) + local isSpecial = nil + if bag == REAGENTBANK_CONTAINER then + isSpecial = true + elseif bag == BACKPACK_CONTAINER or bag == BANK_CONTAINER then + isSpecial = false + else + isSpecial = (GetItemFamily(GetInventoryItemLink("player", ContainerIDToInventoryID(bag))) or 0) ~= 0 + end + for slot = 1, GetContainerNumSlots(bag) do + if not GetContainerItemInfo(bag, slot) then + local slotId = SlotId.Join(bag, slot) + tinsert(emptySlotIds, slotId) + sortValue[slotId] = slotId + (isSpecial and 0 or 100000) + end + end +end + +function private.BagBankGetTargetSlotId(itemString, emptySlotIds) + for i, slotId in ipairs(emptySlotIds) do + local bag = SlotId.Split(slotId) + if InventoryInfo.ItemWillGoInBag(ItemInfo.GetLink(itemString), bag) then + return tremove(emptySlotIds, i) + end + end +end diff --git a/Core/Service/Banking/Util.lua b/Core/Service/Banking/Util.lua new file mode 100644 index 0000000..ea91971 --- /dev/null +++ b/Core/Service/Banking/Util.lua @@ -0,0 +1,115 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Util = TSM.Banking:NewPackage("Util") +local TempTable = TSM.Include("Util.TempTable") +local BagTracking = TSM.Include("Service.BagTracking") +local GuildTracking = TSM.Include("Service.GuildTracking") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Util.BagIterator(autoBaseItems) + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + if autoBaseItems then + query:VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Select("bag", "slot", "autoBaseItemString", "quantity") + else + query:Select("bag", "slot", "itemString", "quantity") + end + if TSM.Banking.IsGuildBankOpen() then + query:Equal("isBoP", false) + :Equal("isBoA", false) + end + return query:IteratorAndRelease() +end + +function Util.OpenBankIterator(autoBaseItems) + if TSM.Banking.IsGuildBankOpen() then + local query = GuildTracking.CreateQuery() + if autoBaseItems then + query:VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Select("tab", "slot", "autoBaseItemString", "quantity") + else + query:Select("tab", "slot", "itemString", "quantity") + end + return query:IteratorAndRelease() + else + local query = BagTracking.CreateQueryBank() + :OrderBy("slotId", true) + if autoBaseItems then + query:VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Select("bag", "slot", "autoBaseItemString", "quantity") + else + query:Select("bag", "slot", "itemString", "quantity") + end + return query:IteratorAndRelease() + end +end + +function Util.PopulateGroupItemsFromBags(items, groups, getNumFunc, ...) + local itemQuantity = TempTable.Acquire() + for _, _, _, itemString, quantity in Util.BagIterator(true) do + if private.InGroups(itemString, groups) then + itemQuantity[itemString] = (itemQuantity[itemString] or 0) + quantity + end + end + for itemString, numHave in pairs(itemQuantity) do + local numToMove = getNumFunc(itemString, numHave, ...) + if numToMove > 0 then + items[itemString] = numToMove + end + end + TempTable.Release(itemQuantity) +end + +function Util.PopulateGroupItemsFromOpenBank(items, groups, getNumFunc, ...) + local itemQuantity = TempTable.Acquire() + for _, _, _, itemString, quantity in Util.OpenBankIterator(true) do + if private.InGroups(itemString, groups) then + itemQuantity[itemString] = (itemQuantity[itemString] or 0) + quantity + end + end + for itemString, numHave in pairs(itemQuantity) do + local numToMove = getNumFunc(itemString, numHave, ...) + if numToMove > 0 then + items[itemString] = numToMove + end + end + TempTable.Release(itemQuantity) +end + +function Util.PopulateItemsFromBags(items, getNumFunc, ...) + local itemQuantity = TempTable.Acquire() + for _, _, _, itemString, quantity in Util.BagIterator(true) do + itemQuantity[itemString] = (itemQuantity[itemString] or 0) + quantity + end + for itemString, numHave in pairs(itemQuantity) do + local numToMove = getNumFunc(itemString, numHave, ...) + if numToMove > 0 then + items[itemString] = numToMove + end + end + TempTable.Release(itemQuantity) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.InGroups(itemString, groups) + local groupPath = TSM.Groups.GetPathByItem(itemString) + -- TODO: support the base group + return groupPath and groupPath ~= TSM.CONST.ROOT_GROUP_PATH and groups[groupPath] +end diff --git a/Core/Service/Banking/Warehousing.lua b/Core/Service/Banking/Warehousing.lua new file mode 100644 index 0000000..0ce3f73 --- /dev/null +++ b/Core/Service/Banking/Warehousing.lua @@ -0,0 +1,92 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Warehousing = TSM.Banking:NewPackage("Warehousing") +local TempTable = TSM.Include("Util.TempTable") +local Math = TSM.Include("Util.Math") +local BagTracking = TSM.Include("Service.BagTracking") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Warehousing.MoveGroupsToBank(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromBags(items, groups, private.GetNumToMoveToBank) + TSM.Banking.MoveToBank(items, callback) + TempTable.Release(items) +end + +function Warehousing.MoveGroupsToBags(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromOpenBank(items, groups, private.GetNumToMoveToBags) + TSM.Banking.MoveToBag(items, callback) + TempTable.Release(items) +end + +function Warehousing.RestockBags(callback, groups) + local items = TempTable.Acquire() + TSM.Banking.Util.PopulateGroupItemsFromOpenBank(items, groups, private.GetNumToMoveRestock) + TSM.Banking.MoveToBag(items, callback) + TempTable.Release(items) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetNumToMoveToBank(itemString, numToMove) + local _, operationSettings = TSM.Operations.GetFirstOperationByItem("Warehousing", itemString) + if not operationSettings then + return 0 + end + if operationSettings.keepBagQuantity ~= 0 then + numToMove = max(numToMove - operationSettings.keepBagQuantity, 0) + end + if operationSettings.moveQuantity ~= 0 then + numToMove = min(numToMove, operationSettings.moveQuantity) + end + return numToMove +end + +function private.GetNumToMoveToBags(itemString, numToMove) + local _, operationSettings = TSM.Operations.GetFirstOperationByItem("Warehousing", itemString) + if not operationSettings then + return 0 + end + if operationSettings.keepBankQuantity ~= 0 then + numToMove = max(numToMove - operationSettings.keepBankQuantity, 0) + end + if operationSettings.moveQuantity ~= 0 then + numToMove = min(numToMove, operationSettings.moveQuantity) + end + return Math.Floor(numToMove, operationSettings.stackSize ~= 0 and operationSettings.stackSize or 1) +end + +function private.GetNumToMoveRestock(itemString, numToMove) + local _, operationSettings = TSM.Operations.GetFirstOperationByItem("Warehousing", itemString) + if not operationSettings then + return 0 + end + local numInBags = BagTracking.CreateQueryBagsItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :SumAndRelease("quantity") or 0 + if operationSettings.restockQuantity == 0 or numInBags >= operationSettings.restockQuantity then + return 0 + end + if operationSettings.restockKeepBankQuantity ~= 0 then + numToMove = max(numToMove - operationSettings.restockKeepBankQuantity, 0) + end + numToMove = min(numToMove, operationSettings.restockQuantity - numInBags) + return Math.Floor(numToMove, operationSettings.restockStackSize ~= 0 and operationSettings.restockStackSize or 1) +end diff --git a/Core/Service/Crafting/Core.lua b/Core/Service/Crafting/Core.lua new file mode 100644 index 0000000..01f2201 --- /dev/null +++ b/Core/Service/Crafting/Core.lua @@ -0,0 +1,776 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Crafting = TSM:NewPackage("Crafting") +local L = TSM.Include("Locale").GetTable() +local ProfessionInfo = TSM.Include("Data.ProfessionInfo") +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Math = TSM.Include("Util.Math") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local Vararg = TSM.Include("Util.Vararg") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local Conversions = TSM.Include("Service.Conversions") +local Inventory = TSM.Include("Service.Inventory") +local private = { + spellDB = nil, + matDB = nil, + matItemDB = nil, + matDBSpellIdQuery = nil, + matDBMatsInTableQuery = nil, + matDBMatNamesQuery = nil, + ignoredCooldownDB = nil, +} +local CHARACTER_KEY = UnitName("player").." - "..GetRealmName() +local IGNORED_COOLDOWN_SEP = "\001" +local PROFESSION_SEP = "," +local PLAYER_SEP = "," +local BAD_CRAFTING_PRICE_SOURCES = { + crafting = true, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Crafting.OnInitialize() + local used = TempTable.Acquire() + for _, craftInfo in pairs(TSM.db.factionrealm.internalData.crafts) do + for itemString in pairs(craftInfo.mats) do + if strmatch(itemString, "^o:") then + local _, _, matList = strsplit(":", itemString) + for matItemId in String.SplitIterator(matList, ",") do + used["i:"..matItemId] = true + end + else + used[itemString] = true + end + end + end + for itemString in pairs(used) do + TSM.db.factionrealm.internalData.mats[itemString] = TSM.db.factionrealm.internalData.mats[itemString] or {} + end + for itemString in pairs(TSM.db.factionrealm.internalData.mats) do + if not used[itemString] then + TSM.db.factionrealm.internalData.mats[itemString] = nil + end + end + TempTable.Release(used) + + local professionItems = TempTable.Acquire() + local matSpellCount = TempTable.Acquire() + local matFirstItemString = TempTable.Acquire() + local matFirstQuantity = TempTable.Acquire() + private.matDB = Database.NewSchema("CRAFTING_MATS") + :AddNumberField("spellId") + :AddStringField("itemString") + :AddNumberField("quantity") + :AddIndex("spellId") + :AddIndex("itemString") + :Commit() + private.matDB:BulkInsertStart() + private.spellDB = Database.NewSchema("CRAFTING_SPELLS") + :AddUniqueNumberField("spellId") + :AddStringField("itemString") + :AddStringField("itemName") + :AddStringField("name") + :AddStringField("profession") + :AddNumberField("numResult") + :AddStringField("players") + :AddBooleanField("hasCD") + :AddIndex("itemString") + :Commit() + private.spellDB:BulkInsertStart() + local playersTemp = TempTable.Acquire() + for spellId, craftInfo in pairs(TSM.db.factionrealm.internalData.crafts) do + wipe(playersTemp) + for player in pairs(craftInfo.players) do + tinsert(playersTemp, player) + end + sort(playersTemp) + local playersStr = table.concat(playersTemp, PLAYER_SEP) + local itemName = ItemInfo.GetName(craftInfo.itemString) or "" + private.spellDB:BulkInsertNewRow(spellId, craftInfo.itemString, itemName, craftInfo.name or "", craftInfo.profession, craftInfo.numResult, playersStr, craftInfo.hasCD and true or false) + + for matItemString, matQuantity in pairs(craftInfo.mats) do + private.matDB:BulkInsertNewRow(spellId, matItemString, matQuantity) + professionItems[craftInfo.profession] = professionItems[craftInfo.profession] or TempTable.Acquire() + matSpellCount[spellId] = (matSpellCount[spellId] or 0) + 1 + if matQuantity > 0 then + matFirstItemString[spellId] = matItemString + matFirstQuantity[spellId] = matQuantity + end + if strmatch(matItemString, "^o:") then + local _, _, matList = strsplit(":", matItemString) + for matItemId in String.SplitIterator(matList, ",") do + local optionalMatItemString = "i:"..matItemId + professionItems[craftInfo.profession][optionalMatItemString] = true + end + else + professionItems[craftInfo.profession][matItemString] = true + end + end + end + TempTable.Release(playersTemp) + private.spellDB:BulkInsertEnd() + private.matDB:BulkInsertEnd() + + private.matDBMatsInTableQuery = private.matDB:NewQuery() + :Select("itemString", "quantity") + :Equal("spellId", Database.BoundQueryParam()) + :GreaterThan("quantity", 0) + private.matDBMatNamesQuery = private.matDB:NewQuery() + :Select("name") + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :Equal("spellId", Database.BoundQueryParam()) + :GreaterThan("quantity", 0) + + private.matItemDB = Database.NewSchema("CRAFTING_MAT_ITEMS") + :AddUniqueStringField("itemString") + :AddStringField("professions") + :AddStringField("customValue") + :Commit() + private.matItemDB:BulkInsertStart() + local professionsTemp = TempTable.Acquire() + for itemString, info in pairs(TSM.db.factionrealm.internalData.mats) do + wipe(professionsTemp) + for profession, items in pairs(professionItems) do + if items[itemString] then + tinsert(professionsTemp, profession) + end + end + sort(professionsTemp) + local professionsStr = table.concat(professionsTemp) + private.matItemDB:BulkInsertNewRow(itemString, professionsStr, info.customValue or "") + end + TempTable.Release(professionsTemp) + private.matItemDB:BulkInsertEnd() + + for _, tbl in pairs(professionItems) do + TempTable.Release(tbl) + end + TempTable.Release(professionItems) + + private.matDBSpellIdQuery = private.matDB:NewQuery() + :Equal("spellId", Database.BoundQueryParam()) + + -- register 1:1 crafting conversions + local addedConversion = false + local query = private.spellDB:NewQuery() + :Select("spellId", "itemString", "numResult") + :Equal("hasCD", false) + for _, spellId, itemString, numResult in query:Iterator() do + if not ProfessionInfo.IsMassMill(spellId) and matSpellCount[spellId] == 1 then + Conversions.AddCraft(itemString, matFirstItemString[spellId], numResult / matFirstQuantity[spellId]) + addedConversion = true + end + end + query:Release() + TempTable.Release(matSpellCount) + TempTable.Release(matFirstItemString) + TempTable.Release(matFirstQuantity) + if addedConversion then + CustomPrice.OnSourceChange("Destroy") + end + + local isValid, err = CustomPrice.Validate(TSM.db.global.craftingOptions.defaultCraftPriceMethod, BAD_CRAFTING_PRICE_SOURCES) + if not isValid then + Log.PrintfUser(L["Your default craft value method was invalid so it has been returned to the default. Details: %s"], err) + TSM.db.global.craftingOptions.defaultCraftPriceMethod = TSM.db:GetDefault("global", "craftingOptions", "defaultCraftPriceMethod") + end + + private.ignoredCooldownDB = Database.NewSchema("IGNORED_COOLDOWNS") + :AddStringField("characterKey") + :AddNumberField("spellId") + :Commit() + private.ignoredCooldownDB:BulkInsertStart() + for entry in pairs(TSM.db.factionrealm.userData.craftingCooldownIgnore) do + local characterKey, spellId = strsplit(IGNORED_COOLDOWN_SEP, entry) + spellId = tonumber(spellId) + if Crafting.HasSpellId(spellId) then + private.ignoredCooldownDB:BulkInsertNewRow(characterKey, spellId) + else + TSM.db.factionrealm.userData.craftingCooldownIgnore[entry] = nil + end + end + private.ignoredCooldownDB:BulkInsertEnd() +end + +function Crafting.HasSpellId(spellId) + return private.spellDB:HasUniqueRow("spellId", spellId) +end + +function Crafting.CreateRawCraftsQuery() + return private.spellDB:NewQuery() +end + +function Crafting.CreateCraftsQuery() + return private.spellDB:NewQuery() + :LeftJoin(TSM.Crafting.Queue.GetDBForJoin(), "spellId") + :VirtualField("bagQuantity", "number", Inventory.GetBagQuantity, "itemString") + :VirtualField("auctionQuantity", "number", Inventory.GetAuctionQuantity, "itemString") + :VirtualField("craftingCost", "number", private.CraftingCostVirtualField, "spellId") + :VirtualField("itemValue", "number", private.ItemValueVirtualField, "itemString") + :VirtualField("profit", "number", private.ProfitVirtualField, "spellId") + :VirtualField("profitPct", "number", private.ProfitPctVirtualField, "spellId") + :VirtualField("saleRate", "number", private.SaleRateVirtualField, "itemString") +end + +function Crafting.CreateQueuedCraftsQuery() + return private.spellDB:NewQuery() + :InnerJoin(TSM.Crafting.Queue.GetDBForJoin(), "spellId") +end + +function Crafting.CreateCooldownSpellsQuery() + return private.spellDB:NewQuery() + :Equal("hasCD", true) +end + +function Crafting.CreateRawMatItemQuery() + return private.matItemDB:NewQuery() +end + +function Crafting.CreateMatItemQuery() + return private.matItemDB:NewQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :VirtualField("matCost", "number", private.MatCostVirtualField, "itemString") + :VirtualField("totalQuantity", "number", private.GetTotalQuantity, "itemString") +end + +function Crafting.SpellIterator() + return private.spellDB:NewQuery() + :Select("spellId") + :IteratorAndRelease() +end + +function Crafting.GetSpellIdsByItem(itemString) + local query = private.spellDB:NewQuery() + :Equal("itemString", itemString) + :Select("spellId", "hasCD") + + return query:IteratorAndRelease() +end + +function Crafting.GetMostProfitableSpellIdByItem(itemString, playerFilter, noCD) + local maxProfit, bestSpellId = nil, nil + local maxProfitCD, bestSpellIdCD = nil, nil + for _, spellId, hasCD in Crafting.GetSpellIdsByItem(itemString) do + if not playerFilter or playerFilter == "" or Vararg.In(playerFilter, Crafting.GetPlayers(spellId)) then + local profit = TSM.Crafting.Cost.GetProfitBySpellId(spellId) + if hasCD then + if profit and profit > (maxProfitCD or -math.huge) then + maxProfitCD = profit + bestSpellIdCD = spellId + elseif not maxProfitCD then + bestSpellIdCD = spellId + end + else + if profit and profit > (maxProfit or -math.huge) then + maxProfit = profit + bestSpellId = spellId + elseif not maxProfit then + bestSpellId = spellId + end + end + end + end + if noCD then + maxProfitCD = nil + bestSpellIdCD = nil + end + if maxProfit then + return bestSpellId, maxProfit + elseif maxProfitCD then + return bestSpellIdCD, maxProfitCD + else + return bestSpellId or bestSpellIdCD or nil, nil + end +end + +function Crafting.GetItemString(spellId) + return private.spellDB:GetUniqueRowField("spellId", spellId, "itemString") +end + +function Crafting.GetProfession(spellId) + return private.spellDB:GetUniqueRowField("spellId", spellId, "profession") +end + +function Crafting.GetNumResult(spellId) + return private.spellDB:GetUniqueRowField("spellId", spellId, "numResult") +end + +function Crafting.GetPlayers(spellId) + local players = private.spellDB:GetUniqueRowField("spellId", spellId, "players") + if not players then + return + end + return strsplit(PLAYER_SEP, players) +end + +function Crafting.GetName(spellId) + return private.spellDB:GetUniqueRowField("spellId", spellId, "name") +end + +function Crafting.MatIterator(spellId) + return private.matDB:NewQuery() + :Select("itemString", "quantity") + :Equal("spellId", spellId) + :GreaterThan("quantity", 0) + :IteratorAndRelease() +end + +function Crafting.GetOptionalMatIterator(spellId) + return private.matDB:NewQuery() + :Select("itemString", "slotId", "text") + :VirtualField("slotId", "number", private.OptionalMatSlotIdVirtualField, "itemString") + :VirtualField("text", "string", private.OptionalMatTextVirtualField, "itemString") + :Equal("spellId", spellId) + :LessThan("quantity", 0) + :OrderBy("slotId", true) + :IteratorAndRelease() +end + +function Crafting.GetMatsAsTable(spellId, tbl) + private.matDBMatsInTableQuery + :BindParams(spellId) + :AsTable(tbl) +end + +function Crafting.RemovePlayers(spellId, playersToRemove) + local shouldRemove = TempTable.Acquire() + for _, player in ipairs(playersToRemove) do + shouldRemove[player] = true + end + local players = TempTable.Acquire(Crafting.GetPlayers(spellId)) + for i = #players, 1, -1 do + local player = players[i] + if shouldRemove[player] then + TSM.db.factionrealm.internalData.crafts[spellId].players[player] = nil + tremove(players, i) + end + end + TempTable.Release(shouldRemove) + local query = private.spellDB:NewQuery() + :Equal("spellId", spellId) + local row = query:GetFirstResult() + + local playersStr = strjoin(PLAYER_SEP, TempTable.UnpackAndRelease(players)) + if playersStr ~= "" then + row:SetField("players", playersStr) + :Update() + query:Release() + return true + end + + -- no more players so remove this spell and all its mats + private.spellDB:DeleteRow(row) + query:Release() + TSM.db.factionrealm.internalData.crafts[spellId] = nil + + local removedMats = TempTable.Acquire() + private.matDB:SetQueryUpdatesPaused(true) + query = private.matDB:NewQuery() + :Equal("spellId", spellId) + for _, matRow in query:Iterator() do + removedMats[matRow:GetField("itemString")] = true + private.matDB:DeleteRow(matRow) + end + query:Release() + private.matDB:SetQueryUpdatesPaused(false) + private.ProcessRemovedMats(removedMats) + TempTable.Release(removedMats) + + return false +end + +function Crafting.RemovePlayerSpells(inactiveSpellIds) + local playerName = UnitName("player") + local query = private.spellDB:NewQuery() + :InTable("spellId", inactiveSpellIds) + :Custom(private.QueryPlayerFilter, playerName) + local removedSpellIds = TempTable.Acquire() + local toRemove = TempTable.Acquire() + private.spellDB:SetQueryUpdatesPaused(true) + if query:Count() > 0 then + Log.Info("Removing %d inactive spellds", query:Count()) + end + for _, row in query:Iterator() do + local players = row:GetField("players") + if row:GetField("players") == playerName then + -- the current player was the only player, so we'll delete the entire row and all its mats + local spellId = row:GetField("spellId") + removedSpellIds[spellId] = true + TSM.db.factionrealm.internalData.crafts[spellId] = nil + tinsert(toRemove, row) + else + -- remove this player form the row + local playersTemp = TempTable.Acquire(strsplit(PLAYER_SEP, players)) + assert(Table.RemoveByValue(playersTemp, playerName) == 1) + row:SetField("players", strjoin(PLAYER_SEP, TempTable.UnpackAndRelease(playersTemp))) + :Update() + end + end + for _, row in ipairs(toRemove) do + private.spellDB:DeleteRow(row) + end + TempTable.Release(toRemove) + query:Release() + private.spellDB:SetQueryUpdatesPaused(false) + + local removedMats = TempTable.Acquire() + private.matDB:SetQueryUpdatesPaused(true) + local matQuery = private.matDB:NewQuery() + :InTable("spellId", removedSpellIds) + for _, matRow in matQuery:Iterator() do + removedMats[matRow:GetField("itemString")] = true + private.matDB:DeleteRow(matRow) + end + TempTable.Release(removedSpellIds) + matQuery:Release() + private.matDB:SetQueryUpdatesPaused(false) + private.ProcessRemovedMats(removedMats) + TempTable.Release(removedMats) +end + +function Crafting.SetSpellDBQueryUpdatesPaused(paused) + private.spellDB:SetQueryUpdatesPaused(paused) +end + +function Crafting.CreateOrUpdate(spellId, itemString, profession, name, numResult, player, hasCD) + local row = private.spellDB:GetUniqueRow("spellId", spellId) + if row then + local playersStr = row:GetField("players") + local foundPlayer = String.SeparatedContains(playersStr, PLAYER_SEP, player) + if not foundPlayer then + assert(playersStr ~= "") + playersStr = playersStr .. PLAYER_SEP .. player + end + row:SetField("itemString", itemString) + :SetField("profession", profession) + :SetField("itemName", ItemInfo.GetName(itemString) or "") + :SetField("name", name) + :SetField("numResult", numResult) + :SetField("players", playersStr) + :SetField("hasCD", hasCD) + :Update() + row:Release() + local craftInfo = TSM.db.factionrealm.internalData.crafts[spellId] + craftInfo.itemString = itemString + craftInfo.profession = profession + craftInfo.name = name + craftInfo.numResult = numResult + craftInfo.players[player] = true + craftInfo.hasCD = hasCD or nil + else + TSM.db.factionrealm.internalData.crafts[spellId] = { + mats = {}, + players = { [player] = true }, + queued = 0, + itemString = itemString, + name = name, + profession = profession, + numResult = numResult, + hasCD = hasCD, + } + private.spellDB:NewRow() + :SetField("spellId", spellId) + :SetField("itemString", itemString) + :SetField("profession", profession) + :SetField("itemName", ItemInfo.GetName(itemString) or "") + :SetField("name", name) + :SetField("numResult", numResult) + :SetField("players", player) + :SetField("hasCD", hasCD) + :Create() + end +end + +function Crafting.AddPlayer(spellId, player) + if TSM.db.factionrealm.internalData.crafts[spellId].players[player] then + return + end + local row = private.spellDB:GetUniqueRow("spellId", spellId) + local playersStr = row:GetField("players") + assert(playersStr ~= "") + playersStr = playersStr .. PLAYER_SEP .. player + row:SetField("players", playersStr) + row:Update() + row:Release() + TSM.db.factionrealm.internalData.crafts[spellId].players[player] = true +end + +function Crafting.SetMats(spellId, matQuantities) + if Table.Equal(TSM.db.factionrealm.internalData.crafts[spellId].mats, matQuantities) then + -- nothing changed + return + end + + wipe(TSM.db.factionrealm.internalData.crafts[spellId].mats) + for itemString, quantity in pairs(matQuantities) do + TSM.db.factionrealm.internalData.crafts[spellId].mats[itemString] = quantity + end + + private.matDB:SetQueryUpdatesPaused(true) + local removedMats = TempTable.Acquire() + local usedMats = TempTable.Acquire() + private.matDBSpellIdQuery:BindParams(spellId) + for _, row in private.matDBSpellIdQuery:Iterator() do + local itemString = row:GetField("itemString") + local quantity = matQuantities[itemString] + if not quantity then + -- remove this row + private.matDB:DeleteRow(row) + removedMats[itemString] = true + else + usedMats[itemString] = true + row:SetField("quantity", quantity) + :Update() + end + end + local profession = Crafting.GetProfession(spellId) + for itemString, quantity in pairs(matQuantities) do + if not usedMats[itemString] then + private.matDB:NewRow() + :SetField("spellId", spellId) + :SetField("itemString", itemString) + :SetField("quantity", quantity) + :Create() + if quantity > 0 then + private.MatItemDBUpdateOrInsert(itemString, profession) + else + local _, _, matList = strsplit(":", itemString) + for matItemId in String.SplitIterator(matList, ",") do + private.MatItemDBUpdateOrInsert("i:"..matItemId, profession) + end + end + end + end + TempTable.Release(usedMats) + private.matDB:SetQueryUpdatesPaused(false) + + private.ProcessRemovedMats(removedMats) + TempTable.Release(removedMats) +end + +function Crafting.SetMatCustomValue(itemString, value) + TSM.db.factionrealm.internalData.mats[itemString].customValue = value + private.matItemDB:GetUniqueRow("itemString", itemString) + :SetField("customValue", value or "") + :Update() +end + +function Crafting.CanCraftItem(itemString) + local count = private.spellDB:NewQuery() + :Equal("itemString", itemString) + :CountAndRelease() + return count > 0 +end + +function Crafting.RestockHelp(link) + local itemString = ItemString.Get(link) + if not itemString then + Log.PrintUser(L["No item specified. Usage: /tsm restock_help [ITEM_LINK]"]) + return + end + + local msg = private.GetRestockHelpMessage(itemString) + Log.PrintfUser(L["Restock help for %s: %s"], link, msg) +end + +function Crafting.IgnoreCooldown(spellId) + assert(not TSM.db.factionrealm.userData.craftingCooldownIgnore[CHARACTER_KEY..IGNORED_COOLDOWN_SEP..spellId]) + TSM.db.factionrealm.userData.craftingCooldownIgnore[CHARACTER_KEY..IGNORED_COOLDOWN_SEP..spellId] = true + private.ignoredCooldownDB:NewRow() + :SetField("characterKey", CHARACTER_KEY) + :SetField("spellId", spellId) + :Create() +end + +function Crafting.IsCooldownIgnored(spellId) + return TSM.db.factionrealm.userData.craftingCooldownIgnore[CHARACTER_KEY..IGNORED_COOLDOWN_SEP..spellId] +end + +function Crafting.CreateIgnoredCooldownQuery() + return private.ignoredCooldownDB:NewQuery() +end + +function Crafting.RemoveIgnoredCooldown(characterKey, spellId) + assert(TSM.db.factionrealm.userData.craftingCooldownIgnore[characterKey..IGNORED_COOLDOWN_SEP..spellId]) + TSM.db.factionrealm.userData.craftingCooldownIgnore[characterKey..IGNORED_COOLDOWN_SEP..spellId] = nil + local row = private.ignoredCooldownDB:NewQuery() + :Equal("characterKey", characterKey) + :Equal("spellId", spellId) + :GetFirstResultAndRelease() + assert(row) + private.ignoredCooldownDB:DeleteRow(row) + row:Release() +end + +function Crafting.GetMatNames(spellId) + return private.matDBMatNamesQuery:BindParams(spellId) + :JoinedString("name", "") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ProcessRemovedMats(removedMats) + private.matItemDB:SetQueryUpdatesPaused(true) + for itemString in pairs(removedMats) do + local numSpells = private.matDB:NewQuery() + :Equal("itemString", itemString) + :CountAndRelease() + if numSpells == 0 then + local matItemRow = private.matItemDB:GetUniqueRow("itemString", itemString) + private.matItemDB:DeleteRow(matItemRow) + matItemRow:Release() + end + end + private.matItemDB:SetQueryUpdatesPaused(false) +end + +function private.CraftingCostVirtualField(spellId) + return TSM.Crafting.Cost.GetCraftingCostBySpellId(spellId) or Math.GetNan() +end + +function private.ItemValueVirtualField(itemString) + return TSM.Crafting.Cost.GetCraftedItemValue(itemString) or Math.GetNan() +end + +function private.ProfitVirtualField(spellId) + return TSM.Crafting.Cost.GetProfitBySpellId(spellId) or Math.GetNan() +end + +function private.ProfitPctVirtualField(spellId) + local craftingCost, _, profit = TSM.Crafting.Cost.GetCostsBySpellId(spellId) + return (craftingCost and profit) and floor(profit * 100 / craftingCost) or Math.GetNan() +end + +function private.SaleRateVirtualField(itemString) + local saleRate = TSM.AuctionDB.GetRegionItemData(itemString, "regionSalePercent") + return saleRate and (saleRate / 100) or Math.GetNan() +end + +function private.MatCostVirtualField(itemString) + return TSM.Crafting.Cost.GetMatCost(itemString) or Math.GetNan() +end + +function private.OptionalMatSlotIdVirtualField(matStr) + local _, slotId = strsplit(":", matStr) + return tonumber(slotId) +end + +function private.OptionalMatTextVirtualField(matStr) + local _, _, matList = strsplit(":", matStr) + return TSM.Crafting.ProfessionUtil.GetOptionalMatText(matList) or OPTIONAL_REAGENT_POSTFIX +end + +function private.GetRestockHelpMessage(itemString) + -- check if the item is in a group + local groupPath = TSM.Groups.GetPathByItem(itemString) + if not groupPath then + return L["This item is not in a TSM group."] + end + + -- check that there's a crafting operation applied + if not TSM.Operations.Crafting.HasOperation(itemString) then + return format(L["There is no Crafting operation applied to this item's TSM group (%s)."], TSM.Groups.Path.Format(groupPath)) + end + + -- check if it's an invalid operation + local isValid, err = TSM.Operations.Crafting.IsValid(itemString) + if not isValid then + return err + end + + -- check that this item is craftable + if not TSM.Crafting.CanCraftItem(itemString) then + return L["You don't know how to craft this item."] + end + + -- check the restock quantity + local neededQuantity = TSM.Operations.Crafting.GetRestockQuantity(itemString, private.GetTotalQuantity(itemString)) + if neededQuantity == 0 then + return L["You either already have at least your max restock quantity of this item or the number which would be queued is less than the min restock quantity."] + end + + -- check if we would actually queue any + local cost, spellId = TSM.Crafting.Cost.GetLowestCostByItem(itemString) + local numResult = spellId and TSM.Crafting.GetNumResult(spellId) + if neededQuantity < numResult then + return format(L["A single craft makes %d and you only need to restock %d."], numResult, neededQuantity) + end + + -- check the prices on the item and the min profit + local hasMinProfit, minProfit = TSM.Operations.Crafting.GetMinProfit(itemString) + if hasMinProfit then + local craftedValue = TSM.Crafting.Cost.GetCraftedItemValue(itemString) + local profit = cost and craftedValue and (craftedValue - cost) or nil + + -- check that there's a crafted value + if not craftedValue then + return L["The 'Craft Value Method' did not return a value for this item."] + end + + -- check that there's a crafted cost + if not cost then + return L["This item does not have a crafting cost. Check that all of its mats have mat prices."] + end + + -- check that there's a profit + assert(profit) + + if not minProfit then + return L["The min profit did not evalulate to a valid value for this item."] + end + + if profit < minProfit then + return format(L["The profit of this item (%s) is below the min profit (%s)."], Money.ToString(profit), Money.ToString(minProfit)) + end + end + + return L["This item will be added to the queue when you restock its group. If this isn't happening, please visit http://support.tradeskillmaster.com for further assistance."] +end + +function private.QueryPlayerFilter(row, player) + return String.SeparatedContains(row:GetField("players"), ",", player) +end + +function private.GetTotalQuantity(itemString) + return CustomPrice.GetItemPrice(itemString, "NumInventory") or 0 +end + +function private.MatItemDBUpdateOrInsert(itemString, profession) + local matItemRow = private.matItemDB:GetUniqueRow("itemString", itemString) + if matItemRow then + -- update the professions if necessary + local professions = TempTable.Acquire(strsplit(PROFESSION_SEP, matItemRow:GetField("professions"))) + if not Table.KeyByValue(professions, profession) then + tinsert(professions, profession) + sort(professions) + matItemRow:SetField("professions", table.concat(professions, PROFESSION_SEP)) + :Update() + end + TempTable.Release(professions) + else + private.matItemDB:NewRow() + :SetField("itemString", itemString) + :SetField("professions", profession) + :SetField("customValue", TSM.db.factionrealm.internalData.mats[itemString].customValue or "") + :Create() + end +end diff --git a/Core/Service/Crafting/Cost.lua b/Core/Service/Crafting/Cost.lua new file mode 100644 index 0000000..64555c5 --- /dev/null +++ b/Core/Service/Crafting/Cost.lua @@ -0,0 +1,156 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Cost = TSM.Crafting:NewPackage("Cost") +local TempTable = TSM.Include("Util.TempTable") +local Math = TSM.Include("Util.Math") +local ItemString = TSM.Include("Util.ItemString") +local CustomPrice = TSM.Include("Service.CustomPrice") +local private = { + matsVisited = {}, + matCostCache = {}, + matsTemp = {}, + matsTempInUse = false, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Cost.GetMatCost(itemString) + itemString = ItemString.GetBase(itemString) + if not TSM.db.factionrealm.internalData.mats[itemString] then + return + end + if private.matsVisited[itemString] then + -- there's a loop in the mat cost, so bail + return + end + local prevHash = private.matsVisited.hash + local hash = nil + if prevHash == nil then + -- this is a top-level mat, so just use the itemString as the hash + hash = itemString + else + if type(prevHash) == "string" then + -- this is a second-level mat where the previous hash is the itemString which needs to be hashed itself + prevHash = Math.CalculateHash(prevHash) + end + hash = Math.CalculateHash(itemString, prevHash) + end + private.matsVisited.hash = hash + private.matsVisited[itemString] = true + if private.matCostCache.lastUpdate ~= GetTime() then + wipe(private.matCostCache) + private.matCostCache.lastUpdate = GetTime() + end + if not private.matCostCache[hash] then + local priceStr = TSM.db.factionrealm.internalData.mats[itemString].customValue or TSM.db.global.craftingOptions.defaultMatCostMethod + private.matCostCache[hash] = CustomPrice.GetValue(priceStr, itemString) + end + private.matsVisited[itemString] = nil + private.matsVisited.hash = prevHash + return private.matCostCache[hash] +end + +function Cost.GetCraftingCostBySpellId(spellId) + local cost = 0 + local hasMats = false + local mats = nil + if private.matsTempInUse then + mats = TempTable.Acquire() + else + mats = private.matsTemp + private.matsTempInUse = true + wipe(mats) + end + TSM.Crafting.GetMatsAsTable(spellId, mats) + for itemString, quantity in pairs(mats) do + hasMats = true + local matCost = Cost.GetMatCost(itemString) + if not matCost then + cost = nil + elseif cost then + cost = cost + matCost * quantity + end + end + if mats == private.matsTemp then + private.matsTempInUse = false + else + TempTable.Release(mats) + end + if not cost or not hasMats then + return + end + cost = Math.Round(cost / TSM.Crafting.GetNumResult(spellId)) + return cost > 0 and cost or nil +end + +function Cost.GetCraftedItemValue(itemString) + local hasCraftPriceMethod, craftPrice = TSM.Operations.Crafting.GetCraftedItemValue(itemString) + if hasCraftPriceMethod then + return craftPrice + end + return CustomPrice.GetValue(TSM.db.global.craftingOptions.defaultCraftPriceMethod, itemString) +end + +function Cost.GetProfitBySpellId(spellId) + local _, _, profit = Cost.GetCostsBySpellId(spellId) + return profit +end + +function Cost.GetCostsBySpellId(spellId) + local craftingCost = Cost.GetCraftingCostBySpellId(spellId) + local itemString = TSM.Crafting.GetItemString(spellId) + local craftedItemValue = itemString and Cost.GetCraftedItemValue(itemString) or nil + return craftingCost, craftedItemValue, craftingCost and craftedItemValue and (craftedItemValue - craftingCost) or nil +end + +function Cost.GetSaleRateBySpellId(spellId) + local itemString = TSM.Crafting.GetItemString(spellId) + return itemString and CustomPrice.GetItemPrice(itemString, "DBRegionSaleRate") or nil +end + +function Cost.GetLowestCostByItem(itemString) + itemString = ItemString.GetBase(itemString) + local lowestCost, lowestSpellId = nil, nil + local cdCost, cdSpellId = nil, nil + local numSpells = 0 + local singleSpellId = nil + for _, spellId, hasCD in TSM.Crafting.GetSpellIdsByItem(itemString) do + if not hasCD then + if singleSpellId == nil then + singleSpellId = spellId + elseif singleSpellId then + singleSpellId = 0 + end + end + numSpells = numSpells + 1 + local cost = Cost.GetCraftingCostBySpellId(spellId) + if cost and (not lowestCost or cost < lowestCost) then + -- exclude spells with cooldown if option to ignore is enabled and there is more than one way to craft + if hasCD then + cdCost = cost + cdSpellId = spellId + else + lowestCost = cost + lowestSpellId = spellId + end + end + end + if singleSpellId == 0 then + singleSpellId = nil + end + if numSpells == 1 and not lowestCost and cdCost then + -- only way to craft it is with a CD craft, so use that + lowestCost = cdCost + lowestSpellId = cdSpellId + end + return lowestCost, lowestSpellId or singleSpellId +end diff --git a/Core/Service/Crafting/Gathering.lua b/Core/Service/Crafting/Gathering.lua new file mode 100644 index 0000000..851dfeb --- /dev/null +++ b/Core/Service/Crafting/Gathering.lua @@ -0,0 +1,525 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Gathering = TSM.Crafting:NewPackage("Gathering") +local DisenchantInfo = TSM.Include("Data.DisenchantInfo") +local Database = TSM.Include("Util.Database") +local Table = TSM.Include("Util.Table") +local Delay = TSM.Include("Util.Delay") +local String = TSM.Include("Util.String") +local TempTable = TSM.Include("Util.TempTable") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Conversions = TSM.Include("Service.Conversions") +local BagTracking = TSM.Include("Service.BagTracking") +local Inventory = TSM.Include("Service.Inventory") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local private = { + db = nil, + queuedCraftsUpdateQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons + crafterList = {}, + professionList = {}, + contextChangedCallback = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Gathering.OnInitialize() + if TSM.IsWowClassic() then + Table.RemoveByValue(TSM.db.profile.gatheringOptions.sources, "guildBank") + Table.RemoveByValue(TSM.db.profile.gatheringOptions.sources, "altGuildBank") + end +end + +function Gathering.OnEnable() + private.db = Database.NewSchema("GATHERING_MATS") + :AddUniqueStringField("itemString") + :AddNumberField("numNeed") + :AddNumberField("numHave") + :AddStringField("sourcesStr") + :Commit() + private.queuedCraftsUpdateQuery = TSM.Crafting.CreateQueuedCraftsQuery() + :SetUpdateCallback(private.OnQueuedCraftsUpdated) + private.OnQueuedCraftsUpdated() + BagTracking.RegisterCallback(function() + Delay.AfterTime("GATHERING_BAG_UPDATE", 1, private.UpdateDB) + end) +end + +function Gathering.SetContextChangedCallback(callback) + private.contextChangedCallback = callback +end + +function Gathering.CreateQuery() + return private.db:NewQuery() +end + +function Gathering.SetCrafter(crafter) + if crafter == TSM.db.factionrealm.gatheringContext.crafter then + return + end + TSM.db.factionrealm.gatheringContext.crafter = crafter + wipe(TSM.db.factionrealm.gatheringContext.professions) + private.UpdateProfessionList() + private.UpdateDB() +end + +function Gathering.SetProfessions(professions) + local numProfessions = Table.Count(TSM.db.factionrealm.gatheringContext.professions) + local didChange = false + if numProfessions ~= #professions then + didChange = true + else + for _, profession in ipairs(professions) do + if not TSM.db.factionrealm.gatheringContext.professions[profession] then + didChange = true + end + end + end + if not didChange then + return + end + wipe(TSM.db.factionrealm.gatheringContext.professions) + for _, profession in ipairs(professions) do + assert(private.professionList[profession]) + TSM.db.factionrealm.gatheringContext.professions[profession] = true + end + private.UpdateDB() +end + +function Gathering.GetCrafterList() + return private.crafterList +end + +function Gathering.GetCrafter() + return TSM.db.factionrealm.gatheringContext.crafter ~= "" and TSM.db.factionrealm.gatheringContext.crafter or nil +end + +function Gathering.GetProfessionList() + return private.professionList +end + +function Gathering.GetProfessions() + return TSM.db.factionrealm.gatheringContext.professions +end + +function Gathering.SourcesStrToTable(sourcesStr, info, alts) + for source, num, characters in gmatch(sourcesStr, "([a-zA-Z]+)/([0-9]+)/([^,]*)") do + info[source] = tonumber(num) + if source == "alt" or source == "altGuildBank" then + for character in gmatch(characters, "([^`]+)") do + alts[character] = true + end + end + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateCrafterList() + local query = TSM.Crafting.CreateQueuedCraftsQuery() + :Select("players") + :Distinct("players") + wipe(private.crafterList) + for _, players in query:Iterator() do + for character in gmatch(players, "[^,]+") do + if not private.crafterList[character] then + private.crafterList[character] = true + tinsert(private.crafterList, character) + end + end + end + query:Release() + + if TSM.db.factionrealm.gatheringContext.crafter ~= "" and not private.crafterList[TSM.db.factionrealm.gatheringContext.crafter] then + -- the crafter which was selected no longer exists, so clear the selection + TSM.db.factionrealm.gatheringContext.crafter = "" + elseif #private.crafterList == 1 then + -- there is only one crafter in the list, so select it + TSM.db.factionrealm.gatheringContext.crafter = private.crafterList[1] + end + if TSM.db.factionrealm.gatheringContext.crafter == "" then + wipe(TSM.db.factionrealm.gatheringContext.professions) + end +end + +function private.UpdateProfessionList() + -- update the professionList + wipe(private.professionList) + if TSM.db.factionrealm.gatheringContext.crafter ~= "" then + -- populate the list of professions + local query = TSM.Crafting.CreateQueuedCraftsQuery() + :Select("profession") + :Custom(private.QueryPlayerFilter, TSM.db.factionrealm.gatheringContext.crafter) + :Distinct("profession") + for _, profession in query:Iterator() do + private.professionList[profession] = true + tinsert(private.professionList, profession) + end + query:Release() + end + + -- remove selected professions which are no longer in the list + for profession in pairs(TSM.db.factionrealm.gatheringContext.professions) do + if not private.professionList[profession] then + TSM.db.factionrealm.gatheringContext.professions[profession] = nil + end + end + + -- select all professions by default + if not next(TSM.db.factionrealm.gatheringContext.professions) then + for _, profession in ipairs(private.professionList) do + TSM.db.factionrealm.gatheringContext.professions[profession] = true + end + end +end + +function private.OnQueuedCraftsUpdated() + private.UpdateCrafterList() + private.UpdateProfessionList() + private.UpdateDB() + private.contextChangedCallback() +end + +function private.UpdateDB() + -- delay the update if we're in combat + if InCombatLockdown() then + Delay.AfterTime("DELAYED_GATHERING_UPDATE", 1, private.UpdateDB) + return + end + local crafter = TSM.db.factionrealm.gatheringContext.crafter + if crafter == "" or not next(TSM.db.factionrealm.gatheringContext.professions) then + private.db:Truncate() + return + end + + local matsNumNeed = TempTable.Acquire() + local query = TSM.Crafting.CreateQueuedCraftsQuery() + :Select("spellId", "num") + :Custom(private.QueryPlayerFilter, crafter) + :Or() + for profession in pairs(TSM.db.factionrealm.gatheringContext.professions) do + query:Equal("profession", profession) + end + query:End() + for _, spellId, numQueued in query:Iterator() do + for _, itemString, quantity in TSM.Crafting.MatIterator(spellId) do + matsNumNeed[itemString] = (matsNumNeed[itemString] or 0) + quantity * numQueued + end + end + query:Release() + + local matQueue = TempTable.Acquire() + local matsNumHave = TempTable.Acquire() + local matsNumHaveExtra = TempTable.Acquire() + for itemString, numNeed in pairs(matsNumNeed) do + matsNumHave[itemString] = private.GetCrafterInventoryQuantity(itemString) + local numUsed = nil + numNeed, numUsed = private.HandleNumHave(itemString, numNeed, matsNumHave[itemString]) + if numUsed < matsNumHave[itemString] then + matsNumHaveExtra[itemString] = matsNumHave[itemString] - numUsed + end + if numNeed > 0 then + matsNumNeed[itemString] = numNeed + tinsert(matQueue, itemString) + else + matsNumNeed[itemString] = nil + end + end + + local sourceList = TempTable.Acquire() + local matSourceList = TempTable.Acquire() + while #matQueue > 0 do + local itemString = tremove(matQueue) + wipe(sourceList) + local numNeed = matsNumNeed[itemString] + -- always add a task to get mail on the crafter if possible + numNeed = private.ProcessSource(itemString, numNeed, "openMail", sourceList) + assert(numNeed >= 0) + for _, source in ipairs(TSM.db.profile.gatheringOptions.sources) do + local isCraftSource = source == "craftProfit" or source == "craftNoProfit" + local ignoreSource = false + if isCraftSource then + -- check if we are already crafting some materials of this craft so shouldn't craft this item + local spellId = TSM.Crafting.GetMostProfitableSpellIdByItem(itemString, crafter, true) + if spellId then + for _, matItemString in TSM.Crafting.MatIterator(spellId) do + if not ignoreSource and matSourceList[matItemString] and strmatch(matSourceList[matItemString], "craft[a-zA-Z]+/[^,]+/") then + ignoreSource = true + end + end + else + -- can't craft this item + ignoreSource = true + end + end + if not ignoreSource then + local prevNumNeed = numNeed + numNeed = private.ProcessSource(itemString, numNeed, source, sourceList) + assert(numNeed >= 0) + if numNeed == 0 then + if isCraftSource then + -- we are crafting these, so add the necessary mats + local spellId = TSM.Crafting.GetMostProfitableSpellIdByItem(itemString, crafter, true) + assert(spellId) + local numToCraft = ceil(prevNumNeed / TSM.Crafting.GetNumResult(spellId)) + for _, intMatItemString, intMatQuantity in TSM.Crafting.MatIterator(spellId) do + local intMatNumNeed, numUsed = private.HandleNumHave(intMatItemString, numToCraft * intMatQuantity, matsNumHaveExtra[intMatItemString] or 0) + if numUsed > 0 then + matsNumHaveExtra[intMatItemString] = matsNumHaveExtra[intMatItemString] - numUsed + end + if intMatNumNeed > 0 then + if not matsNumNeed[intMatItemString] then + local intMatNumHave = private.GetCrafterInventoryQuantity(intMatItemString) + if intMatNumNeed > intMatNumHave then + matsNumHave[intMatItemString] = intMatNumHave + matsNumNeed[intMatItemString] = intMatNumNeed - intMatNumHave + tinsert(matQueue, intMatItemString) + elseif intMatNumHave > intMatNumNeed then + matsNumHaveExtra[intMatItemString] = intMatNumHave - intMatNumNeed + end + else + matsNumNeed[intMatItemString] = (matsNumNeed[intMatItemString] or 0) + intMatNumNeed + if matSourceList[intMatItemString] then + -- already processed this item, so queue it again + tinsert(matQueue, intMatItemString) + end + end + end + end + end + break + end + end + end + sort(sourceList) + matSourceList[itemString] = table.concat(sourceList, ",") + end + private.db:TruncateAndBulkInsertStart() + for itemString, numNeed in pairs(matsNumNeed) do + private.db:BulkInsertNewRow(itemString, numNeed, matsNumHave[itemString], matSourceList[itemString]) + end + private.db:BulkInsertEnd() + + TempTable.Release(sourceList) + TempTable.Release(matSourceList) + TempTable.Release(matsNumNeed) + TempTable.Release(matsNumHave) + TempTable.Release(matsNumHaveExtra) + TempTable.Release(matQueue) +end + +function private.ProcessSource(itemString, numNeed, source, sourceList) + local crafter = TSM.db.factionrealm.gatheringContext.crafter + local playerName = UnitName("player") + if source == "openMail" then + local crafterMailQuantity = Inventory.GetMailQuantity(itemString, crafter) + if crafterMailQuantity > 0 then + crafterMailQuantity = min(crafterMailQuantity, numNeed) + if crafter == playerName then + tinsert(sourceList, "openMail/"..crafterMailQuantity.."/") + else + tinsert(sourceList, "alt/"..crafterMailQuantity.."/"..crafter) + end + return numNeed - crafterMailQuantity + end + elseif source == "vendor" then + if ItemInfo.GetVendorBuy(itemString) then + -- assume we can buy all we need from the vendor + tinsert(sourceList, "vendor/"..numNeed.."/") + return 0 + end + elseif source == "guildBank" then + local guild = PlayerInfo.GetPlayerGuild(crafter) + local guildBankQuantity = guild and Inventory.GetGuildQuantity(itemString, guild) or 0 + if guildBankQuantity > 0 then + guildBankQuantity = min(guildBankQuantity, numNeed) + if crafter == playerName then + -- we are on the crafter + tinsert(sourceList, "guildBank/"..guildBankQuantity.."/") + else + -- need to switch to the crafter to get items from the guild bank + tinsert(sourceList, "altGuildBank/"..guildBankQuantity.."/"..crafter) + end + return numNeed - guildBankQuantity + end + elseif source == "alt" then + if ItemInfo.IsSoulbound(itemString) then + -- can't mail soulbound items + return numNeed + end + if crafter ~= playerName then + -- we are on the alt, so see if we can gather items from this character + local bagQuantity = Inventory.GetBagQuantity(itemString) + local bankQuantity = Inventory.GetBankQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString) + local mailQuantity = Inventory.GetMailQuantity(itemString) + + if bagQuantity > 0 then + bagQuantity = min(numNeed, bagQuantity) + tinsert(sourceList, "sendMail/"..bagQuantity.."/") + numNeed = numNeed - bagQuantity + if numNeed == 0 then + return 0 + end + end + if mailQuantity > 0 then + mailQuantity = min(numNeed, mailQuantity) + tinsert(sourceList, "openMail/"..mailQuantity.."/") + numNeed = numNeed - mailQuantity + if numNeed == 0 then + return 0 + end + end + if bankQuantity > 0 then + bankQuantity = min(numNeed, bankQuantity) + tinsert(sourceList, "bank/"..bankQuantity.."/") + numNeed = numNeed - bankQuantity + if numNeed == 0 then + return 0 + end + end + end + + -- check alts + local altNum = 0 + local altCharacters = TempTable.Acquire() + for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + local characterKey = nil + if factionrealm == UnitFactionGroup("player").." - "..GetRealmName() then + characterKey = character + else + characterKey = character.." - "..factionrealm + end + if characterKey ~= crafter and characterKey ~= playerName then + local num = 0 + num = num + Inventory.GetBagQuantity(itemString, character, factionrealm) + num = num + Inventory.GetBankQuantity(itemString, character, factionrealm) + num = num + Inventory.GetReagentBankQuantity(itemString, character, factionrealm) + num = num + Inventory.GetMailQuantity(itemString, character, factionrealm) + if num > 0 then + tinsert(altCharacters, characterKey) + altNum = altNum + num + end + end + end + end + + local altCharactersStr = table.concat(altCharacters, "`") + TempTable.Release(altCharacters) + if altNum > 0 then + altNum = min(altNum, numNeed) + tinsert(sourceList, "alt/"..altNum.."/"..altCharactersStr) + return numNeed - altNum + end + elseif source == "altGuildBank" then + local currentGuild = PlayerInfo.GetPlayerGuild(playerName) + if currentGuild and crafter ~= playerName then + -- we are on an alt, so see if we can gather items from this character's guild bank + local guildBankQuantity = Inventory.GetGuildQuantity(itemString) + if guildBankQuantity > 0 then + guildBankQuantity = min(numNeed, guildBankQuantity) + tinsert(sourceList, "guildBank/"..guildBankQuantity.."/") + numNeed = numNeed - guildBankQuantity + if numNeed == 0 then + return 0 + end + end + end + + -- check alts + local totalGuildBankQuantity = 0 + local altCharacters = TempTable.Acquire() + for _, character in PlayerInfo.CharacterIterator(true) do + local guild = PlayerInfo.GetPlayerGuild(character) + if guild and guild ~= currentGuild then + local guildBankQuantity = Inventory.GetGuildQuantity(itemString, guild) + if guildBankQuantity > 0 then + tinsert(altCharacters, character) + totalGuildBankQuantity = totalGuildBankQuantity + guildBankQuantity + end + end + end + local altCharactersStr = table.concat(altCharacters, "`") + TempTable.Release(altCharacters) + if totalGuildBankQuantity > 0 then + totalGuildBankQuantity = min(totalGuildBankQuantity, numNeed) + tinsert(sourceList, "altGuildBank/"..totalGuildBankQuantity.."/"..altCharactersStr) + return numNeed - totalGuildBankQuantity + end + elseif source == "craftProfit" or source == "craftNoProfit" then + local spellId, maxProfit = TSM.Crafting.GetMostProfitableSpellIdByItem(itemString, crafter, true) + if spellId and (source == "craftNoProfit" or (maxProfit and maxProfit > 0)) then + -- assume we can craft all we need + local numToCraft = ceil(numNeed / TSM.Crafting.GetNumResult(spellId)) + tinsert(sourceList, source.."/"..numToCraft.."/") + return 0 + end + elseif source == "auction" then + if ItemInfo.IsSoulbound(itemString) then + -- can't buy soulbound items + return numNeed + end + -- assume we can buy all we need from the AH + tinsert(sourceList, "auction/"..numNeed.."/") + return 0 + elseif source == "auctionCrafting" then + if ItemInfo.IsSoulbound(itemString) then + -- can't buy soulbound items + return numNeed + end + if not Conversions.GetSourceItems(itemString) then + -- can't convert to get this item + return numNeed + end + -- assume we can buy all we need from the AH + tinsert(sourceList, "auctionCrafting/"..numNeed.."/") + return 0 + elseif source == "auctionDE" then + if ItemInfo.IsSoulbound(itemString) then + -- can't buy soulbound items + return numNeed + end + if not DisenchantInfo.IsTargetItem(itemString) then + -- can't disenchant to get this item + return numNeed + end + -- assume we can buy all we need from the AH + tinsert(sourceList, "auctionDE/"..numNeed.."/") + return 0 + else + error("Unkown source: "..tostring(source)) + end + return numNeed +end + +function private.QueryPlayerFilter(row, player) + return String.SeparatedContains(row:GetField("players"), ",", player) +end + +function private.GetCrafterInventoryQuantity(itemString) + local crafter = TSM.db.factionrealm.gatheringContext.crafter + return Inventory.GetBagQuantity(itemString, crafter) + Inventory.GetReagentBankQuantity(itemString, crafter) + Inventory.GetBankQuantity(itemString, crafter) +end + +function private.HandleNumHave(itemString, numNeed, numHave) + if numNeed > numHave then + -- use everything we have + numNeed = numNeed - numHave + return numNeed, numHave + else + -- we have at least as many as we need, so use all of them + return 0, numNeed + end +end diff --git a/Core/Service/Crafting/PlayerProfessions.lua b/Core/Service/Crafting/PlayerProfessions.lua new file mode 100644 index 0000000..b8ffb65 --- /dev/null +++ b/Core/Service/Crafting/PlayerProfessions.lua @@ -0,0 +1,303 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local PlayerProfessions = TSM.Crafting:NewPackage("PlayerProfessions") +local ProfessionInfo = TSM.Include("Data.ProfessionInfo") +local Database = TSM.Include("Util.Database") +local Event = TSM.Include("Util.Event") +local Delay = TSM.Include("Util.Delay") +local TempTable = TSM.Include("Util.TempTable") +local Vararg = TSM.Include("Util.Vararg") +local Threading = TSM.Include("Service.Threading") +local private = { + playerProfessionsThread = nil, + playerProfessionsThreadRunning = false, + db = nil, + query = nil, +} +local TAILORING_ES = "Sastrería" +local TAILORING_SKILL_ES = "Costura" +local LEATHERWORKING_ES = "Peletería" +local LEATHERWORKING_SKILL_ES = "Marroquinería" +local ENGINEERING_FR = "Ingénieur" +local ENGINEERING_SKILL_FR = "Ingénierie" +local FIRST_AID_FR = "Premiers soins" +local FIRST_AID_SKILL_FR = "Secourisme" + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function PlayerProfessions.OnInitialize() + private.db = Database.NewSchema("PLAYER_PROFESSIONS") + :AddStringField("player") + :AddStringField("profession") + :AddNumberField("skillId") + :AddNumberField("level") + :AddNumberField("maxLevel") + :AddBooleanField("isSecondary") + :AddIndex("player") + :Commit() + private.query = private.db:NewQuery() + :Select("player", "profession", "skillId", "level", "maxLevel") + :OrderBy("isSecondary", true) + :OrderBy("level", false) + :OrderBy("profession", true) + private.playerProfessionsThread = Threading.New("PLAYER_PROFESSIONS", private.PlayerProfessionsThread) + private.StartPlayerProfessionsThread() + Event.Register("SKILL_LINES_CHANGED", private.PlayerProfessionsSkillUpdate) + Event.Register("LEARNED_SPELL_IN_TAB", private.StartPlayerProfessionsThread) +end + +function PlayerProfessions.CreateQuery() + return private.db:NewQuery() +end + +function PlayerProfessions.Iterator() + return private.query:Iterator() +end + + + +-- ============================================================================ +-- Player Professions Thread +-- ============================================================================ + +function private.StartPlayerProfessionsThread() + if private.playerProfessionsThreadRunning then + Threading.Kill(private.playerProfessionsThread) + end + private.playerProfessionsThreadRunning = true + Threading.Start(private.playerProfessionsThread) +end + +function private.UpdatePlayerProfessionInfo(name, skillId, level, maxLevel, isSecondary) + local professionInfo = TSM.db.sync.internalData.playerProfessions[name] or {} + TSM.db.sync.internalData.playerProfessions[name] = professionInfo + -- preserve whether or not we've prompted to create groups and the profession link if possible + local oldPrompted = professionInfo.prompted or nil + local oldLink = professionInfo.link or nil + wipe(professionInfo) + professionInfo.skillId = skillId + professionInfo.level = level + professionInfo.maxLevel = maxLevel + professionInfo.isSecondary = isSecondary + professionInfo.prompted = oldPrompted + professionInfo.link = oldLink +end + +function private.PlayerProfessionsSkillUpdate() + if TSM.IsWowClassic() then + local _, _, offset, numSpells = GetSpellTabInfo(1) + for i = offset + 1, offset + numSpells do + local name, subName = GetSpellBookItemName(i, BOOKTYPE_SPELL) + if not subName then + Delay.AfterTime(0.05, private.PlayerProfessionsSkillUpdate) + return + end + if name and subName and (ProfessionInfo.IsSubNameClassic(strtrim(subName, " ")) or name == ProfessionInfo.GetName("Smelting") or name == ProfessionInfo.GetName("Poisons") or name == LEATHERWORKING_ES or name == TAILORING_ES or name == ENGINEERING_FR or name == FIRST_AID_FR) and not TSM.UI.CraftingUI.IsProfessionIgnored(name) then + local level, maxLevel = nil, nil + for j = 1, GetNumSkillLines() do + local skillName, _, _, skillRank, _, _, skillMaxRank = GetSkillLineInfo(j) + if skillName == name then + level = skillRank + maxLevel = skillMaxRank + break + elseif name == ProfessionInfo.GetName("Smelting") and skillName == ProfessionInfo.GetName("Mining") then + name = ProfessionInfo.GetName("Mining") + level = skillRank + maxLevel = skillMaxRank + break + elseif name == LEATHERWORKING_ES and skillName == LEATHERWORKING_SKILL_ES then + name = LEATHERWORKING_SKILL_ES + level = skillRank + maxLevel = skillMaxRank + break + elseif name == TAILORING_ES and skillName == TAILORING_SKILL_ES then + name = TAILORING_SKILL_ES + level = skillRank + maxLevel = skillMaxRank + break + elseif name == ENGINEERING_FR and skillName == ENGINEERING_SKILL_FR then + name = ENGINEERING_SKILL_FR + level = skillRank + maxLevel = skillMaxRank + break + elseif name == FIRST_AID_FR and skillName == FIRST_AID_SKILL_FR then + name = FIRST_AID_SKILL_FR + level = skillRank + maxLevel = skillMaxRank + break + end + end + if level and maxLevel and not TSM.UI.CraftingUI.IsProfessionIgnored(name) then -- exclude ignored professions + private.UpdatePlayerProfessionInfo(name, -1, level, maxLevel, name == GetSpellInfo(129)) + end + end + end + else + local professionIds = TempTable.Acquire(GetProfessions()) + for i, id in pairs(professionIds) do -- needs to be pairs since there might be holes + if id ~= 8 and id ~= 9 then -- ignore fishing and arheology + local name, _, level, maxLevel, _, _, skillId = GetProfessionInfo(id) + if not TSM.UI.CraftingUI.IsProfessionIgnored(name) then -- exclude ignored professions + private.UpdatePlayerProfessionInfo(name, skillId, level, maxLevel, i > 2) + end + end + end + TempTable.Release(professionIds) + end + + -- update our DB + private.db:TruncateAndBulkInsertStart() + for _, character in TSM.db:FactionrealmCharacterIterator() do + local playerProfessions = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character), "internalData", "playerProfessions") + if playerProfessions then + for name, info in pairs(playerProfessions) do + private.db:BulkInsertNewRow(character, name, info.skillId or -1, info.level, info.maxLevel, info.isSecondary) + end + end + end + private.db:BulkInsertEnd() +end + +function private.PlayerProfessionsThread() + -- get the player's tradeskills + if TSM.IsWowClassic() then + SpellBookFrame_UpdateSkillLineTabs() + else + SpellBook_UpdateProfTab() + end + local forgetProfession = Threading.AcquireSafeTempTable() + for name in pairs(TSM.db.sync.internalData.playerProfessions) do + forgetProfession[name] = true + end + if TSM.IsWowClassic() then + local _, _, offset, numSpells = GetSpellTabInfo(1) + for i = offset + 1, offset + numSpells do + local name, subName = GetSpellBookItemName(i, BOOKTYPE_SPELL) + if name and subName and (ProfessionInfo.IsSubNameClassic(strtrim(subName, " ")) or name == ProfessionInfo.GetName("Smelting") or name == ProfessionInfo.GetName("Poisons") or name == LEATHERWORKING_ES or name == TAILORING_ES or name == ENGINEERING_FR or name == FIRST_AID_FR) and not TSM.UI.CraftingUI.IsProfessionIgnored(name) then + local level, maxLevel = nil, nil + for j = 1, GetNumSkillLines() do + local skillName, _, _, skillRank, _, _, skillMaxRank = GetSkillLineInfo(j) + if skillName == name then + level = skillRank + maxLevel = skillMaxRank + break + elseif name == ProfessionInfo.GetName("Smelting") and skillName == ProfessionInfo.GetName("Mining") then + name = ProfessionInfo.GetName("Mining") + level = skillRank + maxLevel = skillMaxRank + break + elseif name == LEATHERWORKING_ES and skillName == LEATHERWORKING_SKILL_ES then + name = LEATHERWORKING_SKILL_ES + level = skillRank + maxLevel = skillMaxRank + break + elseif name == TAILORING_ES and skillName == TAILORING_SKILL_ES then + name = TAILORING_SKILL_ES + level = skillRank + maxLevel = skillMaxRank + break + elseif name == ENGINEERING_FR and skillName == ENGINEERING_SKILL_FR then + name = ENGINEERING_SKILL_FR + level = skillRank + maxLevel = skillMaxRank + break + elseif name == FIRST_AID_FR and skillName == FIRST_AID_SKILL_FR then + name = FIRST_AID_SKILL_FR + level = skillRank + maxLevel = skillMaxRank + break + end + end + if level and maxLevel and not TSM.UI.CraftingUI.IsProfessionIgnored(name) then -- exclude ignored professions + forgetProfession[name] = nil + private.UpdatePlayerProfessionInfo(name, -1, level, maxLevel, name == GetSpellInfo(129)) + end + end + end + else + Threading.WaitForFunction(GetProfessions) + local professionIds = Threading.AcquireSafeTempTable(GetProfessions()) + -- ignore archeology and fishing which are in the 3rd and 4th slots respectively + professionIds[3] = nil + professionIds[4] = nil + for i, id in pairs(professionIds) do -- needs to be pairs since there might be holes + local name, _, level, maxLevel, _, _, skillId = Threading.WaitForFunction(GetProfessionInfo, id) + if not TSM.UI.CraftingUI.IsProfessionIgnored(name) then -- exclude ignored professions + forgetProfession[name] = nil + private.UpdatePlayerProfessionInfo(name, skillId, level, maxLevel, i > 2) + end + end + Threading.ReleaseSafeTempTable(professionIds) + end + for name in pairs(forgetProfession) do + TSM.db.sync.internalData.playerProfessions[name] = nil + end + Threading.ReleaseSafeTempTable(forgetProfession) + + -- clean up crafts which are no longer known + local matUsed = Threading.AcquireSafeTempTable() + local spellIds = Threading.AcquireSafeTempTable() + for _, spellId in TSM.Crafting.SpellIterator() do + tinsert(spellIds, spellId) + end + for _, spellId in ipairs(spellIds) do + local playersToRemove = TempTable.Acquire() + for _, player in Vararg.Iterator(TSM.Crafting.GetPlayers(spellId)) do + -- check if the player still exists and still has this profession + local playerProfessions = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(player), "internalData", "playerProfessions") + if not playerProfessions or not playerProfessions[TSM.Crafting.GetProfession(spellId)] then + tinsert(playersToRemove, player) + end + end + local stillExists = true + if #playersToRemove > 0 then + stillExists = TSM.Crafting.RemovePlayers(spellId, playersToRemove) + end + TempTable.Release(playersToRemove) + if stillExists then + for _, itemString in TSM.Crafting.MatIterator(spellId) do + matUsed[itemString] = true + end + end + Threading.Yield() + end + Threading.ReleaseSafeTempTable(spellIds) + + -- clean up mats which aren't used anymore + local toRemove = TempTable.Acquire() + for itemString, matInfo in pairs(TSM.db.factionrealm.internalData.mats) do + -- clear out old names + matInfo.name = nil + if not matUsed[itemString] then + tinsert(toRemove, itemString) + end + end + Threading.ReleaseSafeTempTable(matUsed) + for _, itemString in ipairs(toRemove) do + TSM.db.factionrealm.internalData.mats[itemString] = nil + end + TempTable.Release(toRemove) + + -- update our DB + private.db:TruncateAndBulkInsertStart() + for _, character in TSM.db:FactionrealmCharacterIterator() do + local playerProfessions = TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character), "internalData", "playerProfessions") + if playerProfessions then + for name, info in pairs(playerProfessions) do + private.db:BulkInsertNewRow(character, name, info.skillId or -1, info.level, info.maxLevel, info.isSecondary) + end + end + end + private.db:BulkInsertEnd() + + private.playerProfessionsThreadRunning = false +end diff --git a/Core/Service/Crafting/ProfessionScanner.lua b/Core/Service/Crafting/ProfessionScanner.lua new file mode 100644 index 0000000..3f1c187 --- /dev/null +++ b/Core/Service/Crafting/ProfessionScanner.lua @@ -0,0 +1,554 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ProfessionScanner = TSM.Crafting:NewPackage("ProfessionScanner") +local ProfessionInfo = TSM.Include("Data.ProfessionInfo") +local Database = TSM.Include("Util.Database") +local Event = TSM.Include("Util.Event") +local Delay = TSM.Include("Util.Delay") +local TempTable = TSM.Include("Util.TempTable") +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local String = TSM.Include("Util.String") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + db = nil, + hasScanned = false, + callbacks = {}, + disabled = false, + ignoreUpdatesUntil = 0, + optionalMatArrayTemp = { { itemID = nil, count = 1, index = nil } }, +} +-- don't want to scan a bunch of times when the profession first loads so add a 10 frame debounce to update events +local SCAN_DEBOUNCE_FRAMES = 10 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function ProfessionScanner.OnInitialize() + private.db = Database.NewSchema("CRAFTING_RECIPES") + :AddUniqueNumberField("index") + :AddUniqueNumberField("spellId") + :AddStringField("name") + :AddNumberField("categoryId") + :AddStringField("difficulty") + :AddNumberField("rank") + :AddNumberField("numSkillUps") + :Commit() + TSM.Crafting.ProfessionState.RegisterUpdateCallback(private.ProfessionStateUpdate) + if TSM.IsWowClassic() then + Event.Register("CRAFT_UPDATE", private.OnTradeSkillUpdateEvent) + Event.Register("TRADE_SKILL_UPDATE", private.OnTradeSkillUpdateEvent) + else + Event.Register("TRADE_SKILL_LIST_UPDATE", private.OnTradeSkillUpdateEvent) + end + Event.Register("CHAT_MSG_SKILL", private.ChatMsgSkillEventHandler) +end + +function ProfessionScanner.SetDisabled(disabled) + if private.disabled == disabled then + return + end + private.disabled = disabled + if not disabled then + private.ScanProfession() + end +end + +function ProfessionScanner.HasScanned() + return private.hasScanned +end + +function ProfessionScanner.HasSkills() + return private.hasScanned and private.db:GetNumRows() > 0 +end + +function ProfessionScanner.RegisterHasScannedCallback(callback) + tinsert(private.callbacks, callback) +end + +function ProfessionScanner.IgnoreNextProfessionUpdates() + private.ignoreUpdatesUntil = GetTime() + 1 +end + +function ProfessionScanner.CreateQuery() + return private.db:NewQuery() +end + +function ProfessionScanner.GetIndexBySpellId(spellId) + assert(TSM.IsWowClassic() or private.hasScanned) + return private.db:GetUniqueRowField("spellId", spellId, "index") +end + +function ProfessionScanner.GetCategoryIdBySpellId(spellId) + assert(private.hasScanned) + return private.db:GetUniqueRowField("spellId", spellId, "categoryId") +end + +function ProfessionScanner.GetNameBySpellId(spellId) + assert(private.hasScanned) + return private.db:GetUniqueRowField("spellId", spellId, "name") +end + +function ProfessionScanner.GetRankBySpellId(spellId) + assert(private.hasScanned) + return private.db:GetUniqueRowField("spellId", spellId, "rank") +end + +function ProfessionScanner.GetNumSkillupsBySpellId(spellId) + assert(private.hasScanned) + return private.db:GetUniqueRowField("spellId", spellId, "numSkillUps") +end + +function ProfessionScanner.GetDifficultyBySpellId(spellId) + assert(private.hasScanned) + return private.db:GetUniqueRowField("spellId", spellId, "difficulty") +end + +function ProfessionScanner.GetFirstSpellId() + if not private.hasScanned then + return + end + return private.db:NewQuery() + :Select("spellId") + :OrderBy("index", true) + :GetFirstResultAndRelease() +end + +function ProfessionScanner.HasSpellId(spellId) + return private.hasScanned and private.db:GetUniqueRowField("spellId", spellId, "index") and true or false +end + + + +-- ============================================================================ +-- Event Handlers +-- ============================================================================ + +function private.ProfessionStateUpdate() + private.hasScanned = false + for _, callback in ipairs(private.callbacks) do + callback() + end + if TSM.Crafting.ProfessionState.GetCurrentProfession() then + private.db:Truncate() + private.OnTradeSkillUpdateEvent() + else + Delay.Cancel("PROFESSION_SCAN_DELAY") + end +end + +function private.OnTradeSkillUpdateEvent() + Delay.Cancel("PROFESSION_SCAN_DELAY") + private.QueueProfessionScan() +end + +function private.ChatMsgSkillEventHandler(_, msg) + local professionName = TSM.Crafting.ProfessionState.GetCurrentProfession() + if not professionName or not strmatch(msg, professionName) then + return + end + private.ignoreUpdatesUntil = 0 + private.QueueProfessionScan() +end + + + +-- ============================================================================ +-- Profession Scanning +-- ============================================================================ + +function private.QueueProfessionScan() + Delay.AfterFrame("PROFESSION_SCAN_DELAY", SCAN_DEBOUNCE_FRAMES, private.ScanProfession) +end + +function private.ScanProfession() + if InCombatLockdown() then + -- we are in combat, so try again in a bit + private.QueueProfessionScan() + return + elseif private.disabled then + return + elseif GetTime() < private.ignoreUpdatesUntil then + return + end + + local professionName = TSM.Crafting.ProfessionState.GetCurrentProfession() + if not professionName then + -- profession hasn't fully opened yet + private.QueueProfessionScan() + return + end + + assert(professionName and TSM.Crafting.ProfessionUtil.IsDataStable()) + if TSM.IsWowClassic() then + -- TODO: check and clear filters on classic + else + local hadFilter = false + if C_TradeSkillUI.GetOnlyShowUnlearnedRecipes() then + C_TradeSkillUI.SetOnlyShowLearnedRecipes(true) + C_TradeSkillUI.SetOnlyShowUnlearnedRecipes(false) + hadFilter = true + end + if C_TradeSkillUI.GetOnlyShowMakeableRecipes() then + C_TradeSkillUI.SetOnlyShowMakeableRecipes(false) + hadFilter = true + end + if C_TradeSkillUI.GetOnlyShowSkillUpRecipes() then + C_TradeSkillUI.SetOnlyShowSkillUpRecipes(false) + hadFilter = true + end + if C_TradeSkillUI.AnyRecipeCategoriesFiltered() then + C_TradeSkillUI.ClearRecipeCategoryFilter() + hadFilter = true + end + if C_TradeSkillUI.AreAnyInventorySlotsFiltered() then + C_TradeSkillUI.ClearInventorySlotFilter() + hadFilter = true + end + for i = 1, C_PetJournal.GetNumPetSources() do + if C_TradeSkillUI.IsAnyRecipeFromSource(i) and C_TradeSkillUI.IsRecipeSourceTypeFiltered(i) then + C_TradeSkillUI.ClearRecipeSourceTypeFilter() + hadFilter = true + break + end + end + if C_TradeSkillUI.GetRecipeItemNameFilter() ~= "" then + C_TradeSkillUI.SetRecipeItemNameFilter(nil) + hadFilter = true + end + local minItemLevel, maxItemLevel = C_TradeSkillUI.GetRecipeItemLevelFilter() + if minItemLevel ~= 0 or maxItemLevel ~= 0 then + C_TradeSkillUI.SetRecipeItemLevelFilter(0, 0) + hadFilter = true + end + + if hadFilter then + -- an update event will be triggered + return + end + end + + if TSM.IsWowClassic() then + local lastHeaderIndex = 0 + private.db:TruncateAndBulkInsertStart() + for i = 1, TSM.Crafting.ProfessionState.IsClassicCrafting() and GetNumCrafts() or GetNumTradeSkills() do + local name, _, skillType, hash = nil, nil, nil, nil + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + name, _, skillType = GetCraftInfo(i) + if skillType ~= "header" then + hash = Math.CalculateHash(name) + for j = 1, GetCraftNumReagents(i) do + local _, _, quantity = GetCraftReagentInfo(i, j) + hash = Math.CalculateHash(ItemString.Get(GetCraftReagentItemLink(i, j)), hash) + hash = Math.CalculateHash(quantity, hash) + end + end + else + name, skillType = GetTradeSkillInfo(i) + if skillType ~= "header" then + hash = Math.CalculateHash(name) + for j = 1, GetTradeSkillNumReagents(i) do + local _, _, quantity = GetTradeSkillReagentInfo(i, j) + hash = Math.CalculateHash(ItemString.Get(GetTradeSkillReagentItemLink(i, j)), hash) + hash = Math.CalculateHash(quantity, hash) + end + end + end + if skillType == "header" then + lastHeaderIndex = i + else + if name then + private.db:BulkInsertNewRow(i, hash, name, lastHeaderIndex, skillType, -1, 1) + end + end + end + private.db:BulkInsertEnd() + else + local prevRecipeIds = TempTable.Acquire() + local nextRecipeIds = TempTable.Acquire() + local recipeLearned = TempTable.Acquire() + local recipes = TempTable.Acquire() + assert(C_TradeSkillUI.GetFilteredRecipeIDs(recipes) == recipes) + local spellIdIndex = TempTable.Acquire() + for index, spellId in ipairs(recipes) do + -- There's a Blizzard bug where First Aid duplicates spellIds, so check that we haven't seen this before + if not spellIdIndex[spellId] then + spellIdIndex[spellId] = index + local info = nil + if not TSM.IsShadowlands() then + info = TempTable.Acquire() + assert(C_TradeSkillUI.GetRecipeInfo(spellId, info) == info) + else + info = C_TradeSkillUI.GetRecipeInfo(spellId) + end + if info.previousRecipeID then + prevRecipeIds[spellId] = info.previousRecipeID + nextRecipeIds[info.previousRecipeID] = spellId + end + if info.nextRecipeID then + nextRecipeIds[spellId] = info.nextRecipeID + prevRecipeIds[info.nextRecipeID] = spellId + end + recipeLearned[spellId] = info.learned + if not TSM.IsShadowlands() then + TempTable.Release(info) + end + end + end + private.db:TruncateAndBulkInsertStart() + local inactiveSpellIds = TempTable.Acquire() + for index, spellId in ipairs(recipes) do + local hasHigherRank = nextRecipeIds[spellId] and recipeLearned[nextRecipeIds[spellId]] + -- TODO: show unlearned recipes in the TSM UI + -- There's a Blizzard bug where First Aid duplicates spellIds, so check that this is the right index + if spellIdIndex[spellId] == index and recipeLearned[spellId] and not hasHigherRank then + local info = nil + if not TSM.IsShadowlands() then + info = TempTable.Acquire() + assert(C_TradeSkillUI.GetRecipeInfo(spellId, info) == info) + else + info = C_TradeSkillUI.GetRecipeInfo(spellId) + end + local rank = -1 + if prevRecipeIds[spellId] or nextRecipeIds[spellId] then + rank = 1 + local tempSpellId = spellId + while prevRecipeIds[tempSpellId] do + rank = rank + 1 + tempSpellId = prevRecipeIds[tempSpellId] + end + end + local numSkillUps = info.difficulty == "optimal" and info.numSkillUps or 1 + private.db:BulkInsertNewRow(index, spellId, info.name, info.categoryID, info.difficulty, rank, numSkillUps) + if not TSM.IsShadowlands() then + TempTable.Release(info) + end + else + inactiveSpellIds[spellId] = true + end + end + private.db:BulkInsertEnd() + -- remove spells which are not active (i.e. older ranks) + if next(inactiveSpellIds) then + TSM.Crafting.RemovePlayerSpells(inactiveSpellIds) + end + TempTable.Release(inactiveSpellIds) + TempTable.Release(spellIdIndex) + TempTable.Release(recipes) + TempTable.Release(prevRecipeIds) + TempTable.Release(nextRecipeIds) + TempTable.Release(recipeLearned) + end + + if TSM.Crafting.ProfessionUtil.IsNPCProfession() or TSM.Crafting.ProfessionUtil.IsLinkedProfession() or TSM.Crafting.ProfessionUtil.IsGuildProfession() then + -- we don't want to store this profession in our DB, so we're done + if not private.hasScanned then + private.hasScanned = true + for _, callback in ipairs(private.callbacks) do + callback() + end + end + return + end + + if not TSM.db.sync.internalData.playerProfessions[professionName] then + -- we are in combat or the player's professions haven't been scanned yet by PlayerProfessions.lua, so try again in a bit + private.QueueProfessionScan() + return + end + + -- update the link for this profession + TSM.db.sync.internalData.playerProfessions[professionName].link = not TSM.IsWowClassic() and C_TradeSkillUI.GetTradeSkillListLink() or nil + + -- scan all the recipes + TSM.Crafting.SetSpellDBQueryUpdatesPaused(true) + local query = private.db:NewQuery() + :Select("spellId") + local numFailed = 0 + for _, spellId in query:Iterator() do + if not private.ScanRecipe(professionName, spellId) then + numFailed = numFailed + 1 + end + end + query:Release() + TSM.Crafting.SetSpellDBQueryUpdatesPaused(false) + + Log.Info("Scanned %s (failed to scan %d)", professionName, numFailed) + if numFailed > 0 then + -- didn't completely scan, so we'll try again + private.QueueProfessionScan() + end + if not private.hasScanned then + private.hasScanned = true + for _, callback in ipairs(private.callbacks) do + callback() + end + end + + -- explicitly run GC + collectgarbage() +end + +function private.ScanRecipe(professionName, spellId) + -- get the links + local itemLink, lNum, hNum = TSM.Crafting.ProfessionUtil.GetRecipeInfo(TSM.IsWowClassic() and ProfessionScanner.GetIndexBySpellId(spellId) or spellId) + assert(itemLink, "Invalid craft: "..tostring(spellId)) + + -- get the itemString and craft name + local itemString, craftName = nil, nil + if strfind(itemLink, "enchant:") then + if TSM.IsWowClassic() then + return true + else + -- result of craft is not an item + itemString = ProfessionInfo.GetIndirectCraftResult(spellId) + if not itemString then + -- we don't care about this craft + return true + end + craftName = GetSpellInfo(spellId) + end + elseif strfind(itemLink, "item:") then + -- result of craft is item + itemString = ItemString.GetBase(itemLink) + craftName = ItemInfo.GetName(itemLink) + -- Blizzard broke Brilliant Scarlet Ruby in 8.3, so just hard-code a workaround + if spellId == 53946 and not itemString and not craftName then + itemString = "i:39998" + craftName = GetSpellInfo(spellId) + end + else + error("Invalid craft: "..tostring(spellId)) + end + if not itemString or not craftName then + Log.Warn("No itemString (%s) or craftName (%s) found (%s, %s)", tostring(itemString), tostring(craftName), tostring(professionName), tostring(spellId)) + return false + end + + -- get the result number + local numResult = nil + local isEnchant = professionName == GetSpellInfo(7411) and strfind(itemLink, "enchant:") + if isEnchant then + numResult = 1 + else + -- workaround for incorrect values returned for Temporal Crystal + if spellId == 169092 and itemString == "i:113588" then + lNum, hNum = 1, 1 + end + -- workaround for incorrect values returned for new mass milling recipes + if ProfessionInfo.IsMassMill(spellId) then + if spellId == 210116 then -- Yseralline + lNum, hNum = 4, 4 -- always four + elseif spellId == 209664 then -- Felwort + lNum, hNum = 42, 42 -- amount is variable but the values are conservative + elseif spellId == 247861 then -- Astral Glory + lNum, hNum = 4, 4 -- amount is variable but the values are conservative + else + lNum, hNum = 8, 8.8 + end + end + numResult = floor(((lNum or 1) + (hNum or 1)) / 2) + end + + -- store general info about this recipe + local hasCD = TSM.Crafting.ProfessionUtil.HasCooldown(spellId) + TSM.Crafting.CreateOrUpdate(spellId, itemString, professionName, craftName, numResult, UnitName("player"), hasCD) + + -- get the mat quantities and add mats to our DB + local matQuantities = TempTable.Acquire() + local haveInvalidMats = false + local numReagents = TSM.Crafting.ProfessionUtil.GetNumMats(spellId) + for i = 1, numReagents do + local matItemLink, name, _, quantity = TSM.Crafting.ProfessionUtil.GetMatInfo(spellId, i) + local matItemString = ItemString.GetBase(matItemLink) + if not matItemString then + Log.Warn("Failed to get itemString for mat %d (%s, %s)", i, tostring(professionName), tostring(spellId)) + haveInvalidMats = true + break + end + if not name or not quantity then + Log.Warn("Failed to get name (%s) or quantity (%s) for mat (%s, %s, %d)", tostring(name), tostring(quantity), tostring(professionName), tostring(spellId), i) + haveInvalidMats = true + break + end + ItemInfo.StoreItemName(matItemString, name) + TSM.db.factionrealm.internalData.mats[matItemString] = TSM.db.factionrealm.internalData.mats[matItemString] or {} + matQuantities[matItemString] = quantity + end + -- if this is an enchant, add a vellum to the list of mats + if isEnchant then + local matItemString = ProfessionInfo.GetVellumItemString() + TSM.db.factionrealm.internalData.mats[matItemString] = TSM.db.factionrealm.internalData.mats[matItemString] or {} + matQuantities[matItemString] = 1 + end + + if not haveInvalidMats then + local optionalMats = private.GetOptionalMats(spellId) + if optionalMats then + for _, matStr in ipairs(optionalMats) do + local _, _, mats = strsplit(":", matStr) + for itemId in String.SplitIterator(mats, ",") do + local matItemString = "i:"..itemId + TSM.db.factionrealm.internalData.mats[matItemString] = TSM.db.factionrealm.internalData.mats[matItemString] or {} + end + matQuantities[matStr] = -1 + end + end + TSM.Crafting.SetMats(spellId, matQuantities) + end + TempTable.Release(matQuantities) + return not haveInvalidMats +end + +function private.GetOptionalMats(spellId) + local optionalMats = TSM.IsShadowlands() and C_TradeSkillUI.GetOptionalReagentInfo(spellId) or nil + if not optionalMats or #optionalMats == 0 then + return nil + end + for i, info in ipairs(optionalMats) do + if info.requiredSkillRank ~= 0 then + -- TODO: handle this case + return nil + else + -- process the options + assert(#info.options > 0) + -- sort the optional mats by itemId + sort(info.options) + -- cache the optional mat info + for _, itemId in ipairs(info.options) do + assert(type(itemId) == "number") + private.CacheOptionalMatInfo(spellId, i, itemId) + end + local matList = table.concat(info.options, ",") + TSM.Crafting.ProfessionUtil.StoreOptionalMatText(matList, info.slotText) + optionalMats[i] = "o:"..i..":"..matList + end + end + return optionalMats +end + +function private.CacheOptionalMatInfo(spellId, index, itemId) + if TSM.db.global.internalData.optionalMatBonusIdLookup[itemId] then + return + end + if not TSMScanTooltip then + CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate") + end + private.optionalMatArrayTemp.itemID = itemId + private.optionalMatArrayTemp.slot = index + TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE") + TSMScanTooltip:ClearLines() + TSMScanTooltip:SetRecipeResultItem(spellId, private.optionalMatArrayTemp) + local _, itemLink = TSMScanTooltip:GetItem() + local bonusId = strmatch(itemLink, "item:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:2:3524:([0-9]+)") + TSM.db.global.internalData.optionalMatBonusIdLookup[itemId] = tonumber(bonusId) +end diff --git a/Core/Service/Crafting/ProfessionState.lua b/Core/Service/Crafting/ProfessionState.lua new file mode 100644 index 0000000..481dcad --- /dev/null +++ b/Core/Service/Crafting/ProfessionState.lua @@ -0,0 +1,191 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ProfessionState = TSM.Crafting:NewPackage("ProfessionState") +local Event = TSM.Include("Util.Event") +local Delay = TSM.Include("Util.Delay") +local FSM = TSM.Include("Util.FSM") +local Log = TSM.Include("Util.Log") +local private = { + fsm = nil, + updateCallbacks = {}, + isClosed = true, + craftOpen = nil, + tradeSkillOpen = nil, + professionName = nil, +} +local WAIT_FRAME_DELAY = 5 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function ProfessionState.OnInitialize() + private.CreateFSM() +end + +function ProfessionState.RegisterUpdateCallback(callback) + tinsert(private.updateCallbacks, callback) +end + +function ProfessionState.GetIsClosed() + return private.isClosed +end + +function ProfessionState.IsClassicCrafting() + return TSM.IsWowClassic() and private.craftOpen +end + +function ProfessionState.SetCraftOpen(open) + private.craftOpen = open +end + +function ProfessionState.GetCurrentProfession() + return private.professionName +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.CreateFSM() + if TSM.IsWowClassic() and not IsAddOnLoaded("Blizzard_CraftUI") then + LoadAddOn("Blizzard_CraftUI") + end + Event.Register("TRADE_SKILL_SHOW", function() + private.tradeSkillOpen = true + private.fsm:ProcessEvent("EV_TRADE_SKILL_SHOW") + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGING") + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGED") + end) + Event.Register("TRADE_SKILL_CLOSE", function() + private.tradeSkillOpen = false + if not private.craftOpen then + private.fsm:ProcessEvent("EV_TRADE_SKILL_CLOSE") + end + end) + if not TSM.IsWowClassic() then + Event.Register("GARRISON_TRADESKILL_NPC_CLOSED", function() + private.fsm:ProcessEvent("EV_TRADE_SKILL_CLOSE") + end) + Event.Register("TRADE_SKILL_DATA_SOURCE_CHANGED", function() + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGED") + end) + Event.Register("TRADE_SKILL_DATA_SOURCE_CHANGING", function() + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGING") + end) + else + Event.Register("CRAFT_SHOW", function() + private.craftOpen = true + private.fsm:ProcessEvent("EV_TRADE_SKILL_SHOW") + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGING") + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGED") + end) + Event.Register("CRAFT_CLOSE", function() + private.craftOpen = false + if not private.tradeSkillOpen then + private.fsm:ProcessEvent("EV_TRADE_SKILL_CLOSE") + end + end) + Event.Register("CRAFT_UPDATE", function() + private.fsm:ProcessEvent("EV_TRADE_SKILL_DATA_SOURCE_CHANGED") + end) + end + local function ToggleDefaultCraftButton() + if not CraftCreateButton then + return + end + if private.craftOpen then + CraftCreateButton:Show() + else + CraftCreateButton:Hide() + end + end + local function FrameDelayCallback() + private.fsm:ProcessEvent("EV_FRAME_DELAY") + end + private.fsm = FSM.New("PROFESSION_STATE") + :AddState(FSM.NewState("ST_CLOSED") + :SetOnEnter(function() + private.isClosed = true + private.RunUpdateCallbacks() + end) + :SetOnExit(function() + private.isClosed = false + private.RunUpdateCallbacks() + end) + :AddTransition("ST_WAITING_FOR_DATA") + :AddEventTransition("EV_TRADE_SKILL_SHOW", "ST_WAITING_FOR_DATA") + ) + :AddState(FSM.NewState("ST_WAITING_FOR_DATA") + :AddTransition("ST_WAITING_FOR_READY") + :AddTransition("ST_CLOSED") + :AddEventTransition("EV_TRADE_SKILL_DATA_SOURCE_CHANGED", "ST_WAITING_FOR_READY") + :AddEventTransition("EV_TRADE_SKILL_CLOSE", "ST_CLOSED") + ) + :AddState(FSM.NewState("ST_WAITING_FOR_READY") + :SetOnEnter(function() + Delay.AfterFrame("PROFESSION_STATE_TIME", WAIT_FRAME_DELAY, FrameDelayCallback, WAIT_FRAME_DELAY) + end) + :SetOnExit(function() + Delay.Cancel("PROFESSION_STATE_TIME") + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_DATA_CHANGING") + :AddTransition("ST_CLOSED") + :AddEvent("EV_FRAME_DELAY", function() + if TSM.Crafting.ProfessionUtil.IsDataStable() then + return "ST_SHOWN" + end + end) + :AddEventTransition("EV_TRADE_SKILL_DATA_SOURCE_CHANGING", "ST_DATA_CHANGING") + :AddEventTransition("EV_TRADE_SKILL_CLOSE", "ST_CLOSED") + ) + :AddState(FSM.NewState("ST_SHOWN") + :SetOnEnter(function() + local name = TSM.Crafting.ProfessionUtil.GetCurrentProfessionName() + assert(name) + Log.Info("Showing profession: %s", name) + private.professionName = name + if TSM.IsWowClassic() then + ToggleDefaultCraftButton() + end + private.RunUpdateCallbacks() + end) + :SetOnExit(function() + private.professionName = nil + private.RunUpdateCallbacks() + end) + :AddTransition("ST_DATA_CHANGING") + :AddTransition("ST_CLOSED") + :AddEventTransition("EV_TRADE_SKILL_DATA_SOURCE_CHANGING", "ST_DATA_CHANGING") + :AddEventTransition("EV_TRADE_SKILL_CLOSE", "ST_CLOSED") + ) + :AddState(FSM.NewState("ST_DATA_CHANGING") + :AddTransition("ST_WAITING_FOR_READY") + :AddTransition("ST_CLOSED") + :AddEventTransition("EV_TRADE_SKILL_DATA_SOURCE_CHANGED", "ST_WAITING_FOR_READY") + :AddEventTransition("EV_TRADE_SKILL_CLOSE", "ST_CLOSED") + ) + :Init("ST_CLOSED") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RunUpdateCallbacks() + for _, callback in ipairs(private.updateCallbacks) do + callback(private.professionName) + end +end diff --git a/Core/Service/Crafting/ProfessionUtil.lua b/Core/Service/Crafting/ProfessionUtil.lua new file mode 100644 index 0000000..1375da3 --- /dev/null +++ b/Core/Service/Crafting/ProfessionUtil.lua @@ -0,0 +1,480 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ProfessionUtil = TSM.Crafting:NewPackage("ProfessionUtil") +local ProfessionInfo = TSM.Include("Data.ProfessionInfo") +local Event = TSM.Include("Util.Event") +local TempTable = TSM.Include("Util.TempTable") +local Log = TSM.Include("Util.Log") +local Delay = TSM.Include("Util.Delay") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local Inventory = TSM.Include("Service.Inventory") +local CustomPrice = TSM.Include("Service.CustomPrice") +local private = { + craftQuantity = nil, + craftSpellId = nil, + craftCallback = nil, + craftName = nil, + castingTimeout = nil, + craftTimeout = nil, + preparedSpellId = nil, + preparedTime = 0, + categoryInfoTemp = {}, +} +local PROFESSION_LOOKUP = { + ["Costura"] = "Sastrería", + ["Marroquinería"] = "Peletería", + ["Ingénierie"] = "Ingénieur", + ["Secourisme"] = "Premiers soins", +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function ProfessionUtil.OnInitialize() + Event.Register("UNIT_SPELLCAST_SUCCEEDED", function(_, unit, _, spellId) + if unit ~= "player" then + return + end + if (TSM.IsWowClassic() and GetSpellInfo(spellId) ~= private.craftName) or (not TSM.IsWowClassic() and spellId ~= private.craftSpellId) then + return + end + + -- check if we need to update bank quantity manually + for _, itemString, quantity in TSM.Crafting.MatIterator(private.craftSpellId) do + local bankUsed = quantity - (Inventory.GetBagQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString)) + if bankUsed > 0 and bankUsed <= Inventory.GetBankQuantity(itemString) then + Log.Info("Used %d from bank", bankUsed) + BagTracking.ForceBankQuantityDeduction(itemString, bankUsed) + end + end + + local callback = private.craftCallback + assert(callback) + private.craftQuantity = private.craftQuantity - 1 + private.DoCraftCallback(true, private.craftQuantity == 0) + -- ignore profession updates from crafting something + TSM.Crafting.ProfessionScanner.IgnoreNextProfessionUpdates() + -- restart the timeout + end) + local function SpellcastFailedEventHandler(_, unit, _, spellId) + if unit ~= "player" then + return + end + if (TSM.IsWowClassic() and GetSpellInfo(spellId) ~= private.craftName) or (not TSM.IsWowClassic() and spellId ~= private.craftSpellId) then + return + end + private.DoCraftCallback(false, true) + end + local function ClearCraftCast() + private.craftQuantity = nil + private.craftSpellId = nil + private.craftName = nil + private.castingTimeout = nil + private.craftTimeout = nil + end + Event.Register("UNIT_SPELLCAST_INTERRUPTED", SpellcastFailedEventHandler) + Event.Register("UNIT_SPELLCAST_FAILED", SpellcastFailedEventHandler) + Event.Register("UNIT_SPELLCAST_FAILED_QUIET", SpellcastFailedEventHandler) + Event.Register("TRADE_SKILL_CLOSE", ClearCraftCast) + if TSM.IsWowClassic() then + Event.Register("CRAFT_CLOSE", ClearCraftCast) + end +end + +function ProfessionUtil.GetCurrentProfessionName() + if TSM.IsWowClassic() then + local name = TSM.Crafting.ProfessionState.IsClassicCrafting() and GetCraftSkillLine(1) or GetTradeSkillLine() + return name + else + local _, name, _, _, _, _, parentName = C_TradeSkillUI.GetTradeSkillLine() + return parentName or name + end +end + +function ProfessionUtil.GetResultInfo(spellId) + -- get the links + local itemLink = ProfessionUtil.GetRecipeInfo(spellId) + assert(itemLink, "Invalid craft: "..tostring(spellId)) + + if strfind(itemLink, "enchant:") then + -- result of craft is not an item + local itemString = ProfessionInfo.GetIndirectCraftResult(spellId) + if itemString and not TSM.IsWowClassic() then + return TSM.UI.GetColoredItemName(itemString), itemString, ItemInfo.GetTexture(itemString) + elseif ProfessionInfo.IsEngineeringTinker(spellId) then + local name, _, icon = GetSpellInfo(spellId) + return name, nil, icon + else + local name, _, icon = GetSpellInfo(TSM.Crafting.ProfessionState.IsClassicCrafting() and GetCraftInfo(TSM.IsWowClassic() and TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId) or spellId) or spellId) + return name, nil, icon + end + elseif strfind(itemLink, "item:") then + -- result of craft is an item + return TSM.UI.GetColoredItemName(itemLink), ItemString.Get(itemLink), ItemInfo.GetTexture(itemLink) + else + error("Invalid craft: "..tostring(spellId)) + end +end + +function ProfessionUtil.GetNumCraftable(spellId) + local num, numAll = math.huge, math.huge + for i = 1, ProfessionUtil.GetNumMats(spellId) do + local matItemLink, _, _, quantity = ProfessionUtil.GetMatInfo(spellId, i) + local itemString = ItemString.Get(matItemLink) + local totalQuantity = CustomPrice.GetItemPrice(itemString, "NumInventory") or 0 + if not itemString or not quantity or totalQuantity == 0 then + return 0, 0 + end + local bagQuantity = Inventory.GetBagQuantity(itemString) + if not TSM.IsWowClassic() then + bagQuantity = bagQuantity + Inventory.GetReagentBankQuantity(itemString) + Inventory.GetBankQuantity(itemString) + end + num = min(num, floor(bagQuantity / quantity)) + numAll = min(numAll, floor(totalQuantity / quantity)) + end + if num == math.huge or numAll == math.huge then + return 0, 0 + end + return num, numAll +end + +function ProfessionUtil.IsCraftable(spellId) + for i = 1, ProfessionUtil.GetNumMats(spellId) do + local matItemLink, _, _, quantity = ProfessionUtil.GetMatInfo(spellId, i) + local itemString = ItemString.Get(matItemLink) + if not itemString or not quantity then + return false + end + local bagQuantity = Inventory.GetBagQuantity(itemString) + if not TSM.IsWowClassic() then + bagQuantity = bagQuantity + Inventory.GetReagentBankQuantity(itemString) + Inventory.GetBankQuantity(itemString) + end + if floor(bagQuantity / quantity) == 0 then + return false + end + end + return true +end + +function ProfessionUtil.GetNumCraftableFromDB(spellId) + local num = math.huge + for _, itemString, quantity in TSM.Crafting.MatIterator(spellId) do + local bagQuantity = Inventory.GetBagQuantity(itemString) + if not TSM.IsWowClassic() then + bagQuantity = bagQuantity + Inventory.GetReagentBankQuantity(itemString) + Inventory.GetBankQuantity(itemString) + end + num = min(num, floor(bagQuantity / quantity)) + end + if num == math.huge then + return 0 + end + return num +end + +function ProfessionUtil.IsEnchant(spellId) + local name = ProfessionUtil.GetCurrentProfessionName() + if name ~= GetSpellInfo(7411) or TSM.IsWowClassic() then + return false + end + if not strfind(C_TradeSkillUI.GetRecipeItemLink(spellId), "enchant:") then + return false + end + local recipeInfo = nil + if not TSM.IsShadowlands() then + recipeInfo = TempTable.Acquire() + assert(C_TradeSkillUI.GetRecipeInfo(spellId, recipeInfo) == recipeInfo) + else + recipeInfo = C_TradeSkillUI.GetRecipeInfo(spellId) + end + local altVerb = recipeInfo.alternateVerb + if not TSM.IsShadowlands() then + TempTable.Release(recipeInfo) + end + return altVerb and true or false +end + +function ProfessionUtil.OpenProfession(profession, skillId) + if TSM.IsWowClassic() then + if profession == ProfessionInfo.GetName("Mining") then + -- mining needs to be opened as smelting + profession = ProfessionInfo.GetName("Smelting") + end + if PROFESSION_LOOKUP[profession] then + profession = PROFESSION_LOOKUP[profession] + end + CastSpellByName(profession) + else + C_TradeSkillUI.OpenTradeSkill(skillId) + end +end + +function ProfessionUtil.PrepareToCraft(spellId, quantity) + quantity = min(quantity, ProfessionUtil.GetNumCraftable(spellId)) + if quantity == 0 then + return + end + if ProfessionUtil.IsEnchant(spellId) then + quantity = 1 + end + + if not TSM.IsWowClassic() then + C_TradeSkillUI.SetRecipeRepeatCount(spellId, quantity) + end + private.preparedSpellId = spellId + private.preparedTime = GetTime() +end + +function ProfessionUtil.Craft(spellId, quantity, useVellum, callback) + assert(TSM.Crafting.ProfessionScanner.HasSpellId(spellId)) + if private.craftSpellId then + private.craftCallback = callback + private.DoCraftCallback(false, true) + return 0 + end + quantity = min(quantity, ProfessionUtil.GetNumCraftable(spellId)) + if quantity == 0 then + return 0 + end + local isEnchant = ProfessionUtil.IsEnchant(spellId) + if isEnchant then + quantity = 1 + elseif spellId ~= private.preparedSpellId or private.preparedTime == GetTime() then + -- We can only craft one of this item due to a bug on Blizzard's end + quantity = 1 + end + private.craftQuantity = quantity + private.craftSpellId = spellId + private.craftCallback = callback + if TSM.IsWowClassic() then + spellId = TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId) + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + private.craftName = GetCraftInfo(spellId) + else + private.craftName = GetTradeSkillInfo(spellId) + DoTradeSkill(spellId, quantity) + end + else + C_TradeSkillUI.CraftRecipe(spellId, quantity) + end + if useVellum and isEnchant then + UseItemByName(ItemInfo.GetName(ProfessionInfo.GetVellumItemString())) + end + private.castingTimeout = nil + private.craftTimeout = nil + Delay.AfterTime("PROFESSION_CRAFT_TIMEOUT_MONITOR", 0.5, private.CraftTimeoutMonitor, 0.5) + return quantity +end + +function ProfessionUtil.IsDataStable() + return TSM.IsWowClassic() or (C_TradeSkillUI.IsTradeSkillReady() and not C_TradeSkillUI.IsDataSourceChanging()) +end + +function ProfessionUtil.HasCooldown(spellId) + if TSM.IsWowClassic() then + return GetTradeSkillCooldown(spellId) and true or false + else + return select(2, C_TradeSkillUI.GetRecipeCooldown(spellId)) and true or false + end +end + +function ProfessionUtil.GetRemainingCooldown(spellId) + if TSM.IsWowClassic() then + return GetTradeSkillCooldown(spellId) + else + return C_TradeSkillUI.GetRecipeCooldown(spellId) + end +end + +function ProfessionUtil.GetRecipeInfo(spellId) + local itemLink, lNum, hNum, toolsStr, hasTools = nil, nil, nil, nil, nil + if TSM.IsWowClassic() then + spellId = TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId) or spellId + itemLink = TSM.Crafting.ProfessionState.IsClassicCrafting() and GetCraftItemLink(spellId) or GetTradeSkillItemLink(spellId) + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + lNum, hNum = 1, 1 + toolsStr, hasTools = GetCraftSpellFocus(spellId) + else + lNum, hNum = GetTradeSkillNumMade(spellId) + toolsStr, hasTools = GetTradeSkillTools(spellId) + end + else + itemLink = C_TradeSkillUI.GetRecipeItemLink(spellId) + lNum, hNum = C_TradeSkillUI.GetRecipeNumItemsProduced(spellId) + toolsStr, hasTools = C_TradeSkillUI.GetRecipeTools(spellId) + end + return itemLink, lNum, hNum, toolsStr, hasTools +end + +function ProfessionUtil.GetNumMats(spellId) + local numMats = nil + if TSM.IsWowClassic() then + spellId = TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId) or spellId + numMats = TSM.Crafting.ProfessionState.IsClassicCrafting() and GetCraftNumReagents(spellId) or GetTradeSkillNumReagents(spellId) + else + numMats = C_TradeSkillUI.GetRecipeNumReagents(spellId) + end + return numMats +end + +function ProfessionUtil.GetMatInfo(spellId, index) + local itemLink, name, texture, quantity = nil, nil, nil, nil + if TSM.IsWowClassic() then + spellId = TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId) or spellId + itemLink = TSM.Crafting.ProfessionState.IsClassicCrafting() and GetCraftReagentItemLink(spellId, index) or GetTradeSkillReagentItemLink(spellId, index) + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + name, texture, quantity = GetCraftReagentInfo(spellId, index) + else + name, texture, quantity = GetTradeSkillReagentInfo(spellId, index) + end + else + itemLink = C_TradeSkillUI.GetRecipeReagentItemLink(spellId, index) + name, texture, quantity = C_TradeSkillUI.GetRecipeReagentInfo(spellId, index) + if itemLink then + name = name or ItemInfo.GetName(itemLink) + texture = texture or ItemInfo.GetTexture(itemLink) + end + end + return itemLink, name, texture, quantity +end + +function ProfessionUtil.CloseTradeSkill(closeBoth) + if TSM.IsWowClassic() then + if closeBoth then + CloseCraft() + CloseTradeSkill() + else + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + CloseCraft() + else + CloseTradeSkill() + end + end + else + C_TradeSkillUI.CloseTradeSkill() + C_Garrison.CloseGarrisonTradeskillNPC() + end +end + +function ProfessionUtil.IsNPCProfession() + return not TSM.IsWowClassic() and C_TradeSkillUI.IsNPCCrafting() +end + +function ProfessionUtil.IsLinkedProfession() + if TSM.IsWowClassic() then + return nil, nil + else + return C_TradeSkillUI.IsTradeSkillLinked() + end +end + +function ProfessionUtil.IsGuildProfession() + return not TSM.IsWowClassic() and C_TradeSkillUI.IsTradeSkillGuild() +end + +function ProfessionUtil.GetCategoryInfo(categoryId) + local name, numIndents, parentCategoryId, currentSkillLevel, maxSkillLevel = nil, nil, nil, nil, nil + if TSM.IsWowClassic() then + name = TSM.Crafting.ProfessionState.IsClassicCrafting() and GetCraftDisplaySkillLine() or (categoryId and GetTradeSkillInfo(categoryId) or nil) + numIndents = 0 + parentCategoryId = nil + else + C_TradeSkillUI.GetCategoryInfo(categoryId, private.categoryInfoTemp) + assert(private.categoryInfoTemp.numIndents) + name = private.categoryInfoTemp.name + numIndents = private.categoryInfoTemp.numIndents + parentCategoryId = private.categoryInfoTemp.numIndents ~= 0 and private.categoryInfoTemp.parentCategoryID or nil + currentSkillLevel = private.categoryInfoTemp.skillLineCurrentLevel + maxSkillLevel = private.categoryInfoTemp.skillLineMaxLevel + wipe(private.categoryInfoTemp) + end + return name, numIndents, parentCategoryId, currentSkillLevel, maxSkillLevel +end + +function ProfessionUtil.StoreOptionalMatText(matList, text) + TSM.db.global.internalData.optionalMatTextLookup[matList] = TSM.db.global.internalData.optionalMatTextLookup[matList] or text +end + +function ProfessionUtil.GetOptionalMatText(matList) + return TSM.db.global.internalData.optionalMatTextLookup[matList] +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.DoCraftCallback(result, isDone) + local callback = private.craftCallback + assert(callback) + -- reset timeouts + private.castingTimeout = nil + private.craftTimeout = nil + if isDone then + private.craftQuantity = nil + private.craftSpellId = nil + private.craftCallback = nil + private.craftName = nil + Delay.Cancel("PROFESSION_CRAFT_TIMEOUT_MONITOR") + end + callback(result, isDone) +end + +function private.CraftTimeoutMonitor() + if not private.craftSpellId then + Log.Info("No longer crafting") + private.castingTimeout = nil + private.craftTimeout = nil + Delay.Cancel("PROFESSION_CRAFT_TIMEOUT_MONITOR") + return + end + local _, _, _, _, castEndTimeMs, _, _, _, spellId = private.GetPlayerCastingInfo() + if spellId then + private.castingTimeout = nil + else + private.craftTimeout = nil + end + if not spellId then + -- no active cast + if GetTime() > (private.castingTimeout or math.huge) then + Log.Err("Craft timed out (%s)", private.craftSpellId) + private.DoCraftCallback(false, true) + return + end + -- set the casting timeout to 1 second from now + private.castingTimeout = GetTime() + 1 + return + elseif private.craftSpellId ~= spellId then + Log.Err("Crafting something else (%s, %s)", private.craftSpellId, spellId) + private.castingTimeout = nil + private.craftTimeout = nil + Delay.Cancel("PROFESSION_CRAFT_TIMEOUT_MONITOR") + return + end + + if GetTime() > (private.craftTimeout or math.huge) then + Log.Err("Craft timed out (%s)", private.craftSpellId) + private.DoCraftCallback(false, true) + return + end + -- set the timeout to 1 second after the end time + private.craftTimeout = castEndTimeMs / 1000 + 1 +end + +function private.GetPlayerCastingInfo() + if TSM.IsWowClassic() then + return CastingInfo() + else + return UnitCastingInfo("player") + end +end diff --git a/Core/Service/Crafting/Queue.lua b/Core/Service/Crafting/Queue.lua new file mode 100644 index 0000000..2cd0a03 --- /dev/null +++ b/Core/Service/Crafting/Queue.lua @@ -0,0 +1,186 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Queue = TSM.Crafting:NewPackage("Queue") +local Database = TSM.Include("Util.Database") +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local Inventory = TSM.Include("Service.Inventory") +local CustomPrice = TSM.Include("Service.CustomPrice") +local private = { + db = nil, +} +local MAX_NUM_QUEUED = 9999 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Queue.OnEnable() + private.db = Database.NewSchema("CRAFTING_QUEUE") + :AddUniqueNumberField("spellId") + :AddNumberField("num") + :Commit() + private.db:SetQueryUpdatesPaused(true) + for spellId, data in pairs(TSM.db.factionrealm.internalData.crafts) do + Queue.SetNum(spellId, data.queued) -- sanitize / cache the number queued + end + private.db:SetQueryUpdatesPaused(false) +end + +function Queue.GetDBForJoin() + return private.db +end + +function Queue.CreateQuery() + return private.db:NewQuery() +end + +function Queue.SetNum(spellId, num) + local craftInfo = TSM.db.factionrealm.internalData.crafts[spellId] + if not craftInfo then + Log.Err("Could not find craft: "..spellId) + return + end + craftInfo.queued = min(max(Math.Round(num or 0), 0), MAX_NUM_QUEUED) + local query = private.db:NewQuery() + :Equal("spellId", spellId) + local row = query:GetFirstResult() + if row and craftInfo.queued == 0 then + -- delete this row + private.db:DeleteRow(row) + elseif row then + -- update this row + row:SetField("num", craftInfo.queued) + :Update() + elseif craftInfo.queued > 0 then + -- insert a new row + private.db:NewRow() + :SetField("spellId", spellId) + :SetField("num", craftInfo.queued) + :Create() + end + query:Release() +end + +function Queue.GetNum(spellId) + return private.db:GetUniqueRowField("spellId", spellId, "num") or 0 +end + +function Queue.Add(spellId, quantity) + Queue.SetNum(spellId, Queue.GetNum(spellId) + quantity) +end + +function Queue.Remove(spellId, quantity) + Queue.SetNum(spellId, Queue.GetNum(spellId) - quantity) +end + +function Queue.Clear() + local query = private.db:NewQuery() + :Select("spellId") + for _, spellId in query:Iterator() do + local craftInfo = TSM.db.factionrealm.internalData.crafts[spellId] + if craftInfo then + craftInfo.queued = 0 + end + end + query:Release() + private.db:Truncate() +end + +function Queue.GetNumItems() + return private.db:NewQuery():CountAndRelease() +end + +function Queue.GetTotals() + local totalCost, totalProfit, totalCastTimeMs, totalNumQueued = nil, nil, nil, 0 + local query = private.db:NewQuery() + :Select("spellId", "num") + for _, spellId, numQueued in query:Iterator() do + local numResult = TSM.db.factionrealm.internalData.crafts[spellId] and TSM.db.factionrealm.internalData.crafts[spellId].numResult or 0 + local cost, _, profit = TSM.Crafting.Cost.GetCostsBySpellId(spellId) + if cost then + totalCost = (totalCost or 0) + cost * numQueued * numResult + end + if profit then + totalProfit = (totalProfit or 0) + profit * numQueued * numResult + end + local castTime = select(4, GetSpellInfo(spellId)) + if castTime then + totalCastTimeMs = (totalCastTimeMs or 0) + castTime * numQueued + end + totalNumQueued = totalNumQueued + numQueued + + end + query:Release() + return totalCost, totalProfit, totalCastTimeMs and ceil(totalCastTimeMs / 1000) or nil, totalNumQueued +end + +function Queue.RestockGroups(groups) + private.db:SetQueryUpdatesPaused(true) + for _, groupPath in ipairs(groups) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + for _, itemString in TSM.Groups.ItemIterator(groupPath) do + if TSM.Crafting.CanCraftItem(itemString) then + local isValid, err = TSM.Operations.Crafting.IsValid(itemString) + if isValid then + private.RestockItem(itemString) + elseif err then + Log.PrintUser(err) + end + end + end + end + end + private.db:SetQueryUpdatesPaused(false) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RestockItem(itemString) + local cheapestCost, cheapestSpellId = TSM.Crafting.Cost.GetLowestCostByItem(itemString) + if not cheapestSpellId then + -- can't craft this item + return + end + local itemValue = TSM.Crafting.Cost.GetCraftedItemValue(itemString) + local profit = itemValue and cheapestCost and (itemValue - cheapestCost) or nil + local hasMinProfit, minProfit = TSM.Operations.Crafting.GetMinProfit(itemString) + if hasMinProfit and (not minProfit or not profit or profit < minProfit) then + -- profit is too low + return + end + + local haveQuantity = CustomPrice.GetItemPrice(itemString, "NumInventory") or 0 + for guild, ignored in pairs(TSM.db.global.craftingOptions.ignoreGuilds) do + if ignored then + haveQuantity = haveQuantity - Inventory.GetGuildQuantity(itemString, guild) + end + end + for player, ignored in pairs(TSM.db.global.craftingOptions.ignoreCharacters) do + if ignored then + haveQuantity = haveQuantity - Inventory.GetBagQuantity(itemString, player) + haveQuantity = haveQuantity - Inventory.GetBankQuantity(itemString, player) + haveQuantity = haveQuantity - Inventory.GetReagentBankQuantity(itemString, player) + haveQuantity = haveQuantity - Inventory.GetAuctionQuantity(itemString, player) + haveQuantity = haveQuantity - Inventory.GetMailQuantity(itemString, player) + end + end + assert(haveQuantity >= 0) + local neededQuantity = TSM.Operations.Crafting.GetRestockQuantity(itemString, haveQuantity) + if neededQuantity == 0 then + return + end + -- queue only if it satisfies all operation criteria + Queue.SetNum(cheapestSpellId, floor(neededQuantity / TSM.Crafting.GetNumResult(cheapestSpellId))) +end diff --git a/Core/Service/Crafting/Sync.lua b/Core/Service/Crafting/Sync.lua new file mode 100644 index 0000000..e93f65c --- /dev/null +++ b/Core/Service/Crafting/Sync.lua @@ -0,0 +1,227 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CraftingSync = TSM.Crafting:NewPackage("Sync") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local TempTable = TSM.Include("Util.TempTable") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local Theme = TSM.Include("Util.Theme") +local Sync = TSM.Include("Service.Sync") +local private = { + hashesTemp = {}, + spellsTemp = {}, + spellsProfessionLookupTemp = {}, + spellInfoTemp = { + spellIds = {}, + mats = {}, + itemStrings = {}, + names = {}, + numResults = {}, + hasCDs = {}, + }, + accountLookup = {}, + accountStatus = {}, +} +local RETRY_DELAY = 5 +local PROFESSION_HASH_FIELDS = { "spellId", "itemString" } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function CraftingSync.OnInitialize() + Sync.RegisterConnectionChangedCallback(private.ConnectionChangedHandler) + Sync.RegisterRPC("CRAFTING_GET_HASHES", private.RPCGetHashes) + Sync.RegisterRPC("CRAFTING_GET_SPELLS", private.RPCGetSpells) + Sync.RegisterRPC("CRAFTING_GET_SPELL_INFO", private.RPCGetSpellInfo) +end + +function CraftingSync.GetStatus(account) + local status = private.accountStatus[account] + if not status then + return Theme.GetFeedbackColor("RED"):ColorText(L["Not Connected"]) + elseif status == "UPDATING" or status == "RETRY" then + return Theme.GetFeedbackColor("YELLOW"):ColorText(L["Updating"]) + elseif status == "SYNCED" then + return Theme.GetFeedbackColor("GREEN"):ColorText(L["Up to date"]) + else + error("Invalid status: "..tostring(status)) + end +end + + + +-- ============================================================================ +-- RPC Functions and Result Handlers +-- ============================================================================ + +function private.RPCGetHashes() + wipe(private.hashesTemp) + local player = UnitName("player") + private.GetPlayerProfessionHashes(player, private.hashesTemp) + return player, private.hashesTemp +end + +function private.RPCGetHashesResultHandler(player, data) + if not player or not private.accountLookup[player] then + -- request timed out, so try again + Log.Warn("Getting hashes timed out") + if private.accountLookup[player] then + private.accountStatus[private.accountLookup[player]] = "RETRY" + Delay.AfterTime(RETRY_DELAY, private.RetryGetHashesRPC) + end + return + end + local currentInfo = TempTable.Acquire() + private.GetPlayerProfessionHashes(player, currentInfo) + local requestProfessions = TempTable.Acquire() + for profession, hash in pairs(data) do + if hash == currentInfo[profession] then + Log.Info("%s data for %s already up to date", profession, player) + else + Log.Info("Need updated %s data from %s (%s, %s)", profession, player, hash, tostring(currentInfo[hash])) + requestProfessions[profession] = true + end + end + TempTable.Release(currentInfo) + if next(requestProfessions) then + private.accountStatus[private.accountLookup[player]] = "UPDATING" + Sync.CallRPC("CRAFTING_GET_SPELLS", player, private.RPCGetSpellsResultHandler, requestProfessions) + else + private.accountStatus[private.accountLookup[player]] = "SYNCED" + end + TempTable.Release(requestProfessions) +end + +function private.RPCGetSpells(professions) + wipe(private.spellsProfessionLookupTemp) + wipe(private.spellsTemp) + local player = UnitName("player") + local query = TSM.Crafting.CreateRawCraftsQuery() + :Select("spellId", "profession") + :Custom(private.QueryProfessionFilter, professions) + :Custom(private.QueryPlayerFilter, player) + :OrderBy("spellId", true) + for _, spellId, profession in query:Iterator() do + private.spellsProfessionLookupTemp[spellId] = profession + tinsert(private.spellsTemp, spellId) + end + query:Release() + return player, private.spellsProfessionLookupTemp, private.spellsTemp +end + +function private.RPCGetSpellsResultHandler(player, professionLookup, spells) + if not player or not private.accountLookup[player] then + -- request timed out, so try again from the start + Log.Warn("Getting spells timed out") + if private.accountLookup[player] then + private.accountStatus[private.accountLookup[player]] = "RETRY" + Delay.AfterTime(RETRY_DELAY, private.RetryGetHashesRPC) + end + return + end + + for i = #spells, 1, -1 do + local spellId = spells[i] + if TSM.Crafting.HasSpellId(spellId) then + -- already have this spell so just make sure this player is added + TSM.Crafting.AddPlayer(spellId, player) + tremove(spells, i) + end + end + if #spells == 0 then + Log.Info("Spells up to date for %s", player) + private.accountStatus[private.accountLookup[player]] = "SYNCED" + else + Log.Info("Requesting %d spells from %s", #spells, player) + Sync.CallRPC("CRAFTING_GET_SPELL_INFO", player, private.RPCGetSpellInfoResultHandler, professionLookup, spells) + end +end + +function private.RPCGetSpellInfo(professionLookup, spells) + for _, tbl in pairs(private.spellInfoTemp) do + wipe(tbl) + end + for i, spellId in ipairs(spells) do + private.spellInfoTemp.spellIds[i] = spellId + private.spellInfoTemp.mats[i] = TSM.db.factionrealm.internalData.crafts[spellId].mats + private.spellInfoTemp.itemStrings[i] = TSM.db.factionrealm.internalData.crafts[spellId].itemString + private.spellInfoTemp.names[i] = TSM.db.factionrealm.internalData.crafts[spellId].name + private.spellInfoTemp.numResults[i] = TSM.db.factionrealm.internalData.crafts[spellId].numResult + private.spellInfoTemp.hasCDs[i] = TSM.db.factionrealm.internalData.crafts[spellId].hasCD + end + Log.Info("Sent %d spells", #private.spellInfoTemp.spellIds) + return UnitName("player"), professionLookup, private.spellInfoTemp +end + +function private.RPCGetSpellInfoResultHandler(player, professionLookup, spellInfo) + if not player or not professionLookup or not spellInfo or not private.accountLookup[player] then + -- request timed out, so try again from the start + Log.Warn("Getting spell info timed out") + if private.accountLookup[player] then + private.accountStatus[private.accountLookup[player]] = "RETRY" + Delay.AfterTime(RETRY_DELAY, private.RetryGetHashesRPC) + end + return + end + + for i, spellId in ipairs(spellInfo.spellIds) do + TSM.Crafting.CreateOrUpdate(spellId, spellInfo.itemStrings[i], professionLookup[spellId], spellInfo.names[i], spellInfo.numResults[i], player, spellInfo.hasCDs[i] and true or false) + for itemString in pairs(spellInfo.mats[i]) do + TSM.db.factionrealm.internalData.mats[itemString] = TSM.db.factionrealm.internalData.mats[itemString] or {} + end + TSM.Crafting.SetMats(spellId, spellInfo.mats[i]) + end + Log.Info("Added %d spells from %s", #spellInfo.spellIds, player) + private.accountStatus[private.accountLookup[player]] = "SYNCED" +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ConnectionChangedHandler(account, player, connected) + if connected then + private.accountLookup[player] = account + private.accountStatus[account] = "UPDATING" + -- issue a request for profession info + Sync.CallRPC("CRAFTING_GET_HASHES", player, private.RPCGetHashesResultHandler) + else + private.accountLookup[player] = nil + private.accountStatus[account] = nil + end +end + +function private.RetryGetHashesRPC() + for player, account in pairs(private.accountLookup) do + if private.accountStatus[account] == "RETRY" then + Sync.CallRPC("CRAFTING_GET_HASHES", player, private.RPCGetHashesResultHandler) + end + end +end + +function private.QueryProfessionFilter(row, professions) + return professions[row:GetField("profession")] +end + +function private.QueryPlayerFilter(row, player) + return String.SeparatedContains(row:GetField("players"), ",", player) +end + +function private.GetPlayerProfessionHashes(player, resultTbl) + local query = TSM.Crafting.CreateRawCraftsQuery() + :Custom(private.QueryPlayerFilter, player) + :OrderBy("spellId", true) + query:GroupedHash(PROFESSION_HASH_FIELDS, "profession", resultTbl) + query:Release() +end diff --git a/Core/Service/Destroying/Core.lua b/Core/Service/Destroying/Core.lua new file mode 100644 index 0000000..f6b854f --- /dev/null +++ b/Core/Service/Destroying/Core.lua @@ -0,0 +1,442 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Destroying = TSM:NewPackage("Destroying") +local Database = TSM.Include("Util.Database") +local Event = TSM.Include("Util.Event") +local SlotId = TSM.Include("Util.SlotId") +local TempTable = TSM.Include("Util.TempTable") +local ItemString = TSM.Include("Util.ItemString") +local Future = TSM.Include("Util.Future") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local Conversions = TSM.Include("Service.Conversions") +local BagTracking = TSM.Include("Service.BagTracking") +local Settings = TSM.Include("Service.Settings") +local private = { + combineThread = nil, + destroyThread = nil, + destroyThreadRunning = false, + settings = nil, + canDestroyCache = {}, + destroyQuantityCache = {}, + pendingCombines = {}, + newBagUpdate = false, + bagUpdateCallback = nil, + pendingSpellId = nil, + ignoreDB = nil, + destroyInfoDB = nil, + combineFuture = Future.New("DESTROYING_COMBINE_FUTURE"), + destroyFuture = Future.New("DESTROYING_DESTROY_FUTURE"), +} +local SPELL_IDS = { + milling = 51005, + prospect = 31252, + disenchant = 13262, +} +local ITEM_SUB_CLASS_METAL_AND_STONE = 7 +local ITEM_SUB_CLASS_HERB = 9 +local TARGET_SLOT_ID_MULTIPLIER = 1000000 +local GEM_CHIPS = { + ["i:129099"] = "i:129100", + ["i:130200"] = "i:129100", + ["i:130201"] = "i:129100", + ["i:130202"] = "i:129100", + ["i:130203"] = "i:129100", + ["i:130204"] = "i:129100", +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Destroying.OnInitialize() + private.combineThread = Threading.New("COMBINE_STACKS", private.CombineThread) + Threading.SetCallback(private.combineThread, private.CombineThreadDone) + private.destroyThread = Threading.New("DESTROY", private.DestroyThread) + Threading.SetCallback(private.destroyThread, private.DestroyThreadDone) + BagTracking.RegisterCallback(private.UpdateBagDB) + + private.settings = Settings.NewView() + :AddKey("global", "internalData", "destroyingHistory") + :AddKey("global", "destroyingOptions", "deAbovePrice") + :AddKey("global", "destroyingOptions", "deMaxQuality") + :AddKey("global", "destroyingOptions", "includeSoulbound") + :AddKey("global", "userData", "destroyingIgnore") + :RegisterCallback("deAbovePrice", private.UpdateBagDB) + :RegisterCallback("deMaxQuality", private.UpdateBagDB) + :RegisterCallback("includeSoulbound", private.UpdateBagDB) + + private.ignoreDB = Database.NewSchema("DESTROYING_IGNORE") + :AddUniqueStringField("itemString") + :AddBooleanField("ignoreSession") + :AddBooleanField("ignorePermanent") + :Commit() + private.ignoreDB:BulkInsertStart() + local used = TempTable.Acquire() + for itemString in pairs(private.settings.destroyingIgnore) do + itemString = ItemString.Get(itemString) + if not used[itemString] then + used[itemString] = true + private.ignoreDB:BulkInsertNewRow(itemString, false, true) + end + end + TempTable.Release(used) + private.ignoreDB:BulkInsertEnd() + + private.destroyInfoDB = Database.NewSchema("DESTROYING_INFO") + :AddUniqueStringField("itemString") + :AddNumberField("minQuantity") + :AddNumberField("spellId") + :Commit() + + Event.Register("LOOT_CLOSED", private.SendEventToThread) + Event.Register("BAG_UPDATE_DELAYED", private.SendEventToThread) + Event.Register("UNIT_SPELLCAST_START", private.SpellCastEventHandler) + Event.Register("UNIT_SPELLCAST_FAILED", private.SpellCastEventHandler) + Event.Register("UNIT_SPELLCAST_FAILED_QUIET", private.SpellCastEventHandler) + Event.Register("UNIT_SPELLCAST_INTERRUPTED", private.SpellCastEventHandler) + Event.Register("UNIT_SPELLCAST_SUCCEEDED", private.SpellCastEventHandler) + + private.destroyFuture:SetScript("OnCleanup", function() + private.destroyThreadRunning = false + Threading.Kill(private.destroyThread) + end) + private.combineFuture:SetScript("OnCleanup", function() + Threading.Kill(private.combineThread) + end) +end + +function Destroying.SetBagUpdateCallback(callback) + assert(not private.bagUpdateCallback) + private.bagUpdateCallback = callback +end + +function Destroying.CreateBagQuery() + return BagTracking.CreateQueryBags() + :LeftJoin(private.ignoreDB, "itemString") + :InnerJoin(private.destroyInfoDB, "itemString") + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :NotEqual("ignoreSession", true) + :NotEqual("ignorePermanent", true) + :GreaterThanOrEqual("quantity", Database.OtherFieldQueryParam("minQuantity")) +end + +function Destroying.CanCombine() + return #private.pendingCombines > 0 +end + +function Destroying.StartCombine() + private.combineFuture:Start() + Threading.Start(private.combineThread) + return private.combineFuture +end + +function Destroying.StartDestroy(button, row, callback) + private.destroyFuture:Start() + private.destroyThreadRunning = true + Threading.Start(private.destroyThread, button, row) + -- we need the thread to run now so send it a sync message + Threading.SendSyncMessage(private.destroyThread) + return private.destroyFuture +end + +function Destroying.IgnoreItemSession(itemString) + local row = private.ignoreDB:GetUniqueRow("itemString", itemString) + if row then + assert(not row:GetField("ignoreSession")) + row:SetField("ignoreSession", true) + row:Update() + row:Release() + else + private.ignoreDB:NewRow() + :SetField("itemString", itemString) + :SetField("ignoreSession", true) + :SetField("ignorePermanent", false) + :Create() + end +end + +function Destroying.IgnoreItemPermanent(itemString) + assert(not private.settings.destroyingIgnore[itemString]) + private.settings.destroyingIgnore[itemString] = true + + local row = private.ignoreDB:GetUniqueRow("itemString", itemString) + if row then + assert(not row:GetField("ignorePermanent")) + row:SetField("ignorePermanent", true) + row:Update() + row:Release() + else + private.ignoreDB:NewRow() + :SetField("itemString", itemString) + :SetField("ignoreSession", false) + :SetField("ignorePermanent", true) + :Create() + end +end + +function Destroying.ForgetIgnoreItemPermanent(itemString) + assert(private.settings.destroyingIgnore[itemString]) + private.settings.destroyingIgnore[itemString] = nil + + local row = private.ignoreDB:GetUniqueRow("itemString", itemString) + assert(row and row:GetField("ignorePermanent")) + if row:GetField("ignoreSession") then + row:SetField("ignorePermanent", false) + row:Update() + else + private.ignoreDB:DeleteRow(row) + end + row:Release() +end + +function Destroying.CreateIgnoreQuery() + return private.ignoreDB:NewQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :Equal("ignorePermanent", true) + :OrderBy("name", true) +end + + + +-- ============================================================================ +-- Combine Stacks Thread +-- ============================================================================ + +function private.CombineThread() + while Destroying.CanCombine() do + for _, combineSlotId in ipairs(private.pendingCombines) do + local sourceBag, sourceSlot, targetBag, targetSlot = private.CombineSlotIdToBagSlot(combineSlotId) + PickupContainerItem(sourceBag, sourceSlot) + PickupContainerItem(targetBag, targetSlot) + end + -- wait for the bagDB to change + private.newBagUpdate = false + Threading.WaitForFunction(private.HasNewBagUpdate) + end +end + +function private.CombineSlotIdToBagSlot(combineSlotId) + local sourceSlotId = combineSlotId % TARGET_SLOT_ID_MULTIPLIER + local targetSlotId = floor(combineSlotId / TARGET_SLOT_ID_MULTIPLIER) + local sourceBag, sourceSlot = SlotId.Split(sourceSlotId) + local targetBag, targetSlot = SlotId.Split(targetSlotId) + return sourceBag, sourceSlot, targetBag, targetSlot +end + +function private.HasNewBagUpdate() + return private.newBagUpdate +end + +function private.CombineThreadDone(result) + private.combineFuture:Done(result) +end + + + +-- ============================================================================ +-- Destroy Thread +-- ============================================================================ + +function private.DestroyThread(button, row) + -- we get sent a sync message so we run right away + Threading.ReceiveMessage() + + local itemString, spellId, bag, slot = row:GetFields("itemString", "spellId", "bag", "slot") + local spellName = GetSpellInfo(spellId) + button:SetMacroText(format("/cast %s;\n/use %d %d", spellName, bag, slot)) + + -- wait for the spell cast to start or fail + private.pendingSpellId = spellId + local event = Threading.ReceiveMessage() + if event ~= "UNIT_SPELLCAST_START" then + -- the spell cast failed for some reason + ClearCursor() + return false + end + + -- discard any other messages + Threading.Yield(true) + while Threading.HasPendingMessage() do + Threading.ReceiveMessage() + end + + -- wait for the spell cast to finish + event = Threading.ReceiveMessage() + if event ~= "UNIT_SPELLCAST_SUCCEEDED" then + -- the spell cast was interrupted + return false + end + + -- wait for the loot window to open + Threading.WaitForEvent("LOOT_READY") + + -- add to the log + local newEntry = { + item = itemString, + time = time(), + result = {}, + } + assert(GetNumLootItems() > 0) + for i = 1, GetNumLootItems() do + local lootItemString = ItemString.Get(GetLootSlotLink(i)) + local _, _, quantity = GetLootSlotInfo(i) + if lootItemString and (quantity or 0) > 0 then + lootItemString = GEM_CHIPS[lootItemString] or lootItemString + newEntry.result[lootItemString] = quantity + end + end + private.settings.destroyingHistory[spellName] = private.settings.destroyingHistory[spellName] or {} + tinsert(private.settings.destroyingHistory[spellName], newEntry) + + -- wait for the loot window to close + local hasLootClosed, hasBagUpdateDelayed = false, false + while not hasLootClosed or not hasBagUpdateDelayed do + event = Threading.ReceiveMessage() + if event == "LOOT_CLOSED" then + hasLootClosed = true + elseif event == "BAG_UPDATE_DELAYED" then + hasBagUpdateDelayed = true + end + end + + -- we're done + return true +end + +function private.SendEventToThread(event) + if not private.destroyThreadRunning then + return + end + Threading.SendMessage(private.destroyThread, event) +end + +function private.SpellCastEventHandler(event, unit, _, spellId) + if unit ~= "player" or spellId ~= private.pendingSpellId then + return + end + private.SendEventToThread(event) +end + +function private.DestroyThreadDone(result) + private.destroyThreadRunning = false + private.destroyFuture:Done(result) +end + + + +-- ============================================================================ +-- Bag Update Functions +-- ============================================================================ + +function private.UpdateBagDB() + wipe(private.pendingCombines) + private.destroyInfoDB:TruncateAndBulkInsertStart() + local itemPrevSlotId = TempTable.Acquire() + local checkedItem = TempTable.Acquire() + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("slotId", "itemString", "quantity") + if not private.settings.includeSoulbound then + query:Equal("isBoP", false) + :Equal("isBoA", false) + end + for _, slotId, itemString, quantity in query:Iterator() do + local minQuantity = nil + if checkedItem[itemString] then + minQuantity = private.destroyInfoDB:GetUniqueRowField("itemString", itemString, "minQuantity") + else + checkedItem[itemString] = true + local spellId = nil + minQuantity, spellId = private.ProcessBagItem(itemString) + if minQuantity then + private.destroyInfoDB:BulkInsertNewRow(itemString, minQuantity, spellId) + end + end + if minQuantity and quantity % minQuantity ~= 0 then + if itemPrevSlotId[itemString] then + -- we can combine this with the previous partial stack + tinsert(private.pendingCombines, itemPrevSlotId[itemString] * TARGET_SLOT_ID_MULTIPLIER + slotId) + itemPrevSlotId[itemString] = nil + else + itemPrevSlotId[itemString] = slotId + end + end + end + query:Release() + TempTable.Release(checkedItem) + TempTable.Release(itemPrevSlotId) + private.destroyInfoDB:BulkInsertEnd() + + private.newBagUpdate = true + if private.bagUpdateCallback then + private.bagUpdateCallback() + end +end + +function private.ProcessBagItem(itemString) + if private.ignoreDB:HasUniqueRow("itemString", itemString) then + return + end + + local spellId, minQuantity = private.IsDestroyable(itemString) + if not spellId then + return + elseif spellId == SPELL_IDS.disenchant then + local deAbovePrice = CustomPrice.GetValue(private.settings.deAbovePrice, itemString) or 0 + local deValue = CustomPrice.GetValue("Destroy", itemString) or math.huge + if deValue < deAbovePrice then + return + end + end + return minQuantity, spellId +end + +function private.IsDestroyable(itemString) + if private.destroyQuantityCache[itemString] then + return private.canDestroyCache[itemString], private.destroyQuantityCache[itemString] + end + + -- disenchanting + local quality = ItemInfo.GetQuality(itemString) + if ItemInfo.IsDisenchantable(itemString) and quality <= private.settings.deMaxQuality then + private.canDestroyCache[itemString] = IsSpellKnown(SPELL_IDS.disenchant) and SPELL_IDS.disenchant + private.destroyQuantityCache[itemString] = 1 + return private.canDestroyCache[itemString], private.destroyQuantityCache[itemString] + end + + local conversionMethod, destroySpellId = nil, nil + local classId = ItemInfo.GetClassId(itemString) + local subClassId = ItemInfo.GetSubClassId(itemString) + if classId == LE_ITEM_CLASS_TRADEGOODS and subClassId == ITEM_SUB_CLASS_HERB then + conversionMethod = Conversions.METHOD.MILL + destroySpellId = SPELL_IDS.milling + elseif classId == LE_ITEM_CLASS_TRADEGOODS and subClassId == ITEM_SUB_CLASS_METAL_AND_STONE then + conversionMethod = Conversions.METHOD.PROSPECT + destroySpellId = SPELL_IDS.prospect + else + private.canDestroyCache[itemString] = false + private.destroyQuantityCache[itemString] = nil + return private.canDestroyCache[itemString], private.destroyQuantityCache[itemString] + end + + local hasSourceItem = false + for _ in Conversions.TargetItemsByMethodIterator(itemString, conversionMethod) do + hasSourceItem = true + end + if hasSourceItem then + private.canDestroyCache[itemString] = IsSpellKnown(destroySpellId) and destroySpellId + private.destroyQuantityCache[itemString] = 5 + return private.canDestroyCache[itemString], private.destroyQuantityCache[itemString] + end + + return private.canDestroyCache[itemString], private.destroyQuantityCache[itemString] +end diff --git a/Core/Service/Groups/Core.lua b/Core/Service/Groups/Core.lua new file mode 100644 index 0000000..5406059 --- /dev/null +++ b/Core/Service/Groups/Core.lua @@ -0,0 +1,623 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM:NewPackage("Groups") +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local SmartMap = TSM.Include("Util.SmartMap") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local private = { + db = nil, + itemDB = nil, + itemStringMap = nil, + itemStringMapReader = nil, + baseItemStringItemIteratorQuery = nil, + groupListCache = {}, +} + + + +-- ============================================================================ +-- New Modules Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.db = Database.NewSchema("GROUPS") + :AddStringField("groupPath") + :AddStringField("orderStr") + :AddBooleanField("hasAuctioningOperation") + :AddBooleanField("hasCraftingOperation") + :AddBooleanField("hasMailingOperation") + :AddBooleanField("hasShoppingOperation") + :AddBooleanField("hasSniperOperation") + :AddBooleanField("hasVendoringOperation") + :AddBooleanField("hasWarehousingOperation") + :AddIndex("groupPath") + :Commit() + private.itemDB = Database.NewSchema("GROUP_ITEMS") + :AddUniqueStringField("itemString") + :AddStringField("groupPath") + :AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString") + :AddIndex("groupPath") + :AddIndex("baseItemString") + :Commit() + private.itemStringMapReader = private.itemStringMap:CreateReader() + Groups.RebuildDatabase() + private.baseItemStringItemIteratorQuery = private.itemDB:NewQuery() + :Select("itemString", "groupPath") + :Equal("baseItemString", Database.BoundQueryParam()) +end + +function Groups.RebuildDatabase() + wipe(private.groupListCache) + + -- convert ignoreRandomEnchants to ignoreItemVariations + for _, info in pairs(TSM.db.profile.userData.groups) do + if info.ignoreRandomEnchants ~= nil then + info.ignoreItemVariations = info.ignoreRandomEnchants + info.ignoreRandomEnchants = nil + end + end + + for groupPath, groupInfo in pairs(TSM.db.profile.userData.groups) do + if type(groupPath) == "string" and not strmatch(groupPath, TSM.CONST.GROUP_SEP..TSM.CONST.GROUP_SEP) then + -- check the contents of groupInfo + for _, moduleName in TSM.Operations.ModuleIterator() do + groupInfo[moduleName] = groupInfo[moduleName] or {} + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + -- root group should have override flag set + groupInfo[moduleName].override = true + end + end + for key in pairs(groupInfo) do + if TSM.Operations.ModuleExists(key) then + -- this is a set of module operations + local operations = groupInfo[key] + while #operations > TSM.Operations.GetMaxNumber(key) do + -- remove extra operations + tremove(operations) + end + for key2 in pairs(operations) do + if key2 == "override" then + -- ensure the override field is either true or nil + operations.override = operations.override and true or nil + elseif type(key2) ~= "number" or key2 <= 0 or key2 > #operations then + -- this is an invalid key + Log.Err("Removing invalid operations key (%s, %s): %s", groupPath, key, tostring(key2)) + operations[key2] = nil + end + end + for i = #operations, 1, -1 do + if type(operations[i]) ~= "string" or operations[i] == "" or not TSM.Operations.Exists(key, operations[i]) then + -- remove operations which no longer exist + -- we used to have a bunch of placeholder "" operations, so don't log for those + if operations[i] ~= "" then + Log.Err("Removing invalid operation from group (%s): %s, %s", groupPath, key, tostring(operations[i])) + end + tremove(operations, i) + end + end + elseif key ~= "ignoreItemVariations" then + -- invalid key + Log.Err("Removing invalid groupInfo key (%s): %s", groupPath, tostring(key)) + groupInfo[key] = nil + end + end + else + -- remove invalid group paths + Log.Err("Removing invalid group path: %s", tostring(groupPath)) + TSM.db.profile.userData.groups[groupPath] = nil + end + end + + if not TSM.db.profile.userData.groups[TSM.CONST.ROOT_GROUP_PATH] then + -- set the override flag for all top-level groups and then create it + for groupPath, moduleOperations in pairs(TSM.db.profile.userData.groups) do + if not strfind(groupPath, TSM.CONST.GROUP_SEP) then + for _, moduleName in TSM.Operations.ModuleIterator() do + moduleOperations[moduleName].override = true + end + end + end + -- create the root group manually with default operations + TSM.db.profile.userData.groups[TSM.CONST.ROOT_GROUP_PATH] = {} + for _, moduleName in TSM.Operations.ModuleIterator() do + assert(TSM.Operations.Exists(moduleName, "#Default")) + TSM.db.profile.userData.groups[TSM.CONST.ROOT_GROUP_PATH][moduleName] = { "#Default", override = true } + end + end + + for _, groupPath in Groups.GroupIterator() do + local parentPath = TSM.Groups.Path.GetParent(groupPath) + if not TSM.db.profile.userData.groups[parentPath] then + -- the parent group doesn't exist, so remove this group + Log.Err("Removing group with non-existent parent: %s", tostring(groupPath)) + TSM.db.profile.userData.groups[groupPath] = nil + else + for _, moduleName in TSM.Operations.ModuleIterator() do + if not Groups.HasOperationOverride(groupPath, moduleName) then + private.InheritParentOperations(groupPath, moduleName) + end + end + end + end + + -- fix up any invalid items + local newPaths = TempTable.Acquire() + for itemString, groupPath in pairs(TSM.db.profile.userData.items) do + local newItemString = ItemString.Get(itemString) + if not newItemString then + -- this itemstring is invalid + Log.Err("Itemstring (%s) is invalid", tostring(itemString)) + TSM.db.profile.userData.items[itemString] = nil + elseif groupPath == TSM.CONST.ROOT_GROUP_PATH or not TSM.db.profile.userData.groups[groupPath] then + -- this group doesn't exist + Log.Err("Group (%s) doesn't exist, so removing item (%s)", groupPath, itemString) + TSM.db.profile.userData.items[itemString] = nil + elseif newItemString ~= itemString then + -- remove this invalid itemstring from this group + Log.Err("Itemstring changed (%s -> %s), so removing it from group (%s)", itemString, newItemString, groupPath) + TSM.db.profile.userData.items[itemString] = nil + -- add this new item to this group if it's not already in one + if not TSM.db.profile.userData.items[newItemString] then + newPaths[newItemString] = groupPath + Log.Err("Adding to group instead (%s)", groupPath) + end + end + end + for itemString, groupPath in pairs(newPaths) do + TSM.db.profile.userData.items[itemString] = groupPath + end + TempTable.Release(newPaths) + + -- populate our database + private.itemDB:TruncateAndBulkInsertStart() + for itemString, groupPath in pairs(TSM.db.profile.userData.items) do + private.itemDB:BulkInsertNewRow(itemString, groupPath) + end + private.itemDB:BulkInsertEnd() + private.itemStringMap:SetCallbacksPaused(true) + for key in private.itemStringMap:Iterator() do + private.itemStringMap:ValueChanged(key) + end + private.RebuildDB() + private.itemStringMap:SetCallbacksPaused(false) +end + +function Groups.TranslateItemString(itemString) + return private.itemStringMapReader[itemString] +end + +function Groups.GetAutoBaseItemStringSmartMap() + return private.itemStringMap +end + +function Groups.GetItemDBForJoin() + return private.itemDB +end + +function Groups.CreateQuery() + return private.db:NewQuery() + :OrderBy("orderStr", true) +end + +function Groups.Create(groupPath) + private.CreateGroup(groupPath) + private.RebuildDB() +end + +function Groups.Move(groupPath, newGroupPath) + assert(not TSM.db.profile.userData.groups[newGroupPath], "Target group already exists") + assert(groupPath ~= TSM.CONST.ROOT_GROUP_PATH, "Can't move root group") + assert(TSM.db.profile.userData.groups[groupPath], "Group doesn't exist") + local newParentPath = TSM.Groups.Path.GetParent(newGroupPath) + assert(newParentPath and TSM.db.profile.userData.groups[newParentPath], "Parent of target is invalid") + + local changes = TempTable.Acquire() + private.itemDB:SetQueryUpdatesPaused(true) + + -- get a list of group path changes for this group and all its subgroups + local gsubEscapedNewGroupPath = gsub(newGroupPath, "%%", "%%%%") + for path in pairs(TSM.db.profile.userData.groups) do + if path == groupPath or TSM.Groups.Path.IsChild(path, groupPath) then + changes[path] = gsub(path, "^"..String.Escape(groupPath), gsubEscapedNewGroupPath) + end + end + + for oldPath, newPath in pairs(changes) do + -- move the group + assert(TSM.db.profile.userData.groups[oldPath] and not TSM.db.profile.userData.groups[newPath]) + TSM.db.profile.userData.groups[newPath] = TSM.db.profile.userData.groups[oldPath] + TSM.db.profile.userData.groups[oldPath] = nil + + -- move the items + local query = private.itemDB:NewQuery() + :Equal("groupPath", oldPath) + for _, row in query:Iterator() do + local itemString = row:GetField("itemString") + assert(TSM.db.profile.userData.items[itemString]) + TSM.db.profile.userData.items[itemString] = newPath + row:SetField("groupPath", newPath) + :Update() + end + query:Release() + end + + -- update the operations all groups which were moved + for _, moduleName in TSM.Operations.ModuleIterator() do + if not Groups.HasOperationOverride(newGroupPath, moduleName) then + private.InheritParentOperations(newGroupPath, moduleName) + private.UpdateChildGroupOperations(newGroupPath, moduleName) + end + end + + TempTable.Release(changes) + private.RebuildDB() + private.itemDB:SetQueryUpdatesPaused(false) +end + +function Groups.Delete(groupPath) + assert(groupPath ~= TSM.CONST.ROOT_GROUP_PATH and TSM.db.profile.userData.groups[groupPath]) + local parentPath = TSM.Groups.Path.GetParent(groupPath) + assert(parentPath) + if parentPath == TSM.CONST.ROOT_GROUP_PATH then + parentPath = nil + end + + -- delete this group and all subgroups + for path in pairs(TSM.db.profile.userData.groups) do + if path == groupPath or TSM.Groups.Path.IsChild(path, groupPath) then + -- delete this group + TSM.db.profile.userData.groups[path] = nil + end + end + -- remove all items from our DB + private.itemDB:SetQueryUpdatesPaused(true) + local query = private.itemDB:NewQuery() + :Or() + :Equal("groupPath", groupPath) + :Matches("groupPath", "^"..String.Escape(groupPath)..TSM.CONST.GROUP_SEP) + :End() + local updateMapItems = TempTable.Acquire() + for _, row in query:Iterator() do + local itemString = row:GetField("itemString") + assert(TSM.db.profile.userData.items[itemString]) + TSM.db.profile.userData.items[itemString] = nil + private.itemDB:DeleteRow(row) + updateMapItems[itemString] = true + end + query:Release() + private.itemStringMap:SetCallbacksPaused(true) + for itemString in private.itemStringMap:Iterator() do + if updateMapItems[itemString] or updateMapItems[ItemString.GetBaseFast(itemString)] then + -- either this item itself was removed from a group, or the base item was - in either case trigger an update + private.itemStringMap:ValueChanged(itemString) + end + end + private.itemStringMap:SetCallbacksPaused(false) + TempTable.Release(updateMapItems) + private.RebuildDB() + private.itemDB:SetQueryUpdatesPaused(false) +end + +function Groups.Exists(groupPath) + return TSM.db.profile.userData.groups[groupPath] and true or false +end + +function Groups.SetItemGroup(itemString, groupPath) + assert(not groupPath or (groupPath ~= TSM.CONST.ROOT_GROUP_PATH and TSM.db.profile.userData.groups[groupPath])) + + local row = private.itemDB:GetUniqueRow("itemString", itemString) + local updateMap = false + if row then + if groupPath then + row:SetField("groupPath", groupPath) + :Update() + row:Release() + else + private.itemDB:DeleteRow(row) + row:Release() + -- we just removed an item from a group, so update the map + updateMap = true + end + else + assert(groupPath) + private.itemDB:NewRow() + :SetField("itemString", itemString) + :SetField("groupPath", groupPath) + :Create() + -- we just added a new item to a group, so update the map + updateMap = true + end + TSM.db.profile.userData.items[itemString] = groupPath + if updateMap then + private.itemStringMap:SetCallbacksPaused(true) + private.itemStringMap:ValueChanged(itemString) + if itemString == ItemString.GetBaseFast(itemString) then + -- this is a base item string, so need to also update all other items whose base item is equal to this item + for mapItemString in private.itemStringMap:Iterator() do + if ItemString.GetBaseFast(mapItemString) == itemString then + private.itemStringMap:ValueChanged(mapItemString) + end + end + end + private.itemStringMap:SetCallbacksPaused(false) + end +end + +function Groups.BulkCreateFromImport(groupName, items, groups, groupOperations, moveExistingItems) + -- create all the groups + assert(not TSM.db.profile.userData.groups[groupName]) + for relGroupPath in pairs(groups) do + local groupPath = relGroupPath == "" and groupName or TSM.Groups.Path.Join(groupName, relGroupPath) + if not TSM.db.profile.userData.groups[groupPath] then + private.CreateGroup(groupPath) + end + end + for relGroupPath, moduleOperations in pairs(groupOperations) do + local groupPath = relGroupPath == "" and groupName or TSM.Groups.Path.Join(groupName, relGroupPath) + for moduleName, operations in pairs(moduleOperations) do + if operations.override then + TSM.db.profile.userData.groups[groupPath][moduleName] = operations + private.UpdateChildGroupOperations(groupPath, moduleName) + end + end + end + local numItems = 0 + for itemString, relGroupPath in pairs(items) do + if moveExistingItems or not Groups.IsItemInGroup(itemString) then + local groupPath = relGroupPath == "" and groupName or TSM.Groups.Path.Join(groupName, relGroupPath) + Groups.SetItemGroup(itemString, groupPath) + numItems = numItems + 1 + end + end + private.RebuildDB() + return numItems +end + +function Groups.GetPathByItem(itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + assert(itemString) + local groupPath = private.itemDB:GetUniqueRowField("itemString", itemString, "groupPath") or TSM.CONST.ROOT_GROUP_PATH + assert(TSM.db.profile.userData.groups[groupPath]) + return groupPath +end + +function Groups.IsItemInGroup(itemString) + return private.itemDB:HasUniqueRow("itemString", itemString) +end + +function Groups.ItemByBaseItemStringIterator(baseItemString) + private.baseItemStringItemIteratorQuery:BindParams(baseItemString) + return private.baseItemStringItemIteratorQuery:Iterator() +end + +function Groups.ItemIterator(groupPathFilter, includeSubGroups) + assert(groupPathFilter ~= TSM.CONST.ROOT_GROUP_PATH) + local query = private.itemDB:NewQuery() + :Select("itemString", "groupPath") + if groupPathFilter then + if includeSubGroups then + query:StartsWith("groupPath", groupPathFilter) + query:Custom(private.GroupPathQueryFilter, groupPathFilter) + else + query:Equal("groupPath", groupPathFilter) + end + end + return query:IteratorAndRelease() +end + +function private.GroupPathQueryFilter(row, groupPathFilter) + return row:GetField("groupPath") == groupPathFilter or strmatch(row:GetField("groupPath"), "^"..String.Escape(groupPathFilter)..TSM.CONST.GROUP_SEP) +end + +function Groups.GetNumItems(groupPathFilter) + assert(groupPathFilter ~= TSM.CONST.ROOT_GROUP_PATH) + return private.itemDB:NewQuery() + :Equal("groupPath", groupPathFilter) + :CountAndRelease() +end + +function Groups.GroupIterator() + if #private.groupListCache == 0 then + for groupPath in pairs(TSM.db.profile.userData.groups) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + tinsert(private.groupListCache, groupPath) + end + end + Groups.SortGroupList(private.groupListCache) + end + return ipairs(private.groupListCache) +end + +function Groups.SortGroupList(list) + Table.Sort(list, private.GroupSortFunction) +end + +function Groups.SetOperationOverride(groupPath, moduleName, override) + assert(TSM.db.profile.userData.groups[groupPath]) + assert(groupPath ~= TSM.CONST.ROOT_GROUP_PATH) + if override == (TSM.db.profile.userData.groups[groupPath][moduleName].override and true or false) then + return + end + + if not override then + TSM.db.profile.userData.groups[groupPath][moduleName].override = nil + private.InheritParentOperations(groupPath, moduleName) + private.UpdateChildGroupOperations(groupPath, moduleName) + else + wipe(TSM.db.profile.userData.groups[groupPath][moduleName]) + TSM.db.profile.userData.groups[groupPath][moduleName].override = true + private.UpdateChildGroupOperations(groupPath, moduleName) + end + private.RebuildDB() +end + +function Groups.HasOperationOverride(groupPath, moduleName) + return TSM.db.profile.userData.groups[groupPath][moduleName].override +end + +function Groups.OperationIterator(groupPath, moduleName) + return ipairs(TSM.db.profile.userData.groups[groupPath][moduleName]) +end + +function Groups.AppendOperation(groupPath, moduleName, operationName) + assert(TSM.Operations.Exists(moduleName, operationName)) + local groupOperations = TSM.db.profile.userData.groups[groupPath][moduleName] + assert(groupOperations.override and #groupOperations < TSM.Operations.GetMaxNumber(moduleName)) + tinsert(groupOperations, operationName) + private.UpdateChildGroupOperations(groupPath, moduleName) + private.RebuildDB() +end + +function Groups.RemoveOperation(groupPath, moduleName, operationIndex) + local groupOperations = TSM.db.profile.userData.groups[groupPath][moduleName] + assert(groupOperations.override and groupOperations[operationIndex]) + tremove(groupOperations, operationIndex) + private.UpdateChildGroupOperations(groupPath, moduleName) + private.RebuildDB() +end + +function Groups.RemoveOperationByName(groupPath, moduleName, operationName) + local groupOperations = TSM.db.profile.userData.groups[groupPath][moduleName] + assert(groupOperations.override) + assert(Table.RemoveByValue(groupOperations, operationName) > 0) + private.UpdateChildGroupOperations(groupPath, moduleName) + private.RebuildDB() +end + +function Groups.RemoveOperationFromAllGroups(moduleName, operationName) + -- just blindly remove from all groups - no need to check for override + Table.RemoveByValue(TSM.db.profile.userData.groups[TSM.CONST.ROOT_GROUP_PATH][moduleName], operationName) + for _, groupPath in Groups.GroupIterator() do + Table.RemoveByValue(TSM.db.profile.userData.groups[groupPath][moduleName], operationName) + end + private.RebuildDB() +end + +function Groups.SwapOperation(groupPath, moduleName, fromIndex, toIndex) + local groupOperations = TSM.db.profile.userData.groups[groupPath][moduleName] + groupOperations[fromIndex], groupOperations[toIndex] = groupOperations[toIndex], groupOperations[fromIndex] + private.UpdateChildGroupOperations(groupPath, moduleName) +end + +function Groups.OperationRenamed(moduleName, oldName, newName) + -- just blindly rename in all groups - no need to check for override + for _, info in pairs(TSM.db.profile.userData.groups) do + for i = 1, #info[moduleName] do + if info[moduleName][i] == oldName then + info[moduleName][i] = newName + end + end + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RebuildDB() + private.db:TruncateAndBulkInsertStart() + for groupPath in pairs(TSM.db.profile.userData.groups) do + local orderStr = gsub(groupPath, TSM.CONST.GROUP_SEP, "\001") + orderStr = strlower(orderStr) + local hasAuctioningOperation = false + for _ in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do + hasAuctioningOperation = true + end + local hasCraftingOperation = false + for _ in TSM.Operations.GroupOperationIterator("Crafting", groupPath) do + hasCraftingOperation = true + end + local hasMailingOperation = false + for _ in TSM.Operations.GroupOperationIterator("Mailing", groupPath) do + hasMailingOperation = true + end + local hasShoppingOperation = false + for _ in TSM.Operations.GroupOperationIterator("Shopping", groupPath) do + hasShoppingOperation = true + end + local hasSniperOperation = false + for _ in TSM.Operations.GroupOperationIterator("Sniper", groupPath) do + hasSniperOperation = true + end + local hasVendoringOperation = false + for _ in TSM.Operations.GroupOperationIterator("Vendoring", groupPath) do + hasVendoringOperation = true + end + local hasWarehousingOperation = false + for _ in TSM.Operations.GroupOperationIterator("Warehousing", groupPath) do + hasWarehousingOperation = true + end + private.db:BulkInsertNewRow(groupPath, orderStr, hasAuctioningOperation, hasCraftingOperation, hasMailingOperation, hasShoppingOperation, hasSniperOperation, hasVendoringOperation, hasWarehousingOperation) + end + private.db:BulkInsertEnd() + wipe(private.groupListCache) +end + +function private.CreateGroup(groupPath) + assert(not TSM.db.profile.userData.groups[groupPath]) + local parentPath = TSM.Groups.Path.GetParent(groupPath) + assert(parentPath) + if parentPath ~= TSM.CONST.ROOT_GROUP_PATH and not TSM.db.profile.userData.groups[parentPath] then + -- recursively create the parent group first + private.CreateGroup(parentPath) + end + TSM.db.profile.userData.groups[groupPath] = {} + for _, moduleName in TSM.Operations.ModuleIterator() do + TSM.db.profile.userData.groups[groupPath][moduleName] = {} + -- assign all parent operations to this group + for _, operationName in ipairs(TSM.db.profile.userData.groups[parentPath][moduleName]) do + tinsert(TSM.db.profile.userData.groups[groupPath][moduleName], operationName) + end + end +end + +function private.GroupSortFunction(a, b) + return strlower(gsub(a, TSM.CONST.GROUP_SEP, "\001")) < strlower(gsub(b, TSM.CONST.GROUP_SEP, "\001")) +end + +function private.InheritParentOperations(groupPath, moduleName) + local parentGroupPath = TSM.Groups.Path.GetParent(groupPath) + local override = TSM.db.profile.userData.groups[groupPath][moduleName].override + wipe(TSM.db.profile.userData.groups[groupPath][moduleName]) + TSM.db.profile.userData.groups[groupPath][moduleName].override = override + for _, operationName in ipairs(TSM.db.profile.userData.groups[parentGroupPath][moduleName]) do + tinsert(TSM.db.profile.userData.groups[groupPath][moduleName], operationName) + end +end + +function private.UpdateChildGroupOperations(groupPath, moduleName) + for _, childGroupPath in Groups.GroupIterator() do + if TSM.Groups.Path.IsChild(childGroupPath, groupPath) and not Groups.HasOperationOverride(childGroupPath, moduleName) then + private.InheritParentOperations(childGroupPath, moduleName) + end + end +end + + + +-- ============================================================================ +-- Item String Smart Map +-- ============================================================================ + +do + private.itemStringMap = SmartMap.New("string", "string", function(itemString) + if Groups.IsItemInGroup(itemString) then + -- this item is in a group, so just return it + return itemString + end + local baseItemString = ItemString.GetBaseFast(itemString) + -- return the base item if it's in a group; otherwise return the original item + return Groups.IsItemInGroup(baseItemString) and baseItemString or itemString + end) +end diff --git a/Core/Service/Groups/ImportExport.lua b/Core/Service/Groups/ImportExport.lua new file mode 100644 index 0000000..b392b72 --- /dev/null +++ b/Core/Service/Groups/ImportExport.lua @@ -0,0 +1,869 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ImportExport = TSM.Groups:NewPackage("ImportExport") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Log = TSM.Include("Util.Log") +local String = TSM.Include("Util.String") +local ItemString = TSM.Include("Util.ItemString") +local CustomPrice = TSM.Include("Service.CustomPrice") +local AceSerializer = LibStub("AceSerializer-3.0") +local LibDeflate = LibStub("LibDeflate") +local LibSerialize = LibStub("LibSerialize") +local private = { + isOperationSettingsTable = {}, + importContext = { + groupName = nil, + items = nil, + groups = nil, + groupOperations = nil, + operations = nil, + customSources = nil, + numChangedOperations = nil, + filteredGroups = {}, + }, +} +local MAGIC_STR = "TSM_EXPORT" +local VERSION = 1 +local EXPORT_OPERATION_MODULES = { + Auctioning = true, + Crafting = true, + Shopping = true, + Sniper = true, + Vendoring = true, + Warehousing = true, +} +local EXPORT_CUSTOM_STRINGS = { + Auctioning = { + postCap = true, + keepQuantity = true, + maxExpires = true, + undercut = true, + minPrice = true, + maxPrice = true, + normalPrice = true, + cancelRepostThreshold = true, + stackSize = TSM.IsWowClassic() or nil, + }, + Crafting = { + minRestock = true, + maxRestock = true, + minProfit = true, + craftPriceMethod = true, + }, + Shopping = { + restockQuantity = true, + maxPrice = true, + }, + Sniper = { + belowPrice = true, + }, + Vendoring = { + vsMarketValue = true, + vsMaxMarketValue = true, + vsDestroyValue = true, + vsMaxDestroyValue = true, + }, + Warehousing = {}, +} +local SERIALIZE_OPTIONS = { + stable = true, + filter = function(tbl, key, value) + return not private.isOperationSettingsTable[tbl] or not TSM.Operations.IsCommonKey(key) + end, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function ImportExport.GenerateExport(exportGroupPath, includeSubGroups, excludeOperations, excludeCustomSources) + assert(exportGroupPath ~= TSM.CONST.ROOT_GROUP_PATH) + local exportGroupName = TSM.Groups.Path.GetName(exportGroupPath) + + -- collect the items and sub groups + local items = TempTable.Acquire() + local groups = TempTable.Acquire() + local groupOperations = TempTable.Acquire() + local operations = TempTable.Acquire() + local customSources = TempTable.Acquire() + for moduleName in pairs(EXPORT_OPERATION_MODULES) do + operations[moduleName] = {} + end + assert(not next(private.isOperationSettingsTable)) + for _, groupPath in TSM.Groups.GroupIterator() do + local relGroupPath = nil + if TSM.Groups.Path.IsChild(groupPath, exportGroupPath) then + relGroupPath = TSM.Groups.Path.GetRelative(groupPath, exportGroupPath) + if not includeSubGroups[relGroupPath] then + relGroupPath = nil + end + elseif groupPath == exportGroupPath then + relGroupPath = TSM.CONST.ROOT_GROUP_PATH + end + if relGroupPath then + groups[relGroupPath] = true + for _, itemString in TSM.Groups.ItemIterator(groupPath) do + items[itemString] = relGroupPath + end + if not excludeOperations then + groupOperations[relGroupPath] = {} + for moduleName in pairs(EXPORT_OPERATION_MODULES) do + groupOperations[relGroupPath][moduleName] = { + -- always override at the top-level + override = TSM.Groups.HasOperationOverride(groupPath, moduleName) or groupPath == exportGroupPath or nil, + } + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator(moduleName, groupPath) do + tinsert(groupOperations[relGroupPath][moduleName], operationName) + operations[moduleName][operationName] = operationSettings + private.isOperationSettingsTable[operationSettings] = true + if not excludeCustomSources then + for key in pairs(EXPORT_CUSTOM_STRINGS[moduleName]) do + private.GetCustomSources(operationSettings[key], customSources) + end + end + end + end + end + end + end + + local serialized = LibSerialize:SerializeEx(SERIALIZE_OPTIONS, MAGIC_STR, VERSION, exportGroupName, items, groups, groupOperations, operations, customSources) + local compressed = LibDeflate:EncodeForPrint(LibDeflate:CompressDeflate(serialized)) + local numItems = Table.Count(items) + local numSubGroups = Table.Count(groups) - 1 + local numOperations = 0 + for _, moduleOperations in pairs(operations) do + numOperations = numOperations + Table.Count(moduleOperations) + end + local numCustomSources = Table.Count(customSources) + + wipe(private.isOperationSettingsTable) + TempTable.Release(customSources) + TempTable.Release(operations) + TempTable.Release(groupOperations) + TempTable.Release(groups) + TempTable.Release(items) + + return compressed, numItems, numSubGroups, numOperations, numCustomSources +end + +function ImportExport.ProcessImport(str) + return private.DecodeNewImport(str) or private.DecodeOldImport(str) or private.DecodeOldGroupOrItemListImport(str) +end + +function ImportExport.GetImportTotals() + local numExistingItems = 0 + for itemString, groupPath in pairs(private.importContext.items) do + if not private.importContext.filteredGroups[groupPath] then + if TSM.Groups.IsItemInGroup(itemString) then + numExistingItems = numExistingItems + 1 + end + end + end + wipe(private.importContext.customSources) + local numOperations, numExistingOperations = 0, 0 + for moduleName, moduleOperations in pairs(private.importContext.operations) do + local usedOperations = TempTable.Acquire() + for groupPath, operations in pairs(private.importContext.groupOperations) do + if not private.importContext.filteredGroups[groupPath] then + for _, operationName in ipairs(operations[moduleName]) do + usedOperations[operationName] = true + end + end + end + for operationName, operationSettings in pairs(moduleOperations) do + if usedOperations[operationName] then + numOperations = numOperations + 1 + if TSM.Operations.Exists(moduleName, operationName) then + numExistingOperations = numExistingOperations + 1 + end + for key in pairs(EXPORT_CUSTOM_STRINGS[moduleName]) do + private.GetCustomSources(operationSettings[key], private.importContext.customSources) + end + end + end + TempTable.Release(usedOperations) + end + local numExistingCustomSources = 0 + for name in pairs(private.importContext.customSources) do + if TSM.db.global.userData.customPriceSources[name] then + numExistingCustomSources = numExistingCustomSources + 1 + end + end + local numItems = 0 + for _, groupPath in pairs(private.importContext.items) do + if not private.importContext.filteredGroups[groupPath] then + numItems = numItems + 1 + end + end + local numGroups = 0 + for groupPath in pairs(private.importContext.groups) do + if not private.importContext.filteredGroups[groupPath] then + numGroups = numGroups + 1 + end + end + return numItems, numGroups, numExistingItems, numOperations, numExistingOperations, numExistingCustomSources +end + +function ImportExport.PendingImportGroupIterator() + assert(private.importContext.groupName) + return pairs(private.importContext.groups) +end + +function ImportExport.GetPendingImportGroupName() + assert(private.importContext.groupName) + return private.importContext.groupName +end + +function ImportExport.SetGroupFiltered(groupPath, isFiltered) + private.importContext.filteredGroups[groupPath] = isFiltered or nil +end + +function ImportExport.CommitImport(moveExistingItems, includeOperations, replaceOperations) + assert(private.importContext.groupName) + local numOperations, numCustomSources = 0, 0 + if includeOperations and next(private.importContext.operations) then + -- remove filtered operations + for moduleName, moduleOperations in pairs(private.importContext.operations) do + local usedOperations = TempTable.Acquire() + for groupPath, operations in pairs(private.importContext.groupOperations) do + if not private.importContext.filteredGroups[groupPath] then + for _, operationName in ipairs(operations[moduleName]) do + usedOperations[operationName] = true + end + end + end + for operationName in pairs(moduleOperations) do + if not usedOperations[operationName] then + moduleOperations[operationName] = nil + end + end + TempTable.Release(usedOperations) + end + if not replaceOperations then + -- remove existing operations and custom sources from the import context + for moduleName, moduleOperations in pairs(private.importContext.operations) do + for operationName in pairs(moduleOperations) do + if TSM.Operations.Exists(moduleName, operationName) then + moduleOperations[operationName] = nil + end + end + if not next(moduleOperations) then + private.importContext.operations[moduleName] = nil + end + end + for name in pairs(private.importContext.customSources) do + if TSM.db.global.userData.customPriceSources[name] then + private.importContext.customSources[name] = nil + end + end + end + if next(private.importContext.customSources) then + -- regenerate the list of custom sources in case some operations were filtered out + wipe(private.importContext.customSources) + for moduleName, moduleOperations in pairs(private.importContext.operations) do + for _, operationSettings in pairs(moduleOperations) do + for key in pairs(EXPORT_CUSTOM_STRINGS[moduleName]) do + private.GetCustomSources(operationSettings[key], private.importContext.customSources) + end + end + end + -- create the custom sources + numCustomSources = Table.Count(private.importContext.customSources) + CustomPrice.BulkCreateCustomPriceSourcesFromImport(private.importContext.customSources, replaceOperations) + end + -- create the operations + for _, moduleOperations in pairs(private.importContext.operations) do + numOperations = numOperations + Table.Count(moduleOperations) + end + TSM.Operations.BulkCreateFromImport(private.importContext.operations, replaceOperations) + end + if not includeOperations then + wipe(private.importContext.groupOperations) + end + -- filter the groups + for groupPath in pairs(private.importContext.filteredGroups) do + private.importContext.groups[groupPath] = nil + private.importContext.groupOperations[groupPath] = nil + end + for itemString, groupPath in pairs(private.importContext.items) do + if private.importContext.filteredGroups[groupPath] then + private.importContext.items[itemString] = nil + end + end + -- create the groups + local numItems = TSM.Groups.BulkCreateFromImport(private.importContext.groupName, private.importContext.items, private.importContext.groups, private.importContext.groupOperations, moveExistingItems) + + -- print the message + Log.PrintfUser(L["Imported group (%s) with %d items, %d operations, and %d custom sources."], private.importContext.groupName, numItems, numOperations, numCustomSources) + ImportExport.ClearImportContext() +end + +function ImportExport.ClearImportContext() + private.importContext.groupName = nil + private.importContext.items = nil + private.importContext.groups = nil + private.importContext.groupOperations = nil + private.importContext.operations = nil + private.importContext.customSources = nil + wipe(private.importContext.filteredGroups) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetCustomSources(str, result) + for _, name, customSourceStr in CustomPrice.DependantCustomSourceIterator(str) do + if not result[name] then + result[name] = customSourceStr + private.GetCustomSources(customSourceStr, result) + end + end +end + +function private.DecodeNewImport(str) + -- decode and decompress (if it's not a new import, the decode should fail) + str = LibDeflate:DecodeForPrint(str) + if not str then + Log.Info("Not a valid new import string") + return false + end + local numExtraBytes = nil + str, numExtraBytes = LibDeflate:DecompressDeflate(str) + if not str then + Log.Err("Failed to decompress new import string") + return false + elseif numExtraBytes > 0 then + Log.Err("Import string had extra bytes") + return false + end + + -- deserialize and validate the data + local success, magicStr, version, groupName, items, groups, groupOperations, operations, customSources = LibSerialize:Deserialize(str) + if not success then + Log.Err("Failed to deserialize new import string") + return false + elseif magicStr ~= MAGIC_STR then + Log.Err("Invalid magic string: "..tostring(magicStr)) + return false + elseif version ~= VERSION then + Log.Err("Invalid version: "..tostring(version)) + return false + elseif type(groupName) ~= "string" or groupName == "" or strmatch(groupName, TSM.CONST.GROUP_SEP) then + Log.Err("Invalid groupName: "..tostring(groupName)) + return false + elseif type(items) ~= "table" then + Log.Err("Invalid items type: "..tostring(items)) + return false + elseif type(groups) ~= "table" then + Log.Err("Invalid groups type: "..tostring(groups)) + return false + elseif type(groupOperations) ~= "table" then + Log.Err("Invalid groupOperations type: "..tostring(groupOperations)) + return false + elseif type(operations) ~= "table" then + Log.Err("Invalid operations type: "..tostring(operations)) + return false + elseif type(customSources) ~= "table" then + Log.Err("Invalid customSources type: "..tostring(customSources)) + return false + end + + -- validate the groups table + for groupPath, trueValue in pairs(groups) do + if not private.IsValidGroupPath(groupPath) then + Log.Err("Invalid groupPath (%s)", tostring(groupPath)) + return false + elseif trueValue ~= true then + Log.Err("Invalid true value (%s)", tostring(trueValue)) + return false + end + end + for groupPath in pairs(groups) do + local parentPath = TSM.Groups.Path.Split(groupPath) + while parentPath do + if not groups[parentPath] then + Log.Err("Orphaned group (%s)", groupPath) + return false + end + parentPath = TSM.Groups.Path.Split(parentPath) + end + end + + -- validate the items table + local numInvalidItems = 0 + for itemString, groupPath in pairs(items) do + if not private.IsValidGroupPath(groupPath) then + Log.Err("Invalid groupPath (%s, %s)", tostring(itemString), tostring(groupPath)) + return false + elseif not groups[groupPath] then + Log.Err("Invalid item group (%s, %s)", itemString, groupPath) + return false + end + local newItemString = type(itemString) == "string" and ItemString.Get(itemString) or nil + if itemString ~= newItemString then + -- just remove this one item and continue + Log.Warn("Invalid itemString (%s, %s)", tostring(itemString), tostring(newItemString)) + items[itemString] = nil + numInvalidItems = numInvalidItems + 1 + end + end + if not next(items) and numInvalidItems > 0 then + Log.Err("All items were invalid") + return false + end + + -- validate the customSources table + for name, customSourceStr in pairs(customSources) do + if type(name) ~= "string" or name == "" or gsub(name, "([a-z]+)", "") ~= "" then + Log.Err("Invalid name (%s)", tostring(name)) + return false + elseif type(str) ~= "string" then + Log.Err("Invalid str (%s)", tostring(customSourceStr)) + return false + end + end + + -- validate the operations table + local numChangedOperations = private.ValidateOperationsTable(operations, true) + if not numChangedOperations then + return false + end + + -- validate the groupOperations table + if not private.ValidateGroupOperationsTable(groupOperations, groups, operations, true) then + return false + end + + if numInvalidItems > 0 then + Log.PrintfUser(L["NOTE: The import contained %d invalid items which were ignored."], numInvalidItems) + end + if numChangedOperations > 0 then + Log.PrintfUser(L["NOTE: The import contained %d operations with at least one invalid setting which was reset."], numChangedOperations) + end + + Log.Info("Decoded new import string") + private.importContext.groupName = private.DedupImportGroupName(groupName) + private.importContext.items = items + private.importContext.groups = groups + private.importContext.groupOperations = groupOperations + private.importContext.operations = operations + private.importContext.customSources = customSources + return true +end + +function private.DecodeOldImport(str) + if strsub(str, 1, 1) ~= "^" then + Log.Info("Not an old import string") + return false + end + + local isValid, data = AceSerializer:Deserialize(str) + if not isValid then + Log.Err("Failed to deserialize") + return false + elseif type(data) ~= "table" then + Log.Err("Invalid data type (%s)", tostring(data)) + return false + elseif data.operations ~= nil and type(data.operations) ~= "table" then + Log.Err("Invalid operations type (%s)", tostring(data.operations)) + return false + elseif data.groupExport ~= nil and type(data.groupExport) ~= "string" then + Log.Err("Invalid groupExport type (%s)", tostring(data.groupExport)) + return false + elseif data.groupOperations ~= nil and type(data.groupOperations) ~= "table" then + Log.Err("Invalid groupOperations type (%s)", tostring(data.groupOperations)) + return false + elseif not data.operations and not data.groupExport then + Log.Err("Doesn't contain operations or groupExport") + return false + end + local operations, numChangedOperations = nil, 0 + if data.operations then + numChangedOperations = private.ValidateOperationsTable(data.operations, false) + if not numChangedOperations then + return false + end + operations = data.operations + else + operations = {} + end + local items, groups, numInvalidItems = nil, nil, nil + if data.groupExport then + items, groups, numInvalidItems = private.DecodeGroupExportHelper(data.groupExport) + if not items then + Log.Err("No items found") + return false + end + else + items = {} + groups = {} + numInvalidItems = 0 + end + local groupOperations = nil + if data.groupOperations then + Log.Info("Parsing group operations") + local changeGroupPaths = TempTable.Acquire() + for groupPath in pairs(data.groupOperations) do + -- We export a "," in a group path as "``" + local newGroupPath = type(groupPath) == "string" and gsub(groupPath, "``", ",") + if newGroupPath and newGroupPath ~= groupPath then + changeGroupPaths[groupPath] = newGroupPath + if data.groupOperations[newGroupPath] then + Log.Err("Duplicated group operations (%s, %s)", tostring(groupPath), tostring(newGroupPath)) + return false + end + end + end + for groupPath, newGroupPath in pairs(changeGroupPaths) do + data.groupOperations[newGroupPath] = data.groupOperations[groupPath] + data.groupOperations[groupPath] = nil + end + TempTable.Release(changeGroupPaths) + if not private.ValidateGroupOperationsTable(data.groupOperations, groups, operations, false) then + Log.Err("Invalid group operations") + return false + end + groupOperations = data.groupOperations + else + groupOperations = {} + end + + -- check if there's a common top-level group within the import + local commonTopLevelGroup = private.GetCommonTopLevelGroup(items, groups, groupOperations) + if commonTopLevelGroup then + private.UpdateTopLevelGroup(commonTopLevelGroup, items, groups, groupOperations) + end + + if numInvalidItems > 0 then + Log.PrintfUser(L["NOTE: The import contained %d invalid items which were ignored."], numInvalidItems) + end + if numChangedOperations > 0 then + Log.PrintfUser(L["NOTE: The import contained %d operations with at least one invalid setting which was reset."], numChangedOperations) + end + + Log.Info("Decoded old import string") + private.importContext.groupName = private.DedupImportGroupName(commonTopLevelGroup or L["Imported Group"]) + private.importContext.items = items + private.importContext.groups = groups + private.importContext.groupOperations = groupOperations + private.importContext.operations = operations + private.importContext.customSources = {} + return true +end + +function private.DecodeOldGroupOrItemListImport(str) + local items, groups, numInvalidItems = private.DecodeGroupExportHelper(str) + if not items then + Log.Err("No items found") + return false + end + local groupOperations = {} + + -- check if there's a common top-level group within the import + local commonTopLevelGroup = private.GetCommonTopLevelGroup(items, groups, groupOperations) + if commonTopLevelGroup then + private.UpdateTopLevelGroup(commonTopLevelGroup, items, groups, groupOperations) + end + + if numInvalidItems > 0 then + Log.PrintfUser(L["NOTE: The import contained %d invalid items which were ignored."], numInvalidItems) + end + + Log.Info("Decoded old group or item list") + private.importContext.groupName = private.DedupImportGroupName(commonTopLevelGroup or L["Imported Group"]) + private.importContext.items = items + private.importContext.groups = groups + private.importContext.groupOperations = groupOperations + private.importContext.operations = {} + private.importContext.customSources = {} + return true +end + +function private.DecodeGroupExportHelper(str) + local items, groups, numInvalidItems = nil, nil, 0 + if strmatch(str, "^[ip0-9%-:;]+$") then + -- this is likely a list of itemStrings separated by semicolons instead of commas, so attempt to fix it + str = gsub(str, ";", ",") + end + if strmatch(str, "^[0-9,]+$") then + -- this is likely a list of itemIds separated by commas, so attempt to fix it + str = gsub(str, "[0-9]+", "i:%1") + end + local relativePath = TSM.CONST.ROOT_GROUP_PATH + for part in String.SplitIterator(str, ",") do + part = strtrim(part) + local groupPath = strmatch(part, "^group:(.+)$") + local itemString = strmatch(part, "^[ip]?:?[0-9%-:]+$") + local newItemString = itemString and ItemString.Get(itemString) or nil + if newItemString and newItemString ~= itemString then + itemString = newItemString + numInvalidItems = numInvalidItems + 1 + end + assert(not groupPath or not itemString) + if groupPath then + -- We export a "," in a group path as "``" + groupPath = gsub(groupPath, "``", ",") + if not private.IsValidGroupPath(groupPath) then + Log.Err("Invalid groupPath (%s)", tostring(groupPath)) + return + end + relativePath = groupPath + groups = groups or {} + -- create the groups all the way up to the root + while groupPath do + groups[groupPath] = true + groupPath = TSM.Groups.Path.GetParent(groupPath) + end + elseif itemString then + items = items or {} + groups = groups or {} + groups[relativePath] = true + items[itemString] = relativePath + else + Log.Err("Unknown part: %s", part) + return + end + end + return items, groups, numInvalidItems +end + +function private.ValidateOperationsTable(operations, strict) + local numChangedOperations = 0 + for moduleName, moduleOperations in pairs(operations) do + local isInvalidModuleName, isNotExportOperationModule = private.IsValidOperationModule(moduleName) + if not isInvalidModuleName then + Log.Err("Invalid module name") + return nil + elseif isNotExportOperationModule then + if strict then + Log.Err("Invalid moduleName (%s)", tostring(moduleName)) + return nil + else + Log.Warn("Ignoring module (%s)", moduleName) + operations[moduleName] = nil + wipe(moduleOperations) + end + elseif type(moduleOperations) ~= "table" then + Log.Err("Invalid moduleOperations type (%s)", tostring(moduleOperations)) + return nil + end + for operationName, operationSettings in pairs(moduleOperations) do + if type(operationName) ~= "string" or not TSM.Operations.IsValidName(operationName) then + Log.Err("Invalid operationName (%s)", tostring(operationName)) + return nil + elseif type(operationSettings) ~= "table" then + Log.Err("Invalid operationSettings type (%s)", tostring(operationSettings)) + return nil + end + -- sanitize the operation settings + if TSM.Operations.SanitizeSettings(moduleName, operationName, operationSettings, true, true) then + numChangedOperations = numChangedOperations + 1 + end + end + end + return numChangedOperations +end + +function private.ValidateGroupOperationsTable(groupOperations, groups, operations, strict) + for groupPath, groupsOperationsTable in pairs(groupOperations) do + if not private.IsValidGroupPath(groupPath) then + Log.Err("Invalid groupPath (%s)", tostring(groupPath)) + return false + elseif not groups[groupPath] then + if strict then + Log.Err("Invalid group (%s)", groupPath) + return false + else + Log.Info("Creating group with operations (%s)", groupPath) + groups[groupPath] = true + end + end + if not strict then + groupsOperationsTable.ignoreItemVariations = nil + end + for moduleName, moduleOperations in pairs(groupsOperationsTable) do + local isInvalidModuleName, isNotExportOperationModule = private.IsValidOperationModule(moduleName) + if not isInvalidModuleName then + Log.Err("Invalid module name") + return false + elseif isNotExportOperationModule then + if strict then + Log.Err("Invalid moduleName (%s)", tostring(moduleName)) + return false + else + Log.Warn("Ignoring module (%s)", moduleName) + groupsOperationsTable[moduleName] = nil + wipe(moduleOperations) + end + elseif type(moduleOperations) ~= "table" then + Log.Err("Invalid moduleOperations type (%s)", tostring(moduleOperations)) + return false + elseif moduleOperations.override ~= nil and moduleOperations.override ~= true then + Log.Err("Invalid moduleOperations override type (%s)", tostring(moduleOperations.override)) + return false + elseif groupPath == TSM.CONST.ROOT_GROUP_PATH and not moduleOperations.override then + if strict then + Log.Err("Top-level group does not have override set") + return false + else + Log.Info("Setting override for top-level group") + moduleOperations.override = true + end + end + local numOperations = #moduleOperations + if numOperations > TSM.Operations.GetMaxNumber(moduleName) then + Log.Err("Too many operations (%s, %s, %d)", groupPath, moduleName, numOperations) + return false + end + for k, v in pairs(moduleOperations) do + if k == "override" then + -- pass + elseif type(k) ~= "number" or k < 1 or k > numOperations then + Log.Err("Unknown key (%s, %s, %s, %s)", groupPath, moduleName, tostring(k), tostring(v)) + return false + elseif type(v) ~= "string" then + Log.Err("Invalid value (%s, %s, %s, %s)", groupPath, moduleName, k, tostring(v)) + return false + end + end + -- some old imports had "" operations attached to groups, so remove them + for i = #moduleOperations, 1, -1 do + if moduleOperations[i] == "" then + tremove(moduleOperations, i) + end + end + for _, operationName in ipairs(moduleOperations) do + if type(operationName) ~= "string" or not TSM.Operations.IsValidName(operationName) then + Log.Err("Invalid operationName (%s)", tostring(operationName)) + return false + elseif not operations[moduleName][operationName] then + Log.Err("Unknown operation (%s)", operationName) + return false + end + end + end + end + return true +end + +function private.DedupImportGroupName(groupName) + if TSM.Groups.Exists(groupName) then + local num = 1 + while TSM.Groups.Exists(groupName.." "..num) do + num = num + 1 + end + groupName = groupName.." "..num + end + return groupName +end + +function private.IsValidGroupPath(groupPath) + return type(groupPath) == "string" and not strmatch(groupPath, "^`") and not strmatch(groupPath, "`$") and not strmatch(groupPath, "``") +end + +function private.IsValidOperationModule(moduleName) + if type(moduleName) ~= "string" then + Log.Err("Invalid moduleName (%s)", tostring(moduleName)) + return false + elseif not TSM.Operations.ModuleExists(moduleName) then + Log.Err("Invalid moduleName (%s)", tostring(moduleName)) + return false + elseif not EXPORT_OPERATION_MODULES[moduleName] then + return true, true + end + return true +end + +function private.GetCommonTopLevelGroup(items, groups, groupOperations) + local commonTopLevelGroup = nil + + -- check the items + for _, groupPath in pairs(items) do + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + return nil + end + local topLevelGroup = TSM.Groups.Path.GetTopLevel(groupPath) + if not commonTopLevelGroup then + commonTopLevelGroup = topLevelGroup + elseif topLevelGroup ~= commonTopLevelGroup then + return nil + end + end + + -- check the groups + for groupPath in pairs(groups) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + local topLevelGroup = TSM.Groups.Path.GetTopLevel(groupPath) + if not commonTopLevelGroup then + commonTopLevelGroup = topLevelGroup + elseif topLevelGroup ~= commonTopLevelGroup then + return nil + end + end + end + + -- check the groupOperations + for groupPath in pairs(groupOperations) do + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + return nil + end + local topLevelGroup = TSM.Groups.Path.GetTopLevel(groupPath) + if not commonTopLevelGroup then + commonTopLevelGroup = topLevelGroup + elseif topLevelGroup ~= commonTopLevelGroup then + return nil + end + end + + return commonTopLevelGroup +end + +function private.UpdateTopLevelGroup(topLevelGroup, items, groups, groupOperations) + -- update items + for itemString, groupPath in pairs(items) do + items[itemString] = TSM.Groups.Path.GetRelative(groupPath, topLevelGroup) + end + + -- update groups + local newGroups = TempTable.Acquire() + groups[TSM.CONST.ROOT_GROUP_PATH] = nil + for groupPath in pairs(groups) do + newGroups[TSM.Groups.Path.GetRelative(groupPath, topLevelGroup)] = true + end + wipe(groups) + for groupPath in pairs(newGroups) do + groups[groupPath] = true + end + TempTable.Release(newGroups) + + -- update groupOperations + local newGroupOperations = TempTable.Acquire() + for groupPath, groupOperationsTable in pairs(groupOperations) do + newGroupOperations[TSM.Groups.Path.GetRelative(groupPath, topLevelGroup)] = groupOperationsTable + end + wipe(groupOperations) + for groupPath, groupOperationsTable in pairs(newGroupOperations) do + groupOperations[groupPath] = groupOperationsTable + end + TempTable.Release(newGroupOperations) + + -- set override on new top-level group + if groupOperations[TSM.CONST.ROOT_GROUP_PATH] then + for _, moduleOperations in pairs(groupOperations[TSM.CONST.ROOT_GROUP_PATH]) do + moduleOperations.override = true + end + end +end diff --git a/Core/Service/Groups/Path.lua b/Core/Service/Groups/Path.lua new file mode 100644 index 0000000..95ef364 --- /dev/null +++ b/Core/Service/Groups/Path.lua @@ -0,0 +1,81 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Path = TSM.Groups:NewPackage("Path") +local String = TSM.Include("Util.String") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Path.GetName(groupPath) + local _, name = private.SplitPath(groupPath) + return name +end + +function Path.GetParent(groupPath) + local parentPath = private.SplitPath(groupPath) + return parentPath +end + +function Path.Split(groupPath) + return private.SplitPath(groupPath) +end + +function Path.Join(...) + if select(1, ...) == TSM.CONST.ROOT_GROUP_PATH then + return Path.Join(select(2, ...)) + end + return strjoin(TSM.CONST.GROUP_SEP, ...) +end + +function Path.IsChild(groupPath, parentPath) + if parentPath == TSM.CONST.ROOT_GROUP_PATH then + return groupPath ~= TSM.CONST.ROOT_GROUP_PATH + end + return strmatch(groupPath, "^"..String.Escape(parentPath)..TSM.CONST.GROUP_SEP) and true or false +end + +function Path.Format(groupPath) + if not groupPath then return end + local result = gsub(groupPath, TSM.CONST.GROUP_SEP, "->") + return result +end + +function Path.GetRelative(groupPath, prefixGroupPath) + if groupPath == prefixGroupPath then + return TSM.CONST.ROOT_GROUP_PATH + end + local relativePath, numSubs = gsub(groupPath, "^"..String.Escape(prefixGroupPath)..TSM.CONST.GROUP_SEP, "") + assert(numSubs == 1 and relativePath) + return relativePath +end + +function Path.GetTopLevel(groupPath) + assert(groupPath ~= TSM.CONST.ROOT_GROUP_PATH) + return strmatch(groupPath, "^([^"..TSM.CONST.GROUP_SEP.."]+)") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SplitPath(groupPath) + local parentPath, groupName = strmatch(groupPath, "^(.+)"..TSM.CONST.GROUP_SEP.."([^"..TSM.CONST.GROUP_SEP.."]+)$") + if parentPath then + return parentPath, groupName + elseif groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + return TSM.CONST.ROOT_GROUP_PATH, groupPath + else + return nil, groupPath + end +end diff --git a/Core/Service/Groups/Sync.lua b/Core/Service/Groups/Sync.lua new file mode 100644 index 0000000..518e116 --- /dev/null +++ b/Core/Service/Groups/Sync.lua @@ -0,0 +1,99 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local GroupsSync = TSM.Groups:NewPackage("Sync") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local Sync = TSM.Include("Service.Sync") +local private = {} + + + +-- ============================================================================ +-- New Modules Functions +-- ============================================================================ + +function GroupsSync.OnInitialize() + Sync.RegisterRPC("CREATE_PROFILE", private.RPCCreateProfile) +end + +function GroupsSync.SendCurrentProfile(targetPlayer) + local profileName = TSM.db:GetCurrentProfile() + local data = TempTable.Acquire() + data.groups = TempTable.Acquire() + for groupPath, moduleOperations in pairs(TSM.db:Get("profile", profileName, "userData", "groups")) do + data.groups[groupPath] = {} + for _, module in TSM.Operations.ModuleIterator() do + local operations = moduleOperations[module] + if operations.override then + data.groups[groupPath][module] = operations + end + end + end + data.items = TSM.db:Get("profile", profileName, "userData", "items") + data.operations = TSM.db:Get("profile", profileName, "userData", "operations") + local result, estimatedTime = Sync.CallRPC("CREATE_PROFILE", targetPlayer, private.RPCCreateProfileResultHandler, profileName, UnitName("player"), data) + if result then + estimatedTime = max(Math.Round(estimatedTime, 60), 60) + Log.PrintfUser(L["Sending your '%s' profile to %s. Please keep both characters online until this completes. This will take approximately: %s"], profileName, targetPlayer, SecondsToTime(estimatedTime)) + else + Log.PrintUser(L["Failed to send profile. Ensure both characters are online and try again."]) + end + TempTable.Release(data.groups) + TempTable.Release(data) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.CopyTable(srcTbl, dstTbl) + for k, v in pairs(srcTbl) do + dstTbl[k] = v + end +end + +function private.RPCCreateProfile(profileName, playerName, data) + assert(TSM.db:IsValidProfileName(profileName)) + if TSM.db:ProfileExists(profileName) then + return false, L["A profile with that name already exists on the target account. Rename it first and try again."] + end + + -- create and switch to the new profile + local currentProfile = TSM.db:GetCurrentProfile() + TSM.db:SetProfile(profileName) + + -- copy all the data into this profile + private.CopyTable(data.groups, TSM.db.profile.userData.groups) + private.CopyTable(data.items, TSM.db.profile.userData.items) + TSM.Operations.ReplaceProfileOperations(data.operations) + + -- switch back to our previous profile + TSM.db:SetProfile(currentProfile) + + Log.PrintfUser(L["Added '%s' profile which was received from %s."], profileName, playerName) + + return true, profileName, UnitName("player") +end + +function private.RPCCreateProfileResultHandler(success, ...) + if success == nil then + Log.PrintUser(L["Failed to send profile."].." "..L["Ensure both characters are online and try again."]) + return + elseif not success then + local errMsg = ... + Log.PrintUser(L["Failed to send profile."].." "..errMsg) + return + end + + local profileName, targetPlayer = ... + Log.PrintfUser(L["Successfully sent your '%s' profile to %s!"], profileName, targetPlayer) +end diff --git a/Core/Service/Mailing/Core.lua b/Core/Service/Mailing/Core.lua new file mode 100644 index 0000000..a97d57a --- /dev/null +++ b/Core/Service/Mailing/Core.lua @@ -0,0 +1,55 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Mailing = TSM:NewPackage("Mailing") +local Event = TSM.Include("Util.Event") +local private = { + mailOpen = false, + frameCallbacks = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Mailing.OnInitialize() + Event.Register("MAIL_SHOW", private.MailShow) + Event.Register("MAIL_CLOSED", private.MailClosed) +end + +function Mailing.RegisterFrameCallback(callback) + tinsert(private.frameCallbacks, callback) +end + +function Mailing.IsOpen() + return private.mailOpen +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.MailShow() + private.mailOpen = true + for _, callback in ipairs(private.frameCallbacks) do + callback(true) + end +end + +function private.MailClosed() + if not private.mailOpen then + return + end + private.mailOpen = false + for _, callback in ipairs(private.frameCallbacks) do + callback(false) + end +end diff --git a/Core/Service/Mailing/Groups.lua b/Core/Service/Mailing/Groups.lua new file mode 100644 index 0000000..43099cd --- /dev/null +++ b/Core/Service/Mailing/Groups.lua @@ -0,0 +1,161 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM.Mailing:NewPackage("Groups") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local Threading = TSM.Include("Service.Threading") +local Inventory = TSM.Include("Service.Inventory") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local private = { + thread = nil, + sendDone = false, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.thread = Threading.New("MAIL_GROUPS", private.GroupsMailThread) +end + +function Groups.KillThread() + Threading.Kill(private.thread) +end + +function Groups.StartSending(callback, groupList, sendRepeat, isDryRun) + Threading.Kill(private.thread) + + Threading.SetCallback(private.thread, callback) + Threading.Start(private.thread, groupList, sendRepeat, isDryRun) +end + + + +-- ============================================================================ +-- Group Sending Thread +-- ============================================================================ + +function private.GroupsMailThread(groupList, sendRepeat, isDryRun) + while true do + local targets = Threading.AcquireSafeTempTable() + local numMailable = Threading.AcquireSafeTempTable() + for _, groupPath in ipairs(groupList) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + local used = Threading.AcquireSafeTempTable() + local keep = Threading.AcquireSafeTempTable() + for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Mailing", groupPath) do + local target = operationSettings.target + if target ~= "" then + local targetItems = targets[target] or Threading.AcquireSafeTempTable() + for _, itemString in TSM.Groups.ItemIterator(groupPath) do + itemString = TSM.Groups.TranslateItemString(itemString) + used[itemString] = used[itemString] or 0 + keep[itemString] = max(keep[itemString] or 0, operationSettings.keepQty) + numMailable[itemString] = numMailable[itemString] or BagTracking.GetNumMailable(itemString) + local numAvailable = numMailable[itemString] - used[itemString] - keep[itemString] + local quantity = private.GetItemQuantity(itemString, numAvailable, operationSettings) + assert(quantity >= 0) + if PlayerInfo.IsPlayer(target) then + keep[itemString] = max(keep[itemString], quantity) + else + used[itemString] = used[itemString] + quantity + if quantity > 0 then + targetItems[itemString] = quantity + end + end + end + if next(targetItems) then + targets[target] = targetItems + else + Threading.ReleaseSafeTempTable(targetItems) + end + end + end + Threading.ReleaseSafeTempTable(used) + Threading.ReleaseSafeTempTable(keep) + end + end + Threading.ReleaseSafeTempTable(numMailable) + + if not next(targets) then + Log.PrintUser(L["Nothing to send."]) + end + for name, items in pairs(targets) do + private.SendItems(name, items, isDryRun) + Threading.ReleaseSafeTempTable(items) + Threading.Sleep(0.5) + end + + Threading.ReleaseSafeTempTable(targets) + + if sendRepeat then + Threading.Sleep(TSM.db.global.mailingOptions.resendDelay * 60) + else + break + end + end +end + +function private.SendItems(target, items, isDryRun) + private.sendDone = false + TSM.Mailing.Send.StartSending(private.SendCallback, target, "", "", 0, items, true, isDryRun) + while not private.sendDone do + Threading.Yield(true) + end +end + +function private.SendCallback() + private.sendDone = true +end + +function private.GetItemQuantity(itemString, numAvailable, operationSettings) + if numAvailable <= 0 then + return 0 + end + local numToSend = 0 + local isTargetPlayer = PlayerInfo.IsPlayer(operationSettings.target) + if operationSettings.maxQtyEnabled then + if operationSettings.restock then + local targetQty = private.GetTargetQuantity(operationSettings.target, itemString, operationSettings.restockSources) + if isTargetPlayer and targetQty <= operationSettings.maxQty then + numToSend = numAvailable + else + numToSend = min(numAvailable, operationSettings.maxQty - targetQty) + end + if isTargetPlayer then + numToSend = numAvailable - (targetQty - operationSettings.maxQty) + end + else + numToSend = min(numAvailable, operationSettings.maxQty) + end + elseif not isTargetPlayer then + numToSend = numAvailable + end + return max(numToSend, 0) +end + +function private.GetTargetQuantity(player, itemString, sources) + if player then + player = strtrim(strmatch(player, "^[^-]+")) + end + local num = Inventory.GetBagQuantity(itemString, player) + Inventory.GetMailQuantity(itemString, player) + Inventory.GetAuctionQuantity(itemString, player) + if sources then + if sources.guild then + num = num + Inventory.GetGuildQuantity(itemString, PlayerInfo.GetPlayerGuild(player)) + end + if sources.bank then + num = num + Inventory.GetBankQuantity(itemString, player) + Inventory.GetReagentBankQuantity(itemString, player) + end + end + + return num +end diff --git a/Core/Service/Mailing/Inbox.lua b/Core/Service/Mailing/Inbox.lua new file mode 100644 index 0000000..93a952e --- /dev/null +++ b/Core/Service/Mailing/Inbox.lua @@ -0,0 +1,53 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Inbox = TSM.Mailing:NewPackage("Inbox") +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local MailTracking = TSM.Include("Service.MailTracking") +local private = { + itemsQuery = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Inbox.OnInitialize() + private.itemsQuery = MailTracking.CreateMailItemQuery() + :Equal("index", Database.BoundQueryParam()) +end + +function Inbox.CreateQuery() + return MailTracking.CreateMailInboxQuery() + :VirtualField("itemList", "string", private.GetVirtualItemList) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetVirtualItemList(row) + private.itemsQuery:BindParams(row:GetField("index")) + + local items = TempTable.Acquire() + for _, itemsRow in private.itemsQuery:Iterator() do + local itemName = TSM.UI.GetColoredItemName(itemsRow:GetField("itemLink")) or "" + local qty = itemsRow:GetField("quantity") + + tinsert(items, qty > 1 and (itemName.." (x"..qty..")") or itemName) + end + + local result = table.concat(items, ", ") + TempTable.Release(items) + + return result +end diff --git a/Core/Service/Mailing/Open.lua b/Core/Service/Mailing/Open.lua new file mode 100644 index 0000000..ec0faac --- /dev/null +++ b/Core/Service/Mailing/Open.lua @@ -0,0 +1,233 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Open = TSM.Mailing:NewPackage("Open") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Event = TSM.Include("Util.Event") +local String = TSM.Include("Util.String") +local Money = TSM.Include("Util.Money") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local MailTracking = TSM.Include("Service.MailTracking") +local private = { + thread = nil, + isOpening = false, + lastCheck = nil, + moneyCollected = 0, +} +local INBOX_SIZE = TSM.IsWowClassic() and 50 or 100 +local MAIL_REFRESH_TIME = TSM.IsWowClassic() and 60 or 15 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Open.OnInitialize() + private.thread = Threading.New("MAIL_OPENING", private.OpenMailThread) + + Event.Register("MAIL_SHOW", private.ScheduleCheck) + Event.Register("MAIL_CLOSED", private.MailClosedHandler) +end + +function Open.KillThread() + Threading.Kill(private.thread) + + private.PrintMoneyCollected() + private.isOpening = false +end + +function Open.StartOpening(callback, autoRefresh, keepMoney, filterText, filterType) + Threading.Kill(private.thread) + + private.isOpening = true + private.moneyCollected = 0 + + Threading.SetCallback(private.thread, callback) + Threading.Start(private.thread, autoRefresh, keepMoney, filterText, filterType) +end + +function Open.GetLastCheckTime() + return private.lastCheck +end + + + +-- ============================================================================ +-- Mail Opening Thread +-- ============================================================================ + +function private.OpenMailThread(autoRefresh, keepMoney, filterText, filterType) + local isLastLoop = false + while true do + local query = TSM.Mailing.Inbox.CreateQuery() + query:ResetOrderBy() + :OrderBy("index", false) + :Or() + :Matches("itemList", filterText) + :Matches("subject", filterText) + :End() + :Select("index") + + if filterType then + query:Equal("icon", filterType) + end + + local mails = Threading.AcquireSafeTempTable() + for _, index in query:Iterator() do + tinsert(mails, index) + end + query:Release() + + private.OpenMails(mails, keepMoney, filterType) + Threading.ReleaseSafeTempTable(mails) + + if not autoRefresh or isLastLoop then + break + end + + local numLeftMail, totalLeftMail = GetInboxNumItems() + if totalLeftMail == numLeftMail or numLeftMail == INBOX_SIZE then + isLastLoop = true + end + + CheckInbox() + Threading.Sleep(1) + end + + private.PrintMoneyCollected() + private.isOpening = false +end + +function private.CanOpenMail() + return not C_Mail.IsCommandPending() +end + +function private.OpenMails(mails, keepMoney, filterType) + for i = 1, #mails do + local index = mails[i] + Threading.WaitForFunction(private.CanOpenMail) + + local mailType = MailTracking.GetMailType(index) + local matchesFilter = (not filterType and mailType) or (filterType and filterType == mailType) + local hasBagSpace = not MailTracking.GetInboxItemLink(index) or CalculateTotalNumberOfFreeBagSlots() > TSM.db.global.mailingOptions.keepMailSpace + if matchesFilter and hasBagSpace then + local _, _, _, _, money = GetInboxHeaderInfo(index) + if not keepMoney or (keepMoney and money <= 0) then + -- marks the mail as read + GetInboxText(index) + AutoLootMailItem(index) + private.moneyCollected = private.moneyCollected + money + + if Threading.WaitForEvent("CLOSE_INBOX_ITEM", "MAIL_FAILED") ~= "MAIL_FAILED" then + if TSM.db.global.mailingOptions.inboxMessages then + private.PrintOpenMailMessage(index) + end + end + end + end + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.CheckInbox() + if private.isOpening then + private.ScheduleCheck() + return + end + + if not TSM.UI.MailingUI.Inbox.IsMailOpened() then + CheckInbox() + end + private.ScheduleCheck() +end + +function private.PrintMoneyCollected() + if TSM.db.global.mailingOptions.inboxMessages and private.moneyCollected > 0 then + Log.PrintfUser(L["Total Gold Collected: %s"], Money.ToString(private.moneyCollected)) + end + private.moneyCollected = 0 +end + +function private.PrintOpenMailMessage(index) + local _, _, sender, subject, money, cod, _, hasItem = GetInboxHeaderInfo(index) + sender = sender or "?" + local _, _, _, _, isInvoice = GetInboxText(index) + if isInvoice then + -- it's an invoice + local invoiceType, itemName, playerName, bid, _, _, ahcut, _, _, _, quantity = GetInboxInvoiceInfo(index) + playerName = playerName or (invoiceType == "buyer" and AUCTION_HOUSE_MAIL_MULTIPLE_SELLERS or AUCTION_HOUSE_MAIL_MULTIPLE_BUYERS) + if invoiceType == "buyer" then + local itemLink = MailTracking.GetInboxItemLink(index) or "["..itemName.."]" + Log.PrintfUser(L["Bought %sx%d for %s from %s"], itemLink, quantity, Money.ToString(bid, Theme.GetFeedbackColor("RED"):GetTextColorPrefix()), playerName) + elseif invoiceType == "seller" then + Log.PrintfUser(L["Sold [%s]x%d for %s to %s"], itemName, quantity, Money.ToString(bid - ahcut, Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix()), playerName) + end + elseif hasItem then + local itemLink + local quantity = 0 + for i = 1, hasItem do + local link = GetInboxItemLink(index, i) + itemLink = itemLink or link + quantity = quantity + (select(4, GetInboxItem(index, i)) or 0) + if ItemString.Get(itemLink) ~= ItemString.Get(link) then + itemLink = L["Multiple Items"] + quantity = -1 + break + end + end + if hasItem == 1 then + itemLink = MailTracking.GetInboxItemLink(index) or itemLink + end + local itemName = ItemInfo.GetName(itemLink) or "?" + local itemDesc = (quantity > 0 and format("%sx%d", itemLink, quantity)) or (quantity == -1 and "Multiple Items") or "?" + if hasItem == 1 and itemLink and strfind(subject, "^" .. String.Escape(format(AUCTION_EXPIRED_MAIL_SUBJECT, itemName))) then + Log.PrintfUser(L["Your auction of %s expired"], itemDesc) + elseif hasItem == 1 and quantity > 0 and (subject == format(AUCTION_REMOVED_MAIL_SUBJECT.."x%d", itemName, quantity) or subject == format(AUCTION_REMOVED_MAIL_SUBJECT, itemName)) then + Log.PrintfUser(L["Cancelled auction of %sx%d"], itemLink, quantity) + elseif cod > 0 then + Log.PrintfUser(L["%s sent you a COD of %s for %s"], sender, Money.ToString(cod, Theme.GetFeedbackColor("RED"):GetTextColorPrefix()), itemDesc) + elseif money > 0 then + Log.PrintfUser(L["%s sent you %s and %s"], sender, itemDesc, Money.ToString(money, Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix())) + else + Log.PrintfUser(L["%s sent you %s"], sender, itemDesc) + end + elseif money > 0 then + Log.PrintfUser(L["%s sent you %s"], sender, Money.ToString(money, Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix())) + elseif subject then + Log.PrintfUser(L["%s sent you a message: %s"], sender, subject) + end +end + + +-- ============================================================================ +-- Event Handlers +-- ============================================================================ + +function private.ScheduleCheck() + if not private.lastCheck or time() - private.lastCheck > (MAIL_REFRESH_TIME - 1) then + private.lastCheck = time() + Delay.AfterTime("mailInboxCheck", MAIL_REFRESH_TIME, private.CheckInbox) + else + local nextUpdate = MAIL_REFRESH_TIME - (time() - private.lastCheck) + Delay.AfterTime("mailInboxCheck", nextUpdate, private.CheckInbox) + end +end + +function private.MailClosedHandler() + Delay.Cancel("mailInboxCheck") +end diff --git a/Core/Service/Mailing/Send.lua b/Core/Service/Mailing/Send.lua new file mode 100644 index 0000000..ca287b3 --- /dev/null +++ b/Core/Service/Mailing/Send.lua @@ -0,0 +1,287 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Send = TSM.Mailing:NewPackage("Send") +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Money = TSM.Include("Util.Money") +local SlotId = TSM.Include("Util.SlotId") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local InventoryInfo = TSM.Include("Service.InventoryInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local private = { + thread = nil, + bagUpdate = nil, +} + +local PLAYER_NAME = UnitName("player") +local PLAYER_NAME_REALM = string.gsub(PLAYER_NAME.."-"..GetRealmName(), "%s+", "") + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Send.OnInitialize() + private.thread = Threading.New("MAIL_SENDING", private.SendMailThread) + BagTracking.RegisterCallback(private.BagUpdate) +end + +function Send.KillThread() + Threading.Kill(private.thread) +end + +function Send.StartSending(callback, recipient, subject, body, money, items, isGroup, isDryRun) + Threading.Kill(private.thread) + + Threading.SetCallback(private.thread, callback) + Threading.Start(private.thread, recipient, subject, body, money, items, isGroup, isDryRun) +end + + + +-- ============================================================================ +-- Mail Sending Thread +-- ============================================================================ + +function private.SendMailThread(recipient, subject, body, money, items, isGroup, isDryRun) + if recipient == "" or recipient == PLAYER_NAME or recipient == PLAYER_NAME_REALM then + return + end + + private.PrintMailMessage(money, items, recipient, isGroup, isDryRun) + if isDryRun then + return + end + + if not items then + private.SendMail(recipient, subject, body, money, true) + return + end + + ClearSendMail() + local itemInfo = Threading.AcquireSafeTempTable() + + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("bag", "slot", "itemString", "quantity") + :Equal("isBoP", false) + for _, bag, slot, itemString, quantity in query:Iterator() do + if isGroup then + itemString = TSM.Groups.TranslateItemString(itemString) + end + if items[itemString] and not InventoryInfo.IsBagSlotLocked(bag, slot) then + if not itemInfo[itemString] then + itemInfo[itemString] = { locations = {} } + end + tinsert(itemInfo[itemString].locations, { bag = bag, slot = slot, quantity = quantity }) + end + end + query:Release() + + for itemString, quantity in pairs(items) do + if quantity > 0 and itemInfo[itemString] and #itemInfo[itemString].locations > 0 then + for i = 1, #itemInfo[itemString].locations do + local info = itemInfo[itemString].locations[i] + if info.quantity > 0 then + if quantity == info.quantity then + PickupContainerItem(info.bag, info.slot) + ClickSendMailItemButton() + + if private.GetNumPendingAttachments() == ATTACHMENTS_MAX_SEND or (isGroup and TSM.db.global.mailingOptions.sendItemsIndividually) then + private.SendMail(recipient, subject, body, money) + end + + items[itemString] = 0 + info.quantity = 0 + + break + end + end + end + end + end + + for itemString in pairs(items) do + if items[itemString] > 0 and itemInfo[itemString] and #itemInfo[itemString].locations > 0 then + local emptySlotIds = private.GetEmptyBagSlotsThreaded(ItemString.IsItem(itemString) and GetItemFamily(ItemString.ToId(itemString)) or 0) + for i = 1, #itemInfo[itemString].locations do + local info = itemInfo[itemString].locations[i] + if items[itemString] > 0 and info.quantity > 0 then + if items[itemString] < info.quantity then + if #emptySlotIds > 0 then + local splitBag, splitSlot = SlotId.Split(tremove(emptySlotIds, 1)) + SplitContainerItem(info.bag, info.slot, items[itemString]) + PickupContainerItem(splitBag, splitSlot) + Threading.WaitForFunction(private.BagSlotHasItem, splitBag, splitSlot) + PickupContainerItem(splitBag, splitSlot) + ClickSendMailItemButton() + + if private.GetNumPendingAttachments() == ATTACHMENTS_MAX_SEND then + private.SendMail(recipient, subject, body, money) + end + + items[itemString] = 0 + info.quantity = 0 + + break + end + else + PickupContainerItem(info.bag, info.slot) + ClickSendMailItemButton() + + if private.GetNumPendingAttachments() == ATTACHMENTS_MAX_SEND then + private.SendMail(recipient, subject, body, money) + end + + items[itemString] = items[itemString] - info.quantity + info.quantity = 0 + end + end + end + + if isGroup and TSM.db.global.mailingOptions.sendItemsIndividually then + private.SendMail(recipient, subject, body, money) + end + Threading.ReleaseSafeTempTable(emptySlotIds) + end + end + + if private.HasPendingAttachments() then + private.SendMail(recipient, subject, body, money) + end + + Threading.ReleaseSafeTempTable(itemInfo) +end + +function private.PrintMailMessage(money, items, target, isGroup, isDryRun) + if not TSM.db.global.mailingOptions.sendMessages and not isDryRun then + return + end + if money > 0 and not items then + Log.PrintfUser(L["Sending %s to %s"], Money.ToString(money), target) + return + end + + if not items then + return + end + + local itemList = "" + for k, v in pairs(items) do + local coloredItem = ItemInfo.GetLink(k) + itemList = itemList..coloredItem.."x"..v..", " + end + itemList = strtrim(itemList, ", ") + + if next(items) and money < 0 then + if isDryRun then + Log.PrintfUser(L["Would send %s to %s with a COD of %s"], itemList, target, Money.ToString(money, Theme.GetFeedbackColor("RED"):GetTextColorPrefix())) + else + Log.PrintfUser(L["Sending %s to %s with a COD of %s"], itemList, target, Money.ToString(money, Theme.GetFeedbackColor("RED"):GetTextColorPrefix())) + end + elseif next(items) then + if isDryRun then + Log.PrintfUser(L["Would send %s to %s"], itemList, target) + else + Log.PrintfUser(L["Sending %s to %s"], itemList, target) + end + end +end + +function private.SendMail(recipient, subject, body, money, noItem) + if subject == "" then + local text = SendMailSubjectEditBox:GetText() + subject = text ~= "" and text or "TSM Mailing" + end + + if money > 0 then + SetSendMailMoney(money) + SetSendMailCOD(0) + elseif money < 0 then + SetSendMailCOD(abs(money)) + SetSendMailMoney(0) + else + SetSendMailMoney(0) + SetSendMailCOD(0) + end + + private.bagUpdate = false + SendMail(recipient, subject, body) + + if Threading.WaitForEvent("MAIL_SUCCESS", "MAIL_FAILED") == "MAIL_SUCCESS" then + if noItem then + Threading.Sleep(0.5) + else + Threading.WaitForFunction(private.HasNewBagUpdate) + end + else + Threading.Sleep(0.5) + end +end + +function private.BagUpdate() + private.bagUpdate = true +end + +function private.HasNewBagUpdate() + return private.bagUpdate +end + +function private.HasPendingAttachments() + for i = 1, ATTACHMENTS_MAX_SEND do + if GetSendMailItem(i) then + return true + end + end + + return false +end + +function private.GetNumPendingAttachments() + local totalAttached = 0 + for i = 1, ATTACHMENTS_MAX_SEND do + if GetSendMailItem(i) then + totalAttached = totalAttached + 1 + end + end + + return totalAttached +end + +function private.BagSlotHasItem(bag, slot) + return GetContainerItemInfo(bag, slot) and true or false +end + +function private.GetEmptyBagSlotsThreaded(itemFamily) + local emptySlotIds = Threading.AcquireSafeTempTable() + local sortvalue = Threading.AcquireSafeTempTable() + for bag = 0, NUM_BAG_SLOTS do + -- make sure the item can go in this bag + local bagFamily = bag ~= 0 and GetItemFamily(GetInventoryItemLink("player", ContainerIDToInventoryID(bag))) or 0 + if bagFamily == 0 or bit.band(itemFamily, bagFamily) > 0 then + for slot = 1, GetContainerNumSlots(bag) do + if not GetContainerItemInfo(bag, slot) then + local slotId = SlotId.Join(bag, slot) + tinsert(emptySlotIds, slotId) + -- use special bags first + sortvalue[slotId] = slotId + (bagFamily > 0 and 0 or 100000) + end + end + end + Threading.Yield() + end + Table.SortWithValueLookup(emptySlotIds, sortvalue) + Threading.ReleaseSafeTempTable(sortvalue) + + return emptySlotIds +end diff --git a/Core/Service/MyAuctions/Core.lua b/Core/Service/MyAuctions/Core.lua new file mode 100644 index 0000000..f0d0d0f --- /dev/null +++ b/Core/Service/MyAuctions/Core.lua @@ -0,0 +1,263 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local MyAuctions = TSM:NewPackage("MyAuctions") +local L = TSM.Include("Locale").GetTable() +local Database = TSM.Include("Util.Database") +local Event = TSM.Include("Util.Event") +local TempTable = TSM.Include("Util.TempTable") +local Log = TSM.Include("Util.Log") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local ItemInfo = TSM.Include("Service.ItemInfo") +local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper") +local private = { + pendingDB = nil, + ahOpen = false, + pendingHashes = {}, + expectedCounts = {}, + auctionInfo = { numPosted = 0, numSold = 0, postedGold = 0, soldGold = 0 }, + dbHashFields = {}, + pendingFuture = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function MyAuctions.OnInitialize() + private.pendingDB = Database.NewSchema("MY_AUCTIONS_PENDING") + :AddUniqueNumberField("index") + :AddNumberField("hash") + :AddBooleanField("isPending") + :AddNumberField("pendingAuctionId") + :AddIndex("index") + :Commit() + for field in AuctionTracking.DatabaseFieldIterator() do + if field ~= "index" and field ~= "auctionId" then + tinsert(private.dbHashFields, field) + end + end + + Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowEventHandler) + Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseHideEventHandler) + Event.Register("CHAT_MSG_SYSTEM", private.ChatMsgSystemEventHandler) + Event.Register("UI_ERROR_MESSAGE", private.UIErrorMessageEventHandler) + AuctionTracking.RegisterCallback(private.OnAuctionsUpdated) +end + +function MyAuctions.CreateQuery() + local query = AuctionTracking.CreateQuery() + :LeftJoin(private.pendingDB, "index") + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :VirtualField("group", "string", private.AuctionsGetGroupText, "itemString") + if TSM.IsWowClassic() then + query:OrderBy("index", false) + else + query:OrderBy("saleStatus", false) + query:OrderBy("name", true) + query:OrderBy("auctionId", true) + end + return query +end + +function MyAuctions.CancelAuction(auctionId) + local row = private.pendingDB:NewQuery() + :Equal("pendingAuctionId", auctionId) + :GetFirstResultAndRelease() + local hash = row:GetField("hash") + assert(hash) + + Log.Info("Canceling (auctionId=%d, hash=%d)", auctionId, hash) + if TSM.IsWowClassic() then + CancelAuction(auctionId) + else + private.pendingFuture = AuctionHouseWrapper.CancelAuction(auctionId) + if not private.pendingFuture then + Log.PrintUser(L["Failed to cancel auction due to the auction house being busy. Ensure no other addons are scanning the AH and try again."]) + return + end + private.pendingFuture:SetScript("OnDone", private.PendingFutureOnDone) + end + + if private.expectedCounts[hash] and private.expectedCounts[hash] > 0 then + private.expectedCounts[hash] = private.expectedCounts[hash] - 1 + else + private.expectedCounts[hash] = private.GetNumRowsByHash(hash) - 1 + end + assert(private.expectedCounts[hash] >= 0) + assert(not row:GetField("isPending")) + row:SetField("isPending", true) + :Update() + row:Release() + + tinsert(private.pendingHashes, hash) +end + +function MyAuctions.CanCancel(index) + if TSM.IsWowClassic() then + local numPending = private.pendingDB:NewQuery() + :Equal("isPending", true) + :LessThanOrEqual("index", index) + :CountAndRelease() + return numPending == 0 + else + return not private.pendingFuture + end +end + +function MyAuctions.GetNumPending() + if TSM.IsWowClassic() then + return private.pendingDB:NewQuery() + :Equal("isPending", true) + :CountAndRelease() + else + return private.pendingFuture and 1 or 0 + end +end + +function MyAuctions.GetAuctionInfo() + if not private.ahOpen then + return + end + return private.auctionInfo.numPosted, private.auctionInfo.numSold, private.auctionInfo.postedGold, private.auctionInfo.soldGold +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.AuctionHouseShowEventHandler() + private.ahOpen = true +end + +function private.AuctionHouseHideEventHandler() + private.ahOpen = false + if private.pendingFuture then + private.pendingFuture:Cancel() + private.pendingFuture = nil + end +end + +function private.ChatMsgSystemEventHandler(_, msg) + if msg == ERR_AUCTION_REMOVED and #private.pendingHashes > 0 and TSM.IsWowClassic() then + local hash = tremove(private.pendingHashes, 1) + assert(hash) + Log.Info("Confirmed (hash=%d)", hash) + end +end + +function private.UIErrorMessageEventHandler(_, _, msg) + if (msg == ERR_ITEM_NOT_FOUND or msg == ERR_NOT_ENOUGH_MONEY) and #private.pendingHashes > 0 and TSM.IsWowClassic() then + local hash = tremove(private.pendingHashes, 1) + assert(hash) + Log.Info("Failed to cancel (hash=%d)", hash) + if private.expectedCounts[hash] then + private.expectedCounts[hash] = private.expectedCounts[hash] + 1 + end + end +end + +function private.PendingFutureOnDone() + local result = private.pendingFuture:GetValue() + private.pendingFuture = nil + local hash = tremove(private.pendingHashes, 1) + assert(hash) + if result then + Log.Info("Confirmed (hash=%d)", hash) + else + Log.Info("Failed to cancel (hash=%d)", hash) + if private.expectedCounts[hash] then + private.expectedCounts[hash] = private.expectedCounts[hash] + 1 + end + private.OnAuctionsUpdated() + AuctionTracking.QueryOwnedAuctions() + end +end + +function private.GetNumRowsByHash(hash) + return private.pendingDB:NewQuery() + :Equal("hash", hash) + :CountAndRelease() +end + +function private.AuctionsGetGroupText(itemString) + local groupPath = TSM.Groups.GetPathByItem(itemString) + if not groupPath then + return "" + end + return groupPath +end + +function private.OnAuctionsUpdated() + local minPendingIndexByHash = TempTable.Acquire() + local numByHash = TempTable.Acquire() + local query = AuctionTracking.CreateQuery() + :OrderBy("index", true) + for _, row in query:Iterator() do + local index = row:GetField("index") + local hash = row:CalculateHash(private.dbHashFields) + numByHash[hash] = (numByHash[hash] or 0) + 1 + if not minPendingIndexByHash[hash] and private.pendingDB:GetUniqueRowField("index", index, "isPending") then + minPendingIndexByHash[hash] = index + end + end + local numUsed = TempTable.Acquire() + private.pendingDB:TruncateAndBulkInsertStart() + for _, row in query:Iterator() do + local hash = row:CalculateHash(private.dbHashFields) + assert(numByHash[hash] > 0) + local expectedCount = private.expectedCounts[hash] + local isPending = nil + if not expectedCount then + -- this was never pending + isPending = false + elseif numByHash[hash] <= expectedCount then + -- this is no longer pending + isPending = false + private.expectedCounts[hash] = nil + elseif row:GetField("index") >= (minPendingIndexByHash[hash] or math.huge) then + local numPending = numByHash[hash] - expectedCount + assert(numPending > 0) + numUsed[hash] = (numUsed[hash] or 0) + 1 + isPending = numUsed[hash] <= numPending + else + -- it's a later auction which is pending + isPending = false + end + private.pendingDB:BulkInsertNewRow(row:GetField("index"), hash, isPending, row:GetField("auctionId")) + end + private.pendingDB:BulkInsertEnd() + TempTable.Release(numByHash) + TempTable.Release(numUsed) + TempTable.Release(minPendingIndexByHash) + + -- update the player's auction status + private.auctionInfo.numPosted = 0 + private.auctionInfo.numSold = 0 + private.auctionInfo.postedGold = 0 + private.auctionInfo.soldGold = 0 + for _, row in query:Iterator() do + local itemString, saleStatus, buyout, currentBid, stackSize = row:GetFields("itemString", "saleStatus", "buyout", "currentBid", "stackSize") + if saleStatus == 1 then + private.auctionInfo.numSold = private.auctionInfo.numSold + 1 + -- if somebody did a buyout, then bid will be equal to buyout, otherwise it'll be the winning bid + private.auctionInfo.soldGold = private.auctionInfo.soldGold + currentBid + else + private.auctionInfo.numPosted = private.auctionInfo.numPosted + 1 + if ItemInfo.IsCommodity(itemString) then + private.auctionInfo.postedGold = private.auctionInfo.postedGold + (buyout * stackSize) + else + private.auctionInfo.postedGold = private.auctionInfo.postedGold + buyout + end + end + end + query:Release() +end diff --git a/Core/Service/Operations/Auctioning.lua b/Core/Service/Operations/Auctioning.lua new file mode 100644 index 0000000..4433424 --- /dev/null +++ b/Core/Service/Operations/Auctioning.lua @@ -0,0 +1,152 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctioning = TSM.Operations:NewPackage("Auctioning") +local private = {} +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local OPERATION_INFO = { + -- general + blacklist = { type = "string", default = "" }, + ignoreLowDuration = { type = "number", default = 0 }, + -- post + postCap = { type = "string", default = "5" }, + keepQuantity = { type = "string", default = "0" }, + maxExpires = { type = "string", default = "0" }, + duration = { type = "number", default = 2, customSanitizeFunction = nil }, + bidPercent = { type = "number", default = 1 }, + undercut = { type = "string", default = "0c", customSanitizeFunction = nil }, + minPrice = { type = "string", default = "check(first(crafting,dbmarket,dbregionmarketavg),max(0.25*avg(crafting,dbmarket,dbregionmarketavg),1.5*vendorsell))" }, + maxPrice = { type = "string", default = "check(first(crafting,dbmarket,dbregionmarketavg),max(5*avg(crafting,dbmarket,dbregionmarketavg),30*vendorsell))" }, + normalPrice = { type = "string", default = "check(first(crafting,dbmarket,dbregionmarketavg),max(2*avg(crafting,dbmarket,dbregionmarketavg),12*vendorsell))" }, + priceReset = { type = "string", default = "none" }, + aboveMax = { type = "string", default = "maxPrice" }, + -- cancel + cancelUndercut = { type = "boolean", default = true }, + cancelRepost = { type = "boolean", default = true }, + cancelRepostThreshold = { type = "string", default = "1g" }, +} +local OPERATION_VALUE_LIMITS = { + postCap = { min = 0, max = 50000 }, + keepQuantity = { min = 0, max = 50000 }, + maxExpires = { min = 0, max = 50000 }, +} +if TSM.IsWowClassic() then + OPERATION_INFO.undercut.default = "1c" + OPERATION_INFO.matchStackSize = { type = "boolean", default = false } + OPERATION_INFO.stackSize = { type = "string", default = "1" } + OPERATION_INFO.stackSizeIsCap = { type = "boolean", default = false } + OPERATION_VALUE_LIMITS.stackSize = { min = 1, max = 200 } + OPERATION_VALUE_LIMITS.postCap.max = 200 +end + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctioning.OnInitialize() + OPERATION_INFO.duration.customSanitizeFunction = private.SanitizeDuration + OPERATION_INFO.undercut.customSanitizeFunction = private.SanitizeUndercut + TSM.Operations.Register("Auctioning", L["Auctioning"], OPERATION_INFO, 20, private.GetOperationInfo, private.OperationSanitize) +end + +function Auctioning.GetMinMaxValues(key) + local info = OPERATION_VALUE_LIMITS[key] + return info and info.min or -math.huge, info and info.max or math.huge +end + +function Auctioning.GetMinPrice(itemString) + return private.GetOperationValueHelper(itemString, "minPrice") +end + +function Auctioning.GetMaxPrice(itemString) + return private.GetOperationValueHelper(itemString, "maxPrice") +end + +function Auctioning.GetNormalPrice(itemString) + return private.GetOperationValueHelper(itemString, "normalPrice") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.OperationSanitize(operation) + if not TSM.IsWowClassic() then + if operation.stackSize then + operation.postCap = tonumber(operation.postCap) * tonumber(operation.stackSize) + end + if (type(operation.undercut) == "number" and operation.undercut or Money.FromString(operation.undercut) or math.huge) < COPPER_PER_SILVER then + operation.undercut = "0c" + end + end +end + +function private.SanitizeDuration(value) + -- convert from 12/24/48 durations to 1/2/3 API values + if value == 12 then + return 1 + elseif value == 24 then + return 2 + elseif value == 48 then + return 3 + else + return value + end +end + +function private.SanitizeUndercut(value) + if not TSM.IsWowClassic() and (Money.FromString(Money.ToString(value) or value) or math.huge) < COPPER_PER_SILVER then + return "0c" + end + return value +end + +function private.GetOperationValueHelper(itemString, key) + local origItemString = itemString + itemString = TSM.Groups.TranslateItemString(itemString) + local operationName, operationSettings = TSM.Operations.GetFirstOperationByItem("Auctioning", itemString) + if not operationName then + return + end + return TSM.Auctioning.Util.GetPrice(key, operationSettings, origItemString) +end + +function private.GetOperationInfo(operationSettings) + local parts = TempTable.Acquire() + + -- get the post string + if operationSettings.postCap == 0 then + tinsert(parts, L["No posting."]) + else + if TSM.IsWowClassic() then + tinsert(parts, format(L["Posting %d stack(s) of %d for %s hours."], operationSettings.postCap, operationSettings.stackSize, strmatch(TSM.CONST.AUCTION_DURATIONS[operationSettings.duration], "%d+"))) + else + tinsert(parts, format(L["Posting %d items for %s hours."], operationSettings.postCap, strmatch(TSM.CONST.AUCTION_DURATIONS[operationSettings.duration], "%d+"))) + end + end + + -- get the cancel string + if operationSettings.cancelUndercut and operationSettings.cancelRepost then + tinsert(parts, format(L["Canceling undercut auctions and to repost higher."])) + elseif operationSettings.cancelUndercut then + tinsert(parts, format(L["Canceling undercut auctions."])) + elseif operationSettings.cancelRepost then + tinsert(parts, format(L["Canceling to repost higher."])) + else + tinsert(parts, L["Not canceling."]) + end + + local result = table.concat(parts, " ") + TempTable.Release(parts) + return result +end diff --git a/Core/Service/Operations/Core.lua b/Core/Service/Operations/Core.lua new file mode 100644 index 0000000..0f10d2f --- /dev/null +++ b/Core/Service/Operations/Core.lua @@ -0,0 +1,466 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Operations = TSM:NewPackage("Operations") +local TempTable = TSM.Include("Util.TempTable") +local Log = TSM.Include("Util.Log") +local Database = TSM.Include("Util.Database") +local private = { + db = nil, + operations = nil, + operationInfo = {}, + operationModules = {}, + shouldCreateDefaultOperations = false, + ignoreProfileUpdate = false, +} +local COMMON_OPERATION_INFO = { + ignorePlayer = { type = "table", default = {} }, + ignoreFactionrealm = { type = "table", default = {} }, + relationships = { type = "table", default = {} }, +} +local FACTION_REALM = UnitFactionGroup("player").." - "..GetRealmName() +local PLAYER_KEY = UnitName("player").." - "..FACTION_REALM + + + +-- ============================================================================ +-- Modules Functions +-- ============================================================================ + +function Operations.OnInitialize() + private.db = Database.NewSchema("OPERATIONS") + :AddStringField("moduleName") + :AddStringField("operationName") + :AddIndex("moduleName") + :Commit() + if TSM.db.global.coreOptions.globalOperations then + private.operations = TSM.db.global.userData.operations + else + private.operations = TSM.db.profile.userData.operations + end + private.RebuildDB() + private.shouldCreateDefaultOperations = not TSM.db.profile.internalData.createdDefaultOperations + TSM.db.profile.internalData.createdDefaultOperations = true + TSM.db:RegisterCallback("OnProfileUpdated", private.OnProfileUpdated) +end + +function Operations.Register(moduleName, localizedName, operationInfo, maxOperations, infoCallback, customSanitizeFunction) + for key, info in pairs(operationInfo) do + assert(type(key) == "string" and type(info) == "table") + assert(info.type == type(info.default)) + end + for key, info in pairs(COMMON_OPERATION_INFO) do + assert(not operationInfo[key]) + operationInfo[key] = info + end + tinsert(private.operationModules, moduleName) + private.operationInfo[moduleName] = { + info = operationInfo, + localizedName = localizedName, + maxOperations = maxOperations, + infoCallback = infoCallback, + customSanitizeFunction = customSanitizeFunction, + } + + local shouldCreateDefaultOperations = private.shouldCreateDefaultOperations or not private.operations[moduleName] + private.operations[moduleName] = private.operations[moduleName] or {} + + if shouldCreateDefaultOperations and not private.operations[moduleName]["#Default"] then + -- create default operation + Operations.Create(moduleName, "#Default") + end + private.ValidateOperations(moduleName) + private.RebuildDB() +end + +function Operations.IsCommonKey(key) + return COMMON_OPERATION_INFO[key] and true or false +end + +function Operations.IsValidName(operationName) + return operationName == strtrim(operationName) and operationName ~= "" and not strmatch(operationName, TSM.CONST.OPERATION_SEP) +end + +function Operations.ModuleIterator() + return ipairs(private.operationModules) +end + +function Operations.ModuleExists(moduleName) + return private.operationInfo[moduleName] and true or false +end + +function Operations.GetLocalizedName(moduleName) + return private.operationInfo[moduleName].localizedName +end + +function Operations.GetMaxNumber(moduleName) + return private.operationInfo[moduleName].maxOperations +end + +function Operations.GetSettingDefault(moduleName, key) + local info = private.operationInfo[moduleName].info[key] + return info.type == "table" and CopyTable(info.default) or info.default +end + +function Operations.OperationIterator(moduleName) + local operations = TempTable.Acquire() + for operationName in pairs(private.operations[moduleName]) do + tinsert(operations, operationName) + end + sort(operations) + return TempTable.Iterator(operations) +end + +function Operations.Exists(moduleName, operationName) + return private.operations[moduleName][operationName] and true or false +end + +function Operations.GetSettings(moduleName, operationName) + return private.operations[moduleName][operationName] +end + +function Operations.Create(moduleName, operationName) + assert(not private.operations[moduleName][operationName]) + private.operations[moduleName][operationName] = {} + Operations.Reset(moduleName, operationName) + private.RebuildDB() +end + +function Operations.BulkCreateFromImport(operations, replaceExisting) + for moduleName, moduleOperations in pairs(operations) do + for operationName, operationSettings in pairs(moduleOperations) do + assert(replaceExisting or not private.operations[moduleName][operationName]) + private.operations[moduleName][operationName] = operationSettings + end + end + private.RebuildDB() +end + +function Operations.Rename(moduleName, oldName, newName) + assert(private.operations[moduleName][oldName]) + private.operations[moduleName][newName] = private.operations[moduleName][oldName] + private.operations[moduleName][oldName] = nil + -- redirect relationships + for _, operation in pairs(private.operations[moduleName]) do + for key, target in pairs(operation.relationships) do + if target == oldName then + operation.relationships[key] = newName + end + end + end + TSM.Groups.OperationRenamed(moduleName, oldName, newName) + private.RebuildDB() +end + +function Operations.Copy(moduleName, operationName, sourceOperationName) + assert(private.operations[moduleName][operationName] and private.operations[moduleName][sourceOperationName]) + for key, info in pairs(private.operationInfo[moduleName].info) do + local sourceValue = private.operations[moduleName][sourceOperationName][key] + private.operations[moduleName][operationName][key] = info.type == "table" and CopyTable(sourceValue) or sourceValue + end + private.RemoveDeadRelationships(moduleName) + private.RebuildDB() +end + +function Operations.Delete(moduleName, operationName) + assert(private.operations[moduleName][operationName]) + private.operations[moduleName][operationName] = nil + private.RemoveDeadRelationships(moduleName) + TSM.Groups.RemoveOperationFromAllGroups(moduleName, operationName) + private.RebuildDB() +end + +function Operations.DeleteList(moduleName, operationNames) + for _, operationName in ipairs(operationNames) do + assert(private.operations[moduleName][operationName]) + private.operations[moduleName][operationName] = nil + private.RemoveDeadRelationships(moduleName) + TSM.Groups.RemoveOperationFromAllGroups(moduleName, operationName) + end + private.RebuildDB() +end + +function Operations.Reset(moduleName, operationName) + for key in pairs(private.operationInfo[moduleName].info) do + private.operations[moduleName][operationName][key] = Operations.GetSettingDefault(moduleName, key) + end +end + +function Operations.Update(moduleName, operationName) + for key in pairs(private.operations[moduleName][operationName].relationships) do + local operation = private.operations[moduleName][operationName] + while operation.relationships[key] do + local newOperation = private.operations[moduleName][operation.relationships[key]] + if not newOperation then + break + end + operation = newOperation + end + private.operations[moduleName][operationName][key] = operation[key] + end +end + +function Operations.IsCircularRelationship(moduleName, operationName, key) + local visited = TempTable.Acquire() + while operationName do + if visited[operationName] then + TempTable.Release(visited) + return true + end + visited[operationName] = true + operationName = private.operations[moduleName][operationName].relationships[key] + end + TempTable.Release(visited) + return false +end + +function Operations.GetFirstOperationByItem(moduleName, itemString) + local groupPath = TSM.Groups.GetPathByItem(itemString) + for _, operationName in TSM.Groups.OperationIterator(groupPath, moduleName) do + Operations.Update(moduleName, operationName) + if not private.IsIgnored(moduleName, operationName) then + return operationName, private.operations[moduleName][operationName] + end + end +end + +function Operations.GroupOperationIterator(moduleName, groupPath) + local operations = TempTable.Acquire() + operations.moduleName = moduleName + for _, operationName in TSM.Groups.OperationIterator(groupPath, moduleName) do + Operations.Update(moduleName, operationName) + if not private.IsIgnored(moduleName, operationName) then + tinsert(operations, operationName) + end + end + return private.GroupOperationIteratorHelper, operations, 0 +end + +function Operations.GroupHasOperation(moduleName, groupPath, targetOperationName) + for _, operationName in TSM.Groups.OperationIterator(groupPath, moduleName) do + if operationName == targetOperationName then + return true + end + end + return false +end + +function Operations.GetDescription(moduleName, operationName) + local operationSettings = private.operations[moduleName][operationName] + assert(operationSettings) + Operations.Update(moduleName, operationName) + return private.operationInfo[moduleName].infoCallback(operationSettings) +end + +function Operations.SanitizeSettings(moduleName, operationName, operationSettings, silentMissingCommonKeys, noRelationshipCheck) + local didReset = false + local operationInfo = private.operationInfo[moduleName].info + if private.operationInfo[moduleName].customSanitizeFunction then + private.operationInfo[moduleName].customSanitizeFunction(operationSettings) + end + for key, value in pairs(operationSettings) do + if not noRelationshipCheck and Operations.IsCircularRelationship(moduleName, operationName, key) then + Log.Err("Removing circular relationship (%s, %s, %s)", moduleName, operationName, key) + operationSettings.relationships[key] = nil + end + if not operationInfo[key] then + operationSettings[key] = nil + elseif type(value) ~= operationInfo[key].type then + if operationInfo[key].type == "string" and type(value) == "number" then + -- some custom price settings were potentially stored as numbers previously, so just convert them + operationSettings[key] = tostring(value) + else + didReset = true + Log.Err("Resetting operation setting %s,%s,%s (%s)", moduleName, operationName, tostring(key), tostring(value)) + operationSettings[key] = operationInfo[key].type == "table" and CopyTable(operationInfo[key].default) or operationInfo[key].default + end + elseif operationInfo[key].customSanitizeFunction then + operationSettings[key] = operationInfo[key].customSanitizeFunction(value) + end + end + for key in pairs(operationInfo) do + if operationSettings[key] == nil then + -- this key was missing + if operationInfo[key].type == "boolean" then + -- we previously stored booleans as nil instead of false + operationSettings[key] = false + else + if not silentMissingCommonKeys or not Operations.IsCommonKey(key) then + didReset = true + Log.Err("Resetting missing operation setting %s,%s,%s", moduleName, operationName, tostring(key)) + end + operationSettings[key] = operationInfo[key].type == "table" and CopyTable(operationInfo[key].default) or operationInfo[key].default + end + end + end + return didReset +end + +function Operations.HasRelationship(moduleName, operationName, settingKey) + return Operations.GetRelationship(moduleName, operationName, settingKey) and true or false +end + +function Operations.GetRelationship(moduleName, operationName, settingKey) + assert(private.operationInfo[moduleName].info[settingKey]) + return private.operations[moduleName][operationName].relationships[settingKey] +end + +function Operations.SetRelationship(moduleName, operationName, settingKey, targetOperationName) + assert(targetOperationName == nil or private.operations[moduleName][targetOperationName]) + assert(private.operationInfo[moduleName].info[settingKey]) + private.operations[moduleName][operationName].relationships[settingKey] = targetOperationName +end + +function Operations.GetRelationshipColors(operationType, operationName, settingKey, value) + local relationshipSet = Operations.HasRelationship(operationType, operationName, settingKey) + local linkColor = nil + if not value and relationshipSet then + linkColor = "INDICATOR_DISABLED" + elseif not value then + linkColor = "TEXT_DISABLED" + elseif relationshipSet then + linkColor = "INDICATOR" + else + linkColor = "TEXT" + end + local linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", linkColor) + return relationshipSet, linkTexture, value and not relationshipSet and "TEXT" or "TEXT_DISABLED" +end + +function Operations.IsStoredGlobally() + return TSM.db.global.coreOptions.globalOperations +end + +function Operations.SetStoredGlobally(storeGlobally) + TSM.db.global.coreOptions.globalOperations = storeGlobally + -- we shouldn't be running the OnProfileUpdated callback while switching profiles + private.ignoreProfileUpdate = true + if storeGlobally then + -- move current profile to global + TSM.db.global.userData.operations = CopyTable(TSM.db.profile.userData.operations) + -- clear out old operations + for _ in TSM.GetTSMProfileIterator() do + TSM.db.profile.userData.operations = nil + end + else + -- move global to all profiles + for _ in TSM.GetTSMProfileIterator() do + TSM.db.profile.userData.operations = CopyTable(TSM.db.global.userData.operations) + end + -- clear out old operations + TSM.db.global.userData.operations = nil + end + private.ignoreProfileUpdate = false + private.OnProfileUpdated() +end + +function Operations.ReplaceProfileOperations(newOperations) + for k, v in pairs(newOperations) do + TSM.db.profile.userData.operations[k] = v + end +end + +function Operations.CreateQuery() + return private.db:NewQuery() +end + +function Operations.GroupIterator(moduleName, filterOperationName, overrideOnly) + local result = TempTable.Acquire() + + -- check the base group + if Operations.GroupHasOperation(moduleName, TSM.CONST.ROOT_GROUP_PATH, filterOperationName) then + tinsert(result, TSM.CONST.ROOT_GROUP_PATH) + end + -- need to filter out the groups without operations + for _, groupPath in TSM.Groups.GroupIterator() do + if (not overrideOnly or TSM.Groups.HasOperationOverride(groupPath, moduleName)) and Operations.GroupHasOperation(moduleName, groupPath, filterOperationName) then + tinsert(result, groupPath) + end + end + return TempTable.Iterator(result) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.OnProfileUpdated() + if private.ignoreProfileUpdate then + return + end + if TSM.db.global.coreOptions.globalOperations then + private.operations = TSM.db.global.userData.operations + else + private.operations = TSM.db.profile.userData.operations + end + for _, moduleName in Operations.ModuleIterator() do + private.ValidateOperations(moduleName) + end + private.RebuildDB() + TSM.Groups.RebuildDatabase() +end + +function private.ValidateOperations(moduleName) + if not private.operations[moduleName] then + -- this is a new profile + private.operations[moduleName] = {} + Operations.Create(moduleName, "#Default") + return + end + for operationName, operationSettings in pairs(private.operations[moduleName]) do + if type(operationName) ~= "string" or not Operations.IsValidName(operationName) then + Log.Err("Removing %s operation with invalid name: ", moduleName, tostring(operationName)) + private.operations[moduleName][operationName] = nil + else + Operations.SanitizeSettings(moduleName, operationName, operationSettings) + for key, target in pairs(operationSettings.relationships) do + if not private.operations[moduleName][target] then + Log.Err("Removing invalid relationship %s,%s,%s -> %s", moduleName, operationName, tostring(key), tostring(target)) + operationSettings.relationships[key] = nil + end + end + end + end +end + +function private.IsIgnored(moduleName, operationName) + local operationSettings = private.operations[moduleName][operationName] + assert(operationSettings) + return operationSettings.ignorePlayer[PLAYER_KEY] or operationSettings.ignoreFactionrealm[FACTION_REALM] +end + +function private.GroupOperationIteratorHelper(operations, index) + index = index + 1 + if index > #operations then + TempTable.Release(operations) + return + end + local operationName = operations[index] + return index, operationName, private.operations[operations.moduleName][operationName] +end + +function private.RemoveDeadRelationships(moduleName) + for _, operation in pairs(private.operations[moduleName]) do + for key, target in pairs(operation.relationships) do + if not private.operations[moduleName][target] then + operation.relationships[key] = nil + end + end + end +end + +function private.RebuildDB() + private.db:TruncateAndBulkInsertStart() + for moduleName, operations in pairs(private.operations) do + for operationName in pairs(operations) do + private.db:BulkInsertNewRow(moduleName, operationName) + end + end + private.db:BulkInsertEnd() +end diff --git a/Core/Service/Operations/Crafting.lua b/Core/Service/Operations/Crafting.lua new file mode 100644 index 0000000..403682f --- /dev/null +++ b/Core/Service/Operations/Crafting.lua @@ -0,0 +1,161 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Crafting = TSM.Operations:NewPackage("Crafting") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local CustomPrice = TSM.Include("Service.CustomPrice") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = {} +local OPERATION_INFO = { + minRestock = { type = "string", default = "10" }, + maxRestock = { type = "string", default = "20" }, + minProfit = { type = "string", default = "100g" }, + craftPriceMethod = { type = "string", default = "" }, +} +local MIN_RESTOCK_VALUE = 0 +local MAX_RESTOCK_VALUE = 2000 +local BAD_CRAFTING_PRICE_SOURCES = { + crafting = true, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Crafting.OnInitialize() + TSM.Operations.Register("Crafting", L["Crafting"], OPERATION_INFO, 1, private.GetOperationInfo) + for _, name in TSM.Operations.OperationIterator("Crafting") do + local operation = TSM.Operations.GetSettings("Crafting", name) + if operation.craftPriceMethod ~= "" then + local isValid, err = CustomPrice.Validate(operation.craftPriceMethod, BAD_CRAFTING_PRICE_SOURCES) + if not isValid then + Log.PrintfUser(L["Your craft value method for '%s' was invalid so it has been returned to the default. Details: %s"], name, err) + operation.craftPriceMethod = "" + end + end + end +end + +function Crafting.HasOperation(itemString) + return private.GetOperationSettings(itemString) and true or false +end + +function Crafting.GetRestockRange() + return MIN_RESTOCK_VALUE, MAX_RESTOCK_VALUE +end + +function Crafting.IsValid(itemString) + local origItemString = itemString + itemString = TSM.Groups.TranslateItemString(itemString) + local operationName, operationSettings = TSM.Operations.GetFirstOperationByItem("Crafting", itemString) + if not operationSettings then + return false + end + local minRestock, maxRestock, errMsg = nil, nil, nil + minRestock, errMsg = private.GetMinRestock(operationSettings, origItemString) + if not minRestock then + return false, errMsg + end + maxRestock, errMsg = private.GetMaxRestock(operationSettings, origItemString) + if not maxRestock then + return false, errMsg + end + if minRestock > maxRestock then + -- invalid cause min > max restock quantity + return false, format(L["'%s' is an invalid operation. Min restock of %d is higher than max restock of %d for %s."], operationName, minRestock, maxRestock, ItemInfo.GetLink(origItemString)) + end + return true +end + +function Crafting.GetMinProfit(itemString) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return false + end + if operationSettings.minProfit == "" then + return false + end + return true, CustomPrice.GetValue(operationSettings.minProfit, itemString) +end + +function Crafting.GetRestockQuantity(itemString, haveQuantity) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return 0 + end + local minRestock = private.GetMinRestock(operationSettings, itemString) + local maxRestock = private.GetMaxRestock(operationSettings, itemString) + if not minRestock or not maxRestock or minRestock > maxRestock then + return 0 + end + local neededQuantity = maxRestock - haveQuantity + if neededQuantity <= 0 then + -- don't need to queue any + return 0 + elseif neededQuantity < minRestock then + -- we're below the min restock quantity + return 0 + end + return neededQuantity +end + +function Crafting.GetCraftedItemValue(itemString) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return false + end + if operationSettings.craftPriceMethod == "" then + return false + end + return true, CustomPrice.GetValue(operationSettings.craftPriceMethod, itemString) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetMinRestock(operationSettings, itemString) + local minRestock, errMsg = CustomPrice.GetValue(operationSettings.minRestock, itemString, true) + if not minRestock then + return nil, format(L["Your min restock (%s) is invalid for %s."], operationSettings.minRestock, ItemInfo.GetLink(itemString) or "?").." "..errMsg + elseif minRestock < MIN_RESTOCK_VALUE or minRestock > MAX_RESTOCK_VALUE then + return nil, format(L["Your min restock (%s) is invalid for %s."], operationSettings.minRestock, ItemInfo.GetLink(itemString) or "?").." "..format(L["Must be between %d and %s."], MIN_RESTOCK_VALUE, MAX_RESTOCK_VALUE) + end + return minRestock +end + +function private.GetMaxRestock(operationSettings, itemString) + local maxRestock, errMsg = CustomPrice.GetValue(operationSettings.maxRestock, itemString, true) + if not maxRestock then + return nil, format(L["Your max restock (%s) is invalid for %s."], operationSettings.maxRestock, ItemInfo.GetLink(itemString) or "?").." "..errMsg + elseif maxRestock < MIN_RESTOCK_VALUE or maxRestock > MAX_RESTOCK_VALUE then + return nil, format(L["Your max restock (%s) is invalid for %s."], operationSettings.maxRestock, ItemInfo.GetLink(itemString) or "?").." "..format(L["Must be between %d and %s."], MIN_RESTOCK_VALUE, MAX_RESTOCK_VALUE) + end + return maxRestock +end + +function private.GetOperationInfo(operationSettings) + if operationSettings.minProfit ~= "" then + return L["Restocking with a min profit."] + else + return L["Restocking with no min profit."] + end +end + +function private.GetOperationSettings(itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + local operationName, operationSettings = TSM.Operations.GetFirstOperationByItem("Crafting", itemString) + if not operationName then + return + end + return operationSettings +end diff --git a/Core/Service/Operations/Mailing.lua b/Core/Service/Operations/Mailing.lua new file mode 100644 index 0000000..8b7d85d --- /dev/null +++ b/Core/Service/Operations/Mailing.lua @@ -0,0 +1,46 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Mailing = TSM.Operations:NewPackage("Mailing") +local private = {} +local L = TSM.Include("Locale").GetTable() +local OPERATION_INFO = { + maxQtyEnabled = { type = "boolean", default = false }, + maxQty = { type = "number", default = 10 }, + target = { type = "string", default = "" }, + restock = { type = "boolean", default = false }, + restockSources = { type = "table", default = { guild = false, bank = false } }, + keepQty = { type = "number", default = 0 }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Mailing.OnInitialize() + TSM.Operations.Register("Mailing", L["Mailing"], OPERATION_INFO, 50, private.GetOperationInfo) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetOperationInfo(operationSettings) + if operationSettings.target == "" then + return + end + + if operationSettings.maxQtyEnabled then + return format(L["Mailing up to %d to %s."], operationSettings.maxQty, operationSettings.target) + else + return format(L["Mailing all to %s."], operationSettings.target) + end +end diff --git a/Core/Service/Operations/Shopping.lua b/Core/Service/Operations/Shopping.lua new file mode 100644 index 0000000..7f1a7a5 --- /dev/null +++ b/Core/Service/Operations/Shopping.lua @@ -0,0 +1,132 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Shopping = TSM.Operations:NewPackage("Shopping") +local private = {} +local L = TSM.Include("Locale").GetTable() +local CustomPrice = TSM.Include("Service.CustomPrice") +local Inventory = TSM.Include("Service.Inventory") +local OPERATION_INFO = { + restockQuantity = { type = "string", default = "0" }, + maxPrice = { type = "string", default = "dbmarket" }, + showAboveMaxPrice = { type = "boolean", default = true }, + restockSources = { type = "table", default = { alts = false, auctions = false, bank = false, guild = false } }, +} +local MIN_RESTOCK_VALUE = 0 +local MAX_RESTOCK_VALUE = 50000 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Shopping.OnInitialize() + TSM.Operations.Register("Shopping", L["Shopping"], OPERATION_INFO, 1, private.GetOperationInfo) +end + +function Shopping.GetRestockRange() + return MIN_RESTOCK_VALUE, MAX_RESTOCK_VALUE +end + +function Shopping.GetMaxPrice(itemString) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return + end + return CustomPrice.GetValue(operationSettings.maxPrice, itemString) +end + +function Shopping.ShouldShowAboveMaxPrice(itemString) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return + end + return operationSettings.showAboveMaxPrice +end + +function Shopping.IsFiltered(itemString, itemBuyout) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return true + end + if operationSettings.showAboveMaxPrice then + return false + end + local maxPrice = CustomPrice.GetValue(operationSettings.maxPrice, itemString) + if itemBuyout > (maxPrice or 0) then + return true, true + end + return false +end + +function Shopping.ValidAndGetRestockQuantity(itemString) + local operationSettings = private.GetOperationSettings(itemString) + if not operationSettings then + return false, nil + end + local isValid, err = CustomPrice.Validate(operationSettings.maxPrice) + if not isValid then + return false, err + end + local maxQuantity, restockQuantity = nil, nil + restockQuantity, err = CustomPrice.GetValue(operationSettings.restockQuantity, itemString, true) + if not restockQuantity then + return false, err + elseif restockQuantity < MIN_RESTOCK_VALUE or restockQuantity > MAX_RESTOCK_VALUE then + return false, format(L["Your restock quantity is invalid. It must be between %d and %s."], MIN_RESTOCK_VALUE, MAX_RESTOCK_VALUE) + end + if restockQuantity > 0 then + -- include mail and bags + local numHave = Inventory.GetBagQuantity(itemString) + Inventory.GetMailQuantity(itemString) + if operationSettings.restockSources.bank then + numHave = numHave + Inventory.GetBankQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString) + end + if operationSettings.restockSources.guild then + numHave = numHave + Inventory.GetGuildQuantity(itemString) + end + local _, numAlts, numAuctions = Inventory.GetPlayerTotals(itemString) + if operationSettings.restockSources.alts then + numHave = numHave + numAlts + end + if operationSettings.restockSources.auctions then + numHave = numHave + numAuctions + end + if numHave >= restockQuantity then + return false, nil + end + maxQuantity = restockQuantity - numHave + end + if not operationSettings.showAboveMaxPrice and not CustomPrice.GetValue(operationSettings.maxPrice, itemString) then + -- we're not showing auctions above the max price and the max price isn't valid for this item, so skip it + return false, nil + end + return true, maxQuantity +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetOperationInfo(operationSettings) + if operationSettings.showAboveMaxPrice then + return format(L["Shopping for auctions including those above the max price."]) + else + return format(L["Shopping for auctions with a max price set."]) + end +end + +function private.GetOperationSettings(itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + local operationName, operationSettings = TSM.Operations.GetFirstOperationByItem("Shopping", itemString) + if not operationName then + return + end + return operationSettings +end diff --git a/Core/Service/Operations/Sniper.lua b/Core/Service/Operations/Sniper.lua new file mode 100644 index 0000000..8c8aa0b --- /dev/null +++ b/Core/Service/Operations/Sniper.lua @@ -0,0 +1,57 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sniper = TSM.Operations:NewPackage("Sniper") +local private = {} +local L = TSM.Include("Locale").GetTable() +local CustomPrice = TSM.Include("Service.CustomPrice") +local OPERATION_INFO = { + belowPrice = { type = "string", default = "max(vendorsell, ifgt(DBRegionMarketAvg, 250000g, 0.8, ifgt(DBRegionMarketAvg, 100000g, 0.7, ifgt(DBRegionMarketAvg, 50000g, 0.6, ifgt(DBRegionMarketAvg, 25000g, 0.5, ifgt(DBRegionMarketAvg, 10000g, 0.4, ifgt(DBRegionMarketAvg, 5000g, 0.3, ifgt(DBRegionMarketAvg, 2000g, 0.2, ifgt(DBRegionMarketAvg, 1000g, 0.1, 0.05)))))))) * DBRegionMarketAvg)" }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Sniper.OnInitialize() + TSM.Operations.Register("Sniper", L["Sniper"], OPERATION_INFO, 1, private.GetOperationInfo) +end + +function Sniper.IsOperationValid(itemString) + local _, operationSettings = TSM.Operations.GetFirstOperationByItem("Sniper", itemString) + if not operationSettings then + return false + end + local isValid = CustomPrice.Validate(operationSettings.belowPrice) + return isValid +end + +function Sniper.HasOperation(itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + return TSM.Operations.GetFirstOperationByItem("Sniper", itemString) and true or false +end + +function Sniper.GetBelowPrice(itemString) + itemString = TSM.Groups.TranslateItemString(itemString) + local operationName, operationSettings = TSM.Operations.GetFirstOperationByItem("Sniper", itemString) + if not operationName then + return + end + return CustomPrice.GetValue(operationSettings.belowPrice, itemString) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetOperationInfo(operationSettings) + return L["Sniping items below a max price"] +end diff --git a/Core/Service/Operations/Vendoring.lua b/Core/Service/Operations/Vendoring.lua new file mode 100644 index 0000000..f8ab3d2 --- /dev/null +++ b/Core/Service/Operations/Vendoring.lua @@ -0,0 +1,60 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Vendoring = TSM.Operations:NewPackage("Vendoring") +local private = {} +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local OPERATION_INFO = { + sellAfterExpired = { type = "number", default = 20 }, + sellSoulbound = { type = "boolean", default = false }, + keepQty = { type = "number", default = 0 }, + restockQty = { type = "number", default = 0 }, + restockSources = { type = "table", default = { alts = false, ah = false, bank = false, guild = false, alts_ah = false, mail = false } }, + enableBuy = { type = "boolean", default = true }, + enableSell = { type = "boolean", default = true }, + vsMarketValue = { type = "string", default = "dbmarket" }, + vsMaxMarketValue = { type = "string", default = "0c" }, + vsDestroyValue = { type = "string", default = "destroy" }, + vsMaxDestroyValue = { type = "string", default = "0c" }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Vendoring.OnInitialize() + TSM.Operations.Register("Vendoring", L["Vendoring"], OPERATION_INFO, 1, private.GetOperationInfo) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetOperationInfo(operationSettings) + local parts = TempTable.Acquire() + if operationSettings.enableBuy and operationSettings.restockQty > 0 then + tinsert(parts, format(L["Restocking to %d."], operationSettings.restockQty)) + end + + if operationSettings.enableSell then + if operationSettings.keepQty > 0 then + tinsert(parts, format(L["Keeping %d."], operationSettings.keepQty)) + end + if operationSettings.sellSoulbound then + tinsert(parts, L["Selling soulbound items."]) + end + end + + local result = table.concat(parts, " ") + TempTable.Release(parts) + return result +end diff --git a/Core/Service/Operations/Warehousing.lua b/Core/Service/Operations/Warehousing.lua new file mode 100644 index 0000000..31173fa --- /dev/null +++ b/Core/Service/Operations/Warehousing.lua @@ -0,0 +1,95 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Warehousing = TSM.Operations:NewPackage("Warehousing") +local private = {} +local L = TSM.Include("Locale").GetTable() +local OPERATION_INFO = { + moveQuantity = { type = "number", default = 0 }, + keepBagQuantity = { type = "number", default = 0 }, + keepBankQuantity = { type = "number", default = 0 }, + restockQuantity = { type = "number", default = 0 }, + stackSize = { type = "number", default = 0 }, + restockKeepBankQuantity = { type = "number", default = 0 }, + restockStackSize = { type = "number", default = 0 }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Warehousing.OnInitialize() + TSM.Operations.Register("Warehousing", L["Warehousing"], OPERATION_INFO, 12, private.GetOperationInfo) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetOperationInfo(operationSettings) + if (operationSettings.keepBagQuantity ~= 0 or operationSettings.keepBankQuantity ~= 0) and operationSettings.moveQuantity == 0 then + if operationSettings.keepBagQuantity ~= 0 then + if operationSettings.keepBankQuantity ~= 0 then + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move all of the items in this group keeping %d of each item back when bags > bank/gbank, %d of each item back when bank/gbank > bags. Restock will maintain %d items in your bags."], operationSettings.keepBagQuantity, operationSettings.keepBankQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move all of the items in this group keeping %d of each item back when bags > bank/gbank, %d of each item back when bank/gbank > bags."], operationSettings.keepBagQuantity, operationSettings.keepBankQuantity) + end + else + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move all of the items in this group keeping %d of each item back when bags > bank/gbank. Restock will maintain %d items in your bags."], operationSettings.keepBagQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move all of the items in this group keeping %d of each item back when bags > bank/gbank."], operationSettings.keepBagQuantity) + end + end + else + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move all of the items in this group keeping %d of each item back when bank/gbank > bags. Restock will maintain %d items in your bags."], operationSettings.keepBankQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move all of the items in this group keeping %d of each item back when bank/gbank > bags."], operationSettings.keepBankQuantity) + end + end + elseif (operationSettings.keepBagQuantity ~= 0 or operationSettings.keepBankQuantity ~= 0) and operationSettings.moveQuantity ~= 0 then + if operationSettings.keepBagQuantity ~= 0 then + if operationSettings.keepBankQuantity ~= 0 then + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move a max of %d of each item in this group keeping %d of each item back when bags > bank/gbank, %d of each item back when bank/gbank > bags. Restock will maintain %d items in your bags."], operationSettings.moveQuantity, operationSettings.keepBagQuantity, operationSettings.keepBankQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move a max of %d of each item in this group keeping %d of each item back when bags > bank/gbank, %d of each item back when bank/gbank > bags."], operationSettings.moveQuantity, operationSettings.keepBagQuantity, operationSettings.keepBankQuantity) + end + else + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move a max of %d of each item in this group keeping %d of each item back when bags > bank/gbank. Restock will maintain %d items in your bags."], operationSettings.keepBankQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move a max of %d of each item in this group keeping %d of each item back when bags > bank/gbank."], operationSettings.keepBankQuantity) + end + end + else + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move a max of %d of each item in this group keeping %d of each item back when bank/gbank > bags. Restock will maintain %d items in your bags."], operationSettings.moveQuantity, operationSettings.keepBankQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move a max of %d of each item in this group keeping %d of each item back when bank/gbank > bags."], operationSettings.moveQuantity, operationSettings.keepBankQuantity) + end + end + elseif operationSettings.moveQuantity ~= 0 then + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move a max of %d of each item in this group. Restock will maintain %d items in your bags."], operationSettings.moveQuantity, operationSettings.restockQuantity) + else + return format(L["Warehousing will move a max of %d of each item in this group."], operationSettings.moveQuantity) + end + else + if operationSettings.restockQuantity ~= 0 then + return format(L["Warehousing will move all of the items in this group. Restock will maintain %d items in your bags."], operationSettings.restockQuantity) + else + return L["Warehousing will move all of the items in this group."] + end + end +end diff --git a/Core/Service/Shopping/Core.lua b/Core/Service/Shopping/Core.lua new file mode 100644 index 0000000..6762449 --- /dev/null +++ b/Core/Service/Shopping/Core.lua @@ -0,0 +1,88 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +TSM:NewPackage("Shopping") +local Threading = TSM.Include("Service.Threading") +local ShoppingSearchContext = TSM.Include("LibTSMClass").DefineClass("ShoppingSearchContext") +TSM.Shopping.ShoppingSearchContext = ShoppingSearchContext + + + +-- ============================================================================ +-- ShoppingSearchContext - Public Class Methods +-- ============================================================================ + +function ShoppingSearchContext.__init(self, threadId, marketValueFunc) + assert(threadId and marketValueFunc) + self._threadId = threadId + self._marketValueFunc = marketValueFunc + self._name = nil + self._filterInfo = nil + self._postContext = nil + self._buyCallback = nil + self._stateCallback = nil + self._pctTooltip = nil +end + +function ShoppingSearchContext.SetScanContext(self, name, filterInfo, postContext, pctTooltip) + assert(name) + self._name = name + self._filterInfo = filterInfo + self._postContext = postContext + -- clear the callbacks when the scan context changes + self._buyCallback = nil + self._stateCallback = nil + self._pctTooltip = pctTooltip + return self +end + +function ShoppingSearchContext.SetCallbacks(self, buyCallback, stateCallback) + self._buyCallback = buyCallback + self._stateCallback = stateCallback + return self +end + +function ShoppingSearchContext.StartThread(self, callback, auctionScan) + Threading.SetCallback(self._threadId, callback) + Threading.Start(self._threadId, auctionScan, self._filterInfo, self._postContext) +end + +function ShoppingSearchContext.KillThread(self) + Threading.Kill(self._threadId) +end + +function ShoppingSearchContext.GetMarketValueFunc(self) + return self._marketValueFunc +end + +function ShoppingSearchContext.GetPctTooltip(self) + return self._pctTooltip +end + +function ShoppingSearchContext.GetMaxCanBuy(self, itemString) + return nil +end + +function ShoppingSearchContext.OnBuy(self, itemString, quantity) + if self._buyCallback then + self._buyCallback(itemString, quantity) + end +end + +function ShoppingSearchContext.OnStateChanged(self, state) + if self._stateCallback then + self._stateCallback(state) + end +end + +function ShoppingSearchContext.GetName(self) + return self._name +end + +function ShoppingSearchContext.GetPostContext(self) + return self._postContext +end diff --git a/Core/Service/Shopping/DisenchantSearch.lua b/Core/Service/Shopping/DisenchantSearch.lua new file mode 100644 index 0000000..9b68e6a --- /dev/null +++ b/Core/Service/Shopping/DisenchantSearch.lua @@ -0,0 +1,104 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local DisenchantSearch = TSM.Shopping:NewPackage("DisenchantSearch") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local private = { + itemList = {}, + scanThreadId = nil, + searchContext = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function DisenchantSearch.OnInitialize() + -- initialize thread + private.scanThreadId = Threading.New("DISENCHANT_SEARCH", private.ScanThread) + private.searchContext = TSM.Shopping.ShoppingSearchContext(private.scanThreadId, private.MarketValueFunction) +end + +function DisenchantSearch.GetSearchContext() + return private.searchContext:SetScanContext(L["Disenchant Search"], nil, nil, L["Disenchant Value"]) +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan) + if (TSM.AuctionDB.GetLastCompleteScanTime() or 0) < time() - 60 * 60 * 12 then + Log.PrintUser(L["No recent AuctionDB scan data found."]) + return false + end + + -- create the list of items + wipe(private.itemList) + for _, itemString, _, minBuyout in TSM.AuctionDB.LastScanIteratorThreaded() do + if minBuyout and private.ShouldInclude(itemString, minBuyout) then + tinsert(private.itemList, itemString) + end + Threading.Yield() + end + + -- run the scan + auctionScan:AddItemListQueriesThreaded(private.itemList) + for _, query in auctionScan:QueryIterator() do + query:AddCustomFilter(private.QueryFilter) + end + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) + end + +end + +function private.ShouldInclude(itemString, minBuyout) + if not ItemInfo.IsDisenchantable(itemString) then + return false + end + + local itemLevel = ItemInfo.GetItemLevel(itemString) or -1 + if itemLevel < TSM.db.global.shoppingOptions.minDeSearchLvl or itemLevel > TSM.db.global.shoppingOptions.maxDeSearchLvl then + return false + end + + if private.IsItemBuyoutTooHigh(itemString, minBuyout) then + return false + end + + return true +end + +function private.QueryFilter(_, row) + local itemString = row:GetItemString() + if not itemString then + return false + end + local _, itemBuyout = row:GetBuyouts() + if not itemBuyout then + return false + end + return private.IsItemBuyoutTooHigh(itemString, itemBuyout) +end + +function private.IsItemBuyoutTooHigh(itemString, itemBuyout) + local disenchantValue = CustomPrice.GetItemPrice(itemString, "Destroy") + return not disenchantValue or itemBuyout > TSM.db.global.shoppingOptions.maxDeSearchPercent / 100 * disenchantValue +end + +function private.MarketValueFunction(row) + return CustomPrice.GetItemPrice(row:GetItemString() or row:GetBaseItemString(), "Destroy") +end diff --git a/Core/Service/Shopping/FilterSearch.lua b/Core/Service/Shopping/FilterSearch.lua new file mode 100644 index 0000000..10e18c1 --- /dev/null +++ b/Core/Service/Shopping/FilterSearch.lua @@ -0,0 +1,405 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local FilterSearch = TSM.Shopping:NewPackage("FilterSearch") +local L = TSM.Include("Locale").GetTable() +local DisenchantInfo = TSM.Include("Data.DisenchantInfo") +local TempTable = TSM.Include("Util.TempTable") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local Math = TSM.Include("Util.Math") +local ItemString = TSM.Include("Util.ItemString") +local Threading = TSM.Include("Service.Threading") +local ItemFilter = TSM.Include("Service.ItemFilter") +local CustomPrice = TSM.Include("Service.CustomPrice") +local Conversions = TSM.Include("Service.Conversions") +local ItemInfo = TSM.Include("Service.ItemInfo") +local FilterSearchContext = TSM.Include("LibTSMClass").DefineClass("FilterSearchContext", TSM.Shopping.ShoppingSearchContext) +local private = { + scanThreadId = nil, + itemFilter = nil, + isSpecial = false, + marketValueSource = nil, + searchContext = nil, + gatheringSearchContext = nil, + targetItem = {}, + itemList = {}, + generalMaxQuantity = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function FilterSearch.OnInitialize() + -- initialize thread + private.scanThreadId = Threading.New("FILTER_SEARCH", private.ScanThread) + private.itemFilter = ItemFilter.New() + private.searchContext = FilterSearchContext(private.scanThreadId, private.MarketValueFunction) + private.gatheringSearchContext = FilterSearchContext(private.scanThreadId, private.MarketValueFunction) +end + +function FilterSearch.GetGreatDealsSearchContext(filterStr) + filterStr = private.ValidateFilterStr(filterStr, "NORMAL") + if not filterStr then + return + end + private.marketValueSource = TSM.db.global.shoppingOptions.pctSource + private.isSpecial = true + return private.searchContext:SetScanContext(L["Great Deals Search"], filterStr, nil, L["Market Value"]) +end + +function FilterSearch.GetSearchContext(filterStr, itemInfo) + local errMsg = nil + filterStr, errMsg = private.ValidateFilterStr(filterStr, "NORMAL") + if not filterStr then + return nil, errMsg + end + private.marketValueSource = TSM.db.global.shoppingOptions.pctSource + private.isSpecial = false + return private.searchContext:SetScanContext(filterStr, filterStr, itemInfo, L["Market Value"]) +end + +function FilterSearch.GetGatheringSearchContext(filterStr, mode) + filterStr = private.ValidateFilterStr(filterStr, mode) + if not filterStr then + return + end + private.marketValueSource = "matprice" + private.isSpecial = true + return private.gatheringSearchContext:SetScanContext(L["Gathering Search"], filterStr, nil, L["Material Cost"]) +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan, filterStr) + wipe(private.generalMaxQuantity) + if not TSM.IsWowClassic() and filterStr == "" then + auctionScan:NewQuery() + :SetStr("") + wipe(private.targetItem) + wipe(private.itemList) + else + local hasFilter, errMsg = false, nil + for filter in String.SplitIterator(filterStr, ";") do + filter = strtrim(filter) + if filter ~= "" then + local filterIsValid, filterErrMsg = private.itemFilter:ParseStr(filter) + if filterIsValid then + hasFilter = true + else + errMsg = errMsg or filterErrMsg + end + end + end + if not hasFilter then + Log.PrintUser(format(L["Invalid search filter (%s)."], filterStr).." "..errMsg) + return false + end + wipe(private.targetItem) + wipe(private.itemList) + local itemFilter = private.itemFilter + for filterPart in String.SplitIterator(filterStr, ";") do + filterPart = strtrim(filterPart) + if filterPart ~= "" and itemFilter:ParseStr(filterPart) then + if itemFilter:GetCrafting() then + wipe(private.itemList) + local targetItem = Conversions.GetTargetItemByName(private.itemFilter:GetStr()) + assert(targetItem) + -- populate the list of items + private.targetItem[targetItem] = targetItem + tinsert(private.itemList, targetItem) + local conversionInfo = Conversions.GetSourceItems(targetItem) + for itemString in pairs(conversionInfo) do + if not private.targetItem[itemString] then + private.targetItem[itemString] = targetItem + tinsert(private.itemList, itemString) + end + end + -- generate the queries and add our filter + local queryOffset = auctionScan:GetNumQueries() + auctionScan:AddItemListQueriesThreaded(private.itemList) + local maxQuantity = itemFilter:GetMaxQuantity() + local firstQuery = nil + for _, query in auctionScan:QueryIterator(queryOffset) do + private.targetItem[query] = targetItem + query:AddCustomFilter(private.TargetItemQueryFilter) + if maxQuantity then + if firstQuery then + -- redirect to the first query so the max quantity spans them all + private.generalMaxQuantity[query] = firstQuery + else + private.generalMaxQuantity[query] = maxQuantity + firstQuery = query + end + end + end + auctionScan:AddResultsUpdateCallback(private.ResultsUpdated) + auctionScan:SetScript("OnQueryDone", private.OnQueryDone) + elseif itemFilter:GetDisenchant() then + local queryOffset = auctionScan:GetNumQueries() + local targetItem = Conversions.GetTargetItemByName(itemFilter:GetStr()) + assert(targetItem) + -- generate queries for groups of items that d/e into the target item + local disenchantInfo = DisenchantInfo.GetInfo(targetItem) + for _, info in ipairs(disenchantInfo.sourceInfo) do + auctionScan:NewQuery() + :SetLevelRange(disenchantInfo.minLevel, disenchantInfo.maxLevel) + :SetQualityRange(info.quality, info.quality) + :SetClass(info.classId) + :SetItemLevelRange(info.minItemLevel, info.maxItemLevel) + end + -- add a query for the target item itself + wipe(private.itemList) + tinsert(private.itemList, targetItem) + private.targetItem[targetItem] = targetItem + auctionScan:AddItemListQueriesThreaded(private.itemList) + -- add our filter to each query and generate a lookup from query to target item + local maxQuantity = itemFilter:GetMaxQuantity() + local firstQuery = nil + for _, query in auctionScan:QueryIterator(queryOffset) do + private.targetItem[query] = targetItem + query:AddCustomFilter(private.TargetItemQueryFilter) + if maxQuantity then + if firstQuery then + -- redirect to the first query so the max quantity spans them all + private.generalMaxQuantity[query] = firstQuery + else + private.generalMaxQuantity[query] = maxQuantity + firstQuery = query + end + end + end + auctionScan:AddResultsUpdateCallback(private.ResultsUpdated) + auctionScan:SetScript("OnQueryDone", private.OnQueryDone) + else + local query = auctionScan:NewQuery() + query:SetStr(itemFilter:GetStr(), itemFilter:GetExactOnly()) + query:SetQualityRange(itemFilter:GetMinQuality(), itemFilter:GetMaxQuality()) + query:SetLevelRange(itemFilter:GetMinLevel(), itemFilter:GetMaxLevel()) + query:SetItemLevelRange(itemFilter:GetMinItemLevel(), itemFilter:GetMaxItemLevel()) + query:SetClass(itemFilter:GetClass(), itemFilter:GetSubClass(), itemFilter:GetInvSlotId()) + query:SetUsable(itemFilter:GetUsableOnly()) + query:SetUncollected(itemFilter:GetUncollected()) + query:SetUpgrades(itemFilter:GetUpgrades()) + query:SetPriceRange(itemFilter:GetMinPrice(), itemFilter:GetMaxPrice()) + query:SetItems(itemFilter:GetItem()) + query:SetCanLearn(itemFilter:GetCanLearn()) + query:SetUnlearned(itemFilter:GetUnlearned()) + private.generalMaxQuantity[query] = itemFilter:GetMaxQuantity() + end + end + end + if not private.isSpecial then + TSM.Shopping.SavedSearches.RecordFilterSearch(filterStr) + end + end + + -- run the scan + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) + end + return true +end + + + +-- ============================================================================ +-- FilterSearchContext Class +-- ============================================================================ + +function FilterSearchContext.GetMaxCanBuy(self, itemString) + local targetItemString = private.targetItem[itemString] + local maxNum = nil + local itemQuery = private.GetMaxQuantityQuery(targetItemString or itemString) + if itemQuery then + maxNum = private.generalMaxQuantity[itemQuery] + if targetItemString then + local rate, chunkSize = private.GetTargetItemRate(targetItemString, itemString) + maxNum = Math.Ceil(maxNum / rate, chunkSize) + end + end + return maxNum +end + +function FilterSearchContext.OnBuy(self, itemString, quantity) + local targetItemString = private.targetItem[itemString] + if targetItemString then + quantity = quantity * private.GetTargetItemRate(targetItemString, itemString) + itemString = targetItemString + end + self.__super:OnBuy(itemString, quantity) + + local itemQuery = private.GetMaxQuantityQuery(itemString) + if itemQuery then + private.generalMaxQuantity[itemQuery] = private.generalMaxQuantity[itemQuery] - quantity + if private.generalMaxQuantity[itemQuery] <= 0 then + itemQuery:WipeBrowseResults() + for query, maxQuantity in pairs(private.generalMaxQuantity) do + if maxQuantity == itemQuery then + query:WipeBrowseResults() + end + end + end + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ValidateFilterStr(filterStr, mode) + assert(mode == "NORMAL" or mode == "CRAFTING" or mode == "DISENCHANT") + filterStr = strtrim(filterStr) + if mode == "NORMAL" and not TSM.IsWowClassic() and filterStr == "" then + return filterStr + end + local isValid, errMsg = true, nil + local filters = TempTable.Acquire() + for filter in String.SplitIterator(filterStr, ";") do + filter = strtrim(filter) + if isValid and gsub(filter, "/", "") ~= "" then + local filterIsValid, filterErrMsg = private.itemFilter:ParseStr(filter) + if filterIsValid then + local str = private.itemFilter:GetStr() + if mode == "CRAFTING" and not strfind(strlower(filter), "/crafting") and str then + filter = filter.."/crafting" + elseif mode == "DISENCHANT" and not strfind(strlower(filter), "/disenchant") and str then + filter = filter.."/disenchant" + end + if strfind(strlower(filter), "/crafting") then + local craftingTargetItem = str and Conversions.GetTargetItemByName(str) or nil + if not craftingTargetItem or not Conversions.GetSourceItems(craftingTargetItem) then + isValid = false + errMsg = errMsg or L["The specified item is not supported for crafting searches."] + end + end + if strfind(strlower(filter), "/disenchant") then + local targetItemString = str and Conversions.GetTargetItemByName(str) or nil + if not DisenchantInfo.IsTargetItem(targetItemString) then + isValid = false + errMsg = errMsg or L["The specified item is not supported for disenchant searches."] + end + end + else + isValid = false + errMsg = errMsg or filterErrMsg + end + else + isValid = false + end + if isValid then + tinsert(filters, filter) + end + end + local result = table.concat(filters, ";") + TempTable.Release(filters) + result = isValid and result ~= "" and result or nil + errMsg = errMsg or L["The specified filter was empty."] + return result, errMsg +end + +function private.MarketValueFunction(subRow) + local baseItemString = subRow:GetBaseItemString() + local itemString = subRow:GetItemString() + if next(private.targetItem) then + local targetItemString = private.targetItem[itemString] + if not itemString or not targetItemString then + return nil + end + local targetItemRate = private.GetTargetItemRate(targetItemString, itemString) + return Math.Round(targetItemRate * CustomPrice.GetValue(private.marketValueSource, targetItemString)) + else + return CustomPrice.GetValue(private.marketValueSource, itemString or baseItemString) + end +end + +function private.GetTargetItemRate(targetItemString, itemString) + if itemString == targetItemString then + return 1, 1 + end + if DisenchantInfo.IsTargetItem(targetItemString) then + local classId = ItemInfo.GetClassId(itemString) + local ilvl = ItemInfo.GetItemLevel(ItemString.GetBaseFast(itemString)) + local quality = ItemInfo.GetQuality(itemString) + local amountOfMats = DisenchantInfo.GetTargetItemSourceInfo(targetItemString, classId, quality, ilvl) + if amountOfMats then + return amountOfMats, 1 + end + end + local conversionInfo = Conversions.GetSourceItems(targetItemString) + local conversionChunkSize = 1 + for _ in Conversions.TargetItemsByMethodIterator(itemString, Conversions.METHOD.MILL) do + conversionChunkSize = 5 + end + for _ in Conversions.TargetItemsByMethodIterator(itemString, Conversions.METHOD.PROSPECT) do + conversionChunkSize = 5 + end + return conversionInfo and conversionInfo[itemString] or 0, conversionChunkSize +end + +function private.TargetItemQueryFilter(query, row) + local itemString = row:GetItemString() + local targetItemString = private.targetItem[itemString] or private.targetItem[query] + return itemString and targetItemString and private.GetTargetItemRate(targetItemString, itemString) == 0 +end + +function private.ResultsUpdated(_, query) + local targetItemString = private.targetItem[query] + if not targetItemString then + return + end + + -- populate the targetItem table for each item in the results + for _, row in query:BrowseResultsIterator() do + if row:HasItemInfo() then + for _, subRow in row:SubRowIterator() do + local itemString = subRow:GetItemString() + if itemString then + private.targetItem[itemString] = targetItemString + end + end + end + end +end + +function private.OnQueryDone(_, query) + private.ResultsUpdated(nil, query) + private.targetItem[query] = nil +end + +function private.GetMaxQuantityQuery(itemString) + if not next(private.generalMaxQuantity) then + return + end + + -- find the query this item belongs to + local itemQuery = nil + for query, value in pairs(private.generalMaxQuantity) do + local containsItem = false + for _ in query:ItemSubRowIterator(itemString) do + containsItem = true + end + if containsItem then + -- resolve any potential redirection to the base query + itemQuery = type(value) == "number" and query or value + break + end + end + if not itemQuery or not private.generalMaxQuantity[itemQuery] then + return + end + return itemQuery +end diff --git a/Core/Service/Shopping/GreatDealsSearch.lua b/Core/Service/Shopping/GreatDealsSearch.lua new file mode 100644 index 0000000..97bf261 --- /dev/null +++ b/Core/Service/Shopping/GreatDealsSearch.lua @@ -0,0 +1,45 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local GreatDealsSearch = TSM.Shopping:NewPackage("GreatDealsSearch") +local Vararg = TSM.Include("Util.Vararg") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + filter = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function GreatDealsSearch.OnEnable() + local appData = TSMAPI.AppHelper and TSMAPI.AppHelper:FetchData("SHOPPING_SEARCHES") + if not appData then + return + end + for _, info in pairs(appData) do + local realmName, data = unpack(info) + if TSMAPI.AppHelper:IsCurrentRealm(realmName) then + private.filter = assert(loadstring(data))().greatDeals + if private.filter == "" then + break + end + -- populate item info cache + for _, item in Vararg.Iterator(strsplit(";", private.filter)) do + item = strsplit("/", item) + ItemInfo.FetchInfo(item) + end + break + end + end +end + +function GreatDealsSearch.GetFilter() + return private.filter +end diff --git a/Core/Service/Shopping/GroupSearch.lua b/Core/Service/Shopping/GroupSearch.lua new file mode 100644 index 0000000..c1f4af1 --- /dev/null +++ b/Core/Service/Shopping/GroupSearch.lua @@ -0,0 +1,172 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local GroupSearch = TSM.Shopping:NewPackage("GroupSearch") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local TempTable = TSM.Include("Util.TempTable") +local ItemString = TSM.Include("Util.ItemString") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local GroupSearchContext = TSM.Include("LibTSMClass").DefineClass("GroupSearchContext", TSM.Shopping.ShoppingSearchContext) +local private = { + groups = {}, + itemList = {}, + maxQuantity = {}, + scanThreadId = nil, + seenMaxPrice = {}, + searchContext = nil, + queries = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function GroupSearch.OnInitialize() + -- initialize thread + private.scanThreadId = Threading.New("GROUP_SEARCH", private.ScanThread) + private.searchContext = GroupSearchContext(private.scanThreadId, private.MarketValueFunction) +end + +function GroupSearch.GetSearchContext(groupList) + return private.searchContext:SetScanContext(L["Group Search"], groupList, nil, L["Max Price"]) +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan, groupList) + wipe(private.seenMaxPrice) + + -- create the list of items, and add filters for them + wipe(private.itemList) + wipe(private.maxQuantity) + wipe(private.queries) + for _, groupPath in ipairs(groupList) do + private.groups[groupPath] = true + for _, itemString in TSM.Groups.ItemIterator(groupPath) do + local isValid, maxQuantityOrErr = TSM.Operations.Shopping.ValidAndGetRestockQuantity(itemString) + if isValid then + private.maxQuantity[itemString] = maxQuantityOrErr + tinsert(private.itemList, itemString) + elseif maxQuantityOrErr then + Log.PrintfUser(L["Invalid custom price source for %s. %s"], ItemInfo.GetLink(itemString), maxQuantityOrErr) + end + end + end + if #private.itemList == 0 then + return false + end + + auctionScan:AddItemListQueriesThreaded(private.itemList) + for _, query in auctionScan:QueryIterator() do + query:SetIsBrowseDoneFunction(private.QueryIsBrowseDoneFunction) + query:AddCustomFilter(private.QueryFilter) + tinsert(private.queries, query) + end + + -- run the scan + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) + end + return true +end + + + +-- ============================================================================ +-- GroupSearchContext Class +-- ============================================================================ + +function GroupSearchContext.GetMaxCanBuy(self, itemString) + return private.maxQuantity[itemString] +end + +function GroupSearchContext.OnBuy(self, itemString, quantity) + self.__super:OnBuy(itemString, quantity) + if not private.maxQuantity[itemString] then + return + end + + private.maxQuantity[itemString] = private.maxQuantity[itemString] - quantity + if private.maxQuantity[itemString] <= 0 then + private.maxQuantity[itemString] = nil + local toRemove = TempTable.Acquire() + for _, query in ipairs(private.queries) do + for _, row in query:BrowseResultsIterator() do + if row:HasItemInfo() then + for _, subRow in row:SubRowIterator() do + if subRow:GetItemString() == itemString then + tinsert(toRemove, subRow) + end + end + for _, subRow in ipairs(toRemove) do + row:RemoveSubRow(subRow) + end + wipe(toRemove) + end + end + end + TempTable.Release(toRemove) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.QueryIsBrowseDoneFunction(query) + local isDone = true + for itemString in query:ItemIterator() do + if TSM.Operations.Shopping.ShouldShowAboveMaxPrice(itemString) then + -- need to scan all the auctions + isDone = false + elseif not private.seenMaxPrice[itemString] then + -- we haven't seen any auctions above the max price, so need to keep scanning + isDone = false + end + end + return isDone +end + +function private.QueryFilter(query, row) + local baseItemString = row:GetBaseItemString() + local itemString = row:GetItemString() + local _, itemBuyout, minItemBuyout = row:GetBuyouts() + itemBuyout = itemBuyout or minItemBuyout + if not itemBuyout then + return false + elseif itemBuyout == 0 then + return true + end + if itemString then + local isFiltered, aboveMax = TSM.Operations.Shopping.IsFiltered(itemString, itemBuyout) + private.seenMaxPrice[itemString] = private.seenMaxPrice[itemString] or aboveMax + return isFiltered + else + local allFiltered = true + for queryItemString in query:ItemIterator() do + if ItemString.GetBaseFast(queryItemString) == baseItemString and not TSM.Operations.Shopping.IsFiltered(queryItemString, itemBuyout) then + allFiltered = false + end + end + return allFiltered + end +end + +function private.MarketValueFunction(row) + local itemString = row:GetItemString() + return itemString and TSM.Operations.Shopping.GetMaxPrice(itemString) or nil +end diff --git a/Core/Service/Shopping/SavedSearches.lua b/Core/Service/Shopping/SavedSearches.lua new file mode 100644 index 0000000..049499e --- /dev/null +++ b/Core/Service/Shopping/SavedSearches.lua @@ -0,0 +1,143 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local SavedSearches = TSM.Shopping:NewPackage("SavedSearches") +local Log = TSM.Include("Util.Log") +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local Settings = TSM.Include("Service.Settings") +local private = { + settings = nil, + db = nil, +} +local MAX_RECENT_SEARCHES = 2000 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function SavedSearches.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "userData", "savedShoppingSearches") + + -- remove duplicates + local seen = TempTable.Acquire() + for i = #private.settings.savedShoppingSearches.filters, 1, -1 do + local filter = private.settings.savedShoppingSearches.filters[i] + local filterLower = strlower(private.settings.savedShoppingSearches.filters[i]) + if seen[filterLower] then + tremove(private.settings.savedShoppingSearches.filters, i) + private.settings.savedShoppingSearches.name[filter] = nil + private.settings.savedShoppingSearches.isFavorite[filter] = nil + else + seen[filterLower] = true + end + end + TempTable.Release(seen) + + -- remove old recent searches + local remainingRecentSearches = MAX_RECENT_SEARCHES + local numRemoved = 0 + for i = #private.settings.savedShoppingSearches.filters, 1, -1 do + local filter = private.settings.savedShoppingSearches.filters + if not private.settings.savedShoppingSearches.isFavorite[filter] then + if remainingRecentSearches > 0 then + remainingRecentSearches = remainingRecentSearches - 1 + else + tremove(private.settings.savedShoppingSearches.filters, i) + private.settings.savedShoppingSearches.name[filter] = nil + numRemoved = numRemoved + 1 + end + end + end + if numRemoved > 0 then + Log.Info("Removed %d old recent searches", numRemoved) + end + + private.db = Database.NewSchema("SHOPPING_SAVED_SEARCHES") + :AddUniqueNumberField("index") + :AddStringField("name") + :AddBooleanField("isFavorite") + :AddStringField("filter") + :AddIndex("index") + :AddIndex("name") + :Commit() + private.RebuildDB() +end + +function SavedSearches.CreateRecentSearchesQuery() + return private.db:NewQuery() + :OrderBy("index", false) +end + +function SavedSearches.CreateFavoriteSearchesQuery() + return private.db:NewQuery() + :Equal("isFavorite", true) + :OrderBy("name", true) +end + +function SavedSearches.SetSearchIsFavorite(dbRow, isFavorite) + local filter = dbRow:GetField("filter") + private.settings.savedShoppingSearches.isFavorite[filter] = isFavorite or nil + dbRow:SetField("isFavorite", isFavorite) + :Update() +end + +function SavedSearches.RenameSearch(dbRow, newName) + local filter = dbRow:GetField("filter") + private.settings.savedShoppingSearches.name[filter] = newName ~= filter and newName or nil + dbRow:SetField("name", newName) + :Update() +end + +function SavedSearches.DeleteSearch(dbRow) + local index, filter = dbRow:GetFields("index", "filter") + tremove(private.settings.savedShoppingSearches.filters, index) + private.settings.savedShoppingSearches.name[filter] = nil + private.settings.savedShoppingSearches.isFavorite[filter] = nil + private.RebuildDB() +end + +function SavedSearches.RecordFilterSearch(filter) + for i, existingFilter in ipairs(private.settings.savedShoppingSearches.filters) do + if strlower(existingFilter) == strlower(filter) then + -- move this to the end of the list and rebuild the DB + -- insert the existing filter so we don't need to update the isFavorite and name tables + tremove(private.settings.savedShoppingSearches.filters, i) + tinsert(private.settings.savedShoppingSearches.filters, existingFilter) + private.RebuildDB() + return + end + end + + -- didn't find an existing entry, so add a new one + tinsert(private.settings.savedShoppingSearches.filters, filter) + private.db:NewRow() + :SetField("index", #private.settings.savedShoppingSearches.filters) + :SetField("name", filter) + :SetField("isFavorite", false) + :SetField("filter", filter) + :Create() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RebuildDB() + private.db:TruncateAndBulkInsertStart() + for index, filter in ipairs(private.settings.savedShoppingSearches.filters) do + local name = private.settings.savedShoppingSearches.name[filter] or filter + local isFavorite = private.settings.savedShoppingSearches.isFavorite[filter] and true or false + private.db:BulkInsertNewRow(index, name, isFavorite, filter) + end + private.db:BulkInsertEnd() +end diff --git a/Core/Service/Shopping/SearchCommon.lua b/Core/Service/Shopping/SearchCommon.lua new file mode 100644 index 0000000..28eb72c --- /dev/null +++ b/Core/Service/Shopping/SearchCommon.lua @@ -0,0 +1,77 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local SearchCommon = TSM.Shopping:NewPackage("SearchCommon") +local Delay = TSM.Include("Util.Delay") +local Threading = TSM.Include("Service.Threading") +local private = { + findThreadId = nil, + callback = nil, + isRunning = false, + pendingStartArgs = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function SearchCommon.OnInitialize() + -- initialize threads + private.findThreadId = Threading.New("FIND_SEARCH", private.FindThread) + Threading.SetCallback(private.findThreadId, private.ThreadCallback) +end + +function SearchCommon.StartFindAuction(auctionScan, auction, callback, noSeller) + wipe(private.pendingStartArgs) + private.pendingStartArgs.auctionScan = auctionScan + private.pendingStartArgs.auction = auction + private.pendingStartArgs.callback = callback + private.pendingStartArgs.noSeller = noSeller + Delay.AfterTime("SEARCH_COMMON_THREAD_START", 0, private.StartThread) +end + +function SearchCommon.StopFindAuction(noKill) + wipe(private.pendingStartArgs) + private.callback = nil + if not noKill then + Threading.Kill(private.findThreadId) + end +end + + + +-- ============================================================================ +-- Find Thread +-- ============================================================================ + + +function private.FindThread(auctionScan, row, noSeller) + return auctionScan:FindAuctionThreaded(row, noSeller) +end + +function private.StartThread() + if not private.pendingStartArgs.auctionScan then + return + end + if private.isRunning then + Delay.AfterTime("SEARCH_COMMON_THREAD_START", 0.1, private.StartThread) + return + end + private.isRunning = true + private.callback = private.pendingStartArgs.callback + Threading.Start(private.findThreadId, private.pendingStartArgs.auctionScan, private.pendingStartArgs.auction, private.pendingStartArgs.noSeller) + wipe(private.pendingStartArgs) +end + +function private.ThreadCallback(...) + private.isRunning = false + if private.callback then + private.callback(...) + end +end diff --git a/Core/Service/Shopping/VendorSearch.lua b/Core/Service/Shopping/VendorSearch.lua new file mode 100644 index 0000000..70e4785 --- /dev/null +++ b/Core/Service/Shopping/VendorSearch.lua @@ -0,0 +1,83 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local VendorSearch = TSM.Shopping:NewPackage("VendorSearch") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + itemList = {}, + scanThreadId = nil, + searchContext = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function VendorSearch.OnInitialize() + -- initialize thread + private.scanThreadId = Threading.New("VENDOR_SEARCH", private.ScanThread) + private.searchContext = TSM.Shopping.ShoppingSearchContext(private.scanThreadId, private.MarketValueFunction) +end + +function VendorSearch.GetSearchContext() + return private.searchContext:SetScanContext(L["Vendor Search"], nil, nil, L["Vendor Sell Price"]) +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan) + if (TSM.AuctionDB.GetLastCompleteScanTime() or 0) < time() - 60 * 60 * 12 then + Log.PrintUser(L["No recent AuctionDB scan data found."]) + return false + end + + -- create the list of items + wipe(private.itemList) + for _, itemString, _, minBuyout in TSM.AuctionDB.LastScanIteratorThreaded() do + local vendorSell = ItemInfo.GetVendorSell(itemString) or 0 + if vendorSell and minBuyout and minBuyout < vendorSell then + tinsert(private.itemList, itemString) + end + Threading.Yield() + end + + -- run the scan + auctionScan:AddItemListQueriesThreaded(private.itemList) + for _, query in auctionScan:QueryIterator() do + query:AddCustomFilter(private.QueryFilter) + end + if not auctionScan:ScanQueriesThreaded() then + Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) + end + return true +end + +function private.QueryFilter(_, row) + local itemString = row:GetItemString() + if not itemString then + return false + end + local _, itemBuyout = row:GetBuyouts() + if not itemBuyout then + return false + end + local vendorSell = ItemInfo.GetVendorSell(itemString) + return not vendorSell or itemBuyout == 0 or itemBuyout >= vendorSell +end + +function private.MarketValueFunction(row) + return ItemInfo.GetVendorSell(row:GetItemString() or row:GetBaseItemString()) +end diff --git a/Core/Service/Sniper/BidSearch.lua b/Core/Service/Sniper/BidSearch.lua new file mode 100644 index 0000000..503305a --- /dev/null +++ b/Core/Service/Sniper/BidSearch.lua @@ -0,0 +1,71 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local BidSearch = TSM.Sniper:NewPackage("BidSearch") +local Threading = TSM.Include("Service.Threading") +local private = { + scanThreadId = nil, + searchContext = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function BidSearch.OnInitialize() + private.scanThreadId = Threading.New("SNIPER_BID_SEARCH", private.ScanThread) + private.searchContext = TSM.Sniper.SniperSearchContext(private.scanThreadId, private.MarketValueFunction, "BID") +end + +function BidSearch.GetSearchContext() + assert(TSM.IsWowClassic()) + return private.searchContext +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan) + assert(TSM.IsWowClassic()) + local numQueries = auctionScan:GetNumQueries() + if numQueries == 0 then + auctionScan:NewQuery() + :AddCustomFilter(private.QueryFilter) + :SetPage("FIRST") + else + assert(numQueries == 1) + end + -- don't care if the scan fails for sniper since it's rerun constantly + auctionScan:ScanQueriesThreaded() + return true +end + +function private.QueryFilter(_, subRow) + local itemString = subRow:GetItemString() + if not itemString or not subRow:IsSubRow() or not subRow:HasRawData() then + -- can only filter complete subRows + return false + end + local maxPrice = TSM.Operations.Sniper.GetBelowPrice(itemString) or nil + if not maxPrice then + -- no Shopping operation applies to this item, so filter it out + return true + end + + local _, itemDisplayedBid = subRow:GetDisplayedBids() + return itemDisplayedBid > maxPrice +end + +function private.MarketValueFunction(row) + local itemString = row:GetItemString() + return itemString and TSM.Operations.Sniper.GetBelowPrice(itemString) or nil +end diff --git a/Core/Service/Sniper/BuyoutSearch.lua b/Core/Service/Sniper/BuyoutSearch.lua new file mode 100644 index 0000000..0e613cd --- /dev/null +++ b/Core/Service/Sniper/BuyoutSearch.lua @@ -0,0 +1,111 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local BuyoutSearch = TSM.Sniper:NewPackage("BuyoutSearch") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + scanThreadId = nil, + searchContext = nil, + itemList = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function BuyoutSearch.OnInitialize() + private.scanThreadId = Threading.New("SNIPER_BUYOUT_SEARCH", private.ScanThread) + private.searchContext = TSM.Sniper.SniperSearchContext(private.scanThreadId, private.MarketValueFunction, "BUYOUT") +end + +function BuyoutSearch.GetSearchContext() + return private.searchContext +end + + + +-- ============================================================================ +-- Scan Thread +-- ============================================================================ + +function private.ScanThread(auctionScan) + local numQueries = auctionScan:GetNumQueries() + if numQueries == 0 then + if TSM.IsWowClassic() then + auctionScan:NewQuery() + :AddCustomFilter(private.QueryFilter) + :SetPage("LAST") + else + wipe(private.itemList) + if not TSM.Sniper.PopulateItemList(private.itemList) then + -- scan the entire AH + auctionScan:NewQuery() + :AddCustomFilter(private.QueryFilter) + elseif #private.itemList == 0 then + Log.PrintUser(L["Failed to start sniper. No groups have a Sniper operation applied."]) + return false + else + -- scan for the list of items + auctionScan:AddItemListQueriesThreaded(private.itemList) + for _, query in auctionScan:QueryIterator() do + query:AddCustomFilter(private.QueryFilter) + end + end + end + end + -- don't care if the scan fails for sniper since it's rerun constantly + auctionScan:ScanQueriesThreaded() + return true +end + +function private.QueryFilter(_, subRow) + local baseItemString = subRow:GetBaseItemString() + local itemString = subRow:GetItemString() + local maxPrice = itemString and TSM.Operations.Sniper.GetBelowPrice(itemString) or nil + if itemString and not maxPrice then + -- no Shopping operation applies to this item, so filter it out + return true + end + local auctionBuyout, itemBuyout, minItemBuyout = subRow:GetBuyouts() + itemBuyout = itemBuyout or minItemBuyout + if not itemBuyout then + -- don't have buyout info yet, so don't filter + return false + elseif auctionBuyout == 0 then + -- no buyout, so filter it out + return true + elseif itemString then + -- filter if the buyout is too high + return itemBuyout > maxPrice + elseif not ItemInfo.CanHaveVariations(baseItemString) then + -- check the buyout against the base item + return itemBuyout > (TSM.Operations.Sniper.GetBelowPrice(baseItemString) or 0) + end + + -- check if any variant of this item is in a group and could potentially be worth scnaning + local hasPotentialItem = false + for _, groupItemString in TSM.Groups.ItemByBaseItemStringIterator(baseItemString) do + hasPotentialItem = hasPotentialItem or itemBuyout < (TSM.Operations.Sniper.GetBelowPrice(groupItemString) or 0) + end + if hasPotentialItem then + return false + elseif not TSM.Operations.Sniper.HasOperation(baseItemString) then + -- no potential other variants we care about + return true + end + return false +end + +function private.MarketValueFunction(row) + local itemString = row:GetItemString() + return itemString and TSM.Operations.Sniper.GetBelowPrice(itemString) or nil +end diff --git a/Core/Service/Sniper/Core.lua b/Core/Service/Sniper/Core.lua new file mode 100644 index 0000000..fd66ac3 --- /dev/null +++ b/Core/Service/Sniper/Core.lua @@ -0,0 +1,77 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sniper = TSM:NewPackage("Sniper") +local Threading = TSM.Include("Service.Threading") +local SniperSearchContext = TSM.Include("LibTSMClass").DefineClass("SniperSearchContext") +TSM.Sniper.SniperSearchContext = SniperSearchContext + + + +-- ============================================================================ +-- Module Methods +-- ============================================================================ + +function Sniper.PopulateItemList(itemList) + local baseHasOperation = false + for _ in TSM.Operations.GroupOperationIterator("Sniper", TSM.CONST.ROOT_GROUP_PATH) do + baseHasOperation = true + end + if baseHasOperation and TSM.IsWowClassic() then + return false + end + + -- add all the items from groups with Sniper operations + for _, groupPath in TSM.Groups.GroupIterator() do + local hasOperations = false + for _ in TSM.Operations.GroupOperationIterator("Sniper", groupPath) do + hasOperations = true + end + if hasOperations then + for _, itemString in TSM.Groups.ItemIterator(groupPath) do + if TSM.Operations.Sniper.IsOperationValid(itemString) then + tinsert(itemList, itemString) + end + end + end + end + return true +end + + + +-- ============================================================================ +-- SniperSearchContext - Public Class Methods +-- ============================================================================ + +function SniperSearchContext.__init(self, threadId, marketValueFunc, scanType) + assert(threadId and marketValueFunc and (scanType == "BUYOUT" or scanType == "BID")) + self._threadId = threadId + self._marketValueFunc = marketValueFunc + self._scanType = scanType +end + +function SniperSearchContext.StartThread(self, callback, auctionScan) + Threading.SetCallback(self._threadId, callback) + Threading.Start(self._threadId, auctionScan) +end + +function SniperSearchContext.KillThread(self) + Threading.Kill(self._threadId) +end + +function SniperSearchContext.GetMarketValueFunc(self) + return self._marketValueFunc +end + +function SniperSearchContext.IsBuyoutScan(self) + return self._scanType == "BUYOUT" +end + +function SniperSearchContext.IsBidScan(self) + return self._scanType == "BID" +end diff --git a/Core/Service/TaskList/Cooldowns.lua b/Core/Service/TaskList/Cooldowns.lua new file mode 100644 index 0000000..1b73425 --- /dev/null +++ b/Core/Service/TaskList/Cooldowns.lua @@ -0,0 +1,116 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Cooldowns = TSM.TaskList:NewPackage("Cooldowns") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local ObjectPool = TSM.Include("Util.ObjectPool") +local Table = TSM.Include("Util.Table") +local String = TSM.Include("Util.String") +local private = { + query = nil, + taskPool = ObjectPool.New("COOLDOWN_TASK", TSM.TaskList.CooldownCraftingTask, 0), + activeTasks = {}, + activeTaskByProfession = {}, + ignoredQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Cooldowns.OnEnable() + TSM.TaskList.RegisterTaskPool(private.ActiveTaskIterator) + private.query = TSM.Crafting.CreateCooldownSpellsQuery() + :Select("profession", "spellId") + :Custom(private.QueryPlayerFilter, UnitName("player")) + :SetUpdateCallback(private.PopulateTasks) + private.ignoredQuery = TSM.Crafting.CreateIgnoredCooldownQuery() + :SetUpdateCallback(private.PopulateTasks) + private.PopulateTasks() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ActiveTaskIterator() + return ipairs(private.activeTasks) +end + +function private.QueryPlayerFilter(row, player) + return String.SeparatedContains(row:GetField("players"), ",", player) +end + +function private.PopulateTasks() + -- clean DB entries with expired times + for spellId, expireTime in pairs(TSM.db.char.internalData.craftingCooldowns) do + if expireTime <= time() then + TSM.db.char.internalData.craftingCooldowns[spellId] = nil + end + end + + -- clear out the existing tasks + for _, task in pairs(private.activeTaskByProfession) do + task:WipeSpellIds() + end + + local minPendingCooldown = math.huge + for _, profession, spellId in private.query:Iterator() do + if TSM.Crafting.IsCooldownIgnored(spellId) then + -- this is ignored + elseif TSM.db.char.internalData.craftingCooldowns[spellId] then + -- this is on CD + minPendingCooldown = min(minPendingCooldown, TSM.db.char.internalData.craftingCooldowns[spellId] - time()) + else + -- this is a new CD task + local task = private.activeTaskByProfession[profession] + if not task then + task = private.taskPool:Get() + task:Acquire(private.RemoveTask, L["Cooldowns"], profession) + private.activeTaskByProfession[profession] = task + end + if not task:HasSpellId(spellId) then + task:AddSpellId(spellId, 1) + end + end + end + + -- update our tasks + wipe(private.activeTasks) + for profession, task in pairs(private.activeTaskByProfession) do + if task:HasSpellIds() then + tinsert(private.activeTasks, task) + task:Update() + else + private.activeTaskByProfession[profession] = nil + task:Release() + private.taskPool:Recycle(task) + end + end + TSM.TaskList.OnTaskUpdated() + + if minPendingCooldown ~= math.huge then + Delay.AfterTime("COOLDOWN_UPDATE", minPendingCooldown, private.PopulateTasks) + else + Delay.Cancel("COOLDOWN_UPDATE") + end +end + +function private.RemoveTask(task) + local profession = task:GetProfession() + assert(Table.RemoveByValue(private.activeTasks, task) == 1) + assert(private.activeTaskByProfession[profession] == task) + private.activeTaskByProfession[profession] = nil + task:Release() + private.taskPool:Recycle(task) + TSM.TaskList.OnTaskUpdated() +end diff --git a/Core/Service/TaskList/Core.lua b/Core/Service/TaskList/Core.lua new file mode 100644 index 0000000..2bb9547 --- /dev/null +++ b/Core/Service/TaskList/Core.lua @@ -0,0 +1,54 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local TaskList = TSM:NewPackage("TaskList") +local TempTable = TSM.Include("Util.TempTable") +local Task = TSM.Include("LibTSMClass").DefineClass("TASK", nil, "ABSTRACT") +TaskList.Task = Task +local private = { + updateCallback = nil, + iterFuncs = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function TaskList.RegisterTaskPool(iterFunc) + tinsert(private.iterFuncs, iterFunc) +end + +function TaskList.SetUpdateCallback(func) + assert(func and not private.updateCallback) + private.updateCallback = func +end + +function TaskList.GetNumTasks() + local num = 0 + for _, iterFunc in ipairs(private.iterFuncs) do + for _ in iterFunc() do + num = num + 1 + end + end + return num +end + +function TaskList.Iterator() + local tasks = TempTable.Acquire() + for _, iterFunc in ipairs(private.iterFuncs) do + for _, task in iterFunc() do + tinsert(tasks, task) + end + end + return TempTable.Iterator(tasks) +end + +function TaskList.OnTaskUpdated() + private.updateCallback() +end diff --git a/Core/Service/TaskList/Expirations.lua b/Core/Service/TaskList/Expirations.lua new file mode 100644 index 0000000..6b57eda --- /dev/null +++ b/Core/Service/TaskList/Expirations.lua @@ -0,0 +1,154 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Expirations = TSM.TaskList:NewPackage("Expirations") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Table = TSM.Include("Util.Table") +local ObjectPool = TSM.Include("Util.ObjectPool") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local MailTracking = TSM.Include("Service.MailTracking") +local private = { + mailTaskPool = ObjectPool.New("EXPIRING_MAIL_TASK", TSM.TaskList.ExpiringMailTask, 0), + auctionTaskPool = ObjectPool.New("EXPIRED_AUCTION_TASK", TSM.TaskList.ExpiredAuctionTask, 0), + activeTasks = {}, + expiringMailTasks = {}, + expiredAuctionTasks = {}, +} +local PLAYER_NAME = UnitName("player") +local DAYS_LEFT_LIMIT = 1 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Expirations.OnEnable() + AuctionTracking.RegisterExpiresCallback(Expirations.Update) + MailTracking.RegisterExpiresCallback(private.UpdateDelayed) + TSM.TaskList.RegisterTaskPool(private.ActiveTaskIterator) + private.PopulateTasks() +end + +function Expirations.Update() + private.PopulateTasks() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateDelayed() + Delay.AfterTime("EXPIRATION_UPDATE_DELAYED", 0.5, private.PopulateTasks) +end + +function private.ActiveTaskIterator() + return ipairs(private.activeTasks) +end + +function private.PopulateTasks() + local minPendingCooldown = math.huge + + wipe(private.activeTasks) + + for _, task in pairs(private.expiringMailTasks) do + task:WipeCharacters() + end + for _, task in pairs(private.expiredAuctionTasks) do + task:WipeCharacters() + end + + -- expiring mails + for k, v in pairs(TSM.db.factionrealm.internalData.expiringMail) do + local task = private.expiringMailTasks["ExpiringMails"] + if not task then + task = private.mailTaskPool:Get() + task:Acquire(private.RemoveMailTask, L["Expirations"]) + private.expiringMailTasks["ExpiringMails"] = task + end + + local expiration = (v - time()) / 24 / 60 / 60 + if expiration <= DAYS_LEFT_LIMIT * -1 then + TSM.db.factionrealm.internalData.expiringMail[PLAYER_NAME] = nil + else + if not task:HasCharacter(k) and expiration <= DAYS_LEFT_LIMIT then + task:AddCharacter(k, expiration) + end + if expiration > 0 and expiration <= DAYS_LEFT_LIMIT then + minPendingCooldown = min(minPendingCooldown, expiration * 24 * 60 * 60) + else + minPendingCooldown = min(minPendingCooldown, (expiration + DAYS_LEFT_LIMIT) * 24 * 60 * 60) + end + end + end + + for character, task in pairs(private.expiringMailTasks) do + if task:HasCharacters() then + tinsert(private.activeTasks, task) + task:Update() + else + private.expiringMailTasks[character] = nil + task:Release() + private.mailTaskPool:Recycle(task) + end + end + + -- expired auctions + for k, v in pairs(TSM.db.factionrealm.internalData.expiringAuction) do + local task = private.expiredAuctionTasks["ExpiredAuctions"] + if not task then + task = private.auctionTaskPool:Get() + task:Acquire(private.RemoveAuctionTask, L["Expirations"]) + private.expiredAuctionTasks["ExpiredAuctions"] = task + end + + local expiration = (v - time()) / 24 / 60 / 60 + if expiration > 0 and expiration <= DAYS_LEFT_LIMIT then + minPendingCooldown = min(minPendingCooldown, expiration * 24 * 60 * 60) + else + if not task:HasCharacter(k) then + task:AddCharacter(k, expiration) + end + end + end + + for character, task in pairs(private.expiredAuctionTasks) do + if task:HasCharacters() then + tinsert(private.activeTasks, task) + task:Update() + else + private.expiredAuctionTasks[character] = nil + task:Release() + private.auctionTaskPool:Recycle(task) + end + end + + TSM.TaskList.OnTaskUpdated() + + if minPendingCooldown ~= math.huge and minPendingCooldown < DAYS_LEFT_LIMIT then + Delay.AfterTime("EXPIRATION_UPDATE", minPendingCooldown, private.PopulateTasks) + else + Delay.Cancel("EXPIRATION_UPDATE") + end +end + +function private.RemoveMailTask(task) + assert(Table.RemoveByValue(private.activeTasks, task) == 1) + task:Release() + private.mailTaskPool:Recycle(task) + TSM.TaskList.OnTaskUpdated() +end + +function private.RemoveAuctionTask(task) + assert(Table.RemoveByValue(private.activeTasks, task) == 1) + task:Release() + private.auctionTaskPool:Recycle(task) + TSM.TaskList.OnTaskUpdated() +end diff --git a/Core/Service/TaskList/Gathering.lua b/Core/Service/TaskList/Gathering.lua new file mode 100644 index 0000000..edbc1e5 --- /dev/null +++ b/Core/Service/TaskList/Gathering.lua @@ -0,0 +1,161 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Gathering = TSM.TaskList:NewPackage("Gathering") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local ObjectPool = TSM.Include("Util.ObjectPool") +local private = { + activeTasks = {}, + query = nil, + sourceTasks = {}, + altTaskPool = ObjectPool.New("GATHERING_ALT_TASK", TSM.TaskList.AltTask, 0), + professionTasks = {}, + prevHash = nil, +} +local ITEM_SOURCES = { + "auction", + "auctionDE", + "auctionCrafting", + "vendor", + "bank", + "guildBank", + "sendMail", + "openMail", +} +local SOURCE_CLASS_CONSTRUCTORS = { + auction = function() return TSM.TaskList.ShoppingTask("NORMAL") end, + auctionDE = function() return TSM.TaskList.ShoppingTask("DISENCHANT") end, + auctionCrafting = function() return TSM.TaskList.ShoppingTask("CRAFTING") end, + vendor = TSM.TaskList.VendoringTask, + bank = function() return TSM.TaskList.BankingTask(false) end, + guildBank = function() return TSM.TaskList.BankingTask(true) end, + sendMail = TSM.TaskList.SendMailTask, + openMail = TSM.TaskList.OpenMailTask, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Gathering.OnInitialize() + for _, source in ipairs(ITEM_SOURCES) do + private.sourceTasks[source] = SOURCE_CLASS_CONSTRUCTORS[source]() + private.sourceTasks[source]:Acquire(private.SourceProfessionTaskDone, L["Gathering"]) + end +end + +function Gathering.OnEnable() + TSM.TaskList.RegisterTaskPool(private.ActiveTaskIterator) + private.query = TSM.Crafting.Gathering.CreateQuery() + :Select("itemString", "sourcesStr") + :GreaterThan("numNeed", 0) + :SetUpdateCallback(private.PopulateTasks) + private.PopulateTasks() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateTasks() + local hash = private.query:Hash() + if hash == private.prevHash then + -- nothing changed + return + end + private.prevHash = hash + + for task in pairs(private.activeTasks) do + if task:__isa(TSM.TaskList.AltTask) then + private.RemoveAltTask(task) + end + end + wipe(private.activeTasks) + for _, task in pairs(private.sourceTasks) do + task:WipeItems() + end + for _, task in pairs(private.professionTasks) do + task:WipeSpellIds() + end + + local alts = TempTable.Acquire() + local sourceInfo = TempTable.Acquire() + for _, itemString, sourcesStr in private.query:Iterator() do + TSM.Crafting.Gathering.SourcesStrToTable(sourcesStr, sourceInfo, alts) + sourceInfo.alt = nil + sourceInfo.altGuildBank = nil + for _, source in ipairs(ITEM_SOURCES) do + if sourceInfo[source] then + private.sourceTasks[source]:AddItem(itemString, sourceInfo[source]) + sourceInfo[source] = nil + end + end + if sourceInfo.craftProfit or sourceInfo.craftNoProfit then + local spellId = TSM.Crafting.GetMostProfitableSpellIdByItem(itemString, TSM.db.factionrealm.gatheringContext.crafter) + assert(spellId) + local profession = TSM.Crafting.GetProfession(spellId) + if not private.professionTasks[profession] then + private.professionTasks[profession] = TSM.TaskList.CraftingTask() + private.professionTasks[profession]:Acquire(private.SourceProfessionTaskDone, L["Gathering"], profession) + end + private.professionTasks[profession]:AddSpellId(spellId, sourceInfo.craftProfit or sourceInfo.craftNoProfit) + sourceInfo.craftProfit = nil + sourceInfo.craftNoProfit = nil + end + -- make sure we processed everything from the sourceInfo table + assert(not next(sourceInfo)) + end + TempTable.Release(sourceInfo) + + for character in pairs(alts) do + local task = private.altTaskPool:Get() + task:Acquire(private.RemoveAltTask, L["Gathering"], character) + private.activeTasks[task] = task + task:Update() + end + TempTable.Release(alts) + + if TSM.db.factionrealm.gatheringContext.crafter ~= "" then + private.sourceTasks.sendMail:SetTarget(TSM.db.factionrealm.gatheringContext.crafter) + end + for _, task in pairs(private.sourceTasks) do + if task:HasItems() then + private.activeTasks[task] = task + task:Update() + end + end + for _, task in pairs(private.professionTasks) do + if task:HasSpellIds() then + private.activeTasks[task] = task + task:Update() + end + end + + TSM.TaskList.OnTaskUpdated() +end + +function private.ActiveTaskIterator() + return pairs(private.activeTasks) +end + +function private.RemoveAltTask(task) + assert(private.activeTasks[task]) + private.activeTasks[task] = nil + task:Release() + private.altTaskPool:Recycle(task) +end + +function private.SourceProfessionTaskDone(task) + assert(private.activeTasks[task]) + private.activeTasks[task] = nil + TSM.TaskList.OnTaskUpdated() +end diff --git a/Core/Service/TaskList/Tasks/AltTask.lua b/Core/Service/TaskList/Tasks/AltTask.lua new file mode 100644 index 0000000..a1eb5f1 --- /dev/null +++ b/Core/Service/TaskList/Tasks/AltTask.lua @@ -0,0 +1,44 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local AltTask = TSM.Include("LibTSMClass").DefineClass("AltTask", TSM.TaskList.Task) +local L = TSM.Include("Locale").GetTable() +TSM.TaskList.AltTask = AltTask + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function AltTask.Acquire(self, doneHandler, category, character) + self.__super:Acquire(doneHandler, category, format(L["Switch to %s"], character)) +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function AltTask.IsSecureMacro(self) + return true +end + +function AltTask.GetSecureMacroText(self) + return "/logout" +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function AltTask._UpdateState(self) + return self:_SetButtonState(true, strupper(LOGOUT)) +end diff --git a/Core/Service/TaskList/Tasks/BankingTask.lua b/Core/Service/TaskList/Tasks/BankingTask.lua new file mode 100644 index 0000000..8a7d3b9 --- /dev/null +++ b/Core/Service/TaskList/Tasks/BankingTask.lua @@ -0,0 +1,132 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local BankingTask = TSM.Include("LibTSMClass").DefineClass("BankingTask", TSM.TaskList.ItemTask) +local L = TSM.Include("Locale").GetTable() +local Inventory = TSM.Include("Service.Inventory") +TSM.TaskList.BankingTask = BankingTask +local private = { + registeredCallbacks = false, + currentlyMoving = nil, + activeTasks = {}, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function BankingTask.__init(self, isGuildBank) + self.__super:__init() + self._isMoving = false + self._isGuildBank = isGuildBank + + if not private.registeredCallbacks then + TSM.Banking.RegisterFrameCallback(private.FrameCallback) + private.registeredCallbacks = true + end +end + +function BankingTask.Acquire(self, doneHandler, category) + self.__super:Acquire(doneHandler, category, self._isGuildBank and L["Get from Guild Bank"] or L["Get from Bank"]) + private.activeTasks[self] = true +end + +function BankingTask.Release(self) + self.__super:Release() + self._isMoving = false + private.activeTasks[self] = nil +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function BankingTask.OnButtonClick(self) + private.currentlyMoving = self + self._isMoving = true + TSM.Banking.MoveToBag(self:GetItems(), private.MoveCallback) + self:_UpdateState() + TSM.TaskList.OnTaskUpdated() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function BankingTask._UpdateState(self) + local isOpen = nil + if self._isGuildBank then + isOpen = TSM.Banking.IsGuildBankOpen() + else + isOpen = TSM.Banking.IsBankOpen() + end + if not isOpen then + return self:_SetButtonState(false, L["NOT OPEN"]) + end + local canMove = false + for itemString in pairs(self:GetItems()) do + if self._isGuildBank and Inventory.GetGuildQuantity(itemString) > 0 then + canMove = true + break + elseif not self._isGuildBank and Inventory.GetBankQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString) > 0 then + canMove = true + break + end + end + if self._isMoving then + return self:_SetButtonState(false, L["MOVING"]) + elseif private.currentlyMoving then + return self:_SetButtonState(false, L["BUSY"]) + elseif not canMove then + return self:_SetButtonState(false, L["NO ITEMS"]) + else + return self:_SetButtonState(true, L["MOVE"]) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.FrameCallback() + for task in pairs(private.activeTasks) do + task:Update() + end +end + +function private.MoveCallback(event, ...) + local self = private.currentlyMoving + if not self then + return + end + assert(self._isMoving) + if event == "MOVED" then + local itemString, quantity = ... + if self:_RemoveItem(itemString, quantity) then + TSM.TaskList.OnTaskUpdated() + end + if not private.activeTasks[self] then + -- this task finished + private.currentlyMoving = nil + end + elseif event == "DONE" then + self._isMoving = false + private.currentlyMoving = nil + elseif event == "PROGRESS" then + -- pass + else + error("Unexpected event: "..tostring(event)) + end +end diff --git a/Core/Service/TaskList/Tasks/CooldownCraftingTask.lua b/Core/Service/TaskList/Tasks/CooldownCraftingTask.lua new file mode 100644 index 0000000..d449c93 --- /dev/null +++ b/Core/Service/TaskList/Tasks/CooldownCraftingTask.lua @@ -0,0 +1,94 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CooldownCraftingTask = TSM.Include("LibTSMClass").DefineClass("CooldownCraftingTask", TSM.TaskList.CraftingTask) +local Math = TSM.Include("Util.Math") +TSM.TaskList.CooldownCraftingTask = CooldownCraftingTask +local private = { + registeredCallbacks = false, + activeTasks = {}, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function CooldownCraftingTask.__init(self) + self.__super:__init() + if not private.registeredCallbacks then + TSM.Crafting.CreateIgnoredCooldownQuery() + :SetUpdateCallback(private.UpdateTasks) + private.registeredCallbacks = true + end +end + +function CooldownCraftingTask.Acquire(self, ...) + self.__super:Acquire(...) + private.activeTasks[self] = true +end + +function CooldownCraftingTask.Release(self) + self.__super:Release() + private.activeTasks[self] = nil +end + +function CooldownCraftingTask.CanHideSubTasks(self) + return true +end + +function CooldownCraftingTask.HideSubTask(self, index) + TSM.Crafting.IgnoreCooldown(self._spellIds[index]) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function CooldownCraftingTask._UpdateState(self) + local result = self.__super:_UpdateState() + if not self:HasSpellIds() then + return result + end + for i = #self._spellIds, 1, -1 do + if self:_IsOnCooldown(self._spellIds[i]) or TSM.Crafting.IsCooldownIgnored(self._spellIds[i]) then + self:_RemoveSpellId(self._spellIds[i]) + end + end + if not self:HasSpellIds() then + self:_doneHandler() + return true + end + return result +end + +function CooldownCraftingTask._IsOnCooldown(self, spellId) + assert(not TSM.db.char.internalData.craftingCooldowns[spellId]) + local remainingCooldown = TSM.Crafting.ProfessionUtil.GetRemainingCooldown(spellId) + if remainingCooldown then + TSM.db.char.internalData.craftingCooldowns[spellId] = time() + Math.Round(remainingCooldown) + return true + end + return false +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateTasks() + for task in pairs(private.activeTasks) do + if task:HasSpellIds() then + task:Update() + end + end +end diff --git a/Core/Service/TaskList/Tasks/CraftingTask.lua b/Core/Service/TaskList/Tasks/CraftingTask.lua new file mode 100644 index 0000000..eff5c29 --- /dev/null +++ b/Core/Service/TaskList/Tasks/CraftingTask.lua @@ -0,0 +1,212 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CraftingTask = TSM.Include("LibTSMClass").DefineClass("CraftingTask", TSM.TaskList.Task) +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Log = TSM.Include("Util.Log") +local BagTracking = TSM.Include("Service.BagTracking") +TSM.TaskList.CraftingTask = CraftingTask +local private = { + currentlyCrafting = nil, + registeredCallbacks = false, + activeTasks = {}, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function CraftingTask.__init(self) + self.__super:__init() + self._profession = nil + self._skillId = nil + self._spellIds = {} + self._spellQuantity = {} + + if not private.registeredCallbacks then + TSM.Crafting.ProfessionState.RegisterUpdateCallback(private.UpdateTasks) + TSM.Crafting.ProfessionScanner.RegisterHasScannedCallback(private.UpdateTasks) + BagTracking.RegisterCallback(private.UpdateTasks) + private.registeredCallbacks = true + end +end + +function CraftingTask.Acquire(self, doneHandler, category, profession) + self.__super:Acquire(doneHandler, category, format(L["%s Crafts"], profession)) + self._profession = profession + for _, _, prof, skillId in TSM.Crafting.PlayerProfessions.Iterator() do + if prof == profession and (not self._skillId or (self._skillId == -1 and skillId ~= -1)) then + self._skillId = skillId + end + end + private.activeTasks[self] = true +end + +function CraftingTask.Release(self) + self.__super:Release() + self._profession = nil + self._skillId = nil + wipe(self._spellIds) + wipe(self._spellQuantity) + private.activeTasks[self] = nil +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function CraftingTask.WipeSpellIds(self) + wipe(self._spellIds) + wipe(self._spellQuantity) +end + +function CraftingTask.HasSpellIds(self) + return #self._spellIds > 0 +end + +function CraftingTask.GetProfession(self) + return self._profession +end + +function CraftingTask.HasSpellId(self, spellId) + return self._spellQuantity[spellId] and true or false +end + +function CraftingTask.AddSpellId(self, spellId, quantity) + tinsert(self._spellIds, spellId) + self._spellQuantity[spellId] = quantity +end + +function CraftingTask.OnMouseDown(self) + if self._buttonText == L["CRAFT"] then + local spellId = self._spellIds[1] + local quantity = self._spellQuantity[spellId] + Log.Info("Preparing %d (%d)", spellId, quantity) + TSM.Crafting.ProfessionUtil.PrepareToCraft(spellId, quantity) + end +end + +function CraftingTask.OnButtonClick(self) + if self._buttonText == L["CRAFT"] then + local spellId = self._spellIds[1] + local quantity = self._spellQuantity[spellId] + Log.Info("Crafting %d (%d)", spellId, quantity) + private.currentlyCrafting = self + local numCrafted = TSM.Crafting.ProfessionUtil.Craft(spellId, quantity, true, private.CraftCompleteCallback) + if numCrafted == 0 then + -- we're probably crafting something else already - so just bail + Log.Err("Failed to craft") + private.currentlyCrafting = nil + end + elseif self._buttonText == L["OPEN"] then + TSM.Crafting.ProfessionUtil.OpenProfession(self._profession, self._skillId) + else + error("Invalid state: "..tostring(self._buttonText)) + end + self:Update() +end + +function CraftingTask.HasSubTasks(self) + assert(self:HasSpellIds()) + return true +end + +function CraftingTask.SubTaskIterator(self) + assert(self:HasSpellIds()) + sort(self._spellIds, private.SpellIdSort) + return private.SubTaskIterator, self, 0 +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function CraftingTask._UpdateState(self) + sort(self._spellIds, private.SpellIdSort) + if TSM.Crafting.ProfessionUtil.GetNumCraftableFromDB(self._spellIds[1]) == 0 then + -- don't have the mats to craft this + return self:_SetButtonState(false, L["NEED MATS"]) + elseif self._profession ~= TSM.Crafting.ProfessionState.GetCurrentProfession() then + -- the profession isn't opened + return self:_SetButtonState(true, L["OPEN"]) + elseif not TSM.Crafting.ProfessionScanner.HasScanned() then + -- the profession is opened, but we haven't yet fully scanned it + return self:_SetButtonState(false, strupper(OPENING)) + elseif private.currentlyCrafting == self then + return self:_SetButtonState(false, L["CRAFTING"]) + elseif private.currentlyCrafting then + return self:_SetButtonState(false, L["BUSY"]) + else + -- ready to craft + return self:_SetButtonState(true, L["CRAFT"]) + end +end + +function CraftingTask._RemoveSpellId(self, spellId) + assert(Table.RemoveByValue(self._spellIds, spellId) == 1) + self._spellQuantity[spellId] = nil +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SubTaskIterator(self, index) + index = index + 1 + local spellId = self._spellIds[index] + if not spellId then + return + end + return index, TSM.Crafting.GetName(spellId).." ("..self._spellQuantity[spellId]..")" +end + +function private.CraftCompleteCallback(success, isDone) + local self = private.currentlyCrafting + assert(self) + local spellId = self._spellIds[1] + if isDone then + private.currentlyCrafting = nil + if success then + self:_RemoveSpellId(spellId) + if not self:HasSpellIds() then + self:_doneHandler() + end + end + elseif success then + self._spellQuantity[spellId] = self._spellQuantity[spellId] - 1 + assert(self._spellQuantity[spellId] > 0) + end + if self:HasSpellIds() then + self:Update() + end +end + +function private.UpdateTasks() + for task in pairs(private.activeTasks) do + if task:HasSpellIds() then + task:Update() + end + end +end + +function private.SpellIdSort(a, b) + local aNumCraftable = TSM.Crafting.ProfessionUtil.GetNumCraftableFromDB(a) + local bNumCraftable = TSM.Crafting.ProfessionUtil.GetNumCraftableFromDB(b) + if aNumCraftable == bNumCraftable then + return a < b + end + return aNumCraftable > bNumCraftable +end diff --git a/Core/Service/TaskList/Tasks/ExpiredAuctionTask.lua b/Core/Service/TaskList/Tasks/ExpiredAuctionTask.lua new file mode 100644 index 0000000..6ce54d0 --- /dev/null +++ b/Core/Service/TaskList/Tasks/ExpiredAuctionTask.lua @@ -0,0 +1,124 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ExpiredAuctionTask = TSM.Include("LibTSMClass").DefineClass("ExpiredAuctionTask", TSM.TaskList.Task) +local L = TSM.Include("Locale").GetTable() +TSM.TaskList.ExpiredAuctionTask = ExpiredAuctionTask +local private = {} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function ExpiredAuctionTask.__init(self) + self.__super:__init() + self._characters = {} + self._daysLeft = {} +end + +function ExpiredAuctionTask.Acquire(self, doneHandler, category) + self.__super:Acquire(doneHandler, category, L["Expired Auctions"]) +end + +function ExpiredAuctionTask.Release(self) + self.__super:Release() + wipe(self._characters) + wipe(self._daysLeft) +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ExpiredAuctionTask.IsSecureMacro(self) + return true +end + +function ExpiredAuctionTask.GetSecureMacroText(self) + return "/logout" +end + +function ExpiredAuctionTask.GetDaysLeft(self, character) + return self._daysLeft[character] or false +end + +function ExpiredAuctionTask.WipeCharacters(self) + wipe(self._characters) + wipe(self._daysLeft) +end + +function ExpiredAuctionTask.HasCharacters(self) + return #self._characters > 0 +end + +function ExpiredAuctionTask.HasCharacter(self, character) + return self._daysLeft[character] and true or false +end + +function ExpiredAuctionTask.AddCharacter(self, character, days) + tinsert(self._characters, character) + self._daysLeft[character] = days +end + +function ExpiredAuctionTask.CanHideSubTasks(self) + return true +end + +function ExpiredAuctionTask.HideSubTask(self, index) + local character = self._characters[index] + if not character then + return + end + TSM.db.factionrealm.internalData.expiringAuction[character] = nil + + TSM.TaskList.Expirations.Update() +end + +function ExpiredAuctionTask.HasSubTasks(self) + assert(self:HasCharacters()) + return true +end + +function ExpiredAuctionTask.SubTaskIterator(self) + assert(self:HasCharacters()) + sort(self._characters) + return private.SubTaskIterator, self, 0 +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ExpiredAuctionTask._UpdateState(self) + return self:_SetButtonState(true, strupper(LOGOUT)) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SubTaskIterator(self, index) + index = index + 1 + local character = self._characters[index] + if not character then + return + end + local charColored = character + local classColor = RAID_CLASS_COLORS[TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character), "internalData", "classKey")] + if classColor then + charColored = "|c"..classColor.colorStr..charColored.."|r" + end + return index, charColored +end diff --git a/Core/Service/TaskList/Tasks/ExpiringMailTask.lua b/Core/Service/TaskList/Tasks/ExpiringMailTask.lua new file mode 100644 index 0000000..0d36a2a --- /dev/null +++ b/Core/Service/TaskList/Tasks/ExpiringMailTask.lua @@ -0,0 +1,140 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ExpiringMailTask = TSM.Include("LibTSMClass").DefineClass("ExpiringMailTask", TSM.TaskList.Task) +local L = TSM.Include("Locale").GetTable() +TSM.TaskList.ExpiringMailTask = ExpiringMailTask +local private = {} + + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function ExpiringMailTask.__init(self) + self.__super:__init() + self._characters = {} + self._daysLeft = {} +end + +function ExpiringMailTask.Acquire(self, doneHandler, category) + self.__super:Acquire(doneHandler, category, L["Expiring Mails"]) +end + +function ExpiringMailTask.Release(self) + self.__super:Release() + wipe(self._characters) + wipe(self._daysLeft) +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ExpiringMailTask.IsSecureMacro(self) + return true +end + +function ExpiringMailTask.GetSecureMacroText(self) + return "/logout" +end + +function ExpiringMailTask.GetDaysLeft(self, character) + return self._daysLeft[character] or false +end + +function ExpiringMailTask.WipeCharacters(self) + wipe(self._characters) + wipe(self._daysLeft) +end + +function ExpiringMailTask.HasCharacters(self) + return #self._characters > 0 +end + +function ExpiringMailTask.HasCharacter(self, character) + return self._daysLeft[character] and true or false +end + +function ExpiringMailTask.AddCharacter(self, character, days) + tinsert(self._characters, character) + self._daysLeft[character] = days +end + +function ExpiringMailTask.CanHideSubTasks(self) + return true +end + +function ExpiringMailTask.HideSubTask(self, index) + local character = self._characters[index] + if not character then + return + end + TSM.db.factionrealm.internalData.expiringMail[character] = nil + + TSM.TaskList.Expirations.Update() +end + +function ExpiringMailTask.HasSubTasks(self) + assert(self:HasCharacters()) + return true +end + +function ExpiringMailTask.SubTaskIterator(self) + assert(self:HasCharacters()) + sort(self._characters) + return private.SubTaskIterator, self, 0 +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ExpiringMailTask._UpdateState(self) + return self:_SetButtonState(true, strupper(LOGOUT)) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SubTaskIterator(self, index) + index = index + 1 + local character = self._characters[index] + if not character then + return + end + local timeLeft = self._daysLeft[character] + if timeLeft < 0 then + timeLeft = L["Expired"] + elseif timeLeft >= 1 then + timeLeft = floor(timeLeft).." "..DAYS + else + local hoursLeft = floor(timeLeft * 24) + if hoursLeft > 1 then + timeLeft = hoursLeft.." "..L["Hrs"] + elseif hoursLeft == 1 then + timeLeft = hoursLeft.." "..L["Hr"] + else + timeLeft = floor(hoursLeft / 60).." "..L["Min"] + end + end + local charColored = character + local classColor = RAID_CLASS_COLORS[TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character), "internalData", "classKey")] + if classColor then + charColored = "|c"..classColor.colorStr..charColored.."|r" + end + return index, charColored.." ("..timeLeft..")" +end diff --git a/Core/Service/TaskList/Tasks/ItemTask.lua b/Core/Service/TaskList/Tasks/ItemTask.lua new file mode 100644 index 0000000..b5b5363 --- /dev/null +++ b/Core/Service/TaskList/Tasks/ItemTask.lua @@ -0,0 +1,118 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ItemTask = TSM.Include("LibTSMClass").DefineClass("ItemTask", TSM.TaskList.Task, "ABSTRACT") +local Table = TSM.Include("Util.Table") +local Math = TSM.Include("Util.Math") +local ItemInfo = TSM.Include("Service.ItemInfo") +TSM.TaskList.ItemTask = ItemTask +local private = {} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function ItemTask.__init(self) + self.__super:__init() + self._itemList = {} + self._itemNum = {} +end + +function ItemTask.Release(self) + self.__super:Release() + self:WipeItems() +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ItemTask.WipeItems(self) + wipe(self._itemList) + wipe(self._itemNum) +end + +function ItemTask.AddItem(self, itemString, quantity) + if not self._itemNum[itemString] then + tinsert(self._itemList, itemString) + self._itemNum[itemString] = 0 + end + self._itemNum[itemString] = self._itemNum[itemString] + quantity +end + +function ItemTask.GetItems(self) + return self._itemNum +end + +function ItemTask.HasItems(self) + return next(self._itemNum) and true or false +end + +function ItemTask.HasSubTasks(self) + assert(#self._itemList > 0) + return true +end + +function ItemTask.SubTaskIterator(self) + assert(#self._itemList > 0) + Table.Sort(self._itemList, private.ItemSortHelper) + return private.SubTaskIterator, self, 0 +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ItemTask._RemoveItem(self, itemString, quantity) + if not self._itemNum[itemString] then + return false + end + self._itemNum[itemString] = Math.Round(self._itemNum[itemString] - quantity, 0.01) + if self._itemNum[itemString] <= 0.01 then + self._itemNum[itemString] = nil + assert(Table.RemoveByValue(self._itemList, itemString) == 1) + end + if #self._itemList == 0 then + self:_doneHandler() + end + return true +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SubTaskIterator(self, index) + index = index + 1 + local itemString = self._itemList[index] + if not itemString then + return + end + return index, format("%s (%d)", ItemInfo.GetLink(itemString), self._itemNum[itemString]) +end + +function private.ItemSortHelper(a, b) + local aName = ItemInfo.GetName(a) + local bName = ItemInfo.GetName(b) + if aName == bName then + return a < b + end + if not aName then + return false + elseif not bName then + return true + end + return aName < bName +end diff --git a/Core/Service/TaskList/Tasks/OpenMailTask.lua b/Core/Service/TaskList/Tasks/OpenMailTask.lua new file mode 100644 index 0000000..d395b68 --- /dev/null +++ b/Core/Service/TaskList/Tasks/OpenMailTask.lua @@ -0,0 +1,74 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local OpenMailTask = TSM.Include("LibTSMClass").DefineClass("OpenMailTask", TSM.TaskList.ItemTask) +local L = TSM.Include("Locale").GetTable() +TSM.TaskList.OpenMailTask = OpenMailTask +local private = { + activeTasks = {}, + registeredCallbacks = false, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function OpenMailTask.__init(self) + self.__super:__init() + if not private.registeredCallbacks then + TSM.Mailing.RegisterFrameCallback(private.FrameCallback) + private.registeredCallbacks = true + end +end + +function OpenMailTask.Acquire(self, doneHandler, category) + self.__super:Acquire(doneHandler, category, L["Open Mail"]) + private.activeTasks[self] = true +end + +function OpenMailTask.Release(self) + self.__super:Release() + private.activeTasks[self] = nil +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function OpenMailTask.OnButtonClick(self) + -- TODO +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function OpenMailTask._UpdateState(self) + if not TSM.Mailing.IsOpen() then + return self:_SetButtonState(false, L["NOT OPEN"]) + else + return self:_SetButtonState(false, L["OPEN"]) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.FrameCallback() + for task in pairs(private.activeTasks) do + task:Update() + end +end diff --git a/Core/Service/TaskList/Tasks/SendMailTask.lua b/Core/Service/TaskList/Tasks/SendMailTask.lua new file mode 100644 index 0000000..ac63834 --- /dev/null +++ b/Core/Service/TaskList/Tasks/SendMailTask.lua @@ -0,0 +1,105 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local SendMailTask = TSM.Include("LibTSMClass").DefineClass("SendMailTask", TSM.TaskList.ItemTask) +local L = TSM.Include("Locale").GetTable() +TSM.TaskList.SendMailTask = SendMailTask +local private = { + registeredCallbacks = false, + currentlySending = nil, + activeTasks = {}, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function SendMailTask.__init(self) + self.__super:__init() + self._target = nil + self._isSending = false + if not private.registeredCallbacks then + TSM.Mailing.RegisterFrameCallback(private.FrameCallback) + private.registeredCallbacks = true + end +end + +function SendMailTask.Acquire(self, doneHandler, category) + self.__super:Acquire(doneHandler, category, "") + private.activeTasks[self] = true +end + +function SendMailTask.Release(self) + self.__super:Release() + self._target = nil + self._isSending = false + private.activeTasks[self] = nil +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SendMailTask.SetTarget(self, target) + self._target = target + self._desc = format(L["Mail to %s"], target) +end + +function SendMailTask.OnButtonClick(self) + private.currentlySending = self + self._isSending = true + TSM.Mailing.Send.StartSending(private.SendCallback, self._target, "", "", 0, self:GetItems()) + self:_UpdateState() + TSM.TaskList.OnTaskUpdated() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SendMailTask._UpdateState(self) + if not TSM.Mailing.IsOpen() then + return self:_SetButtonState(false, L["NOT OPEN"]) + elseif self._isSending then + return self:_SetButtonState(false, L["SENDING"]) + elseif private.currentlySending then + return self:_SetButtonState(false, L["BUSY"]) + else + return self:_SetButtonState(true, strupper(L["Send"])) + end +end + + + +-- ============================================================================ +-- Private Helper Methods +-- ============================================================================ + +function private.FrameCallback() + for task in pairs(private.activeTasks) do + task:Update() + end +end + +function private.SendCallback() + local self = private.currentlySending + if not self then + return + end + assert(self._isSending) + self._isSending = false + private.currentlySending = nil + for itemString, quantity in pairs(self:GetItems()) do + self:_RemoveItem(itemString, quantity) + end +end diff --git a/Core/Service/TaskList/Tasks/ShoppingTask.lua b/Core/Service/TaskList/Tasks/ShoppingTask.lua new file mode 100644 index 0000000..4b41154 --- /dev/null +++ b/Core/Service/TaskList/Tasks/ShoppingTask.lua @@ -0,0 +1,141 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ShoppingTask = TSM.Include("LibTSMClass").DefineClass("ShoppingTask", TSM.TaskList.ItemTask) +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Log = TSM.Include("Util.Log") +TSM.TaskList.ShoppingTask = ShoppingTask +local private = { + registeredCallbacks = false, + currentlyScanning = nil, + activeTasks = {}, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function ShoppingTask.__init(self, searchType) + self.__super:__init() + self._isScanning = false + self._isShowingResults = false + assert(searchType == "NORMAL" or searchType == "DISENCHANT" or searchType == "CRAFTING") + self._searchType = searchType + + if not private.registeredCallbacks then + TSM.UI.AuctionUI.RegisterUpdateCallback(private.UIUpdateCallback) + TSM.UI.AuctionUI.Shopping.RegisterUpdateCallback(private.UIUpdateCallback) + private.registeredCallbacks = true + end +end + +function ShoppingTask.Acquire(self, doneHandler, category) + local name = nil + if self._searchType == "NORMAL" then + name = L["Buy from AH"] + elseif self._searchType == "DISENCHANT" then + name = L["Buy from AH (Disenchant)"] + elseif self._searchType == "CRAFTING" then + name = L["Buy from AH (Crafting)"] + else + error("Invalid searchType: "..tostring(self._searchType)) + end + self.__super:Acquire(doneHandler, category, name) + private.activeTasks[self] = true +end + +function ShoppingTask.Release(self) + self.__super:Release() + self._isScanning = false + self._isShowingResults = false + private.activeTasks[self] = nil +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ShoppingTask.OnButtonClick(self) + private.currentlyScanning = self + TSM.UI.AuctionUI.Shopping.StartGatheringSearch(self:GetItems(), private.StateCallback, private.BuyCallback, self._searchType) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ShoppingTask._UpdateState(self) + if not TSM.UI.AuctionUI.Shopping.IsVisible() then + return self:_SetButtonState(false, L["NOT OPEN"]) + elseif self._isScanning then + return self:_SetButtonState(false, L["SCANNING"]) + elseif self._isShowingResults then + return self:_SetButtonState(false, L["BUY"]) + elseif TSM.UI.AuctionUI.IsScanning() or private.currentlyScanning then + return self:_SetButtonState(false, L["AH BUSY"]) + else + return self:_SetButtonState(true, L["SCAN ALL"]) + end +end + +function ShoppingTask._OnSearchStateChanged(self, state) + if state == "SCANNING" then + self._isScanning = true + self._isShowingResults = false + elseif state == "RESULTS" then + self._isScanning = false + self._isShowingResults = true + elseif state == "DONE" then + assert(private.currentlyScanning == self) + private.currentlyScanning = nil + self._isScanning = false + self._isShowingResults = false + else + error("Unexpected state: "..tostring(state)) + end + self:Update() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UIUpdateCallback() + Delay.AfterFrame("SHOPPING_TASK_UPDATE_CALLBACK", 1, private.UIUpdateCallbackDelayed) +end + +function private.UIUpdateCallbackDelayed() + for task in pairs(private.activeTasks) do + task:Update() + end +end + +function private.StateCallback(state) + Log.Info("State changed (%s)", state) + local self = private.currentlyScanning + assert(self) + self:_OnSearchStateChanged(state) + private.UIUpdateCallback() +end + +function private.BuyCallback(itemString, quantity) + Log.Info("Bought item (%s,%d)", itemString, quantity) + local self = private.currentlyScanning + assert(self) + if self:_RemoveItem(itemString, quantity) then + TSM.TaskList.OnTaskUpdated() + end +end diff --git a/Core/Service/TaskList/Tasks/Task.lua b/Core/Service/TaskList/Tasks/Task.lua new file mode 100644 index 0000000..ff274cb --- /dev/null +++ b/Core/Service/TaskList/Tasks/Task.lua @@ -0,0 +1,112 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Task = TSM.Include("LibTSMClass").DefineClass("TASK", nil, "ABSTRACT") +TSM.TaskList.Task = Task + + + +-- ============================================================================ +-- Task - Class Meta Methods +-- ============================================================================ + +function Task.__init(self) + self._category = nil + self._desc = nil + self._buttonEnabled = nil + self._buttonText = nil + self._doneHandler = nil +end + +function Task.Acquire(self, doneHandler, category, desc) + self._doneHandler = doneHandler + self._category = category + self._desc = desc +end + +function Task.Release(self) + self._category = nil + self._desc = nil + self._buttonEnabled = nil + self._buttonText = nil + self._doneHandler = nil +end + + + +-- ============================================================================ +-- Task - Public Methods +-- ============================================================================ + +function Task.GetCategory(self) + return self._category +end + +function Task.GetTaskDesc(self) + return self._desc +end + +function Task.HasSubTasks(self) + return false +end + +function Task.SubTaskIterator(self) + error("Must be implemented by the subclass") +end + +function Task.IsSecureMacro(self) + return false +end + +function Task.GetSecureMacroText(self) + error("Must be implemented by the subclass") +end + +function Task.GetButtonState(self) + return self._buttonEnabled, self._buttonText +end + +function Task.Update(self) + if self:_UpdateState() then + TSM.TaskList.OnTaskUpdated() + end +end + +function Task.OnMouseDown(self) +end + +function Task.OnButtonClick(self) + error("Must be implemented by the subclass") +end + +function Task.CanHideSubTasks(self) + return false +end + +function Task.HideSubTask(self) + error("Must be implemented by the subclass") +end + + + +-- ============================================================================ +-- Task - Private Methods +-- ============================================================================ + +function Task._UpdateState(self) + error("Must be implemented by the subclass") +end + +function Task._SetButtonState(self, buttonEnabled, buttonText) + if buttonEnabled == self._buttonEnabled and buttonText == self._buttonText then + -- nothing changed + return false + end + self._buttonEnabled = buttonEnabled + self._buttonText = buttonText + return true +end diff --git a/Core/Service/TaskList/Tasks/VendoringTask.lua b/Core/Service/TaskList/Tasks/VendoringTask.lua new file mode 100644 index 0000000..e8f19b4 --- /dev/null +++ b/Core/Service/TaskList/Tasks/VendoringTask.lua @@ -0,0 +1,104 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local VendoringTask = TSM.Include("LibTSMClass").DefineClass("VendoringTask", TSM.TaskList.ItemTask) +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +TSM.TaskList.VendoringTask = VendoringTask +local private = { + query = nil, + activeTasks = {}, +} + + + +-- ============================================================================ +-- Class Meta Methods +-- ============================================================================ + +function VendoringTask.__init(self) + self.__super:__init() + + if not private.query then + private.query = TSM.Vendoring.Buy.CreateMerchantQuery() + :SetUpdateCallback(private.QueryUpdateCallback) + end +end + +function VendoringTask.Acquire(self, doneHandler, category) + self.__super:Acquire(doneHandler, category, L["Buy from Vendor"]) + private.activeTasks[self] = true +end + +function VendoringTask.Release(self) + self.__super:Release() + private.activeTasks[self] = nil +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function VendoringTask.OnButtonClick(self) + local itemsToBuy = TempTable.Acquire() + local query = TSM.Vendoring.Buy.CreateMerchantQuery() + :Select("itemString") + for _, itemString in query:Iterator() do + itemsToBuy[itemString] = self:GetItems()[itemString] + end + query:Release() + + local didBuy = false + for itemString, quantity in pairs(itemsToBuy) do + TSM.Vendoring.Buy.BuyItem(itemString, quantity) + self:_RemoveItem(itemString, quantity) + didBuy = true + end + TempTable.Release(itemsToBuy) + + if didBuy then + TSM.TaskList.OnTaskUpdated(self) + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function VendoringTask._UpdateState(self) + if not TSM.UI.VendoringUI.IsVisible() then + return self:_SetButtonState(false, L["NOT OPEN"]) + end + local canBuy = false + for itemString in pairs(self:GetItems()) do + if TSM.Vendoring.Buy.CanBuyItem(itemString) then + canBuy = true + break + end + end + if not canBuy then + return self:_SetButtonState(false, L["NO ITEMS"]) + else + return self:_SetButtonState(true, L["BUY"]) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.QueryUpdateCallback() + for task in pairs(private.activeTasks) do + task:Update() + end +end diff --git a/Core/Service/Tooltip/Accounting.lua b/Core/Service/Tooltip/Accounting.lua new file mode 100644 index 0000000..490c637 --- /dev/null +++ b/Core/Service/Tooltip/Accounting.lua @@ -0,0 +1,153 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Accounting = TSM.Tooltip:NewPackage("Accounting") +local L = TSM.Include("Locale").GetTable() +local Math = TSM.Include("Util.Math") +local ItemString = TSM.Include("Util.ItemString") +local CustomPrice = TSM.Include("Service.CustomPrice") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Accounting.OnInitialize() + TSM.Tooltip.Register(TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM Accounting"]) + :SetSettingsModule("Accounting") + :AddSettingEntry("purchase", true, private.PopulatePurchaseLines) + :AddSettingEntry("sale", true, private.PopulateSaleLines) + :AddSettingEntry("saleRate", false, private.PopulateSaleRateLine) + :AddSettingEntry("expiredAuctions", false, private.PopulateExpireLine) + :AddSettingEntry("cancelledAuctions", false, private.PopulateCancelLine) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateSaleLines(tooltip, itemString) + local showTotals = itemString ~= ItemString.GetPlaceholder() and IsShiftKeyDown() + local avgSalePrice, totalSaleNum, lastSaleTime, minSellPrice, maxSellPrice = nil, nil, nil, nil, nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + avgSalePrice = 20 + totalSaleNum = 5 + lastSaleTime = time() - 60 + minSellPrice = 10 + maxSellPrice = 50 + else + local totalPrice = nil + totalPrice, totalSaleNum = TSM.Accounting.Transactions.GetSaleStats(itemString) + if not totalSaleNum then + return + end + avgSalePrice = totalPrice and Math.Round(totalPrice / totalSaleNum) or nil + lastSaleTime = TSM.Accounting.Transactions.GetLastSaleTime(itemString) + if not showTotals then + minSellPrice = CustomPrice.GetItemPrice(itemString, "MinSell") or 0 + maxSellPrice = CustomPrice.GetItemPrice(itemString, "MaxSell") or 0 + end + end + + if showTotals then + tooltip:AddQuantityValueLine(L["Sold (Total Price)"], totalSaleNum, avgSalePrice * totalSaleNum) + else + assert(minSellPrice and maxSellPrice) + tooltip:AddQuantityValueLine(L["Sold (Min/Avg/Max Price)"], totalSaleNum, minSellPrice, avgSalePrice, maxSellPrice) + end + tooltip:AddTextLine(L["Last Sold"], format(L["%s ago"], SecondsToTime(time() - lastSaleTime))) +end + +function private.PopulateExpireLine(tooltip, itemString) + local expiredNum = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + expiredNum = 2 + else + local lastSaleTime = TSM.Accounting.Transactions.GetLastSaleTime(itemString) + expiredNum = select(2, TSM.Accounting.Auctions.GetStats(itemString, lastSaleTime)) + if expiredNum == 0 then + expiredNum = nil + end + end + if expiredNum then + tooltip:AddTextLine(L["Expired Since Last Sale"], expiredNum) + end +end + +function private.PopulateCancelLine(tooltip, itemString) + local cancelledNum = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + cancelledNum = 2 + else + local lastSaleTime = TSM.Accounting.Transactions.GetLastSaleTime(itemString) + cancelledNum = TSM.Accounting.Auctions.GetStats(itemString, lastSaleTime) + if cancelledNum == 0 then + cancelledNum = nil + end + end + if cancelledNum then + tooltip:AddTextLine(L["Cancelled Since Last Sale"], cancelledNum) + end +end + +function private.PopulateSaleRateLine(tooltip, itemString) + local saleRate = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + saleRate = 0.7 + else + saleRate = CustomPrice.GetItemPrice(itemString, "SaleRate") + if not saleRate then + return + end + end + tooltip:AddTextLine(L["Sale Rate"], saleRate) +end + +function private.PopulatePurchaseLines(tooltip, itemString) + local showTotals = itemString ~= ItemString.GetPlaceholder() and IsShiftKeyDown() + local smartAvgPrice, totalPrice, totalNum, minPrice, maxPrice, lastBuyTime = nil, nil, nil, nil, nil, nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + smartAvgPrice = 25 + totalPrice = 78 + totalNum = 3 + minPrice = 15 + maxPrice = 55 + lastBuyTime = time() - 3600 + else + smartAvgPrice = CustomPrice.GetItemPrice(itemString, "SmartAvgBuy") + totalPrice, totalNum = TSM.Accounting.Transactions.GetBuyStats(itemString, false) + if not totalPrice then + return + end + if not showTotals then + minPrice = CustomPrice.GetItemPrice(itemString, "MinBuy") or 0 + maxPrice = CustomPrice.GetItemPrice(itemString, "MaxBuy") or 0 + end + lastBuyTime = TSM.Accounting.Transactions.GetLastBuyTime(itemString) + end + + + if showTotals then + tooltip:AddQuantityValueLine(L["Purchased (Total Price)"], totalNum, totalPrice) + else + assert(minPrice and maxPrice) + tooltip:AddQuantityValueLine(L["Purchased (Min/Avg/Max Price)"], totalNum, minPrice, Math.Round(totalPrice / totalNum), maxPrice) + end + tooltip:AddValueLine(L["Smart Avg Buy Price"], smartAvgPrice) + tooltip:AddTextLine(L["Last Purchased"], format(L["%s ago"], SecondsToTime(time() - lastBuyTime))) +end diff --git a/Core/Service/Tooltip/AuctionDB.lua b/Core/Service/Tooltip/AuctionDB.lua new file mode 100644 index 0000000..4fb8d95 --- /dev/null +++ b/Core/Service/Tooltip/AuctionDB.lua @@ -0,0 +1,84 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local AuctionDB = TSM.Tooltip:NewPackage("AuctionDB") +local L = TSM.Include("Locale").GetTable() +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local private = {} +local INFO = { + { key = "minBuyout", default = true, label = L["Min Buyout"] }, + { key = "marketValue", default = true, label = L["Market Value"] }, + { key = "historical", default = false, label = L["Historical Price"] }, + { key = "regionMinBuyout", default = false, label = L["Region Min Buyout Avg"] }, + { key = "regionMarketValue", default = true, label = L["Region Market Value Avg"] }, + { key = "regionHistorical", default = false, label = L["Region Historical Price"] }, + { key = "regionSale", default = true, label = L["Region Sale Avg"] }, + { key = "regionSalePercent", default = true, label = L["Region Sale Rate"] }, + { key = "regionSoldPerDay", default = true, label = L["Region Avg Daily Sold"] }, +} +local DATA_OLD_THRESHOLD_SECONDS = 60 * 60 * 3 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function AuctionDB.OnInitialize() + local tooltipInfo = TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM AuctionDB"], private.PopulateRightText) + :SetSettingsModule("AuctionDB") + for _, info in ipairs(INFO) do + tooltipInfo:AddSettingEntry(info.key, info.default, private.PopulateLine, info) + end + TSM.Tooltip.Register(tooltipInfo) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateLine(tooltip, itemString, info) + local value = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + value = 11 + elseif strmatch(info.key, "^region") then + value = TSM.AuctionDB.GetRegionItemData(itemString, info.key) + else + value = TSM.AuctionDB.GetRealmItemData(itemString, info.key) + end + if value then + if info.key == "regionSalePercent" or info.key == "regionSoldPerDay" then + tooltip:AddTextLine(info.label, format("%0.2f", value/100)) + else + tooltip:AddItemValueLine(info.label, value) + end + end +end + +function private.PopulateRightText(tooltip, itemString) + local lastScan, numAuctions = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + lastScan = time() - 120 + numAuctions = 5 + else + lastScan = TSM.AuctionDB.GetRealmItemData(itemString, "lastScan") + numAuctions = TSM.AuctionDB.GetRealmItemData(itemString, "numAuctions") or 0 + end + if lastScan then + local timeColor = (time() - lastScan) > DATA_OLD_THRESHOLD_SECONDS and Theme.GetFeedbackColor("RED") or Theme.GetFeedbackColor("GREEN") + local timeDiff = SecondsToTime(time() - lastScan) + return tooltip:ApplyValueColor(format(L["%d auctions"], numAuctions)).." ("..timeColor:ColorText(format(L["%s ago"], timeDiff))..")" + else + return tooltip:ApplyValueColor(L["Not Scanned"]) + end +end diff --git a/Core/Service/Tooltip/Auctioning.lua b/Core/Service/Tooltip/Auctioning.lua new file mode 100644 index 0000000..71dfd0f --- /dev/null +++ b/Core/Service/Tooltip/Auctioning.lua @@ -0,0 +1,79 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctioning = TSM.Tooltip:NewPackage("Auctioning") +local L = TSM.Include("Locale").GetTable() +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctioning.OnInitialize() + TSM.Tooltip.Register(TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM Auctioning"]) + :SetSettingsModule("Auctioning") + :AddSettingEntry("postQuantity", false, private.PopulatePostQuantityLine) + :AddSettingEntry("operationPrices", false, private.PopulatePricesLine) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulatePostQuantityLine(tooltip, itemString) + local postCap, stackSize = nil, nil + if itemString == ItemString.GetPlaceholder() then + postCap = 5 + stackSize = TSM.IsWowClassic() and 200 + elseif ItemInfo.IsSoulbound(itemString) then + return + else + itemString = TSM.Groups.TranslateItemString(itemString) + local _, operation = TSM.Operations.GetFirstOperationByItem("Auctioning", itemString) + if not operation then + return + end + + postCap = TSM.Auctioning.Util.GetPrice("postCap", operation, itemString) + stackSize = TSM.IsWowClassic() and TSM.Auctioning.Util.GetPrice("stackSize", operation, itemString) + end + if TSM.IsWowClassic() then + tooltip:AddTextLine(L["Post Quantity"], postCap and stackSize and postCap.."x"..stackSize or "---") + else + tooltip:AddTextLine(L["Post Quantity"], postCap or "---") + end +end + +function private.PopulatePricesLine(tooltip, itemString) + local minPrice, normalPrice, maxPrice = nil, nil, nil + if itemString == ItemString.GetPlaceholder() then + minPrice = 20 + normalPrice = 24 + maxPrice = 29 + elseif ItemInfo.IsSoulbound(itemString) then + return + else + itemString = TSM.Groups.TranslateItemString(itemString) + local _, operation = TSM.Operations.GetFirstOperationByItem("Auctioning", itemString) + if not operation then + return + end + + minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operation, itemString) + normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operation, itemString) + maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operation, itemString) + end + tooltip:AddValueLine(L["Min/Normal/Max Prices"], minPrice, normalPrice, maxPrice) +end diff --git a/Core/Service/Tooltip/Core.lua b/Core/Service/Tooltip/Core.lua new file mode 100644 index 0000000..620ec75 --- /dev/null +++ b/Core/Service/Tooltip/Core.lua @@ -0,0 +1,275 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Tooltip = TSM:NewPackage("Tooltip") +local ItemString = TSM.Include("Util.ItemString") +local ObjectPool = TSM.Include("Util.ObjectPool") +local ItemTooltip = TSM.Include("Service.ItemTooltip") +local Settings = TSM.Include("Service.Settings") +local LibTSMClass = TSM.Include("LibTSMClass") +local TooltipInfo = LibTSMClass.DefineClass("TooltipInfo") +local TooltipEntry = LibTSMClass.DefineClass("TooltipEntry") +local private = { + entryObjPool = ObjectPool.New("TOOLTIP_ENTRY", TooltipEntry), + settings = nil, + registeredInfo = {}, + settingsBuilder = nil, +} +local ITER_INDEX_PART_MULTIPLE = 1000 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Tooltip.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "tooltipOptions", "moduleTooltips") + :AddKey("global", "tooltipOptions", "customPriceTooltips") + :AddKey("global", "tooltipOptions", "vendorBuyTooltip") + :AddKey("global", "tooltipOptions", "vendorSellTooltip") + :AddKey("global", "tooltipOptions", "groupNameTooltip") + :AddKey("global", "tooltipOptions", "detailedDestroyTooltip") + :AddKey("global", "tooltipOptions", "millTooltip") + :AddKey("global", "tooltipOptions", "prospectTooltip") + :AddKey("global", "tooltipOptions", "deTooltip") + :AddKey("global", "tooltipOptions", "transformTooltip") + :AddKey("global", "tooltipOptions", "operationTooltips") + :AddKey("global", "tooltipOptions", "inventoryTooltipFormat") + ItemTooltip.SetWrapperPopulateFunction(private.PopulateTooltip) + private.settingsBuilder = ItemTooltip.CreateBuilder() +end + +function Tooltip.CreateInfo() + return TooltipInfo() +end + +function Tooltip.Register(info) + info:_CheckDefaults() + tinsert(private.registeredInfo, info) +end + +function Tooltip.SettingsLineIterator() + assert(not private.settingsBuilder:_Prepare(ItemString.GetPlaceholder(), 1)) + return private.SettingsLineIteratorHelper, nil, 0 +end + + + +-- ============================================================================ +-- TooltipInfo Class +-- ============================================================================ + +function TooltipInfo.__init(self) + self._headingLeft = nil + self._headingRight = nil + self._settingsModule = nil + self._entries = {} +end + +function TooltipInfo.SetHeadings(self, left, right) + self._headingLeft = left + self._headingRight = right + return self +end + +function TooltipInfo.SetSettingsModule(self, settingsModule) + self._settingsModule = settingsModule + return self +end + +function TooltipInfo.AddSettingEntry(self, key, defaultValue, populateFunc, populateArg) + if defaultValue == nil then + defaultValue = private.settings:GetDefaultReadOnly(key) + end + assert(type(defaultValue) == "boolean") + local entry = private.entryObjPool:Get() + entry:_Acquire(self, key, true, false, defaultValue, populateFunc, populateArg) + tinsert(self._entries, entry) + return self +end + +function TooltipInfo.AddSettingValueEntry(self, key, setValue, clearValue, populateFunc, populateArg) + local entry = private.entryObjPool:Get() + entry:_Acquire(self, key, setValue, clearValue, nil, populateFunc, populateArg) + tinsert(self._entries, entry) + return self +end + +function TooltipInfo.DeleteSettingsByKeyMatch(self, matchStr) + for i = #self._entries, 1, -1 do + local entry = self._entries[i] + if entry:KeyMatches(matchStr) then + tremove(self._entries, i) + entry:_Release() + private.entryObjPool:Recycle(entry) + end + end +end + +function TooltipInfo._CheckDefaults(self) + if self._settingsModule and not private.settings.moduleTooltips[self._settingsModule] then + -- populate all the default values + private.settings.moduleTooltips[self._settingsModule] = {} + for _, entry in ipairs(self._entries) do + entry:_ResetSetting() + end + end +end + +function TooltipInfo._GetSettingsTable(self) + if self._settingsModule then + return private.settings.moduleTooltips[self._settingsModule] + else + return private.settings + end +end + +function TooltipInfo._Populate(self, tooltip, itemString) + local headingRightText = self._headingRight and self._headingRight(tooltip, itemString) or nil + tooltip:StartSection(self._headingLeft, headingRightText) + for _, entry in ipairs(self._entries) do + if entry:IsEnabled() then + entry:_Populate(tooltip, itemString) + end + end + tooltip:EndSection() +end + +function TooltipInfo._GetEntry(self, index) + return self._entries[index] +end + + + +-- ============================================================================ +-- TooltipEntry Class +-- ============================================================================ + +function TooltipEntry.__init(self) + self._info = nil + self._settingKey = nil + self._settingSetValue = nil + self._settingClearValue = nil + self._settingDefaultValue = nil + self._populateFunc = nil + self._populateArg = nil +end + +function TooltipEntry._Acquire(self, info, key, setValue, clearValue, defaultValue, populateFunc, populateArg) + assert(setValue == nil or setValue, "'setValue' must be truthy") + assert(info and key and populateFunc) + assert(clearValue ~= nil or defaultValue ~= nil) + self._info = info + self._settingKey = key + self._settingSetValue = setValue or true + self._settingClearValue = clearValue or false + self._settingDefaultValue = defaultValue + self._populateFunc = populateFunc + self._populateArg = populateArg +end + +function TooltipEntry._Release(self) + self._info = nil + self._settingKey = nil + self._settingSetValue = nil + self._settingClearValue = nil + self._settingDefaultValue = nil + self._populateFunc = nil + self._populateArg = nil +end + +function TooltipEntry.GetSettingInfo(self) + local tbl = self._info:_GetSettingsTable() + local key, key2, extra = strsplit(".", self._settingKey) + assert(key and not extra) + if key2 then + tbl = tbl[key] + key = key2 + end + assert(type(tbl) == "table") + return tbl, key +end + +function TooltipEntry.IsEnabled(self) + local settingTbl, settingKey = self:GetSettingInfo() + local settingValue = settingTbl[settingKey] + if settingValue == nil then + assert(self._settingDefaultValue ~= nil) + settingTbl[settingKey] = self._settingDefaultValue + settingValue = settingTbl[settingKey] + end + return settingValue == self._settingSetValue +end + +function TooltipEntry.KeyMatches(self, matchStr) + return strmatch(self._settingKey, matchStr) and true or false +end + +function TooltipEntry._ResetSetting(self, value) + local tbl = self._info:_GetSettingsTable() + if self._settingDefaultValue ~= nil then + tbl[self._settingKey] = self._settingDefaultValue + elseif self._settingClearValue ~= nil then + tbl[self._settingKey] = self._settingClearValue + else + error("Invalid setting info") + end +end + +function TooltipEntry._Populate(self, tooltip, itemString) + self._populateFunc(tooltip, itemString, self._populateArg) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateTooltip(tooltip, itemString) + for _, info in ipairs(private.registeredInfo) do + info:_Populate(tooltip, itemString) + end +end + +function private.SettingsLineIteratorHelper(_, index) + local infoIndex = floor(index / (ITER_INDEX_PART_MULTIPLE ^ 2)) + local entryIndex = floor(index / ITER_INDEX_PART_MULTIPLE) % ITER_INDEX_PART_MULTIPLE + local lineIndex = index % ITER_INDEX_PART_MULTIPLE + local info, entry = nil, nil + while lineIndex >= private.settingsBuilder:GetNumLines() do + -- move to the next entry + info = private.registeredInfo[infoIndex] + entryIndex = entryIndex + 1 + entry = info and info:_GetEntry(entryIndex) + if entry then + private.settingsBuilder:SetDisabled(not entry:IsEnabled()) + entry:_Populate(private.settingsBuilder, ItemString.GetPlaceholder()) + private.settingsBuilder:SetDisabled(false) + else + -- move to the next info + if infoIndex > 0 then + private.settingsBuilder:EndSection() + end + infoIndex = infoIndex + 1 + info = private.registeredInfo[infoIndex] + if info then + private.settingsBuilder:StartSection(info._headingLeft) + entryIndex = 0 + else + return + end + end + end + lineIndex = lineIndex + 1 + index = infoIndex * ITER_INDEX_PART_MULTIPLE ^ 2 + entryIndex * ITER_INDEX_PART_MULTIPLE + lineIndex + local leftText, rightText, lineColor = private.settingsBuilder:GetLine(lineIndex) + assert(infoIndex < ITER_INDEX_PART_MULTIPLE and entryIndex < ITER_INDEX_PART_MULTIPLE and lineIndex < ITER_INDEX_PART_MULTIPLE) + return index, leftText, rightText, lineColor +end diff --git a/Core/Service/Tooltip/Crafting.lua b/Core/Service/Tooltip/Crafting.lua new file mode 100644 index 0000000..2fe6ad1 --- /dev/null +++ b/Core/Service/Tooltip/Crafting.lua @@ -0,0 +1,95 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Crafting = TSM.Tooltip:NewPackage("Crafting") +local L = TSM.Include("Locale").GetTable() +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Crafting.OnInitialize() + TSM.Tooltip.Register(TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM Crafting"]) + :SetSettingsModule("Crafting") + :AddSettingEntry("craftingCost", true, private.PopulateCostLine) + :AddSettingEntry("detailedMats", false, private.PopulateDetailedMatsLines) + :AddSettingEntry("matPrice", false, private.PopulateMatPriceLine) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateCostLine(tooltip, itemString) + itemString = itemString and ItemString.GetBaseFast(itemString) + assert(itemString) + local cost, profit = nil, nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + cost = 55 + profit = 20 + elseif not TSM.Crafting.CanCraftItem(itemString) then + return + else + cost = TSM.Crafting.Cost.GetLowestCostByItem(itemString) + local buyout = cost and TSM.Crafting.Cost.GetCraftedItemValue(itemString) or nil + profit = buyout and (buyout - cost) or nil + end + + local costText = tooltip:FormatMoney(cost) + local profitText = tooltip:FormatMoney(profit, profit and Theme.GetFeedbackColor(profit >= 0 and "GREEN" or "RED") or nil) + tooltip:AddLine(L["Crafting Cost"], format(L["%s (%s profit)"], costText, profitText)) +end + +function private.PopulateDetailedMatsLines(tooltip, itemString) + itemString = itemString and ItemString.GetBaseFast(itemString) + assert(itemString) + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + tooltip:StartSection() + tooltip:AddSubItemValueLine(ItemString.GetPlaceholder(), 11, 5) + tooltip:EndSection() + return + elseif not TSM.Crafting.CanCraftItem(itemString) then + return + end + + local _, spellId = TSM.Crafting.Cost.GetLowestCostByItem(itemString) + if not spellId then + return + end + + tooltip:StartSection() + local numResult = TSM.Crafting.GetNumResult(spellId) + for _, matItemString, matQuantity in TSM.Crafting.MatIterator(spellId) do + tooltip:AddSubItemValueLine(matItemString, TSM.Crafting.Cost.GetMatCost(matItemString), matQuantity / numResult) + end + tooltip:EndSection() +end + +function private.PopulateMatPriceLine(tooltip, itemString) + itemString = itemString and ItemString.GetBase(itemString) or nil + local matCost = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + matCost = 17 + else + matCost = TSM.Crafting.Cost.GetMatCost(itemString) + end + if matCost then + tooltip:AddItemValueLine(L["Material Cost"], matCost) + end +end diff --git a/Core/Service/Tooltip/General.lua b/Core/Service/Tooltip/General.lua new file mode 100644 index 0000000..e57135e --- /dev/null +++ b/Core/Service/Tooltip/General.lua @@ -0,0 +1,389 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local General = TSM.Tooltip:NewPackage("General") +local L = TSM.Include("Locale").GetTable() +local DisenchantInfo = TSM.Include("Data.DisenchantInfo") +local TempTable = TSM.Include("Util.TempTable") +local String = TSM.Include("Util.String") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local Conversions = TSM.Include("Service.Conversions") +local Inventory = TSM.Include("Service.Inventory") +local private = { + tooltipInfo = nil, +} +local DESTROY_INFO = { + { key = "deTooltip", method = Conversions.METHOD.DISENCHANT }, + { key = "millTooltip", method = Conversions.METHOD.MILL }, + { key = "prospectTooltip", method = Conversions.METHOD.PROSPECT }, + { key = "transformTooltip", method = Conversions.METHOD.TRANSFORM }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function General.OnInitialize() + local tooltipInfo = TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM General Info"]) + private.tooltipInfo = tooltipInfo + CustomPrice.RegisterCustomSourceCallback(private.UpdateCustomSources) + + -- group name + tooltipInfo:AddSettingEntry("groupNameTooltip", nil, private.PopulateGroupLine) + + -- operations + for _, moduleName in TSM.Operations.ModuleIterator() do + tooltipInfo:AddSettingEntry("operationTooltips."..moduleName, false, private.PopulateOperationLine, moduleName) + end + + -- destroy info + for _, info in ipairs(DESTROY_INFO) do + tooltipInfo:AddSettingEntry(info.key, nil, private.PopulateDestroyValueLine, info.method) + tooltipInfo:AddSettingEntry("detailedDestroyTooltip", nil, private.PopulateDetailLines, info.method) + end + + -- vendor prices + tooltipInfo:AddSettingEntry("vendorBuyTooltip", nil, private.PopulateVendorBuyLine) + tooltipInfo:AddSettingEntry("vendorSellTooltip", nil, private.PopulateVendorSellLine) + + -- custom sources + private.UpdateCustomSources() + + -- inventory info + tooltipInfo:AddSettingValueEntry("inventoryTooltipFormat", "full", "none", private.PopulateFullInventoryLines) + tooltipInfo:AddSettingValueEntry("inventoryTooltipFormat", "simple", "none", private.PopulateSimpleInventoryLine) + + TSM.Tooltip.Register(tooltipInfo) +end + +function private.UpdateCustomSources() + private.tooltipInfo:DeleteSettingsByKeyMatch("^customPriceTooltips%.") + local customPriceSources = TempTable.Acquire() + for name in pairs(TSM.db.global.userData.customPriceSources) do + tinsert(customPriceSources, name) + end + sort(customPriceSources) + for _, name in ipairs(customPriceSources) do + private.tooltipInfo:AddSettingEntry("customPriceTooltips."..name, false, private.PopulateCustomPriceLine, name) + end + TempTable.Release(customPriceSources) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateGroupLine(tooltip, itemString) + -- add group / operation info + local groupPath, itemInGroup = nil, nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + groupPath = L["Example"] + itemInGroup = true + else + groupPath = TSM.Groups.GetPathByItem(itemString) + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + groupPath = nil + else + itemInGroup = TSM.Groups.IsItemInGroup(itemString) + end + end + if groupPath then + local leftText = itemInGroup and GROUP or (GROUP.." ("..L["Base Item"]..")") + tooltip:AddTextLine(leftText, TSM.Groups.Path.Format(groupPath)) + end +end + +function private.PopulateOperationLine(tooltip, itemString, moduleName) + assert(moduleName) + local operations = TempTable.Acquire() + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + tinsert(operations, L["Example"]) + else + local groupPath = TSM.Groups.GetPathByItem(itemString) + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + groupPath = nil + end + if not groupPath then + TempTable.Release(operations) + return + end + for _, operationName in TSM.Operations.GroupOperationIterator(moduleName, groupPath) do + tinsert(operations, operationName) + end + end + if #operations > 0 then + tooltip:AddLine(format(#operations == 1 and L["%s operation"] or L["%s operations"], TSM.Operations.GetLocalizedName(moduleName)), tooltip:ApplyValueColor(table.concat(operations, ", "))) + end + TempTable.Release(operations) +end + +function private.PopulateDestroyValueLine(tooltip, itemString, method) + local value = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + if method == Conversions.METHOD.DISENCHANT then + value = 10 + elseif method == Conversions.METHOD.MILL then + value = 50 + elseif method == Conversions.METHOD.PROSPECT then + value = 20 + elseif method == Conversions.METHOD.TRANSFORM then + value = 30 + else + error("Invalid method: "..tostring(method)) + end + else + value = CustomPrice.GetConversionsValue(itemString, TSM.db.global.coreOptions.destroyValueSource, method) + end + if not value then + return + end + + local label = nil + if method == Conversions.METHOD.DISENCHANT then + label = L["Disenchant Value"] + elseif method == Conversions.METHOD.MILL then + label = L["Mill Value"] + elseif method == Conversions.METHOD.PROSPECT then + label = L["Prospect Value"] + elseif method == Conversions.METHOD.TRANSFORM then + label = L["Transform Value"] + else + error("Invalid method: "..tostring(method)) + end + tooltip:AddItemValueLine(label, value) +end + +function private.PopulateDetailLines(tooltip, itemString, method) + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + tooltip:StartSection() + if method == Conversions.METHOD.DISENCHANT then + tooltip:AddSubItemValueLine(ItemString.GetPlaceholder(), 1, 10, 1, 1, 20) + elseif method == Conversions.METHOD.MILL then + tooltip:AddSubItemValueLine(ItemString.GetPlaceholder(), 5, 10, 1) + elseif method == Conversions.METHOD.PROSPECT then + tooltip:AddSubItemValueLine(ItemString.GetPlaceholder(), 2, 10, 1, 1, 20) + elseif method == Conversions.METHOD.TRANSFORM then + tooltip:AddSubItemValueLine(ItemString.GetPlaceholder(), 3, 10, 1) + else + error("Invalid method: "..tostring(method)) + end + tooltip:EndSection() + return + elseif not CustomPrice.GetConversionsValue(itemString, TSM.db.global.coreOptions.destroyValueSource, method) then + return + end + + tooltip:StartSection() + if method == Conversions.METHOD.DISENCHANT then + local quality = ItemInfo.GetQuality(itemString) + local ilvl = ItemInfo.GetItemLevel(ItemString.GetBase(itemString)) + local classId = ItemInfo.GetClassId(itemString) + for targetItemString in DisenchantInfo.TargetItemIterator() do + local amountOfMats, matRate, minAmount, maxAmount = DisenchantInfo.GetTargetItemSourceInfo(targetItemString, classId, quality, ilvl) + if amountOfMats then + local matValue = CustomPrice.GetValue(TSM.db.global.coreOptions.destroyValueSource, targetItemString) or 0 + if matValue > 0 then + tooltip:AddSubItemValueLine(targetItemString, matValue, amountOfMats, matRate, minAmount, maxAmount) + end + end + end + else + for targetItemString, amountOfMats, matRate, minAmount, maxAmount in Conversions.TargetItemsByMethodIterator(itemString, method) do + local matValue = CustomPrice.GetValue(TSM.db.global.coreOptions.destroyValueSource, targetItemString) or 0 + if matValue > 0 then + tooltip:AddSubItemValueLine(targetItemString, matValue, amountOfMats, matRate, minAmount, maxAmount) + end + end + end + tooltip:EndSection() +end + +function private.PopulateVendorBuyLine(tooltip, itemString) + local value = nil + if itemString == ItemString.GetPlaceholder() then + -- example item + value = 50 + else + value = ItemInfo.GetVendorBuy(itemString) or 0 + end + if value > 0 then + tooltip:AddItemValueLine(L["Vendor Buy Price"], value) + end +end + +function private.PopulateVendorSellLine(tooltip, itemString) + local value = nil + if itemString == ItemString.GetPlaceholder() then + -- example item + value = 8 + else + value = ItemInfo.GetVendorSell(itemString) or 0 + end + if value > 0 then + tooltip:AddItemValueLine(L["Vendor Sell Price"], value) + end +end + +function private.PopulateCustomPriceLine(tooltip, itemString, name) + assert(name) + if not TSM.db.global.userData.customPriceSources[name] then + -- TODO: this custom price source has been removed (ideally shouldn't get here) + return + end + local value = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + value = 10 + else + value = CustomPrice.GetValue(name, itemString) or 0 + end + if value > 0 then + tooltip:AddItemValueLine(L["Custom Source"].." ("..name..")", value) + end +end + +function private.PopulateFullInventoryLines(tooltip, itemString) + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + local totalNum = 0 + local playerName = UnitName("player") + local bag, bank, auction, mail, guildQuantity = 5, 4, 4, 9, 1 + local playerTotal = bag + bank + auction + mail + totalNum = totalNum + playerTotal + tooltip:StartSection(L["Inventory"], format(L["%s total"], tooltip:ApplyValueColor(totalNum))) + local classColor = RAID_CLASS_COLORS[TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(UnitName("player")), "internalData", "classKey")] + local rightText = private.RightTextFormatHelper(tooltip, L["%s (%s bags, %s bank, %s AH, %s mail)"], playerTotal, bag, bank, auction, mail) + if classColor then + tooltip:AddLine("|c"..classColor.colorStr..playerName.."|r", rightText) + else + tooltip:AddLine(playerName, rightText) + end + totalNum = totalNum + guildQuantity + tooltip:AddLine(L["Example"], format(L["%s in guild vault"], tooltip:ApplyValueColor(guildQuantity))) + tooltip:EndSection() + return + end + + -- calculate the total number + local totalNum = 0 + for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + local bag = Inventory.GetBagQuantity(itemString, character, factionrealm) + local bank = Inventory.GetBankQuantity(itemString, character, factionrealm) + local reagentBank = Inventory.GetReagentBankQuantity(itemString, character, factionrealm) + local auction = Inventory.GetAuctionQuantity(itemString, character, factionrealm) + local mail = Inventory.GetMailQuantity(itemString, character, factionrealm) + totalNum = totalNum + bag + bank + reagentBank + auction + mail + end + end + for guildName in pairs(TSM.db.factionrealm.internalData.guildVaults) do + local guildQuantity = Inventory.GetGuildQuantity(itemString, guildName) + totalNum = totalNum + guildQuantity + end + tooltip:StartSection(L["Inventory"], format(L["%s total"], tooltip:ApplyValueColor(totalNum))) + + -- add the lines + for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + local realm = strmatch(factionrealm, "^.* "..String.Escape("-").." (.*)") + if realm == GetRealmName() then + realm = "" + else + realm = " - "..realm + end + local bag = Inventory.GetBagQuantity(itemString, character, factionrealm) + local bank = Inventory.GetBankQuantity(itemString, character, factionrealm) + local reagentBank = Inventory.GetReagentBankQuantity(itemString, character, factionrealm) + local auction = Inventory.GetAuctionQuantity(itemString, character, factionrealm) + local mail = Inventory.GetMailQuantity(itemString, character, factionrealm) + local playerTotal = bag + bank + reagentBank + auction + mail + if playerTotal > 0 then + local classColor = RAID_CLASS_COLORS[TSM.db:Get("sync", TSM.db:GetSyncScopeKeyByCharacter(character, factionrealm), "internalData", "classKey")] + local rightText = private.RightTextFormatHelper(tooltip, L["%s (%s bags, %s bank, %s AH, %s mail)"], playerTotal, bag, bank + reagentBank, auction, mail) + if classColor then + tooltip:AddLine("|c"..classColor.colorStr..character..realm.."|r", rightText) + else + tooltip:AddLine(character..realm, rightText) + end + end + end + end + for guildName in pairs(TSM.db.factionrealm.internalData.guildVaults) do + local guildQuantity = Inventory.GetGuildQuantity(itemString, guildName) + if guildQuantity > 0 then + tooltip:AddLine(guildName, format(L["%s in guild vault"], tooltip:ApplyValueColor(guildQuantity))) + end + end + tooltip:EndSection() +end + +function private.PopulateSimpleInventoryLine(tooltip, itemString) + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + local totalPlayer, totalAlt, totalGuild, totalAuction = 18, 0, 1, 4 + local totalNum2 = totalPlayer + totalAlt + totalGuild + totalAuction + local rightText2 = nil + if not TSM.IsWowClassic() then + rightText2 = private.RightTextFormatHelper(tooltip, L["%s (%s player, %s alts, %s guild, %s AH)"], totalNum2, totalPlayer, totalAlt, totalGuild, totalAuction) + else + rightText2 = private.RightTextFormatHelper(tooltip, L["%s (%s player, %s alts, %s AH)"], totalNum2, totalPlayer, totalAlt, totalAuction) + end + tooltip:AddLine(L["Inventory"], rightText2) + end + + local totalPlayer, totalAlt, totalGuild, totalAuction = 0, 0, 0, 0 + for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + local bag = Inventory.GetBagQuantity(itemString, character, factionrealm) + local bank = Inventory.GetBankQuantity(itemString, character, factionrealm) + local reagentBank = Inventory.GetReagentBankQuantity(itemString, character, factionrealm) + local auction = Inventory.GetAuctionQuantity(itemString, character, factionrealm) + local mail = Inventory.GetMailQuantity(itemString, character, factionrealm) + if character == UnitName("player") then + totalPlayer = totalPlayer + bag + bank + reagentBank + mail + totalAuction = totalAuction + auction + else + totalAlt = totalAlt + bag + bank + reagentBank + mail + totalAuction = totalAuction + auction + end + end + end + for guildName in pairs(TSM.db.factionrealm.internalData.guildVaults) do + totalGuild = totalGuild + Inventory.GetGuildQuantity(itemString, guildName) + end + local totalNum = totalPlayer + totalAlt + totalGuild + totalAuction + if totalNum > 0 then + local rightText = nil + if not TSM.IsWowClassic() then + rightText = private.RightTextFormatHelper(tooltip, L["%s (%s player, %s alts, %s guild, %s AH)"], totalNum, totalPlayer, totalAlt, totalGuild, totalAuction) + else + rightText = private.RightTextFormatHelper(tooltip, L["%s (%s player, %s alts, %s AH)"], totalNum, totalPlayer, totalAlt, totalAuction) + end + tooltip:AddLine(L["Inventory"], rightText) + end +end + +function private.RightTextFormatHelper(tooltip, fmtStr, ...) + local parts = TempTable.Acquire(...) + for i = 1, #parts do + parts[i] = tooltip:ApplyValueColor(parts[i]) + end + local result = format(fmtStr, unpack(parts)) + TempTable.Release(parts) + return result +end diff --git a/Core/Service/Tooltip/Shopping.lua b/Core/Service/Tooltip/Shopping.lua new file mode 100644 index 0000000..4fe9560 --- /dev/null +++ b/Core/Service/Tooltip/Shopping.lua @@ -0,0 +1,44 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Shopping = TSM.Tooltip:NewPackage("Shopping") +local L = TSM.Include("Locale").GetTable() +local ItemString = TSM.Include("Util.ItemString") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Shopping.OnInitialize() + TSM.Tooltip.Register(TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM Shopping"]) + :SetSettingsModule("Shopping") + :AddSettingEntry("maxPrice", false, private.PopulateMaxPriceLine) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateMaxPriceLine(tooltip, itemString) + local maxPrice = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + maxPrice = 37 + else + maxPrice = TSM.Operations.Shopping.GetMaxPrice(itemString) + end + if maxPrice then + tooltip:AddItemValueLine(L["Max Shopping Price"], maxPrice) + end +end diff --git a/Core/Service/Tooltip/Sniper.lua b/Core/Service/Tooltip/Sniper.lua new file mode 100644 index 0000000..d9e8ae7 --- /dev/null +++ b/Core/Service/Tooltip/Sniper.lua @@ -0,0 +1,44 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sniper = TSM.Tooltip:NewPackage("Sniper") +local L = TSM.Include("Locale").GetTable() +local ItemString = TSM.Include("Util.ItemString") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Sniper.OnInitialize() + TSM.Tooltip.Register(TSM.Tooltip.CreateInfo() + :SetHeadings(L["TSM Sniper"]) + :SetSettingsModule("Sniper") + :AddSettingEntry("belowPrice", false, private.PopulateBelowPriceLine) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateBelowPriceLine(tooltip, itemString) + local belowPrice = nil + if itemString == ItemString.GetPlaceholder() then + -- example tooltip + belowPrice = 35 + else + belowPrice = TSM.Operations.Sniper.GetBelowPrice(itemString) + end + if belowPrice then + tooltip:AddItemValueLine(L["Sniper Below Price"], belowPrice) + end +end diff --git a/Core/Service/Vendoring/Buy.lua b/Core/Service/Vendoring/Buy.lua new file mode 100644 index 0000000..bff0eaa --- /dev/null +++ b/Core/Service/Vendoring/Buy.lua @@ -0,0 +1,300 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Buy = TSM.Vendoring:NewPackage("Buy") +local Database = TSM.Include("Util.Database") +local Delay = TSM.Include("Util.Delay") +local Event = TSM.Include("Util.Event") +local Log = TSM.Include("Util.Log") +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Inventory = TSM.Include("Service.Inventory") +local private = { + merchantDB = nil, + pendingIndex = nil, + pendingQuantity = 0, +} +local FIRST_BUY_TIMEOUT = 5 +local FIRST_BUY_TIMEOUT_PER_STACK = 1 +local CONSECUTIVE_BUY_TIMEOUT = 5 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Buy.OnInitialize() + private.merchantDB = Database.NewSchema("MERCHANT") + :AddUniqueNumberField("index") + :AddStringField("itemString") + :AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString") + :AddNumberField("price") + :AddStringField("costItemsText") + :AddStringField("firstCostItemString") + :AddNumberField("stackSize") + :AddNumberField("numAvailable") + :Commit() + Event.Register("MERCHANT_SHOW", private.MerchantShowEventHandler) + Event.Register("MERCHANT_CLOSED", private.MerchantClosedEventHandler) + Event.Register("MERCHANT_UPDATE", private.MerchantUpdateEventHandler) + Event.Register("CHAT_MSG_LOOT", private.ChatMsgLootEventHandler) +end + +function Buy.CreateMerchantQuery() + return private.merchantDB:NewQuery() +end + +function Buy.NeedsRepair() + local _, needsRepair = GetRepairAllCost() + return needsRepair +end + +function Buy.CanGuildRepair() + return Buy.NeedsRepair() and not TSM.IsWowClassic() and CanGuildBankRepair() +end + +function Buy.DoGuildRepair() + RepairAllItems(true) +end + +function Buy.DoRepair() + RepairAllItems() +end + +function Buy.GetMaxCanAfford(index) + local maxCanAfford = math.huge + local _, _, price, stackSize, _, _, _, extendedCost = GetMerchantItemInfo(index) + local numAltCurrencies = GetMerchantItemCostInfo(index) + -- bug with big keech vendor returning extendedCost = true for gold only items + if numAltCurrencies == 0 then + extendedCost = false + end + + -- check the price + if price > 0 then + maxCanAfford = min(floor(GetMoney() / price), maxCanAfford) + end + + -- check the extended cost + if extendedCost then + assert(numAltCurrencies > 0) + for i = 1, numAltCurrencies do + local _, costNum, costItemLink, currencyName = GetMerchantItemCostItem(index, i) + local costItemString = ItemString.Get(costItemLink) + local costNumHave = nil + if costItemString then + costNumHave = Inventory.GetBagQuantity(costItemString) + Inventory.GetBankQuantity(costItemString) + Inventory.GetReagentBankQuantity(costItemString) + elseif currencyName then + if TSM.IsShadowlands() then + for j = 1, C_CurrencyInfo.GetCurrencyListSize() do + local info = C_CurrencyInfo.GetCurrencyListInfo(j) + if not info.isHeader and info.name == currencyName then + costNumHave = info.quantity + break + end + end + else + for j = 1, GetCurrencyListSize() do + local name, isHeader, _, _, _, count = GetCurrencyListInfo(j) + if not isHeader and name == currencyName then + costNumHave = count + break + end + end + end + end + if costNumHave then + maxCanAfford = min(floor(costNumHave / costNum), maxCanAfford) + end + end + end + + return maxCanAfford * stackSize +end + +function Buy.BuyItem(itemString, quantity) + local index = private.GetFirstIndex(itemString) + if not index then + return + end + private.BuyIndex(index, quantity) +end + +function Buy.BuyItemIndex(index, quantity) + private.BuyIndex(index, quantity) +end + +function Buy.CanBuyItem(itemString) + local index = private.GetFirstIndex(itemString) + return index and true or false +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.MerchantShowEventHandler() + Delay.AfterFrame("UPDATE_MERCHANT_DB", 1, private.UpdateMerchantDB) +end + +function private.MerchantClosedEventHandler() + private.ClearPendingContext() + Delay.Cancel("UPDATE_MERCHANT_DB") + Delay.Cancel("RESCAN_MERCHANT_DB") + private.merchantDB:Truncate() +end + +function private.MerchantUpdateEventHandler() + Delay.AfterFrame("UPDATE_MERCHANT_DB", 1, private.UpdateMerchantDB) +end + +function private.UpdateMerchantDB() + local needsRetry = false + private.merchantDB:TruncateAndBulkInsertStart() + for i = 1, GetMerchantNumItems() do + local itemLink = GetMerchantItemLink(i) + local itemString = ItemString.Get(itemLink) + if itemString then + ItemInfo.StoreItemInfoByLink(itemLink) + local _, _, price, stackSize, numAvailable, _, _, extendedCost = GetMerchantItemInfo(i) + local numAltCurrencies = GetMerchantItemCostInfo(i) + -- bug with big keech vendor returning extendedCost = true for gold only items + if numAltCurrencies == 0 then + extendedCost = false + end + local costItemsText, firstCostItemString = "", "" + if extendedCost then + assert(numAltCurrencies > 0) + local costItems = TempTable.Acquire() + for j = 1, numAltCurrencies do + local _, costNum, costItemLink = GetMerchantItemCostItem(i, j) + local costItemString = ItemString.Get(costItemLink) + local texture = nil + if not costItemLink then + needsRetry = true + elseif costItemString then + firstCostItemString = firstCostItemString ~= "" and firstCostItemString or costItemString + texture = ItemInfo.GetTexture(costItemString) + elseif strmatch(costItemLink, "currency:") then + if TSM.IsShadowlands() then + texture = C_CurrencyInfo.GetCurrencyInfoFromLink(costItemLink).iconFileID + else + _, _, texture = GetCurrencyInfo(costItemLink) + end + firstCostItemString = strmatch(costItemLink, "(currency:%d+)") + else + error(format("Unknown item cost (%d, %d, %s)", i, costNum, tostring(costItemLink))) + end + if TSM.Vendoring.Buy.GetMaxCanAfford(i) < stackSize then + costNum = Theme.GetFeedbackColor("RED"):ColorText(costNum) + end + tinsert(costItems, costNum.." |T"..(texture or "")..":12|t") + end + costItemsText = table.concat(costItems, " ") + TempTable.Release(costItems) + end + private.merchantDB:BulkInsertNewRow(i, itemString, price, costItemsText, firstCostItemString, stackSize, numAvailable) + end + end + private.merchantDB:BulkInsertEnd() + + if needsRetry then + Log.Err("Failed to scan merchant") + Delay.AfterTime("RESCAN_MERCHANT_DB", 0.2, private.UpdateMerchantDB) + else + Delay.Cancel("RESCAN_MERCHANT_DB") + end +end + +function private.GetFirstIndex(itemString) + local index = Buy.CreateMerchantQuery() + :Equal("itemString", itemString) + :OrderBy("index", true) + :Select("index") + :GetFirstResultAndRelease() + if not index and ItemString.GetBaseFast(itemString) == itemString then + index = Buy.CreateMerchantQuery() + :Equal("baseItemString", itemString) + :OrderBy("index", true) + :Select("index") + :GetFirstResultAndRelease() + end + return index +end + +function private.BuyIndex(index, quantity) + local maxStack = GetMerchantItemMaxStack(index) + quantity = min(quantity, Buy.GetMaxCanAfford(index)) + if quantity == 0 then + return + end + private.ClearPendingContext() + private.pendingIndex = index + local numStacks = 0 + while quantity > 0 do + local buyQuantity = min(quantity, maxStack) + BuyMerchantItem(index, buyQuantity) + private.pendingQuantity = private.pendingQuantity + buyQuantity + quantity = quantity - buyQuantity + numStacks = numStacks + 1 + end + Log.Info("Buying %d of %d (%d stacks)", private.pendingQuantity, index, numStacks) + Delay.AfterTime("VENDORING_BUY_TIMEOUT", numStacks * FIRST_BUY_TIMEOUT_PER_STACK + FIRST_BUY_TIMEOUT, private.BuyTimeout) +end + +function private.ChatMsgLootEventHandler(_, msg) + if not private.pendingIndex then + return + end + local link = GetMerchantItemLink(private.pendingIndex) + if not link then + Log.Err("Failed to get link (%s)", private.pendingIndex) + private.ClearPendingContext() + return + end + local quantity = nil + if msg == format(LOOT_ITEM_PUSHED_SELF, link) then + quantity = 1 + else + for i = 1, GetMerchantItemMaxStack(private.pendingIndex) do + if msg == format(LOOT_ITEM_PUSHED_SELF_MULTIPLE, link, i) then + quantity = i + break + end + end + end + Log.Info("Got CHAT_MSG_LOOT(%s) with a quantity of %s (%d pending)", msg, tostring(quantity), private.pendingQuantity) + if not quantity then + return + end + private.pendingQuantity = private.pendingQuantity - quantity + if private.pendingQuantity <= 0 then + -- we're done + private.ClearPendingContext() + return + end + + -- reset the timeout + Delay.Cancel("VENDORING_BUY_TIMEOUT") + Delay.AfterTime("VENDORING_BUY_TIMEOUT", CONSECUTIVE_BUY_TIMEOUT, private.BuyTimeout) +end + +function private.BuyTimeout() + Log.Warn("Retrying buying (%d, %d)", private.pendingIndex, private.pendingQuantity) + Buy.BuyItemIndex(private.pendingIndex, private.pendingQuantity) +end + +function private.ClearPendingContext() + private.pendingIndex = nil + private.pendingQuantity = 0 + Delay.Cancel("VENDORING_BUY_TIMEOUT") +end diff --git a/Core/Service/Vendoring/Buyback.lua b/Core/Service/Vendoring/Buyback.lua new file mode 100644 index 0000000..6388deb --- /dev/null +++ b/Core/Service/Vendoring/Buyback.lua @@ -0,0 +1,74 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Buyback = TSM.Vendoring:NewPackage("Buyback") +local Database = TSM.Include("Util.Database") +local Delay = TSM.Include("Util.Delay") +local Event = TSM.Include("Util.Event") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + buybackDB = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Buyback.OnInitialize() + private.buybackDB = Database.NewSchema("BUYBACK") + :AddUniqueNumberField("index") + :AddStringField("itemString") + :AddNumberField("price") + :AddNumberField("quantity") + :Commit() + Event.Register("MERCHANT_SHOW", private.MerchantShowEventHandler) + Event.Register("MERCHANT_CLOSED", private.MerchantClosedEventHandler) + Event.Register("MERCHANT_UPDATE", private.MerchantUpdateEventHandler) +end + +function Buyback.CreateQuery() + return private.buybackDB:NewQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") +end + +function Buyback.BuybackItem(index) + BuybackItem(index) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.MerchantShowEventHandler() + Delay.AfterFrame("UPDATE_BUYBACK_DB", 1, private.UpdateBuybackDB) +end + +function private.MerchantClosedEventHandler() + Delay.Cancel("UPDATE_BUYBACK_DB") + private.buybackDB:Truncate() +end + +function private.MerchantUpdateEventHandler() + Delay.AfterFrame("UPDATE_BUYBACK_DB", 1, private.UpdateBuybackDB) +end + +function private.UpdateBuybackDB() + private.buybackDB:TruncateAndBulkInsertStart() + for i = 1, GetNumBuybackItems() do + local itemString = ItemString.Get(GetBuybackItemLink(i)) + if itemString then + local _, _, price, quantity = GetBuybackItemInfo(i) + private.buybackDB:BulkInsertNewRow(i, itemString, price, quantity) + end + end + private.buybackDB:BulkInsertEnd() +end diff --git a/Core/Service/Vendoring/Core.lua b/Core/Service/Vendoring/Core.lua new file mode 100644 index 0000000..c603835 --- /dev/null +++ b/Core/Service/Vendoring/Core.lua @@ -0,0 +1,8 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +TSM:NewPackage("Vendoring") diff --git a/Core/Service/Vendoring/Groups.lua b/Core/Service/Vendoring/Groups.lua new file mode 100644 index 0000000..8776eff --- /dev/null +++ b/Core/Service/Vendoring/Groups.lua @@ -0,0 +1,278 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM.Vendoring:NewPackage("Groups") +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Money = TSM.Include("Util.Money") +local SlotId = TSM.Include("Util.SlotId") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local BagTracking = TSM.Include("Service.BagTracking") +local Inventory = TSM.Include("Service.Inventory") +local private = { + buyThreadId = nil, + sellThreadId = nil, + tempGroups = {}, + printedBagsFullMsg = false, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.buyThreadId = Threading.New("VENDORING_GROUP_BUY", private.BuyThread) + private.sellThreadId = Threading.New("VENDORING_GROUP_SELL", private.SellThread) +end + +function Groups.BuyGroups(groups, callback) + Groups.StopBuySell() + + wipe(private.tempGroups) + for _, groupPath in ipairs(groups) do + tinsert(private.tempGroups, groupPath) + end + Threading.SetCallback(private.buyThreadId, callback) + Threading.Start(private.buyThreadId, private.tempGroups) +end + +function Groups.SellGroups(groups, callback) + Groups.StopBuySell() + + wipe(private.tempGroups) + for _, groupPath in ipairs(groups) do + tinsert(private.tempGroups, groupPath) + end + Threading.SetCallback(private.sellThreadId, callback) + Threading.Start(private.sellThreadId, private.tempGroups) +end + +function Groups.StopBuySell() + Threading.Kill(private.buyThreadId) + Threading.Kill(private.sellThreadId) +end + + + +-- ============================================================================ +-- Buy Thread +-- ============================================================================ + +function private.BuyThread(groups) + for _, groupPath in ipairs(groups) do + groups[groupPath] = true + end + + local itemsToBuy = Threading.AcquireSafeTempTable() + local itemBuyQuantity = Threading.AcquireSafeTempTable() + local query = TSM.Vendoring.Buy.CreateMerchantQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :InnerJoin(TSM.Groups.GetItemDBForJoin(), "itemString") + :Select("itemString", "groupPath", "numAvailable") + for _, itemString, groupPath, numAvailable in query:Iterator() do + if groups[groupPath] then + local _, operationSettings = TSM.Operations.GetFirstOperationByItem("Vendoring", itemString) + if operationSettings.enableBuy then + local numToBuy = private.GetNumToBuy(itemString, operationSettings) + if numAvailable ~= -1 then + numToBuy = min(numToBuy, numAvailable) + end + if numToBuy > 0 then + assert(not itemBuyQuantity[itemString]) + tinsert(itemsToBuy, itemString) + itemBuyQuantity[itemString] = numToBuy + end + end + end + end + query:Release() + + for _, itemString in ipairs(itemsToBuy) do + local numToBuy = itemBuyQuantity[itemString] + TSM.Vendoring.Buy.BuyItem(itemString, numToBuy) + Threading.Yield(true) + end + + Threading.ReleaseSafeTempTable(itemsToBuy) + Threading.ReleaseSafeTempTable(itemBuyQuantity) +end + +function private.GetNumToBuy(itemString, operationSettings) + local numHave = BagTracking.CreateQueryBagsItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :Equal("isBoA", false) + :SumAndRelease("quantity") or 0 + if operationSettings.restockSources.bank then + numHave = numHave + Inventory.GetBankQuantity(itemString) + Inventory.GetReagentBankQuantity(itemString) + end + if operationSettings.restockSources.guild then + numHave = numHave + Inventory.GetGuildQuantity(itemString) + end + if operationSettings.restockSources.ah then + numHave = numHave + Inventory.GetAuctionQuantity(itemString) + end + if operationSettings.restockSources.mail then + numHave = numHave + Inventory.GetMailQuantity(itemString) + end + if operationSettings.restockSources.alts or operationSettings.restockSources.alts_ah then + local _, alts, _, altsAH = Inventory.GetPlayerTotals(itemString) + numHave = numHave + (operationSettings.restockSources.alts and alts or 0) + (operationSettings.restockSources.alts_ah and altsAH or 0) + end + return max(operationSettings.restockQty - numHave, 0) +end + + + +-- ============================================================================ +-- Sell Thread +-- ============================================================================ + +function private.SellThread(groups) + private.printedBagsFullMsg = false + local totalValue = 0 + local operationsTemp = Threading.AcquireSafeTempTable() + for _, groupPath in ipairs(groups) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + wipe(operationsTemp) + for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Vendoring", groupPath) do + if operationSettings.enableSell then + tinsert(operationsTemp, operationName) + end + end + for _, operationName in ipairs(operationsTemp) do + for _, itemString in TSM.Groups.ItemIterator(groupPath) do + totalValue = totalValue + private.SellItemThreaded(itemString, TSM.Operations.GetSettings("Vendoring", operationName)) + end + end + end + end + Threading.ReleaseSafeTempTable(operationsTemp) + + if TSM.db.global.vendoringOptions.displayMoneyCollected then + Log.PrintfUser(L["Sold %s worth of items."], Money.ToString(totalValue)) + end +end + +function private.SellItemThreaded(itemString, operationSettings) + -- calculate the number to sell + local numHave = BagTracking.CreateQueryBagsItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :Equal("isBoA", false) + :SumAndRelease("quantity") or 0 + local numToSell = numHave - operationSettings.keepQty + if numToSell <= 0 then + return 0 + end + + -- check the expires + if operationSettings.sellAfterExpired > 0 and TSM.Accounting.Auctions.GetNumExpiresSinceSale(itemString) < operationSettings.sellAfterExpired then + return 0 + end + + -- check the destroy value + local destroyValue = CustomPrice.GetValue(operationSettings.vsDestroyValue, itemString) or 0 + local maxDestroyValue = CustomPrice.GetValue(operationSettings.vsMaxDestroyValue, itemString) or 0 + if maxDestroyValue > 0 and destroyValue >= maxDestroyValue then + return 0 + end + + -- check the market value + local marketValue = CustomPrice.GetValue(operationSettings.vsMarketValue, itemString) or 0 + local maxMarketValue = CustomPrice.GetValue(operationSettings.vsMaxMarketValue, itemString) or 0 + if maxMarketValue > 0 and marketValue >= maxMarketValue then + return 0 + end + + -- get a list of empty slots which we can use to split items into + local emptySlotIds = private.GetEmptyBagSlotsThreaded(ItemString.IsItem(itemString) and GetItemFamily(ItemString.ToId(itemString)) or 0) + + -- get a list of slots containing the item we want to sell + local slotIds = Threading.AcquireSafeTempTable() + local bagQuery = BagTracking.CreateQueryBagsItem(itemString) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :Select("slotId", "quantity") + :Equal("isBoA", false) + :OrderBy("quantity", true) + if not operationSettings.sellSoulbound then + bagQuery:Equal("isBoP", false) + end + for _, slotId in bagQuery:Iterator() do + tinsert(slotIds, slotId) + end + bagQuery:Release() + + local totalValue = 0 + for _, slotId in ipairs(slotIds) do + local bag, slot = SlotId.Split(slotId) + local quantity = BagTracking.GetQuantityBySlotId(slotId) + if quantity <= numToSell then + UseContainerItem(bag, slot) + totalValue = totalValue + ((ItemInfo.GetVendorSell(itemString) or 0) * quantity) + numToSell = numToSell - quantity + else + if #emptySlotIds > 0 then + local splitBag, splitSlot = SlotId.Split(tremove(emptySlotIds, 1)) + SplitContainerItem(bag, slot, numToSell) + PickupContainerItem(splitBag, splitSlot) + -- wait for the stack to be split + Threading.WaitForFunction(private.BagSlotHasItem, splitBag, splitSlot) + PickupContainerItem(splitBag, splitSlot) + UseContainerItem(splitBag, splitSlot) + totalValue = totalValue + ((ItemInfo.GetVendorSell(itemString) or 0) * quantity) + elseif not private.printedBagsFullMsg then + Log.PrintUser(L["Could not sell items due to not having free bag space available to split a stack of items."]) + private.printedBagsFullMsg = true + end + -- we're done + numToSell = 0 + end + if numToSell == 0 then + break + end + Threading.Yield(true) + end + + Threading.ReleaseSafeTempTable(slotIds) + Threading.ReleaseSafeTempTable(emptySlotIds) + return totalValue +end + +function private.GetEmptyBagSlotsThreaded(itemFamily) + local emptySlotIds = Threading.AcquireSafeTempTable() + local sortvalue = Threading.AcquireSafeTempTable() + for bag = 0, NUM_BAG_SLOTS do + -- make sure the item can go in this bag + local bagFamily = bag ~= 0 and GetItemFamily(GetInventoryItemLink("player", ContainerIDToInventoryID(bag))) or 0 + if bagFamily == 0 or bit.band(itemFamily, bagFamily) > 0 then + for slot = 1, GetContainerNumSlots(bag) do + if not GetContainerItemInfo(bag, slot) then + local slotId = SlotId.Join(bag, slot) + tinsert(emptySlotIds, slotId) + -- use special bags first + sortvalue[slotId] = slotId + (bagFamily > 0 and 0 or 100000) + end + end + end + Threading.Yield() + end + Table.SortWithValueLookup(emptySlotIds, sortvalue) + Threading.ReleaseSafeTempTable(sortvalue) + return emptySlotIds +end + +function private.BagSlotHasItem(bag, slot) + return GetContainerItemInfo(bag, slot) and true or false +end diff --git a/Core/Service/Vendoring/Sell.lua b/Core/Service/Vendoring/Sell.lua new file mode 100644 index 0000000..39e8f98 --- /dev/null +++ b/Core/Service/Vendoring/Sell.lua @@ -0,0 +1,168 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sell = TSM.Vendoring:NewPackage("Sell") +local Database = TSM.Include("Util.Database") +local TempTable = TSM.Include("Util.TempTable") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local BagTracking = TSM.Include("Service.BagTracking") +local private = { + ignoreDB = nil, + potentialValueDB = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Sell.OnInitialize() + local used = TempTable.Acquire() + private.ignoreDB = Database.NewSchema("VENDORING_IGNORE") + :AddUniqueStringField("itemString") + :AddBooleanField("ignoreSession") + :AddBooleanField("ignorePermanent") + :Commit() + private.ignoreDB:BulkInsertStart() + for itemString in pairs(TSM.db.global.userData.vendoringIgnore) do + itemString = ItemString.Get(itemString) + if not used[itemString] then + used[itemString] = true + private.ignoreDB:BulkInsertNewRow(itemString, false, true) + end + end + private.ignoreDB:BulkInsertEnd() + TempTable.Release(used) + + private.potentialValueDB = Database.NewSchema("VENDORING_POTENTIAL_VALUE") + :AddUniqueStringField("itemString") + :AddNumberField("potentialValue") + :Commit() + BagTracking.RegisterCallback(private.UpdatePotentialValueDB) +end + +function Sell.IgnoreItemSession(itemString) + local row = private.ignoreDB:GetUniqueRow("itemString", itemString) + if row then + assert(not row:GetField("ignoreSession")) + row:SetField("ignoreSession", true) + row:Update() + row:Release() + else + private.ignoreDB:NewRow() + :SetField("itemString", itemString) + :SetField("ignoreSession", true) + :SetField("ignorePermanent", false) + :Create() + end +end + +function Sell.IgnoreItemPermanent(itemString) + assert(not TSM.db.global.userData.vendoringIgnore[itemString]) + TSM.db.global.userData.vendoringIgnore[itemString] = true + + local row = private.ignoreDB:GetUniqueRow("itemString", itemString) + if row then + assert(not row:GetField("ignorePermanent")) + row:SetField("ignorePermanent", true) + row:Update() + row:Release() + else + private.ignoreDB:NewRow() + :SetField("itemString", itemString) + :SetField("ignoreSession", false) + :SetField("ignorePermanent", true) + :Create() + end +end + +function Sell.ForgetIgnoreItemPermanent(itemString) + assert(TSM.db.global.userData.vendoringIgnore[itemString]) + TSM.db.global.userData.vendoringIgnore[itemString] = nil + + local row = private.ignoreDB:GetUniqueRow("itemString", itemString) + assert(row and row:GetField("ignorePermanent")) + if row:GetField("ignoreSession") then + row:SetField("ignorePermanent") + row:Update() + else + private.ignoreDB:DeleteRow(row) + end + row:Release() +end + +function Sell.CreateIgnoreQuery() + return private.ignoreDB:NewQuery() + :Equal("ignorePermanent", true) + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :OrderBy("name", true) +end + +function Sell.CreateBagsQuery() + local query = BagTracking.CreateQueryBags() + :Distinct("itemString") + :LeftJoin(private.ignoreDB, "itemString") + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :LeftJoin(private.potentialValueDB, "itemString") + :Equal("isBoP", false) + :Equal("isBoA", false) + Sell.ResetBagsQuery(query) + return query +end + +function Sell.ResetBagsQuery(query) + query:ResetOrderBy() + query:ResetFilters() + BagTracking.FilterQueryBags(query) + query:NotEqual("ignoreSession", true) + :NotEqual("ignorePermanent", true) + :Equal("isBoP", false) + :Equal("isBoA", false) + :GreaterThan("vendorSell", 0) + :OrderBy("name", true) +end + +function Sell.SellItem(itemString, includeSoulbound) + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("bag", "slot", "itemString") + :Equal("isBoP", false) + :Equal("isBoA", false) + for _, bag, slot, bagItemString in query:Iterator() do + if itemString == bagItemString and ItemString.Get(GetContainerItemLink(bag, slot)) == itemString then + UseContainerItem(bag, slot) + end + end + query:Release() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdatePotentialValueDB() + private.potentialValueDB:TruncateAndBulkInsertStart() + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("itemString") + :Distinct("itemString") + :Equal("isBoP", false) + :Equal("isBoA", false) + for _, itemString in query:Iterator() do + local value = CustomPrice.GetValue(TSM.db.global.vendoringOptions.qsMarketValue, itemString) + if value then + private.potentialValueDB:BulkInsertNewRow(itemString, value) + end + end + query:Release() + private.potentialValueDB:BulkInsertEnd() +end diff --git a/Core/UI/AuctionUI/Auctioning.lua b/Core/UI/AuctionUI/Auctioning.lua new file mode 100644 index 0000000..f29c4f0 --- /dev/null +++ b/Core/UI/AuctionUI/Auctioning.lua @@ -0,0 +1,1758 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctioning = TSM.UI.AuctionUI:NewPackage("Auctioning") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local Event = TSM.Include("Util.Event") +local Table = TSM.Include("Util.Table") +local Sound = TSM.Include("Util.Sound") +local Money = TSM.Include("Util.Money") +local Log = TSM.Include("Util.Log") +local Theme = TSM.Include("Util.Theme") +local ItemString = TSM.Include("Util.ItemString") +local Threading = TSM.Include("Service.Threading") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local BagTracking = TSM.Include("Service.BagTracking") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local AuctionScan = TSM.Include("Service.AuctionScan") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + contentPath = "selection", + hasLastScan = false, + fsm = nil, + scanContext = {}, + auctionScan = nil, + groupSearch = "", + selectionFrame = nil, + logQuery = nil, + perItem = true, + canStartNewScan = false, +} +local SECONDS_PER_MIN = 60 +local SECONDS_PER_HOUR = 60 * SECONDS_PER_MIN +local SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctioning.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "auctionUIContext", "auctioningSelectionDividedContainer") + :AddKey("global", "auctionUIContext", "auctioningSelectionVerticalDividedContainer") + :AddKey("global", "auctionUIContext", "auctioningBagScrollingTable") + :AddKey("global", "auctionUIContext", "auctioningLogScrollingTable") + :AddKey("global", "auctionUIContext", "auctioningAuctionScrollingTable") + :AddKey("global", "auctionUIContext", "auctioningTabGroup") + :AddKey("char", "auctionUIContext", "auctioningGroupTree") + :AddKey("global", "auctioningOptions", "scanCompleteSound") + :AddKey("global", "auctioningOptions", "confirmCompleteSound") + TSM.UI.AuctionUI.RegisterTopLevelPage(L["Auctioning"], private.GetAuctioningFrame, private.OnItemLinked) + private.FSMCreate() +end + + + +-- ============================================================================ +-- Auctioning UI +-- ============================================================================ + +function private.GetAuctioningFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "auctioning") + if not private.hasLastScan then + private.contentPath = "selection" + end + return UIElements.New("ViewContainer", "auctioning") + :SetNavCallback(private.GetAuctioningContentFrame) + :AddPath("selection") + :AddPath("scan") + :SetPath(private.contentPath) +end + +function private.GetAuctioningContentFrame(_, path) + private.contentPath = path + if path == "selection" then + return private.GetAuctioningSelectionFrame() + elseif path == "scan" then + return private.GetAuctioningScanFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetAuctioningSelectionFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "auctioning", "selection") + local frame = UIElements.New("DividedContainer", "selection") + :SetSettingsContext(private.settings, "auctioningSelectionDividedContainer") + :SetMinWidth(220, 250) + :SetBackgroundColor("PRIMARY_BG") + :SetLeftChild(UIElements.New("Frame", "groupSelection") + :SetLayout("VERTICAL") + :SetPadding(0, 0, 8, 0) + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetMargin(0, 0, 0, 8) + :SetHeight(24) + :AddChild(UIElements.New("Input", "search") + :SetMargin(8, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert(true) + :SetHintText(L["Search Groups"]) + :SetValue(private.groupSearch) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(0, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetMargin(0, 8, 0, 0) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetSettingsContext(private.settings, "auctioningGroupTree") + :SetQuery(TSM.Groups.CreateQuery(), "Auctioning") + :SetSearchString(private.groupSearch) + :SetScript("OnGroupSelectionChanged", private.GroupTreeOnGroupSelectionChanged) + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(74) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ActionButton", "postScanBtn") + :SetHeight(24) + :SetMargin(8) + :SetText(L["Run Post Scan"]) + :SetScript("OnClick", private.RunPostButtonOnclick) + ) + :AddChild(UIElements.New("ActionButton", "cancelScanBtn") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :SetText(L["Run Cancel Scan"]) + :SetScript("OnClick", private.RunCancelButtonOnclick) + ) + ) + ) + :SetRightChild(UIElements.New("DividedContainer", "content") + :SetVertical() + :SetMargin(0, 0, 6, 0) + :SetSettingsContext(private.settings, "auctioningSelectionVerticalDividedContainer") + :SetMinWidth(50, 100) + :SetBackgroundColor("PRIMARY_BG") + :SetTopChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("TabGroup", "buttons") + :SetNavCallback(private.GetScansElement) + :SetSettingsContext(private.settings, "auctioningTabGroup") + :AddPath(L["Recent Scans"]) + :AddPath(L["Favorite Scans"]) + ) + ) + :SetBottomChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(37) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "label") + :SetHeight(24) + :SetMargin(4, 0, 6, 6) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Post Items from Bags"]) + ) + ) + :AddChild(UIElements.New("SelectionScrollingTable", "bagScrollingTable") + :SetSettingsContext(private.settings, "auctioningBagScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetHeaderIndent(18) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetTextInfo("autoBaseItemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemTexture") + :SetTooltipInfo("autoBaseItemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("operation") + :SetTitle(L["Auctioning Operation"]) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("LEFT") + :SetTextInfo("firstOperation", private.BagGetOperationText) + :SetSortInfo("firstOperation") + :Commit() + :Commit() + :SetQuery(TSM.Auctioning.PostScan.CreateBagsQuery()) + :SetAutoReleaseQuery(true) + :SetSelectionValidator(private.BagScrollingTableIsSelectionEnabled) + :SetScript("OnSelectionChanged", private.BagOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "button") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "postSelected") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetDisabled(true) + :SetText(L["Post Selected"]) + :SetScript("OnClick", private.RunPostBagsButtonOnClick) + ) + :AddChild(UIElements.New("Button", "selectAll") + :SetSize("AUTO", 20) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Select All"]) + :SetScript("OnClick", private.SelectAllOnClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetSize(2, 20) + :SetMargin(0, 8, 0, 0) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Button", "clearAll") + :SetSize("AUTO", 20) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Clear All"]) + :SetDisabled(true) + :SetScript("OnClick", private.ClearAllOnClick) + ) + ) + ) + ) + :SetScript("OnHide", private.SelectionOnHide) + local noGroupSelected = frame:GetElement("groupSelection.groupTree"):IsSelectionCleared(true) + frame:GetElement("groupSelection.bottom.postScanBtn"):SetDisabled(noGroupSelected) + frame:GetElement("groupSelection.bottom.cancelScanBtn"):SetDisabled(noGroupSelected) + private.selectionFrame = frame + return frame +end + +function private.GetScansElement(_, button) + if button == L["Recent Scans"] then + return UIElements.New("SearchList", "list") + :SetQuery(TSM.Auctioning.SavedSearches.CreateRecentSearchesQuery()) + :SetEditButtonHidden(true) + :SetScript("OnFavoriteChanged", private.SearchListOnFavoriteChanged) + :SetScript("OnDelete", private.SearchListOnDelete) + :SetScript("OnRowClick", private.SearchListOnRowClick) + elseif button == L["Favorite Scans"] then + return UIElements.New("SearchList", "list") + :SetQuery(TSM.Auctioning.SavedSearches.CreateFavoriteSearchesQuery()) + :SetScript("OnFavoriteChanged", private.SearchListOnFavoriteChanged) + :SetScript("OnEditClick", private.SearchListOnEditClick) + :SetScript("OnDelete", private.SearchListOnDelete) + :SetScript("OnRowClick", private.SearchListOnRowClick) + else + error("Unexpected button: "..tostring(button)) + end +end + +function private.GetAuctioningScanFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "auctioning", "scan") + return UIElements.New("Frame", "scan") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetPadding(8) + :SetHeight(92) + :AddChild(UIElements.New("Frame", "back") + :SetLayout("VERTICAL") + :SetWidth(54) + :SetHeight(76) + :SetMargin(0, 8, 0, 0) + :SetPadding(0, 0, 4, 0) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Button", "backBtn") + :SetMargin(0, 0, 2, 0) + :SetSize(28, 28) + :SetBackground("iconPack.24x24/Close/Default") + :SetScript("OnEnter", private.ExitScanButtonOnEnter) + :SetScript("OnLeave", private.ExitScanButtonOnLeave) + :SetScript("OnClick", private.ExitScanButtonOnClick) + ) + :AddChild(UIElements.New("Text", "text") + :SetHeight(18) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Exit"]) + ) + :AddChild(UIElements.New("Text", "text") + :SetHeight(18) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Scan"]) + ) + :SetScript("OnEnter", private.ExitScanFrameOnEnter) + :SetScript("OnLeave", private.ExitScanFrameOnLeave) + :SetScript("OnMouseUp", private.ExitScanButtonOnClick) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetPadding(8, 8, 4, 4) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Frame", "item") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Button", "icon") + :SetSize(18, 18) + ) + :AddChild(UIElements.New("Button", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("ITEM_BODY1") + :SetJustifyH("LEFT") + ) + ) + :AddChild(UIElements.New("Frame", "cost") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Deposit Cost"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("TABLE_TABLE1") + ) + ) + :AddChild(UIElements.New("Frame", "operation") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Operation"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + ) + ) + ) + :AddChild(UIElements.New("Frame", "details") + :SetLayout("VERTICAL") + :SetWidth(371) + :SetMargin(16, 0, 0, 0) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Text", "text") + :SetMargin(0, 10, 0, 0) + :SetSize("AUTO", 16) + :SetFont("BODY_BODY1") + :SetText(L["Auctioning Details"]) + ) + :AddChild(UIElements.New("ActionButton", "editBtn") + :SetHeight(16) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Edit Post"]) + :SetScript("OnClick", private.EditButtonOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "details") + :SetLayout("HORIZONTAL") + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Frame", "details1") + :SetLayout("VERTICAL") + :SetWidth(200) + :SetMargin(0, 8, 0, 0) + :AddChild(UIElements.New("Frame", "bid") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Bid Price"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("TABLE_TABLE1") + ) + ) + :AddChild(UIElements.New("Frame", "buyout") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Buyout Price"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("TABLE_TABLE1") + ) + ) + ) + :AddChild(UIElements.New("Frame", "details2") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "quantity") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText((TSM.IsWowClassic() and L["Stack / Quantity"] or L["Quantity"])..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("TABLE_TABLE1") + ) + ) + :AddChild(UIElements.New("Frame", "duration") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Duration"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(4, 0, 0, 0) + :SetFont("TABLE_TABLE1") + ) + ) + ) + ) + ) + ) + ) + :AddChild(UIElements.New("SimpleTabGroup", "tabs") + :SetNavCallback(private.ScanNavCallback) + :AddPath(L["Auctioning Log"], true) + :AddPath(L["All Auctions"]) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "pauseResumeBtn") + :SetSize(24, 24) + :SetMargin(0, 8, 0, 0) + :SetIcon("iconPack.18x18/PlayPause") + :SetScript("OnClick", private.PauseResumeBtnOnClick) + ) + :AddChild(UIElements.New("ProgressBar", "progressBar") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetProgress(0) + :SetProgressIconHidden(false) + :SetText(L["Starting Scan..."]) + ) + :AddChild(UIElements.NewNamed("ActionButton", "processBtn", "TSMAuctioningBtn") + :SetSize(160, 24) + :SetMargin(0, 8, 0, 0) + :SetText(L["Post"]) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.ProcessButtonOnClick) + ) + :AddChild(UIElements.New("ActionButton", "skipBtn") + :SetSize(160, 24) + :SetText(L["Skip"]) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.SkipButtonOnClick) + ) + ) + :SetScript("OnUpdate", private.ScanFrameOnUpdate) + :SetScript("OnHide", private.ScanFrameOnHide) +end + +function private.ScanNavCallback(_, path) + if path == L["Auctioning Log"] then + TSM.UI.AnalyticsRecordPathChange("auction", "auctioning", "scan", "log") + private.logQuery = private.logQuery or TSM.Auctioning.Log.CreateQuery() + return UIElements.New("Frame", "logFrame") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("QueryScrollingTable", "log") + :SetSettingsContext(private.settings, "auctioningLogScrollingTable") + :GetScrollingTableInfo() + :NewColumn("index") + :SetTitleIcon("iconPack.14x14/Attention") + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("CENTER") + :SetIconInfo(nil, private.LogGetIndexIcon) + :SetSortInfo("index") + :Commit() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :Commit() + :NewColumn("buyout") + :SetTitle(L["Your Buyout"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("buyout", private.LogGetBuyoutText) + :SetSortInfo("buyout") + :Commit() + :NewColumn("operation") + :SetTitle(L["Operation"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("operation") + :SetSortInfo("operation") + :Commit() + :NewColumn("seller") + :SetTitle(L["Seller"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("seller") + :SetSortInfo("seller") + :Commit() + :NewColumn("info") + :SetTitle(INFO) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo(nil, TSM.Auctioning.Log.GetInfoStr) + :SetSortInfo("reasonStr") + :Commit() + :Commit() + :SetQuery(private.logQuery) + :SetSelectionDisabled(true) + ) + elseif path == L["All Auctions"] then + TSM.UI.AnalyticsRecordPathChange("auction", "auctioning", "scan", "auctions") + return UIElements.New("Frame", "auctionsFrame") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("AuctionScrollingTable", "auctions") + :SetSettingsContext(private.settings, "auctioningAuctionScrollingTable") + :SetBrowseResultsVisible(true) + :SetMarketValueFunction(private.MarketValueFunction) + :SetAuctionScan(private.auctionScan) + ) + else + error("Unexpected path: "..tostring(path)) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnItemLinked(_, itemLink) + if private.selectionFrame then + if not TSM.UI.AuctionUI.StartingScan(L["Auctioning"]) then + return false + end + wipe(private.scanContext) + private.scanContext.isItems = true + tinsert(private.scanContext, TSM.Groups.TranslateItemString(ItemString.Get(itemLink))) + private.selectionFrame:GetParentElement():SetPath("scan", true) + private.fsm:ProcessEvent("EV_START_SCAN", "POST", private.scanContext) + return true + else + if not private.canStartNewScan then + return false + end + wipe(private.scanContext) + private.scanContext.isItems = true + tinsert(private.scanContext, TSM.Groups.TranslateItemString(ItemString.Get(itemLink))) + private.fsm:ProcessEvent("EV_START_SCAN", "POST", private.scanContext) + return true + end +end + +function private.SelectionOnHide(frame) + assert(frame == private.selectionFrame) + private.selectionFrame = nil +end + +function private.GroupSearchOnValueChanged(input) + private.groupSearch = strlower(input:GetValue()) + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(private.groupSearch) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.GroupTreeOnGroupSelectionChanged(groupTree) + local postScanBtn = groupTree:GetElement("__parent.bottom.postScanBtn") + postScanBtn:SetDisabled(groupTree:IsSelectionCleared()) + postScanBtn:Draw() + local cancelScanBtn = groupTree:GetElement("__parent.bottom.cancelScanBtn") + cancelScanBtn:SetDisabled(groupTree:IsSelectionCleared()) + cancelScanBtn:Draw() +end + +function private.RunPostButtonOnclick(button) + if not TSM.UI.AuctionUI.StartingScan(L["Auctioning"]) then + return + end + wipe(private.scanContext) + for _, groupPath in button:GetElement("__parent.__parent.groupTree"):SelectedGroupsIterator() do + tinsert(private.scanContext, groupPath) + end + button:GetElement("__parent.__parent.__parent.__parent"):SetPath("scan", true) + private.fsm:ProcessEvent("EV_START_SCAN", "POST", private.scanContext) +end + +function private.RunCancelButtonOnclick(button) + if not TSM.UI.AuctionUI.StartingScan(L["Auctioning"]) then + return + end + wipe(private.scanContext) + for _, groupPath in button:GetElement("__parent.__parent.groupTree"):SelectedGroupsIterator() do + tinsert(private.scanContext, groupPath) + end + button:GetElement("__parent.__parent.__parent.__parent"):SetPath("scan", true) + private.fsm:ProcessEvent("EV_START_SCAN", "CANCEL", private.scanContext) +end + +function private.SearchListOnFavoriteChanged(_, dbRow, isFavorite) + TSM.Auctioning.SavedSearches.SetSearchIsFavorite(dbRow, isFavorite) +end + +function private.SearchListOnEditClick(searchList, dbRow) + local dialog = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(600, 187) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG") + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Text", "title") + :SetHeight(44) + :SetMargin(16, 16, 24, 16) + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Rename Search"]) + ) + :AddChild(UIElements.New("Input", "nameInput") + :SetHeight(26) + :SetMargin(16, 16, 0, 25) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AllowItemInsert(true) + :SetContext(dbRow) + :SetValue(dbRow:GetField("name")) + :SetScript("OnEnterPressed", private.RenameInputOnEnterPressed) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetMargin(16, 16, 0, 16) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("ActionButton", "closeBtn") + :SetSize(126, 26) + :SetText(CLOSE) + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + searchList:GetBaseElement():ShowDialogFrame(dialog) + dialog:GetElement("nameInput"):SetFocused(true) +end + +function private.RenameInputOnEnterPressed(input) + local name = input:GetValue() + if name == "" then + return + end + local dbRow = input:GetContext() + local baseElement = input:GetBaseElement() + baseElement:HideDialog() + TSM.Auctioning.SavedSearches.RenameSearch(dbRow, name) +end + +function private.DialogCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.SearchListOnDelete(_, dbRow) + TSM.Auctioning.SavedSearches.DeleteSearch(dbRow) +end + +function private.SearchListOnRowClick(searchList, dbRow) + if not TSM.UI.AuctionUI.StartingScan(L["Auctioning"]) then + return + end + local scanType = dbRow:GetField("searchType") + wipe(private.scanContext) + private.scanContext.isItems = scanType == "postItems" or nil + TSM.Auctioning.SavedSearches.FiltersToTable(dbRow, private.scanContext) + searchList:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement():SetPath("scan", true) + private.fsm:ProcessEvent("EV_START_SCAN", scanType == "cancelGroups" and "CANCEL" or "POST", private.scanContext) +end + +function private.PauseResumeBtnOnClick(button) + private.fsm:ProcessEvent("EV_PAUSE_RESUME_CLICKED") +end + +function private.ProcessButtonOnClick(button) + private.fsm:ProcessEvent("EV_PROCESS_CLICKED") +end + +function private.SkipButtonOnClick(button) + private.fsm:ProcessEvent("EV_SKIP_CLICKED") +end + +function private.ScanFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.fsm:ProcessEvent("EV_SCAN_FRAME_SHOWN", frame) +end + +function private.ScanFrameOnHide(frame) + private.fsm:ProcessEvent("EV_SCAN_FRAME_HIDDEN") +end + +function private.BagOnSelectionChanged(scrollingTable) + local selectionCleared = scrollingTable:IsSelectionCleared() + scrollingTable:GetElement("__parent.button.postSelected"):SetDisabled(selectionCleared) + :Draw() + scrollingTable:GetElement("__parent.button.selectAll"):SetDisabled(scrollingTable:IsAllSelected()) + :Draw() + scrollingTable:GetElement("__parent.button.clearAll"):SetDisabled(selectionCleared) + :Draw() +end + +function private.SelectAllOnClick(button) + button:GetElement("__parent.__parent.bagScrollingTable"):SelectAll() +end + +function private.ClearAllOnClick(button) + button:GetElement("__parent.__parent.bagScrollingTable"):ClearSelection() +end + +function private.RunPostBagsButtonOnClick(button) + if not TSM.UI.AuctionUI.StartingScan(L["Auctioning"]) then + return + end + wipe(private.scanContext) + private.scanContext.isItems = true + for _, row in button:GetElement("__parent.__parent.bagScrollingTable"):SelectionIterator() do + local autoBaseItemString, operation = row:GetFields("autoBaseItemString", "firstOperation") + if operation then + tinsert(private.scanContext, autoBaseItemString) + end + end + button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement():SetPath("scan", true) + private.fsm:ProcessEvent("EV_START_SCAN", "POST", private.scanContext) +end + +function private.ExitScanButtonOnEnter(button) + private.ExitScanFrameOnEnter(button:GetParentElement()) +end + +function private.ExitScanButtonOnLeave(button) + private.ExitScanFrameOnLeave(button:GetParentElement()) +end + +function private.ExitScanFrameOnEnter(frame) + frame:SetBackgroundColor("PRIMARY_BG_ALT+HOVER", true) + :Draw() +end + +function private.ExitScanFrameOnLeave(frame) + frame:SetBackgroundColor("PRIMARY_BG_ALT", true) + :Draw() +end + +function private.ExitScanButtonOnClick() + if TSM.IsWowClassic() then + ClearCursor() + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + ClearCursor() + end + private.fsm:ProcessEvent("EV_BACK_BUTTON_CLICKED") +end + +function private.EditButtonOnClick(button) + private.fsm:ProcessEvent("EV_EDIT_BUTTON_CLICKED") +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + itemString = nil, + scanFrame = nil, + scanThreadId = nil, + scanType = nil, + auctionScan = nil, + pausePending = nil, + isScanning = false, + pendingFuture = nil, + } + Event.Register("AUCTION_HOUSE_CLOSED", function() + private.fsm:ProcessEvent("EV_AUCTION_HOUSE_CLOSED") + end) + if TSM.IsWowClassic() then + Event.Register("CHAT_MSG_SYSTEM", function(_, msg) + if msg == ERR_AUCTION_STARTED then + private.fsm:SetLoggingEnabled(false) + private.fsm:ProcessEvent("EV_AUCTION_POST_CONFIRM", true) + private.fsm:SetLoggingEnabled(true) + elseif msg == ERR_AUCTION_REMOVED then + private.fsm:SetLoggingEnabled(false) + private.fsm:ProcessEvent("EV_AUCTION_CANCEL_CONFIRM", true) + private.fsm:SetLoggingEnabled(true) + end + end) + local POST_ERR_MSGS = { + -- errors where we can retry + [ERR_ITEM_NOT_FOUND] = true, + [ERR_AUCTION_DATABASE_ERROR] = true, + -- errors where we can't retry + [ERR_AUCTION_REPAIR_ITEM] = false, + [ERR_AUCTION_LIMITED_DURATION_ITEM] = false, + [ERR_AUCTION_USED_CHARGES] = false, + [ERR_AUCTION_WRAPPED_ITEM] = false, + [ERR_AUCTION_BAG] = false, + [ERR_NOT_ENOUGH_MONEY] = false, + } + Event.Register("UI_ERROR_MESSAGE", function(_, _, msg) + if POST_ERR_MSGS[msg] ~= nil then + private.fsm:ProcessEvent("EV_AUCTION_POST_CONFIRM", false, POST_ERR_MSGS[msg]) + end + if msg == ERR_ITEM_NOT_FOUND then + private.fsm:ProcessEvent("EV_AUCTION_CANCEL_CONFIRM", false, true) + end + end) + end + local fsmPrivate = {} + function fsmPrivate.UpdateDepositCost(context) + if context.scanType ~= "POST" then + return + end + + local header = context.scanFrame:GetElement("header") + local detailsHeader1 = header:GetElement("content.details.details.details1") + + local currentRow = TSM.Auctioning.PostScan.GetCurrentRow() + if not currentRow then + return + end + + local itemString = currentRow:GetField("itemString") + local postBag, postSlot = BagTracking.CreateQueryBagsAuctionable() + :OrderBy("slotId", true) + :Select("bag", "slot") + :Equal("baseItemString", ItemString.GetBaseFast(itemString)) + :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") + :Equal("autoBaseItemString", itemString) + :GetFirstResultAndRelease() + local postTime = currentRow:GetField("postTime") + local stackSize = tonumber(currentRow:GetField("stackSize")) + local depositCost = 0 + if postBag and postSlot then + if TSM.IsWowClassic() then + ClearCursor() + PickupContainerItem(postBag, postSlot) + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + ClearCursor() + local bid = Money.FromString(detailsHeader1:GetElement("bid.text"):GetText()) + local buyout = Money.FromString(detailsHeader1:GetElement("buyout.text"):GetText()) + depositCost = GetAuctionDeposit(postTime, bid, buyout, stackSize, 1) + ClearCursor() + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + ClearCursor() + else + local isCommodity = ItemInfo.IsCommodity(itemString) + depositCost = max(floor(0.15 * (ItemInfo.GetVendorSell(itemString) or 0) * (isCommodity and stackSize or 1) * (postTime == 3 and 4 or postTime)), 100) * (isCommodity and 1 or stackSize) + end + end + + header:GetElement("content.item.cost.text"):SetText(Money.ToString(depositCost)) + :Draw() + end + function fsmPrivate.UpdateScanFrame(context) + if not context.scanFrame then + return + end + + local header = context.scanFrame:GetElement("header") + local currentRow, numProcessed, numConfirmed, _, totalNum = nil, nil, nil, nil, nil + if context.scanType == "POST" then + currentRow = TSM.Auctioning.PostScan.GetCurrentRow() + numProcessed, numConfirmed, _, totalNum = TSM.Auctioning.PostScan.GetStatus() + header:GetElement("content.item.cost") + :Show() + :Draw() + elseif context.scanType == "CANCEL" then + currentRow = TSM.Auctioning.CancelScan.GetCurrentRow() + numProcessed, numConfirmed, _, totalNum = TSM.Auctioning.CancelScan.GetStatus() + header:GetElement("content.item.cost") + :Hide() + :Draw() + else + error("Invalid scan type: "..tostring(context.scanType)) + end + local itemContent = header:GetElement("content.item.content") + local detailsHeader1 = header:GetElement("content.details.details.details1") + local detailsHeader2 = header:GetElement("content.details.details.details2") + if currentRow then + local selectedRow = nil + for _, row in private.logQuery:Iterator() do + if currentRow:GetField("auctionId") == row:GetField("index") then + selectedRow = row + end + end + if selectedRow and context.scanFrame:GetElement("tabs"):GetPath() == L["Auctioning Log"] then + context.scanFrame:GetElement("tabs.logFrame.log") + :SetSelection(selectedRow:GetUUID()) + :Draw() + end + + local itemString = currentRow:GetField("itemString") + local rowStacksRemaining = currentRow:GetField("numStacks") - currentRow:GetField("numProcessed") + itemContent:GetElement("icon") + :SetBackground(ItemInfo.GetTexture(itemString)) + :SetTooltip(itemString) + :Draw() + itemContent:GetElement("text") + :SetText(TSM.UI.GetColoredItemName(itemString)) + :SetTooltip(itemString) + :Draw() + header:GetElement("content.item.operation.text") + :SetText(currentRow:GetField("operationName")) + :Draw() + detailsHeader1:GetElement("bid.text") + :SetText(Money.ToString(currentRow:GetField(ItemInfo.IsCommodity(itemString) and "itemBuyout" or "bid"), nil, "OPT_83_NO_COPPER")) + :Draw() + detailsHeader1:GetElement("buyout.text") + :SetText(Money.ToString(currentRow:GetField(ItemInfo.IsCommodity(itemString) and "itemBuyout" or "buyout"), nil, "OPT_83_NO_COPPER")) + :Draw() + detailsHeader2:GetElement("quantity.text") + :SetText(TSM.IsWowClassic() and format(L["%d of %d"], rowStacksRemaining, currentRow:GetField("stackSize")) or currentRow:GetField("stackSize")) + :Draw() + local duration = nil + if context.scanType == "POST" then + duration = TSM.CONST.AUCTION_DURATIONS[currentRow:GetField("postTime")] + elseif context.scanType == "CANCEL" then + if TSM.IsWowClassic() then + duration = _G["AUCTION_TIME_LEFT"..currentRow:GetField("duration")] + else + duration = currentRow:GetField("duration") - time() + if duration < SECONDS_PER_MIN then + duration = duration.."s" + elseif duration < SECONDS_PER_HOUR then + duration = floor(duration / SECONDS_PER_MIN).."m" + elseif duration < SECONDS_PER_DAY then + duration = floor(duration / SECONDS_PER_HOUR).."h" + else + duration = floor(duration / SECONDS_PER_DAY).."d" + end + end + else + error("Invalid scanType: "..tostring(context.scanType)) + end + detailsHeader2:GetElement("duration.text") + :SetText(duration) + :Draw() + if context.scanType == "POST" and context.itemString ~= itemString then + fsmPrivate.UpdateDepositCost(context) + context.itemString = itemString + end + header:GetElement("content.details.header.editBtn") + :SetDisabled(context.scanType ~= "POST") + :Draw() + else + itemContent:GetElement("icon") + :SetBackground(nil) + :SetTooltip(nil) + :Draw() + itemContent:GetElement("text") + :SetText("-") + :SetTooltip(nil) + :Draw() + header:GetElement("content.item.cost.text") + :SetText("-") + :Draw() + header:GetElement("content.item.operation.text") + :SetText("-") + :Draw() + detailsHeader1:GetElement("bid.text") + :SetText("-") + :Draw() + detailsHeader1:GetElement("buyout.text") + :SetText("-") + :Draw() + detailsHeader2:GetElement("quantity.text") + :SetText("-") + :Draw() + detailsHeader2:GetElement("duration.text") + :SetText("-") + :Draw() + if context.scanFrame:GetElement("tabs"):GetPath() == L["Auctioning Log"] then + context.scanFrame:GetElement("tabs.logFrame.log") + :SetSelection(nil) + :Draw() + end + header:GetElement("content.details.header.editBtn") + :SetDisabled(true) + :Draw() + end + + local processText = nil + if context.scanType == "POST" then + processText = L["Post"] + elseif context.scanType == "CANCEL" then + processText = CANCEL + else + error("Invalid scan type: "..tostring(context.scanType)) + end + local bottom = context.scanFrame:GetElement("bottom") + bottom:GetElement("processBtn") + :SetText(processText) + local progress, isPaused = context.auctionScan:GetProgress() + local scanDone = progress == 1 and not context.isScanning + local isPausePending = context.pausePending ~= nil + if not isPausePending and (scanDone or isPaused) then + -- we're done (or paused) scanning so start Posting/Canceling + local doneStr, progressFmtStr = nil, nil + if context.scanType == "POST" then + doneStr = L["Done Posting"] + progressFmtStr = (isPaused and (L["Scan Paused"].." | ") or "")..L["Posting %d / %d"] + elseif context.scanType == "CANCEL" then + doneStr = L["Done Canceling"] + progressFmtStr = (isPaused and (L["Scan Paused"].." | ") or "")..L["Canceling %d / %d"] + else + error("Invalid scan type: "..tostring(context.scanType)) + end + local progressText, iconHidden = nil, false + if numConfirmed == totalNum then + progressText = doneStr + iconHidden = true + elseif numProcessed == totalNum then + progressText = format(L["Confirming %d / %d"], numConfirmed + 1, totalNum) + elseif numProcessed == numConfirmed then + progressText = format(progressFmtStr, numProcessed + 1, totalNum) + iconHidden = true + else + progressText = format(progressFmtStr.." ("..L["Confirming %d / %d"]..")", numProcessed + 1, totalNum, numConfirmed + 1, totalNum) + end + bottom:GetElement("progressBar") + :SetProgress(totalNum > 0 and (numProcessed / totalNum) or 1) + :SetProgressIconHidden(iconHidden) + :SetText(progressText) + local deposit = context.scanType == "POST" and Money.FromString(header:GetElement("content.item.cost.text"):GetText()) + bottom:GetElement("processBtn"):SetDisabled(numProcessed == totalNum or (deposit and deposit > GetMoney()) or (not TSM.IsWowClassic() and context.pendingFuture)) + bottom:GetElement("skipBtn"):SetDisabled(numProcessed == totalNum) + bottom:GetElement("pauseResumeBtn") + :SetDisabled(numProcessed ~= numConfirmed or scanDone) + :SetHighlightLocked(false) + else + -- we're scanning or pausing + local text = nil + if isPausePending then + text = isPaused and L["Resuming Scan..."] or L["Pausing Scan..."] + else + local numItems = context.auctionScan:GetNumItems() + text = numItems and format(L["Scanning (%d Items)"], numItems) or L["Scanning"] + end + bottom:GetElement("progressBar") + :SetProgress(progress) + :SetProgressIconHidden(false) + :SetText(text) + bottom:GetElement("processBtn"):SetDisabled(true) + bottom:GetElement("skipBtn"):SetDisabled(true) + bottom:GetElement("pauseResumeBtn") + :SetDisabled(isPausePending or numProcessed == totalNum) + :SetHighlightLocked(isPausePending) + end + bottom:Draw() + end + function fsmPrivate.ShowEditDialog(context) + if context.scanType ~= "POST" then + return + end + local currentRow = TSM.Auctioning.PostScan.GetCurrentRow() + local itemString = currentRow:GetField("itemString") + local isCommodity = ItemInfo.IsCommodity(itemString) + local bid = currentRow:GetField(isCommodity and "itemBuyout" or "bid") + local buyout = currentRow:GetField(isCommodity and "itemBuyout" or "buyout") + + private.perItem = true + + context.scanFrame:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(328, 328) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(24) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Edit Post"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.EditDialogCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "item") + :SetLayout("HORIZONTAL") + :SetPadding(6) + :SetMargin(0, 0, 0, 16) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Button", "icon") + :SetSize(36, 36) + :SetMargin(0, 8, 0, 0) + :SetBackground(ItemInfo.GetTexture(itemString)) + :SetTooltip(itemString) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(36) + :SetFont("ITEM_BODY1") + :SetText(TSM.UI.GetColoredItemName(itemString)) + ) + ) + -- TODO: implement editing stack sizes + :AddChild(UIElements.New("Frame", "stacksFrame") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Text", "stacksText") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Stack(s)"]..":") + ) + :AddChild(UIElements.New("Input", "stacksInput") + :SetSize(62, 24) + :SetMargin(0, 16, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValidateFunc("NUMBER", "1:5000") + :SetDisabled(true) + :SetValue(currentRow:GetField("numStacks")) + ) + :AddChild(UIElements.New("Text", "quantityText") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Quantity"]..":") + ) + :AddChild(UIElements.New("Input", "quantityInput") + :SetSize(62, 24) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValidateFunc("NUMBER", "1:5000") + :SetDisabled(true) + :SetValue(currentRow:GetField("stackSize")) + ) + ) + :AddChild(UIElements.New("Frame", "duration") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 24) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Duration"]..":") + ) + :AddChild(UIElements.New("Toggle", "toggle") + :SetMargin(0, 48, 0, 0) + :AddOption(TSM.CONST.AUCTION_DURATIONS[1]) + :AddOption(TSM.CONST.AUCTION_DURATIONS[2]) + :AddOption(TSM.CONST.AUCTION_DURATIONS[3]) + :SetOption(TSM.CONST.AUCTION_DURATIONS[currentRow:GetField("postTime")]) + ) + ) + :AddChild(UIElements.New("Frame", "per") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "item") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetTextColor("INDICATOR") + :SetText(L["Per Item"]) + :SetScript("OnClick", TSM.IsWowClassic() and private.PerItemOnClick) + ) + :AddChildIf(TSM.IsWowClassic(), UIElements.New("Button", "stack") + :SetWidth("AUTO") + :SetTextColor("TEXT") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetTextColor("INDICATOR") + :SetText(L["Per Stack"]) + :SetScript("OnClick", TSM.IsWowClassic() and private.PerStackOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "bid") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Bid Price"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Input", "input") + :SetWidth(132) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetFont("TABLE_TABLE1") + :SetValidateFunc(private.BidBuyoutValidateFunc) + :SetJustifyH("RIGHT") + :SetDisabled(isCommodity) + :SetTabPaths("__parent.__parent.buyout.input", "__parent.__parent.buyout.input") + :SetContext("bid") + :SetValue(Money.ToString(bid, nil, "OPT_83_NO_COPPER")) + :SetScript("OnValidationChanged", private.BidBuyoutOnValidationChanged) + :SetScript("OnValueChanged", private.BidBuyoutInputOnValueChanged) + :SetScript("OnEnterPressed", private.BidBuyoutInputOnEnterPressed) + ) + ) + :AddChild(UIElements.New("Frame", "buyout") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Buyout Price"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Input", "input") + :SetWidth(132) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetFont("TABLE_TABLE1") + :SetValidateFunc(private.BidBuyoutValidateFunc) + :SetJustifyH("RIGHT") + :SetContext(isCommodity and "itemBuyout" or "buyout") + :SetTabPaths("__parent.__parent.bid.input", "__parent.__parent.bid.input") + :SetValue(Money.ToString(buyout, nil, "OPT_83_NO_COPPER")) + :SetScript("OnValidationChanged", private.BidBuyoutOnValidationChanged) + :SetScript("OnValueChanged", private.BidBuyoutInputOnValueChanged) + :SetScript("OnEnterPressed", private.BidBuyoutInputOnEnterPressed) + ) + ) + :AddChild(UIElements.New("ActionButton", "saveBtn") + :SetHeight(24) + :SetText(SAVE) + :SetContext(context) + :SetScript("OnClick", fsmPrivate.EditPostingDetailsSaveOnClick) + ) + ) + end + function fsmPrivate.EditPostingDetailsSaveOnClick(button) + local context = button:GetContext() + assert(context.scanType == "POST") + local currentRow = TSM.Auctioning.PostScan.GetCurrentRow() + + local buyout = private.ParseBidBuyout(button:GetElement("__parent.buyout.input"):GetValue()) + local bid = min(private.ParseBidBuyout(button:GetElement("__parent.bid.input"):GetValue()), buyout) + if not TSM.IsWowClassic() and ItemInfo.IsCommodity(context.itemString) and bid ~= buyout then + Log.PrintUser(L["Did not change prices due to an invalid bid or buyout value."]) + else + local duration = Table.KeyByValue(TSM.CONST.AUCTION_DURATIONS, button:GetElement("__parent.duration.toggle"):GetValue()) + if duration ~= currentRow:GetField("postTime") then + TSM.Auctioning.PostScan.ChangePostDetail("postTime", duration) + end + + -- update buyout first since doing so may change the bid + if buyout ~= currentRow:GetField("buyout") then + TSM.Auctioning.PostScan.ChangePostDetail("buyout", private.perItem and buyout or buyout / currentRow:GetField("stackSize")) + end + + if not ItemInfo.IsCommodity(context.itemString) and bid ~= currentRow:GetField("bid") then + TSM.Auctioning.PostScan.ChangePostDetail("bid", private.perItem and bid or bid / currentRow:GetField("stackSize")) + end + end + + fsmPrivate.UpdateDepositCost(context) + fsmPrivate.UpdateScanFrame(context) + button:GetBaseElement():HideDialog() + end + private.fsm = FSM.New("AUCTIONING") + :AddState(FSM.NewState("ST_INIT") + :SetOnEnter(function(context, scanType, scanContext) + private.hasLastScan = false + TSM.Auctioning.Log.Truncate() + TSM.Auctioning.PostScan.Reset() + TSM.Auctioning.CancelScan.Reset() + + if context.scanThreadId then + Threading.Kill(context.scanThreadId) + context.scanThreadId = nil + end + context.pausePending = nil + context.itemString = nil + context.isScanning = false + if context.auctionScan then + context.auctionScan:Release() + context.auctionScan = nil + private.auctionScan = context.auctionScan + end + if context.pendingFuture then + context.pendingFuture:Cancel() + context.pendingFuture = nil + end + + if scanType then + return "ST_STARTING_SCAN", scanType, scanContext + elseif context.scanFrame then + context.scanFrame:GetParentElement():SetPath("selection", true) + context.scanFrame = nil + end + TSM.UI.AuctionUI.EndedScan(L["Auctioning"]) + end) + :AddTransition("ST_INIT") + :AddTransition("ST_STARTING_SCAN") + ) + :AddState(FSM.NewState("ST_STARTING_SCAN") + :SetOnEnter(function(context, scanType, scanContext) + private.hasLastScan = true + context.scanType = scanType + if context.scanType == "POST" then + context.scanThreadId = TSM.Auctioning.PostScan.Prepare() + private.logQuery:ResetOrderBy() + private.logQuery:OrderBy("index", true) + elseif context.scanType == "CANCEL" then + context.scanThreadId = TSM.Auctioning.CancelScan.Prepare() + private.logQuery:ResetOrderBy() + private.logQuery:OrderBy("index", false) + else + error("Invalid scan type: "..tostring(context.scanType)) + end + context.auctionScan = AuctionScan.GetManager() + :SetResolveSellers(true) + :SetScript("OnProgressUpdate", private.FSMAuctionScanOnProgressUpdate) + private.auctionScan = context.auctionScan + fsmPrivate.UpdateScanFrame(context) + Threading.SetCallback(context.scanThreadId, private.FSMScanCallback) + Threading.Start(context.scanThreadId, context.auctionScan, scanContext) + return "ST_SCANNING" + end) + :AddTransition("ST_SCANNING") + ) + :AddState(FSM.NewState("ST_SCANNING") + :SetOnEnter(function(context) + context.isScanning = true + fsmPrivate.UpdateScanFrame(context) + end) + :SetOnExit(function(context) + context.isScanning = false + end) + :AddTransition("ST_RESULTS") + :AddTransition("ST_INIT") + :AddEvent("EV_SCAN_PROGRESS_UPDATE", function(context) + local _, isPaused = context.auctionScan:GetProgress() + if context.pausePending == isPaused then + context.pausePending = nil + end + if isPaused and context.pausePending == nil then + return "ST_RESULTS" + else + fsmPrivate.UpdateScanFrame(context) + end + end) + :AddEvent("EV_SCAN_COMPLETE", function(context) + Sound.PlaySound(private.settings.scanCompleteSound) + return "ST_RESULTS" + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + assert(context.pausePending == nil) + context.pausePending = true + context.auctionScan:SetPaused(true) + fsmPrivate.UpdateScanFrame(context) + end) + :AddEvent("EV_EDIT_BUTTON_CLICKED", function(context) + fsmPrivate.ShowEditDialog(context) + end) + ) + :AddState(FSM.NewState("ST_HANDLING_CONFIRM") + :SetOnEnter(function(context, success, canRetry) + local isDone = false + if context.scanType == "POST" then + TSM.Auctioning.PostScan.HandleConfirm(success, canRetry) + local _, numConfirmed, numFailed, totalNum = TSM.Auctioning.PostScan.GetStatus() + if numConfirmed == totalNum then + if numFailed > 0 then + -- TODO: need to wait for the player's bags to settle + Log.PrintfUser(L["Retrying %d auction(s) which failed."], numFailed) + TSM.Auctioning.PostScan.PrepareFailedPosts() + else + isDone = true + end + end + elseif context.scanType == "CANCEL" then + TSM.Auctioning.CancelScan.HandleConfirm(success, canRetry) + local _, numConfirmed, numFailed, totalNum = TSM.Auctioning.CancelScan.GetStatus() + if numConfirmed == totalNum then + if numFailed > 0 then + -- TODO: need to wait for the player's auctions to settle + Log.PrintfUser(L["Retrying %d auction(s) which failed."], numFailed) + TSM.Auctioning.CancelScan.PrepareFailedCancels() + else + isDone = true + end + end + else + error("Invalid scan type: "..tostring(context.scanType)) + end + local _, isPaused = context.auctionScan:GetProgress() + if not isDone then + return "ST_RESULTS" + elseif isPaused then + -- unpause the scan now that we're done + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_SCANNING" + else + return "ST_DONE" + end + end) + :AddTransition("ST_SCANNING") + :AddTransition("ST_RESULTS") + :AddTransition("ST_DONE") + ) + :AddState(FSM.NewState("ST_RESULTS") + :SetOnEnter(function(context) + local _, isPaused = context.auctionScan:GetProgress() + if not isPaused then + TSM.UI.AuctionUI.EndedScan(L["Auctioning"]) + Threading.Kill(context.scanThreadId) + end + fsmPrivate.UpdateScanFrame(context) + end) + :AddTransition("ST_INIT") + :AddTransition("ST_HANDLING_CONFIRM") + :AddTransition("ST_SCANNING") + :AddTransition("ST_DONE") + :AddEvent("EV_PROCESS_CLICKED", function(context) + context.scanFrame:GetBaseElement():HideDialog() + local result, noRetry = nil, nil + if context.scanType == "POST" then + result, noRetry = TSM.Auctioning.PostScan.DoProcess() + elseif context.scanType == "CANCEL" then + result, noRetry = TSM.Auctioning.CancelScan.DoProcess() + else + error("Invalid scan type: "..tostring(context.scanType)) + end + if not result then + -- we failed to post / cancel + return "ST_HANDLING_CONFIRM", false, not noRetry + elseif not TSM.IsWowClassic() then + context.pendingFuture = result + context.pendingFuture:SetScript("OnDone", private.FSMPendingFutureOneDone) + end + fsmPrivate.UpdateScanFrame(context) + end) + :AddEvent("EV_SKIP_CLICKED", function(context) + local isDone = nil + if context.scanType == "POST" then + TSM.Auctioning.PostScan.DoSkip() + local _, numConfirmed, numFailed, totalNum = TSM.Auctioning.PostScan.GetStatus() + isDone = numConfirmed == totalNum and numFailed == 0 + elseif context.scanType == "CANCEL" then + TSM.Auctioning.CancelScan.DoSkip() + local _, numConfirmed, numFailed, totalNum = TSM.Auctioning.CancelScan.GetStatus() + isDone = numConfirmed == totalNum and numFailed == 0 + else + error("Invalid scan type: "..tostring(context.scanType)) + end + fsmPrivate.UpdateScanFrame(context) + local _, isPaused = context.auctionScan:GetProgress() + if isDone and isPaused then + -- unpause the scan now that we're done + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_SCANNING" + elseif isDone then + return "ST_DONE" + end + end) + :AddEvent("EV_PENDING_FUTURE_DONE", function(context) + assert(context.pendingFuture:IsDone()) + local value = context.pendingFuture:GetValue() + context.pendingFuture = nil + if value == true then + return "ST_HANDLING_CONFIRM", true, false + elseif value == false then + return "ST_HANDLING_CONFIRM", false, true + elseif value == nil then + return "ST_HANDLING_CONFIRM", false, false + else + error("Invalid value: "..tostring(value)) + end + end) + :AddEvent("EV_AUCTION_POST_CONFIRM", function(context, success, canRetry) + if context.scanType == "POST" then + return "ST_HANDLING_CONFIRM", success, canRetry + end + end) + :AddEvent("EV_AUCTION_CANCEL_CONFIRM", function(context, success, canRetry) + if context.scanType == "CANCEL" then + return "ST_HANDLING_CONFIRM", success, canRetry + end + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_SCANNING" + end) + :AddEvent("EV_EDIT_BUTTON_CLICKED", function(context) + fsmPrivate.ShowEditDialog(context) + end) + ) + :AddState(FSM.NewState("ST_DONE") + :SetOnEnter(function(context) + private.canStartNewScan = true + AuctionTracking.QueryOwnedAuctions() + Sound.PlaySound(private.settings.confirmCompleteSound) + fsmPrivate.UpdateScanFrame(context) + end) + :SetOnExit(function(context) + private.canStartNewScan = false + end) + :AddTransition("ST_INIT") + ) + :AddDefaultEvent("EV_START_SCAN", function(context, scanType, scanContext) + return "ST_INIT", scanType, scanContext + end) + :AddDefaultEvent("EV_SCAN_FRAME_SHOWN", function(context, scanFrame) + context.scanFrame = scanFrame + fsmPrivate.UpdateScanFrame(context) + end) + :AddDefaultEvent("EV_SCAN_FRAME_HIDDEN", function(context) + context.scanFrame = nil + context.itemString = nil + end) + :AddDefaultEventTransition("EV_BACK_BUTTON_CLICKED", "ST_INIT") + :AddDefaultEventTransition("EV_AUCTION_HOUSE_CLOSED", "ST_INIT") + :AddDefaultEvent("EV_PENDING_FUTURE_DONE", function(context) + error("Unexpected pending future done event") + end) + :Init("ST_INIT", fsmContext) +end + +function private.FSMAuctionScanOnProgressUpdate(auctionScan) + -- this even is very spammy while we scan, so silence the FSM logging + private.fsm:SetLoggingEnabled(false) + private.fsm:ProcessEvent("EV_SCAN_PROGRESS_UPDATE") + private.fsm:SetLoggingEnabled(true) +end + +function private.FSMScanCallback() + private.fsm:ProcessEvent("EV_SCAN_COMPLETE") +end + +function private.FSMPendingFutureOneDone(value) + private.fsm:ProcessEvent("EV_PENDING_FUTURE_DONE") +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.BagScrollingTableIsSelectionEnabled(_, record) + return record:GetField("firstOperation") and true or false +end + +function private.BagGetOperationText(firstOperation) + return firstOperation or Theme.GetFeedbackColor("RED"):ColorText(L["Skipped: No assigned operation"]) +end + +function private.LogGetBuyoutText(buyout) + return buyout == 0 and "-" or Money.ToString(buyout, nil, "OPT_83_NO_COPPER") +end + +function private.LogGetIndexIcon(row) + if row:GetField("state") == "PENDING" then + -- color the circle icon to match the color of the text + return TSM.UI.TexturePacks.GetColoredKey("iconPack.12x12/Circle", TSM.Auctioning.Log.GetColorFromReasonKey(row:GetField("reasonKey"))) + else + return "iconPack.12x12/Checkmark/Default" + end +end + +function private.MarketValueFunction(row) + return CustomPrice.GetValue("dbmarket", row:GetItemString() or row:GetBaseItemString()) +end + +function private.EditDialogCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.PerItemOnClick(button) + if private.perItem then + return + end + + private.perItem = true + button:GetElement("__parent.stack") + :SetTextColor("TEXT") + :Draw() + button:SetTextColor("INDICATOR") + :Draw() + + local row = TSM.Auctioning.PostScan.GetCurrentRow() + local bidInput = button:GetElement("__parent.__parent.bid.input") + local buyoutInput = button:GetElement("__parent.__parent.buyout.input") + buyoutInput:SetFocused(false) + :SetValue(row:GetField("buyout")) + :Draw() + bidInput:SetFocused(false) + :SetValue(row:GetField("bid")) + :Draw() +end + +function private.PerStackOnClick(button) + if not private.perItem then + return + end + + private.perItem = false + button:GetElement("__parent.item") + :SetTextColor("TEXT") + :Draw() + button:SetTextColor("INDICATOR") + :Draw() + + local row = TSM.Auctioning.PostScan.GetCurrentRow() + local stackSize = row:GetField("stackSize") + local bidInput = button:GetElement("__parent.__parent.bid.input") + local buyoutInput = button:GetElement("__parent.__parent.buyout.input") + buyoutInput:SetFocused(false) + :SetValue(row:GetField("buyout") * stackSize) + :Draw() + bidInput:SetFocused(false) + :SetValue(row:GetField("bid") * stackSize) + :Draw() +end + +function private.UpdateSaveButtonState(frame) + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local bid = private.ParseBidBuyout(bidInput:GetValue()) + local buyout = private.ParseBidBuyout(buyoutInput:GetValue()) + frame:GetElement("saveBtn") + :SetDisabled(bid > buyout or not bidInput:IsValid() or not buyoutInput:IsValid()) + :Draw() +end + +function private.BidBuyoutOnValidationChanged(input) + private.UpdateSaveButtonState(input:GetElement("__parent.__parent")) +end + +function private.BidBuyoutInputOnValueChanged(input) + local frame = input:GetElement("__parent.__parent") + local context = frame:GetElement("saveBtn"):GetContext() + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local bid = private.ParseBidBuyout(bidInput:GetValue()) + local buyout = private.ParseBidBuyout(buyoutInput:GetValue()) + if input == buyoutInput and not TSM.IsWowClassic() and ItemInfo.IsCommodity(context.itemString) then + -- update the bid to match + bidInput:SetValue(Money.ToString(buyout, nil, "OPT_83_NO_COPPER", "OPT_DISABLE")) + :Draw() + elseif input == bidInput and private.ParseBidBuyout(input:GetValue()) > private.ParseBidBuyout(buyoutInput:GetValue()) then + -- update the buyout to match + buyoutInput:SetValue(Money.ToString(bid, nil, "OPT_83_NO_COPPER")) + :Draw() + end + private.UpdateSaveButtonState(frame) +end + +function private.BidBuyoutInputOnEnterPressed(input) + local frame = input:GetElement("__parent.__parent") + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local value = private.ParseBidBuyout(input:GetValue()) + input:SetValue(Money.ToString(value, nil, "OPT_83_NO_COPPER")) + input:Draw() + if input == buyoutInput and private.ParseBidBuyout(buyoutInput:GetValue()) < private.ParseBidBuyout(bidInput:GetValue()) then + -- update the bid to match + bidInput:SetValue(Money.ToString(value, nil, "OPT_83_NO_COPPER")) + :Draw() + end + private.UpdateSaveButtonState(frame) +end + +function private.ParseBidBuyout(value) + value = Money.FromString(value) or tonumber(value) + if not value then + return nil + end + if not TSM.IsWowClassic() and value % COPPER_PER_SILVER ~= 0 then + return nil + end + return (value or 0) > 0 and value <= MAXIMUM_BID_PRICE and value or nil +end + +function private.BidBuyoutValidateFunc(input, value) + value = private.ParseBidBuyout(value) + if not value then + return false, L["Invalid price."] + end + return true +end diff --git a/Core/UI/AuctionUI/BuyUtil.lua b/Core/UI/AuctionUI/BuyUtil.lua new file mode 100644 index 0000000..dbbdf63 --- /dev/null +++ b/Core/UI/AuctionUI/BuyUtil.lua @@ -0,0 +1,441 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local BuyUtil = TSM.UI.AuctionUI:NewPackage("BuyUtil") +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local Log = TSM.Include("Util.Log") +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local UIElements = TSM.Include("UI.UIElements") +local private = { + totalBuyout = nil, + isBuy = nil, + auctionScan = nil, + subRow = nil, + index = nil, + noSeller = nil, + baseFrame = nil, + dialogFrame = nil, + future = nil, + prepareQuantity = nil, + prepareSuccess = false, + marketValueFunc = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function BuyUtil.ShowConfirmation(baseFrame, subRow, isBuy, auctionNum, numFound, maxQuantity, callback, auctionScan, index, noSeller, marketValueFunc) + auctionNum = min(auctionNum, numFound) + local buyout = subRow:GetBuyouts() + if not isBuy then + buyout = subRow:GetRequiredBid(subRow) + end + local quantity = subRow:GetQuantities() + local itemString = subRow:GetItemString() + local _, _, _, isHighBidder = subRow:GetBidInfo() + local isCommodity = not TSM.IsWowClassic() and subRow:IsCommodity() + local shouldConfirm = false + if isCommodity then + shouldConfirm = true + elseif isBuy and isHighBidder then + shouldConfirm = true + elseif TSM.db.global.shoppingOptions.buyoutConfirm then + shouldConfirm = ceil(buyout / quantity) >= (CustomPrice.GetValue(TSM.db.global.shoppingOptions.buyoutAlertSource, itemString) or 0) + end + if not shouldConfirm then + return false + end + + baseFrame = baseFrame:GetBaseElement() + private.isBuy = isBuy + private.auctionScan = auctionScan + private.subRow = subRow + private.index = index + private.noSeller = noSeller + private.baseFrame = baseFrame + private.marketValueFunc = marketValueFunc + if private.dialogFrame then + return true + end + + local defaultQuantity = isCommodity and numFound or 1 + assert(not isCommodity or isBuy) + + local displayItemBuyout, displayTotalBuyout = nil, nil + if isCommodity then + displayTotalBuyout = private.CommodityResultsByQuantity(itemString, defaultQuantity) + displayItemBuyout = Math.Ceil(displayTotalBuyout / defaultQuantity, COPPER_PER_SILVER) + else + displayItemBuyout = ceil(buyout / quantity) + displayTotalBuyout = TSM.IsWowClassic() and buyout or ceil(buyout / quantity) + end + + private.dialogFrame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(isCommodity and 600 or 326, isCommodity and 272 or 262) + :SetPadding(12) + :AddAnchor("CENTER") + :SetContext(callback) + :SetMouseEnabled(true) + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(20) + ) + :AddChild(UIElements.New("Text", "title") + :SetJustifyH("CENTER") + :SetFont("BODY_BODY1_BOLD") + :SetText(isCommodity and L["Order Confirmation"] or L["Buyout"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.BuyoutConfirmCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Frame", "left") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "item") + :SetLayout("HORIZONTAL") + :SetPadding(6) + :SetMargin(0, 0, 0, 16) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Button", "icon") + :SetSize(36, 36) + :SetMargin(0, 8, 0, 0) + :SetBackground(ItemInfo.GetTexture(itemString)) + :SetTooltip(itemString) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(36) + :SetFont("ITEM_BODY1") + :SetText(TSM.UI.GetColoredItemName(itemString)) + ) + ) + :AddChildIf(isCommodity, UIElements.New("Frame", "quantity") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Text", "desc") + :SetFont("BODY_BODY2") + :SetText(L["Quantity"]..":") + ) + :AddChild(UIElements.New("Input", "input") + :SetWidth(140) + :SetJustifyH("RIGHT") + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValidateFunc("NUMBER", "1:"..maxQuantity) + :SetValue(tostring(defaultQuantity)) + :SetContext(maxQuantity) + :SetScript("OnValueChanged", private.InputQtyOnValueChanged) + ) + ) + :AddChildIf(not isCommodity, UIElements.New("Frame", "stacks") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(isBuy and L["Purchasing Auction"]..":" or L["Bidding Auction"]..":") + ) + :AddChild(UIElements.New("Text", "number") + :SetJustifyH("RIGHT") + :SetFont("TABLE_TABLE1") + :SetText(auctionNum.."/"..numFound) + ) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Frame", "price") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Unit Price"]..":") + ) + :AddChild(UIElements.New("Text", "money") + :SetJustifyH("RIGHT") + :SetFont("TABLE_TABLE1") + :SetContext(displayItemBuyout) + :SetText(private.GetUnitPriceMoneyStr(displayItemBuyout)) + ) + ) + :AddChild(UIElements.New("Frame", "total") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Total Price"]..":") + ) + :AddChild(UIElements.New("Text", "money") + :SetJustifyH("RIGHT") + :SetFont("TABLE_TABLE1") + :SetText(Money.ToString(displayTotalBuyout, nil, "OPT_83_NO_COPPER")) + ) + ) + :AddChild(UIElements.New("Text", "warning") + :SetHeight(20) + :SetMargin(0, 0, 0, 10) + :SetFont("BODY_BODY3") + :SetJustifyH("CENTER") + :SetTextColor(Theme.GetFeedbackColor("YELLOW")) + :SetText("") + ) + :AddChild(UIElements.NewNamed("ActionButton", "confirmBtn", "TSMBidBuyConfirmBtn") + :SetHeight(24) + :SetText(isCommodity and L["Buy Commodity"] or (isBuy and L["Buy Auction"] or L["Bid Auction"])) + :SetContext(not isCommodity and quantity or nil) + :SetDisabled(private.future and true or false) + :SetScript("OnClick", private.ConfirmBtnOnClick) + ) + ) + :AddChildIf(isCommodity, UIElements.New("Frame", "item") + :SetLayout("HORIZONTAL") + :SetWidth(266) + :SetMargin(12, 0, 0, 0) + :SetPadding(4, 4, 8, 8) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("CommodityList", "items") + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetData(subRow:GetResultRow()) + :SetMarketValueFunction(marketValueFunc) + :SetAlertThreshold(TSM.db.global.shoppingOptions.buyoutConfirm and (CustomPrice.GetValue(TSM.db.global.shoppingOptions.buyoutAlertSource, itemString) or 0) or nil) + :SelectQuantity(defaultQuantity) + :SetScript("OnRowClick", private.CommodityOnRowClick) + ) + ) + ) + :SetScript("OnHide", private.DialogOnHide) + baseFrame:ShowDialogFrame(private.dialogFrame) + private.prepareQuantity = nil + private.Prepare(defaultQuantity) + return true +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.DialogOnHide() + if private.future then + private.future:Cancel() + private.future = nil + end + private.baseFrame = nil + private.dialogFrame = nil + private.isBuy = nil + private.auctionScan = nil + private.subRow = nil + private.index = nil + private.noSeller = nil +end + +function private.BuyoutConfirmCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.EnableFutureOnDone() + local result = private.future:GetValue() + private.future = nil + if not result or result ~= (private.subRow:IsCommodity() and private.totalBuyout or select(2, private.subRow:GetBuyouts())) then + -- the unit price changed + Log.PrintUser(L["Failed to buy auction."]) + private.baseFrame:HideDialog() + return + end + local input = private.dialogFrame:GetElement("content.left.quantity.input") + private.prepareSuccess = tonumber(input:GetValue()) == private.prepareQuantity + private.UpdateConfirmButton() +end + +function private.GetUnitPriceMoneyStr(itemBuyout) + local priceStr = Money.ToString(itemBuyout, nil, "OPT_83_NO_COPPER") + local marketValueStr = nil + local marketValue = private.marketValueFunc(private.subRow) + local pct = marketValue and marketValue > 0 and itemBuyout > 0 and Math.Round(100 * itemBuyout / marketValue) or nil + if pct then + local pctColor = Theme.GetAuctionPercentColor(pct) + if pct > 999 then + marketValueStr = pctColor:ColorText(">999%") + else + marketValueStr = pctColor:ColorText(pct.."%") + end + else + marketValueStr = "---" + end + return format("%s (%s)", priceStr, marketValueStr) +end + +function private.CommodityResultsByQuantity(itemString, quantity) + local remainingQuantity = quantity + local totalPrice, maxPrice = 0, 0 + for _, query in private.auctionScan:QueryIterator() do + for _, subRow in query:ItemSubRowIterator(itemString) do + if remainingQuantity > 0 then + local _, itemBuyout = subRow:GetBuyouts() + local _, numOwnerItems = subRow:GetOwnerInfo() + local quantityAvailable = subRow:GetQuantities() - numOwnerItems + local quantityToBuy = min(quantityAvailable, remainingQuantity) + totalPrice = totalPrice + (itemBuyout * quantityToBuy) + remainingQuantity = remainingQuantity - quantityToBuy + maxPrice = max(maxPrice, itemBuyout) + end + end + end + return totalPrice, maxPrice +end + +function private.InputQtyOnValueChanged(input, noListUpdate) + local quantity = tonumber(input:GetValue()) + input:SetValue(quantity) + local totalBuyout = private.subRow:IsCommodity() and private.CommodityResultsByQuantity(private.subRow:GetItemString(), quantity) or input:GetElement("__parent.__parent.price.money"):GetContext() * quantity + local totalQuantity = quantity + local itemBuyout = totalQuantity > 0 and Math.Ceil(totalBuyout / totalQuantity, COPPER_PER_SILVER) or 0 + input:GetElement("__parent.__parent.price.money") + :SetContext(itemBuyout) + :SetText(private.GetUnitPriceMoneyStr(itemBuyout)) + :Draw() + input:GetElement("__parent.__parent.total.money") + :SetText(Money.ToString(totalBuyout, nil, "OPT_83_NO_COPPER")) + :Draw() + if not noListUpdate then + input:GetElement("__parent.__parent.__parent.item.items") + :SelectQuantity(quantity) + end + if quantity ~= private.prepareQuantity then + private.prepareSuccess = false + private.prepareQuantity = nil + end + private.UpdateConfirmButton() +end + +function private.CommodityOnRowClick(list, index) + local input = list:GetElement("__parent.__parent.left.quantity.input") + input:SetValue(list:GetTotalQuantity(index)) + :Draw() + private.Prepare(tonumber(input:GetValue())) + private.InputQtyOnValueChanged(input, true) +end + +function private.ConfirmBtnOnClick(button) + local inputQuantity = nil + if not TSM.IsWowClassic() and private.subRow:IsCommodity() then + local input = private.dialogFrame:GetElement("content.left.quantity.input") + inputQuantity = tonumber(input:GetValue()) + if not private.prepareSuccess and not TSM.IsWowClassic() then + -- this is a prepare click + private.Prepare(inputQuantity) + return + end + assert(private.prepareQuantity == inputQuantity) + end + + local isBuy = private.isBuy + local callbackQuantity = button:GetContext() + if callbackQuantity == nil then + assert(inputQuantity) + callbackQuantity = inputQuantity + end + local callback = button:GetElement("__parent.__parent.__parent"):GetContext() + button:GetBaseElement():HideDialog() + callback(isBuy, callbackQuantity) +end + +function private.UpdateConfirmButton() + local confirmBtn = private.dialogFrame:GetElement("content.left.confirmBtn") + local text, disabled, requireManualClick = nil, false, false + if not TSM.IsWowClassic() and private.subRow:IsCommodity() then + local input = confirmBtn:GetElement("__parent.quantity.input") + local inputQuantity = tonumber(input:GetValue()) + local minQuantity = 1 + local maxQuantity = confirmBtn:GetElement("__parent.quantity.input"):GetContext() + local itemString = private.subRow:GetItemString() + local totalCost, maxCost = private.CommodityResultsByQuantity(itemString, inputQuantity) + local alertThreshold = TSM.db.global.shoppingOptions.buyoutConfirm and (CustomPrice.GetValue(TSM.db.global.shoppingOptions.buyoutAlertSource, itemString) or 0) or math.huge + if maxCost >= alertThreshold then + requireManualClick = true + confirmBtn:GetElement("__parent.warning") + :SetText(L["Contains auctions above your alert threshold!"]) + :Draw() + else + confirmBtn:GetElement("__parent.warning") + :SetText("") + :Draw() + end + + if GetMoney() < totalCost then + text = L["Not Enough Money"] + disabled = true + elseif totalCost <= 0 or inputQuantity < minQuantity or inputQuantity > maxQuantity then + text = L["Invalid Quantity"] + disabled = true + elseif private.prepareSuccess or TSM.IsWowClassic() then + text = L["Buy Commodity"] + disabled = false + elseif private.prepareQuantity then + text = L["Preparing..."] + disabled = true + else + text = private.isBuy and L["Prepare Buy"] or L["Prepare Bid"] + disabled = false + end + else + if GetMoney() < confirmBtn:GetElement("__parent.price.money"):GetContext() then + text = L["Not Enough Money"] + disabled = true + else + text = private.isBuy and L["Buy Auction"] or L["Bid Auction"] + disabled = false + end + end + confirmBtn:SetText(text) + :SetDisabled(disabled) + :SetRequireManualClick(requireManualClick) + :Draw() +end + +function private.Prepare(quantity) + if quantity == private.prepareQuantity then + return + end + if private.future then + private.future:Cancel() + private.future = nil + end + private.prepareQuantity = quantity + private.prepareSuccess = false + local totalBuyout = not TSM.IsWowClassic() and private.subRow:IsCommodity() and private.CommodityResultsByQuantity(private.subRow:GetItemString(), quantity) or (select(2, private.subRow:GetBuyouts())) + local totalQuantity = quantity + private.totalBuyout = totalBuyout + local itemBuyout = totalQuantity > 0 and Math.Ceil(totalBuyout / totalQuantity, COPPER_PER_SILVER) + local result, future = private.auctionScan:PrepareForBidOrBuyout(private.index, private.subRow, private.noSeller, quantity, itemBuyout) + if not result then + private.prepareQuantity = nil + return + elseif future then + private.future = future + future:SetScript("OnDone", private.EnableFutureOnDone) + end + private.UpdateConfirmButton() +end diff --git a/Core/UI/AuctionUI/Core.lua b/Core/UI/AuctionUI/Core.lua new file mode 100644 index 0000000..1c5601a --- /dev/null +++ b/Core/UI/AuctionUI/Core.lua @@ -0,0 +1,271 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local AuctionUI = TSM.UI:NewPackage("AuctionUI") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Event = TSM.Include("Util.Event") +local Log = TSM.Include("Util.Log") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Settings = TSM.Include("Service.Settings") +local ItemLinked = TSM.Include("Service.ItemLinked") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + topLevelPages = {}, + frame = nil, + hasShown = false, + isSwitching = false, + scanningPage = nil, + updateCallbacks = {}, + defaultFrame = nil, +} +local MIN_FRAME_SIZE = { width = 750, height = 450 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function AuctionUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "auctionUIContext", "showDefault") + :AddKey("global", "auctionUIContext", "frame") + UIParent:UnregisterEvent("AUCTION_HOUSE_SHOW") + Event.Register("AUCTION_HOUSE_SHOW", private.AuctionFrameInit) + Event.Register("AUCTION_HOUSE_CLOSED", private.HideAuctionFrame) + if TSM.IsWowClassic() then + Delay.AfterTime(1, function() LoadAddOn("Blizzard_AuctionUI") end) + else + Delay.AfterTime(1, function() LoadAddOn("Blizzard_AuctionHouseUI") end) + end + ItemLinked.RegisterCallback(private.ItemLinkedCallback) +end + +function AuctionUI.OnDisable() + if private.frame then + -- hide the frame + private.frame:Hide() + assert(not private.frame) + end +end + +function AuctionUI.RegisterTopLevelPage(name, callback, itemLinkedHandler) + tinsert(private.topLevelPages, { name = name, callback = callback, itemLinkedHandler = itemLinkedHandler }) +end + +function AuctionUI.StartingScan(pageName) + if private.scanningPage and private.scanningPage ~= pageName then + Log.PrintfUser(L["A scan is already in progress. Please stop that scan before starting another one."]) + return false + end + private.scanningPage = pageName + Log.Info("Starting scan %s", pageName) + if private.frame then + private.frame:SetPulsingNavButton(private.scanningPage) + end + for _, callback in ipairs(private.updateCallbacks) do + callback() + end + return true +end + +function AuctionUI.EndedScan(pageName) + if private.scanningPage == pageName then + Log.Info("Ended scan %s", pageName) + private.scanningPage = nil + if private.frame then + private.frame:SetPulsingNavButton() + end + for _, callback in ipairs(private.updateCallbacks) do + callback() + end + end +end + +function AuctionUI.SetOpenPage(name) + private.frame:SetSelectedNavButton(name, true) +end + +function AuctionUI.IsPageOpen(name) + if not private.frame then + return false + end + return private.frame:GetSelectedNavButton() == name +end + +function AuctionUI.IsScanning() + return private.scanningPage and true or false +end + +function AuctionUI.RegisterUpdateCallback(callback) + tinsert(private.updateCallbacks, callback) +end + +function AuctionUI.IsVisible() + return private.frame and true or false +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.AuctionFrameInit() + local tabTemplateName = nil + if TSM.IsWowClassic() then + private.defaultFrame = AuctionFrame + tabTemplateName = "AuctionTabTemplate" + else + private.defaultFrame = AuctionHouseFrame + tabTemplateName = "AuctionHouseFrameTabTemplate" + end + if not private.hasShown then + private.hasShown = true + local tabId = private.defaultFrame.numTabs + 1 + local tab = CreateFrame("Button", "AuctionFrameTab"..tabId, private.defaultFrame, tabTemplateName) + tab:Hide() + tab:SetID(tabId) + tab:SetText(Log.ColorUserAccentText("TSM4")) + tab:SetNormalFontObject(GameFontHighlightSmall) + if TSM.IsWowClassic() then + tab:SetPoint("LEFT", _G["AuctionFrameTab"..tabId - 1], "RIGHT", -8, 0) + else + tab:SetPoint("LEFT", AuctionHouseFrame.Tabs[tabId - 1], "RIGHT", -15, 0) + tinsert(AuctionHouseFrame.Tabs, tab) + end + tab:Show() + PanelTemplates_SetNumTabs(private.defaultFrame, tabId) + PanelTemplates_EnableTab(private.defaultFrame, tabId) + ScriptWrapper.Set(tab, "OnClick", private.TSMTabOnClick) + end + if private.settings.showDefault then + UIParent_OnEvent(UIParent, "AUCTION_HOUSE_SHOW") + else + PlaySound(SOUNDKIT.AUCTION_WINDOW_OPEN) + private.ShowAuctionFrame() + end +end + +function private.ShowAuctionFrame() + if private.frame then + return + end + private.frame = private.CreateMainFrame() + private.frame:Show() + private.frame:Draw() + for _, callback in ipairs(private.updateCallbacks) do + callback() + end +end + +function private.HideAuctionFrame() + if not private.frame then + return + end + private.frame:Hide() + assert(not private.frame) + for _, callback in ipairs(private.updateCallbacks) do + callback() + end +end + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("auction") + local frame = UIElements.New("LargeApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :SetProtected(TSM.db.global.coreOptions.protectAuctionHouse) + :AddPlayerGold() + :AddAppStatusIcon() + :AddSwitchButton(private.SwitchBtnOnClick) + :SetScript("OnHide", private.BaseFrameOnHide) + for _, info in ipairs(private.topLevelPages) do + frame:AddNavButton(info.name, info.callback) + end + local whatsNewDialog = TSM.UI.WhatsNew.GetDialog() + if whatsNewDialog then + frame:ShowDialogFrame(whatsNewDialog) + end + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide(frame) + assert(frame == private.frame) + frame:Release() + private.frame = nil + if not private.isSwitching then + PlaySound(SOUNDKIT.AUCTION_WINDOW_CLOSE) + if TSM.IsWowClassic() then + CloseAuctionHouse() + else + C_AuctionHouse.CloseAuctionHouse() + end + end + TSM.UI.AnalyticsRecordClose("auction") +end + +function private.SwitchBtnOnClick(button) + private.isSwitching = true + private.settings.showDefault = true + private.HideAuctionFrame() + UIParent_OnEvent(UIParent, "AUCTION_HOUSE_SHOW") + private.isSwitching = false +end + +local function NoOp() + -- do nothing - what did you expect? +end + +function private.TSMTabOnClick() + private.settings.showDefault = false + if TSM.IsWowClassic() then + ClearCursor() + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + end + ClearCursor() + -- Replace CloseAuctionHouse() with a no-op while hiding the AH frame so we don't stop interacting with the AH NPC + if TSM.IsWowClassic() then + local origCloseAuctionHouse = CloseAuctionHouse + CloseAuctionHouse = NoOp + AuctionFrame_Hide() + CloseAuctionHouse = origCloseAuctionHouse + else + local origCloseAuctionHouse = C_AuctionHouse.CloseAuctionHouse + C_AuctionHouse.CloseAuctionHouse = NoOp + HideUIPanel(private.defaultFrame) + C_AuctionHouse.CloseAuctionHouse = origCloseAuctionHouse + end + private.ShowAuctionFrame() +end + +function private.ItemLinkedCallback(name, itemLink) + if not private.frame then + return + end + local path = private.frame:GetSelectedNavButton() + for _, info in ipairs(private.topLevelPages) do + if info.name == path then + if info.itemLinkedHandler(name, itemLink) then + return true + else + return + end + end + end + error("Invalid frame path") +end diff --git a/Core/UI/AuctionUI/MyAuctions.lua b/Core/UI/AuctionUI/MyAuctions.lua new file mode 100644 index 0000000..c0a8b4e --- /dev/null +++ b/Core/UI/AuctionUI/MyAuctions.lua @@ -0,0 +1,715 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local MyAuctions = TSM.UI.AuctionUI:NewPackage("MyAuctions") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local Math = TSM.Include("Util.Math") +local TempTable = TSM.Include("Util.TempTable") +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + fsm = nil, + frame = nil, + query = nil, +} +local DURATION_LIST = { + L["None"], + AUCTION_TIME_LEFT1_DETAIL, + AUCTION_TIME_LEFT2_DETAIL, + AUCTION_TIME_LEFT3_DETAIL, + AUCTION_TIME_LEFT4_DETAIL, +} +local SECONDS_PER_MIN = 60 +local SECONDS_PER_HOUR = 60 * SECONDS_PER_MIN + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function MyAuctions.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "auctionUIContext", "myAuctionsScrollingTable") + private.FSMCreate() + TSM.UI.AuctionUI.RegisterTopLevelPage(L["My Auctions"], private.GetMyAuctionsFrame, private.OnItemLinked) +end + + + +-- ============================================================================ +-- MyAuctions UI +-- ============================================================================ + +function private.GetMyAuctionsFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "my_auctions") + private.query = private.query or TSM.MyAuctions.CreateQuery() + local frame = UIElements.New("Frame", "myAuctions") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("VERTICAL") + :SetHeight(72) + :SetPadding(8) + :AddChild(UIElements.New("Frame", "filters") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("SelectionDropdown", "duration") + :SetWidth(232) + :SetItems(DURATION_LIST) + :SetHintText(L["Filter by duration"]) + :SetScript("OnSelectionChanged", private.DurationFilterChanged) + ) + :AddChild(UIElements.New("GroupSelector", "group") + :SetWidth(232) + :SetMargin(8, 8, 0, 0) + :SetHintText(L["Filter by groups"]) + :SetScript("OnSelectionChanged", private.FilterChanged) + ) + :AddChild(UIElements.New("Input", "keyword") + :SetIconTexture("iconPack.18x18/Search") + :AllowItemInsert(false) + :SetClearButtonEnabled(true) + :SetHintText(L["Filter by keyword"]) + :SetScript("OnValueChanged", private.FilterChanged) + ) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Checkbox", "ignoreBid") + :SetWidth("AUTO") + :SetCheckboxPosition("LEFT") + :SetText(L["Hide auctions with bids"]) + :SetScript("OnValueChanged", private.ToggleFilterChanged) + ) + :AddChild(UIElements.New("Checkbox", "onlyBid") + :SetMargin(16, 0, 0, 0) + :SetWidth("AUTO") + :SetCheckboxPosition("LEFT") + :SetText(L["Show only auctions with bids"]) + :SetScript("OnValueChanged", private.ToggleFilterChanged) + ) + :AddChild(UIElements.New("Checkbox", "onlySold") + :SetMargin(16, 0, 0, 0) + :SetWidth("AUTO") + :SetCheckboxPosition("LEFT") + :SetText(L["Only show sold auctions"]) + :SetScript("OnValueChanged", private.ToggleFilterChanged) + ) + :AddChild(UIElements.New("Spacer")) + :AddChild(UIElements.New("ActionButton", "clearfilter") + :SetSize(142, 24) + :SetText(L["Clear Filters"]) + :SetScript("OnClick", private.ClearFilterOnClick) + ) + ) + ) + if TSM.IsWowClassic() then + frame:AddChild(UIElements.New("MyAuctionsScrollingTable", "auctions") + :SetSettingsContext(private.settings, "myAuctionsScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item Name"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.AuctionsGetItemText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString", private.AuctionsGetItemTooltip) + :SetActionIconInfo(1, 12, private.AuctionsGetSoldIcon) + :DisableHiding() + :Commit() + :NewColumn("stackSize") + :SetTitle(L["Qty"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.AuctionsGetStackSizeText) + :Commit() + :NewColumn("timeLeft") + :SetTitleIcon("iconPack.14x14/Clock") + :SetFont("BODY_BODY3") + :SetJustifyH("CENTER") + :SetTextInfo(nil, private.AuctionsGetTimeLeftText) + :Commit() + :NewColumn("highbidder") + :SetTitle(L["High Bidder"]) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.AuctionsGetHighBidderText) + :Commit() + :NewColumn("group") + :SetTitle(GROUP) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.AuctionsGetGroupNameText) + :Commit() + :NewColumn("currentBid") + :SetTitle(BID) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.AuctionsGetCurrentBidText) + :Commit() + :NewColumn("buyout") + :SetTitle(BUYOUT) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.AuctionsGetCurrentBuyoutText) + :Commit() + :Commit() + :SetQuery(private.query) + :SetSelectionValidator(private.AuctionsValidateSelection) + :SetScript("OnSelectionChanged", private.AuctionsOnSelectionChanged) + :SetScript("OnDataUpdated", private.AuctionsOnDataUpdated) + ) + else + frame:AddChild(UIElements.New("MyAuctionsScrollingTable", "auctions") + :SetSettingsContext(private.settings, "myAuctionsScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item Name"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.AuctionsGetItemText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString", private.AuctionsGetItemTooltip) + :SetActionIconInfo(1, 12, private.AuctionsGetSoldIcon) + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("stackSize") + :SetTitle(L["Qty"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.AuctionsGetStackSizeText) + :SetSortInfo("stackSize") + :Commit() + :NewColumn("timeLeft") + :SetTitleIcon("iconPack.14x14/Clock") + :SetFont("BODY_BODY3") + :SetJustifyH("CENTER") + :SetTextInfo(nil, private.AuctionsGetTimeLeftText) + :SetSortInfo("duration") + :Commit() + :NewColumn("group") + :SetTitle(GROUP) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.AuctionsGetGroupNameText) + :SetSortInfo("group") + :Commit() + :NewColumn("currentBid") + :SetTitle(BID) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.AuctionsGetCurrentBidText) + :SetSortInfo("currentBid") + :Commit() + :NewColumn("buyout") + :SetTitle(BUYOUT) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.AuctionsGetCurrentBuyoutText) + :SetSortInfo("buyout") + :Commit() + :Commit() + :SetQuery(private.query) + :SetSelectionValidator(private.AuctionsValidateSelection) + :SetScript("OnSelectionChanged", private.AuctionsOnSelectionChanged) + :SetScript("OnDataUpdated", private.AuctionsOnDataUpdated) + ) + end + frame:AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(68) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Text", "sold") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + ) + :AddChild(UIElements.New("Text", "soldValue") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + ) + :AddChild(UIElements.New("Texture", "vline") + :SetWidth(1) + :SetMargin(8, 8, 0, 0) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Text", "posted") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + ) + :AddChild(UIElements.New("Text", "postedValue") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("ProgressBar", "progressBar") + :SetMargin(0, 8, 0, 0) + :SetProgress(0) + ) + :AddChild(UIElements.NewNamed("ActionButton", "cancelBtn", "TSMCancelAuctionBtn") + :SetSize(110, 24) + :SetMargin(0, 8, 0, 0) + :SetText(CANCEL) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.CancelButtonOnClick) + ) + :AddChild(UIElements.New("ActionButton", "skipBtn") + :SetSize(110, 24) + :SetText(L["Skip"]) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.SkipButtonOnClick) + ) + ) + ) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) + private.frame = frame + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnItemLinked(name) + private.frame:GetElement("header.filters.keyword") + :SetValue(name) + :Draw() + private.FilterChanged() + return true +end + +function private.FrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.fsm:ProcessEvent("EV_FRAME_SHOWN", frame) +end + +function private.FrameOnHide(frame) + assert(frame == private.frame) + private.frame = nil + private.fsm:ProcessEvent("EV_FRAME_HIDDEN") +end + +function private.ToggleFilterChanged(toggle) + local ignoreBidToggle = toggle:GetElement("__parent.ignoreBid") + local onlyBidToggle = toggle:GetElement("__parent.onlyBid") + local onlySoldToggle = toggle:GetElement("__parent.onlySold") + if ignoreBidToggle:IsChecked() and onlyBidToggle:IsChecked() then + -- uncheck the other toggle in the pair of "bid" toggles + if toggle == ignoreBidToggle then + onlyBidToggle:SetChecked(false, true) + :Draw() + else + ignoreBidToggle:SetChecked(false, true) + :Draw() + end + end + if onlyBidToggle:IsChecked() and onlySoldToggle:IsChecked() then + -- uncheck the other toggle in the pair of "only" toggles + if toggle == onlyBidToggle then + onlySoldToggle:SetChecked(false, true) + :Draw() + else + onlyBidToggle:SetChecked(false, true) + :Draw() + end + end + private.FilterChanged() +end + +function private.DurationFilterChanged(dropdown) + if dropdown:GetSelectedItemKey() == 1 then + -- none + dropdown:SetSelectedItem(nil, true) + end + private.FilterChanged() +end + +function private.FilterChanged() + private.fsm:ProcessEvent("EV_FILTER_UPDATED") +end + +function private.ClearFilterOnClick(button) + button:GetElement("__parent.__parent.filters.duration") + :SetOpen(false) + :SetSelectedItem() + button:GetElement("__parent.__parent.filters.group") + :ClearSelectedGroups() + button:GetElement("__parent.__parent.filters.keyword") + :SetValue("") + :SetFocused(false) + button:GetElement("__parent.ignoreBid") + :SetChecked(false, true) + button:GetElement("__parent.onlyBid") + :SetChecked(false, true) + button:GetElement("__parent.onlySold") + :SetChecked(false, true) + button:GetParentElement():GetParentElement():Draw() + private.fsm:ProcessEvent("EV_FILTER_UPDATED") +end + +function private.AuctionsValidateSelection(_, row) + return row:GetField("saleStatus") == 0 +end + +function private.AuctionsOnSelectionChanged() + private.fsm:ProcessEvent("EV_SELECTION_CHANGED") +end + +function private.AuctionsOnDataUpdated() + if not private.frame then + return + end + + private.fsm:SetLoggingEnabled(false) + private.fsm:ProcessEvent("EV_DATA_UPDATED") + private.fsm:SetLoggingEnabled(true) +end + +function private.CancelButtonOnClick(button) + private.fsm:ProcessEvent("EV_CANCEL_CLICKED") +end + +function private.SkipButtonOnClick(button) + private.fsm:ProcessEvent("EV_SKIP_CLICKED") +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + frame = nil, + durationFilter = nil, + keywordFilter = nil, + groupFilter = {}, + bidFilter = nil, + soldFilter = false, + filterChanged = false, + } + local function UpdateFrame(context) + if not context.frame then + return + end + + local auctions = context.frame:GetElement("auctions") + if context.filterChanged then + context.filterChanged = false + private.query:ResetFilters() + if context.durationFilter then + if TSM.IsWowClassic() then + private.query:Equal("duration", context.durationFilter) + else + if context.durationFilter == 1 then + private.query:LessThan("duration", time() + (30 * SECONDS_PER_MIN)) + elseif context.durationFilter == 2 then + private.query:LessThan("duration", time() + (2 * SECONDS_PER_HOUR)) + elseif context.durationFilter == 3 then + private.query:LessThanOrEqual("duration", time() + (12 * SECONDS_PER_HOUR)) + else + private.query:GreaterThan("duration", time() + (12 * SECONDS_PER_HOUR)) + end + end + end + if context.keywordFilter then + private.query:Matches("itemName", context.keywordFilter) + end + if next(context.groupFilter) then + private.query:InTable("group", context.groupFilter) + end + if context.bidFilter ~= nil then + if context.bidFilter then + private.query:NotEqual("highBidder", "") + else + private.query:Equal("highBidder", "") + end + end + if context.soldFilter then + private.query:Equal("saleStatus", 1) + end + auctions:SetQuery(private.query, true) + end + local selectedRow = auctions:GetSelection() + local hasFilter = context.durationFilter or context.keywordFilter or next(context.groupFilter) or context.bidFilter ~= nil or context.soldFilter + context.frame:GetElement("header.buttons.clearfilter") + :SetDisabled(not hasFilter) + :Draw() + + local numPending = TSM.MyAuctions.GetNumPending() + local progressText = nil + if numPending > 0 then + progressText = format(L["Canceling %d Auctions..."], numPending) + elseif selectedRow then + progressText = L["Ready to Cancel"] + else + progressText = L["Select Auction to Cancel"] + end + local row2 = context.frame:GetElement("bottom.row2") + row2:GetElement("cancelBtn") + :SetDisabled(not selectedRow or (not TSM.IsWowClassic() and numPending > 0) or (not TSM.IsWowClassic() and C_AuctionHouse.GetCancelCost(selectedRow:GetField("auctionId")) > GetMoney())) + row2:GetElement("skipBtn") + :SetDisabled(not selectedRow) + row2:GetElement("progressBar") + :SetProgressIconHidden(numPending == 0) + :SetText(progressText) + row2:Draw() + local numPosted, numSold, postedGold, soldGold = 0, 0, 0, 0 + for _, row in private.query:Iterator() do + local itemString, saleStatus, buyout, currentBid, stackSize = row:GetFields("itemString", "saleStatus", "buyout", "currentBid", "stackSize") + if saleStatus == 1 then + numSold = numSold + 1 + -- if somebody did a buyout, then bid will be equal to buyout, otherwise it'll be the winning bid + soldGold = soldGold + currentBid + else + numPosted = numPosted + 1 + if ItemInfo.IsCommodity(itemString) then + postedGold = postedGold + (buyout * stackSize) + else + postedGold = postedGold + buyout + end + end + end + local row1 = context.frame:GetElement("bottom.row1") + row1:GetElement("sold") + :SetFormattedText((hasFilter and L["%s Sold Auctions (Filtered)"] or L["%s Sold Auctions"])..":", Theme.GetColor("INDICATOR"):ColorText(numSold)) + row1:GetElement("soldValue") + :SetText(Money.ToString(soldGold)) + row1:GetElement("posted") + :SetFormattedText((hasFilter and L["%s Posted Auctions (Filtered)"] or L["%s Posted Auctions"])..":", Theme.GetColor("INDICATOR_ALT"):ColorText(numPosted)) + row1:GetElement("postedValue") + :SetText(Money.ToString(postedGold)) + row1:Draw() + end + private.fsm = FSM.New("MY_AUCTIONS") + :AddState(FSM.NewState("ST_HIDDEN") + :SetOnEnter(function(context) + context.frame = nil + context.durationFilter = nil + context.keywordFilter = nil + wipe(context.groupFilter) + context.bidFilter = nil + context.soldFilter = false + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_SHOWNING") + :AddEventTransition("EV_FRAME_SHOWN", "ST_SHOWNING") + ) + :AddState(FSM.NewState("ST_SHOWNING") + :SetOnEnter(function(context, frame) + context.frame = frame + context.filterChanged = true + return "ST_SHOWN" + end) + :AddTransition("ST_SHOWN") + ) + :AddState(FSM.NewState("ST_SHOWN") + :SetOnEnter(function(context) + UpdateFrame(context) + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_SHOWN") + :AddTransition("ST_CANCELING") + :AddEventTransition("EV_CANCEL_CLICKED", "ST_CANCELING") + :AddEventTransition("EV_SELECTION_CHANGED", "ST_SHOWN") + :AddEventTransition("EV_DATA_UPDATED", "ST_SHOWN") + :AddEvent("EV_SKIP_CLICKED", function(context) + context.frame:GetElement("auctions"):SelectNextRow() + return "ST_SHOWN" + end) + :AddEvent("EV_FILTER_UPDATED", function(context) + local didChange = false + local durationFilter = context.frame:GetElement("header.filters.duration"):GetSelectedItemKey() + durationFilter = durationFilter and (durationFilter - 1) or nil + if durationFilter ~= context.durationFilter then + context.durationFilter = durationFilter + didChange = true + end + local keywordFilter = context.frame:GetElement("header.filters.keyword"):GetValue() + keywordFilter = keywordFilter ~= "" and String.Escape(keywordFilter) or nil + if keywordFilter ~= context.keywordFilter then + context.keywordFilter = keywordFilter + didChange = true + end + local newGroupFilter = TempTable.Acquire() + for groupPath in context.frame:GetElement("header.filters.group"):SelectedGroupIterator() do + newGroupFilter[groupPath] = true + end + if Math.CalculateHash(newGroupFilter) ~= Math.CalculateHash(context.groupFilter) then + didChange = true + wipe(context.groupFilter) + for groupPath in pairs(newGroupFilter) do + context.groupFilter[groupPath] = true + end + end + TempTable.Release(newGroupFilter) + local bidFilter = nil + if context.frame:GetElement("header.buttons.ignoreBid"):IsChecked() then + bidFilter = false + elseif context.frame:GetElement("header.buttons.onlyBid"):IsChecked() then + bidFilter = true + end + if bidFilter ~= context.bidFilter then + context.bidFilter = bidFilter + didChange = true + end + local soldFilter = context.frame:GetElement("header.buttons.onlySold"):IsChecked() + if soldFilter ~= context.soldFilter then + context.soldFilter = soldFilter + didChange = true + end + if didChange then + context.filterChanged = true + return "ST_SHOWN" + end + end) + ) + :AddState(FSM.NewState("ST_CANCELING") + :SetOnEnter(function(context) + local buttonsFrame = context.frame:GetElement("bottom") + buttonsFrame:GetElement("row2.cancelBtn"):SetDisabled(true) + buttonsFrame:GetElement("row2.skipBtn"):SetDisabled(true) + buttonsFrame:Draw() + local auctionId = context.frame:GetElement("auctions"):GetSelection():GetField("auctionId") + if TSM.MyAuctions.CanCancel(auctionId) then + TSM.MyAuctions.CancelAuction(auctionId) + end + return "ST_SHOWN" + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_SHOWN") + ) + :AddDefaultEventTransition("EV_FRAME_HIDDEN", "ST_HIDDEN") + :Init("ST_HIDDEN", fsmContext) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.AuctionsGetItemText(row) + if row:GetField("saleStatus") == 1 then + return Theme.GetColor("INDICATOR"):ColorText(row:GetField("itemName")) + else + return TSM.UI.GetQualityColoredText(row:GetField("itemName"), row:GetField("itemQuality")) + end +end + +function private.AuctionsGetItemTooltip(itemString) + return itemString ~= ItemString.GetPetCage() and itemString or nil +end + +function private.AuctionsGetSoldIcon(self, data, iconIndex) + assert(iconIndex == 1) + local row = private.query:GetResultRowByUUID(data) + if row:GetField("saleStatus") ~= 1 then + return + end + return true, "iconPack.12x12/Bid", true +end + +function private.AuctionsGetStackSizeText(row) + if row:GetField("saleStatus") == 1 then + return Theme.GetColor("INDICATOR"):ColorText(row:GetField("stackSize")) + else + return row:GetField("stackSize") + end +end + +function private.AuctionsGetTimeLeftText(row) + local saleStatus, duration, isPending = row:GetFields("saleStatus", "duration", "isPending") + if saleStatus == 0 and isPending then + return "..." + elseif saleStatus == 1 or not TSM.IsWowClassic() then + local timeLeft = duration - time() + local color = nil + if saleStatus == 0 then + if timeLeft <= 2 * SECONDS_PER_HOUR then + color = Theme.GetFeedbackColor("RED") + elseif timeLeft <= (TSM.IsWowClassic() and 8 or 24) * SECONDS_PER_HOUR then + color = Theme.GetFeedbackColor("YELLOW") + else + color = Theme.GetFeedbackColor("GREEN") + end + else + color = Theme.GetColor("INDICATOR") + end + local str = nil + if timeLeft < SECONDS_PER_MIN then + str = timeLeft.."s" + elseif timeLeft < SECONDS_PER_HOUR then + str = floor(timeLeft / SECONDS_PER_MIN).."m" + else + str = floor(timeLeft / SECONDS_PER_HOUR).."h" + end + return color and color:ColorText(str) or str + else + return TSM.UI.GetTimeLeftString(duration) + end +end + +function private.AuctionsGetHighBidderText(row) + if row:GetField("saleStatus") == 1 then + return Theme.GetColor("INDICATOR"):ColorText(row:GetField("highBidder")) + else + return row:GetField("highBidder") + end +end + +function private.AuctionsGetGroupNameText(row) + local groupPath = row:GetField("group") + local groupName = TSM.Groups.Path.GetName(groupPath) + local level = select('#', strsplit(TSM.CONST.GROUP_SEP, groupPath)) + return Theme.GetGroupColor(level):ColorText(groupName) +end + +function private.AuctionsGetCurrentBidText(row) + if row:GetField("saleStatus") == 1 then + return Theme.GetColor("INDICATOR"):ColorText(L["Sold for:"]) + elseif not TSM.IsWowClassic() and row:GetField("highBidder") ~= "" then + return Money.ToString(row:GetField("currentBid"), Theme.GetColor("INDICATOR"):GetTextColorPrefix()) + else + return Money.ToString(row:GetField("currentBid")) + end +end + +function private.AuctionsGetCurrentBuyoutText(row) + return Money.ToString(row:GetField(row:GetField("saleStatus") == 1 and "currentBid" or "buyout")) +end diff --git a/Core/UI/AuctionUI/Shopping.lua b/Core/UI/AuctionUI/Shopping.lua new file mode 100644 index 0000000..f606534 --- /dev/null +++ b/Core/UI/AuctionUI/Shopping.lua @@ -0,0 +1,2658 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Shopping = TSM.UI.AuctionUI:NewPackage("Shopping") +local ItemClass = TSM.Include("Data.ItemClass") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local Event = TSM.Include("Util.Event") +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Money = TSM.Include("Util.Money") +local Log = TSM.Include("Util.Log") +local Math = TSM.Include("Util.Math") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local BagTracking = TSM.Include("Service.BagTracking") +local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper") +local AuctionScan = TSM.Include("Service.AuctionScan") +local MailTracking = TSM.Include("Service.MailTracking") +local Settings = TSM.Include("Service.Settings") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + fsm = nil, + rarityList = nil, + frame = nil, + hasLastScan = false, + contentPath = "selection", + selectedGroups = {}, + groupSearch = "", + filterText = "", + searchName = "", + postContext = { + itemString = nil, + seller = nil, + stackSize = nil, + displayedBid = nil, + itemDisplayedBid = nil, + buyout = nil, + }, + itemString = nil, + postStack = nil, + postQuantity = nil, + postTimeStr = nil, + perItem = true, + updateCallbacks = {}, + itemLocation = ItemLocation:CreateEmpty(), +} +local MAX_ITEM_LEVEL = 500 +local PLAYER_NAME = UnitName("player") +local ARMOR_TYPES = { + [GetItemSubClassInfo(LE_ITEM_CLASS_ARMOR, LE_ITEM_ARMOR_PLATE)] = true, + [GetItemSubClassInfo(LE_ITEM_CLASS_ARMOR, LE_ITEM_ARMOR_MAIL)] = true, + [GetItemSubClassInfo(LE_ITEM_CLASS_ARMOR, LE_ITEM_ARMOR_LEATHER)] = true, + [GetItemSubClassInfo(LE_ITEM_CLASS_ARMOR, LE_ITEM_ARMOR_CLOTH)] = true, +} +local INVENTORY_TYPES = { + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexHeadType or LE_INVENTORY_TYPE_HEAD_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexShoulderType or LE_INVENTORY_TYPE_SHOULDER_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexChestType or LE_INVENTORY_TYPE_CHEST_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexWaistType or LE_INVENTORY_TYPE_WAIST_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexLegsType or LE_INVENTORY_TYPE_LEGS_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexFeetType or LE_INVENTORY_TYPE_FEET_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexWristType or LE_INVENTORY_TYPE_WRIST_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexHandType or LE_INVENTORY_TYPE_HAND_TYPE), +} +local GENERIC_TYPES = { + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexNeckType or LE_INVENTORY_TYPE_NECK_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexCloakType or LE_INVENTORY_TYPE_CLOAK_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexFingerType or LE_INVENTORY_TYPE_FINGER_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexTrinketType or LE_INVENTORY_TYPE_TRINKET_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexHoldableType or LE_INVENTORY_TYPE_HOLDABLE_TYPE), + GetItemInventorySlotInfo(TSM.IsShadowlands() and Enum.InventoryType.IndexBodyType or LE_INVENTORY_TYPE_BODY_TYPE), +} +local MAX_LEVEL = nil +do + if TSM.IsShadowlands() then + MAX_LEVEL = 60 + else + for _, v in pairs(MAX_PLAYER_LEVEL_TABLE) do + MAX_LEVEL = max(MAX_LEVEL or 0, v) + end + end +end + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Shopping.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "auctionUIContext", "shoppingSelectionDividedContainer") + :AddKey("global", "auctionUIContext", "shoppingAuctionScrollingTable") + :AddKey("global", "auctionUIContext", "shoppingSearchesTabGroup") + :AddKey("global", "shoppingOptions", "searchAutoFocus") + :AddKey("char", "auctionUIContext", "shoppingGroupTree") + private.postTimeStr = TSM.CONST.AUCTION_DURATIONS[2] + TSM.UI.AuctionUI.RegisterTopLevelPage(L["Browse"], private.GetShoppingFrame, private.OnItemLinked) + private.FSMCreate() +end + +function Shopping.StartGatheringSearch(items, stateCallback, buyCallback, mode) + assert(Shopping.IsVisible()) + private.frame:SetPath("selection") + private.StartGatheringSearchHelper(private.frame, items, stateCallback, buyCallback, mode) +end + +function Shopping.StartItemSearch(item) + private.OnItemLinked(ItemInfo.GetName(item), item, true) +end + +function Shopping.IsVisible() + return TSM.UI.AuctionUI.IsPageOpen(L["Browse"]) +end + +function Shopping.RegisterUpdateCallback(callback) + tinsert(private.updateCallbacks, callback) +end + + + +-- ============================================================================ +-- Shopping UI +-- ============================================================================ + +function private.GetShoppingFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "shopping") + if not private.hasLastScan then + private.contentPath = "selection" + end + local frame = UIElements.New("ViewContainer", "shopping") + :SetNavCallback(private.GetShoppingContentFrame) + :AddPath("selection") + :AddPath("scan") + :SetPath(private.contentPath) + :SetScript("OnHide", private.FrameOnHide) + private.frame = frame + for _, callback in ipairs(private.updateCallbacks) do + callback() + end + return frame +end + +function private.GetShoppingContentFrame(viewContainer, path) + private.contentPath = path + if path == "selection" then + return private.GetSelectionFrame() + elseif path == "scan" then + return private.GetScanFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetSelectionFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "shopping", "selection") + local frame = UIElements.New("DividedContainer", "selection") + :SetSettingsContext(private.settings, "shoppingSelectionDividedContainer") + :SetMinWidth(220, 350) + :SetBackgroundColor("PRIMARY_BG") + :SetLeftChild(UIElements.New("Frame", "groupSelection") + :SetLayout("VERTICAL") + :SetPadding(0, 0, 8, 0) + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetMargin(8, 8, 0, 8) + :SetHeight(24) + :AddChild(UIElements.New("Input", "search") + :SetIconTexture("iconPack.18x18/Search") + :AllowItemInsert(true) + :SetClearButtonEnabled(true) + :SetValue(private.groupSearch) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetSettingsContext(private.settings, "shoppingGroupTree") + :SetQuery(TSM.Groups.CreateQuery(), "Shopping") + :SetSearchString(private.groupSearch) + :SetScript("OnGroupSelectionChanged", private.GroupTreeOnGroupSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(40) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "scanBtn") + :SetHeight(24) + :SetMargin(8) + :SetText(L["Run Shopping Scan"]) + :SetDisabled(true) + :SetScript("OnClick", private.ScanButtonOnClick) + ) + ) + ) + :SetRightChild(UIElements.New("ViewContainer", "content") + :SetNavCallback(private.GetSelectionContent) + :AddPath("search", true) + :AddPath("advanced") + ) + :SetScript("OnUpdate", private.SelectionFrameOnUpdate) + + return frame +end + +function private.SelectionFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + frame:GetElement("groupSelection.bottom.scanBtn"):SetDisabled(frame:GetElement("groupSelection.groupTree"):IsSelectionCleared(true)):Draw() +end + +function private.GetSelectionContent(viewContainer, path) + if path == "search" then + return private.GetSelectionSearchFrame() + elseif path == "advanced" then + return private.GetAdvancedFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetSelectionSearchFrame() + return UIElements.New("Frame", "search") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 8, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 20) + :AddChild(UIElements.New("Input", "filterInput") + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetFocused(private.settings.searchAutoFocus) + :SetHintText(L["Search the auction house"]) + :SetScript("OnValueChanged", private.FilterInputOnValueChanged) + :SetScript("OnEnterPressed", private.FilterInputOnEnterPressed) + ) + :AddChild(UIElements.New("ActionButton", "search") + :SetWidth(90) + :SetMargin(8, 0, 0, 0) + :SetDisabled(TSM.IsWowClassic()) + :SetText(L["Search"]) + :SetScript("OnClick", private.SearchButtonOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "buttonsLine1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("ActionButton", "advSearchBtn") + :SetMargin(0, 8, 0, 0) + :SetText(L["Advanced Item Search"]) + :SetScript("OnClick", private.AdvancedButtonOnClick) + ) + :AddChild(UIElements.New("ActionButton", "vendorBtn") + :SetText(L["Vendor Search"]) + :SetScript("OnClick", private.VendorButtonOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "buttonsLine2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("ActionButton", "disenchantBtn") + :SetMargin(0, 8, 0, 0) + :SetText(L["Disenchant Search"]) + :SetScript("OnClick", private.DisenchantButtonOnClick) + ) + :AddChild(UIElements.New("ActionButton", "dealsBtn") + :SetText(L["Great Deals Search"]) + :SetDisabled(not TSM.Shopping.GreatDealsSearch.GetFilter()) + :SetScript("OnClick", private.DealsButtonOnClick) + ) + ) + :AddChild(UIElements.New("TabGroup", "buttons") + :SetMargin(-8, -8, 21, 0) + :SetNavCallback(private.GetSearchesElement) + :SetSettingsContext(private.settings, "shoppingSearchesTabGroup") + :AddPath(L["Recent Searches"]) + :AddPath(L["Favorite Searches"]) + ) +end + +function private.GetAdvancedFrame() + if not private.rarityList then + private.rarityList = {} + for i = 1, 7 do + tinsert(private.rarityList, _G["ITEM_QUALITY"..i.."_DESC"]) + end + end + return UIElements.New("Frame", "advanced") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ScrollFrame", "search") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("ActionButton", "backBtn") + :SetWidth(64) + :SetIcon("iconPack.14x14/Chevron/Right@180") + :SetText(BACK) + :SetScript("OnClick", private.AdvancedBackButtonOnClick) + ) + :AddChild(UIElements.New("Input", "keyword") + :SetMargin(8, 0, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Filter by Keyword"]) + ) + ) + :AddChild(UIElements.New("Frame", "body") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 0, 0) + :AddChild(UIElements.New("Frame", "classAndSubClassLabels") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 16, 0) + :AddChild(UIElements.New("Text", "classLabel") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Item Class"]) + ) + :AddChild(UIElements.New("Text", "subClassLabel") + :SetMargin(20, 0, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Item Subclass"]) + ) + ) + :AddChild(UIElements.New("Frame", "classAndSubClass") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("SelectionDropdown", "classDropdown") + :SetMargin(0, 20, 0, 0) + :SetItems(ItemClass.GetClasses()) + :SetScript("OnSelectionChanged", private.ClassDropdownOnSelectionChanged) + :SetHintText(L["All Item Classes"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "subClassDropdown") + :SetDisabled(true) + :SetScript("OnSelectionChanged", private.SubClassDropdownOnSelectionChanged) + :SetHintText(L["All Subclasses"]) + ) + ) + :AddChild(UIElements.New("Frame", "itemSlot") + :SetLayout("VERTICAL") + :SetMargin(0, 0, 16, 0) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Item Slot"]) + ) + :AddChild(UIElements.New("Frame", "frame") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetWidth(238) + :SetDisabled(true) + :SetHintText(L["All Slots"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(UIElements.New("Frame", "frame") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 16, 0) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Required Level Range"]) + ) + ) + :AddChild(UIElements.New("Frame", "level") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 2, 0) + :AddChild(UIElements.New("Slider", "slider") + :SetRange(0, MAX_LEVEL) + ) + ) + :AddChild(UIElements.New("Frame", "frame") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 18, 0) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Item Level Range"]) + ) + ) + :AddChild(UIElements.New("Frame", "itemLevel") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 2, 0) + :AddChild(UIElements.New("Slider", "slider") + :SetRange(0, MAX_ITEM_LEVEL) + ) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(48) + :SetMargin(0, 0, 18, 0) + :AddChild(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetWidth(254) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Maximum Quantity to Buy"]) + ) + :AddChild(UIElements.New("Frame", "maxQty") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Input", "input") + :SetWidth(178) + :SetMargin(0, 4, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValidateFunc("NUMBER", "0:2000") + :SetValue(0) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth(100) + :SetFont("BODY_BODY3_MEDIUM") + :SetFormattedText("(%d - %d)", 0, 2000) + ) + ) + ) + :AddChild(UIElements.New("Frame", "minRarity") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Minimum Rarity"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetHeight(24) + :SetMargin(0, 0, 4, 0) + :SetItems(private.rarityList) + :SetHintText(L["All"]) + ) + ) + ) + :AddChild(UIElements.New("Frame", "filters") + :SetLayout("HORIZONTAL") + :SetMargin(0, 0, 16, 8) + :AddChildIf(not TSM.IsWowClassic(), UIElements.New("Frame", "uncollected") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetCheckboxPosition("LEFT") + :SetText(L["Uncollected Only"]) + ) + ) + :AddChildIf(not TSM.IsWowClassic(), UIElements.New("Frame", "upgrades") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetCheckboxPosition("LEFT") + :SetText(L["Upgrades Only"]) + ) + ) + :AddChild(UIElements.New("Frame", "usable") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetCheckboxPosition("LEFT") + :SetText(L["Usable Only"]) + ) + ) + ) + :AddChild(UIElements.New("Frame", "filters2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "exact") + :SetCheckboxPosition("LEFT") + :SetText(L["Exact Match"]) + ) + :AddChild(UIElements.New("Checkbox", "crafting") + :SetCheckboxPosition("LEFT") + :SetText(L["Crafting Mode"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "startBtn") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetText(L["Run Advanced Item Search"]) + :SetScript("OnClick", private.AdvancedStartOnClick) + ) + :AddChild(UIElements.New("Button", "resetBtn") + :SetSize("AUTO", 24) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Reset All Filters"]) + :SetScript("OnClick", private.ResetButtonOnClick) + ) + ) +end + +function private.GetSearchesElement(self, button) + if button == L["Recent Searches"] then + return UIElements.New("SearchList", "list") + :SetQuery(TSM.Shopping.SavedSearches.CreateRecentSearchesQuery()) + :SetEditButtonHidden(true) + :SetScript("OnFavoriteChanged", private.SearchListOnFavoriteChanged) + :SetScript("OnDelete", private.SearchListOnDelete) + :SetScript("OnRowClick", private.SearchListOnRowClick) + elseif button == L["Favorite Searches"] then + return UIElements.New("SearchList", "list") + :SetQuery(TSM.Shopping.SavedSearches.CreateFavoriteSearchesQuery()) + :SetScript("OnFavoriteChanged", private.SearchListOnFavoriteChanged) + :SetScript("OnEditClick", private.SearchListOnEditClick) + :SetScript("OnDelete", private.SearchListOnDelete) + :SetScript("OnRowClick", private.SearchListOnRowClick) + else + error("Unexpected button: "..tostring(button)) + end +end + +function private.GetScanFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "shopping", "scan") + local frame = UIElements.New("Frame", "scan") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "searchFrame") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :AddChild(UIElements.New("Frame", "back") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :AddChild(UIElements.New("ActionButton", "button") + :SetWidth(64) + :SetIcon("iconPack.14x14/Chevron/Right@180") + :SetText(BACK) + :SetScript("OnClick", private.ScanBackButtonOnClick) + ) + ) + :AddChild(UIElements.New("Input", "filterInput") + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Enter Filter"]) + :SetValue(private.searchName) + :SetScript("OnEnterPressed", private.ScanFilterInputOnEnterPressed) + ) + :AddChild(UIElements.New("ActionButton", "rescanBtn") + :SetWidth(140) + :SetMargin(8, 0, 0, 0) + :SetText(L["Rescan"]) + :SetScript("OnClick", private.RescanBtnOnClick) + ) + ) + :AddChild(UIElements.New("AuctionScrollingTable", "auctions") + :SetSettingsContext(private.settings, "shoppingAuctionScrollingTable") + :SetBrowseResultsVisible(true) + :SetScript("OnSelectionChanged", private.AuctionsOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "pauseResumeBtn") + :SetSize(24, 24) + :SetMargin(0, 8, 0, 0) + :SetIcon("iconPack.18x18/PlayPause") + :SetScript("OnClick", private.PauseResumeOnClick) + ) + :AddChild(UIElements.New("ProgressBar", "progressBar") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetProgress(0) + :SetProgressIconHidden(false) + :SetText(L["Starting Scan..."]) + ) + :AddChild(UIElements.New("ActionButton", "postBtn") + :SetSize(107, 24) + :SetMargin(0, 8, 0, 0) + :SetText(L["Post"]) + :SetDisabled(true) + :SetScript("OnClick", private.AuctionsOnPostButtonClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetSize(2, 24) + :SetMargin(0, 8, 0, 0) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ActionButton", "bidBtn") + :SetSize(107, 24) + :SetMargin(0, 8, 0, 0) + :SetText(BID) + :SetDisabled(true) + :SetScript("OnClick", private.BidBtnOnClick) + ) + :AddChild(UIElements.NewNamed("ActionButton", "buyoutBtn", "TSMShoppingBuyoutBtn") + :SetSize(107, 24) + :SetText(BUYOUT) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.BuyoutBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "cancelBtn") + :SetSize(107, 24) + :SetText(CANCEL) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.CancelBtnOnClick) + ) + ) + :SetScript("OnUpdate", private.ScanFrameOnUpdate) + :SetScript("OnHide", private.ScanFrameOnHide) + frame:GetElement("bottom.cancelBtn"):Hide() + return frame +end + +function private.BidBtnOnClick(button) + private.fsm:ProcessEvent("EV_BID_CLICKED") +end + +function private.BuyoutBtnOnClick(button) + private.fsm:ProcessEvent("EV_BUYOUT_CLICKED") +end + +function private.CancelBtnOnClick(button) + private.fsm:ProcessEvent("EV_CANCEL_CLICKED") +end + +function private.PostDialogShow(baseFrame, row) + baseFrame:ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(326, TSM.IsWowClassic() and 380 or 344) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("ViewContainer", "view") + :SetNavCallback(private.GetViewContentFrame) + :AddPath("posting", true) + :AddPath("selection") + :SetContext(row) + ) + :SetScript("OnHide", private.PostDialogOnHide) + ) +end + +function private.PostDialogOnHide(frame) + private.itemString = nil +end + +function private.GetViewContentFrame(viewContainer, path) + if path == "posting" then + return private.GetPostingFrame() + elseif path == "selection" then + return private.GetPostSelectionFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetPostingFrame() + return UIElements.New("Frame", "posting") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(24) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Post from Shopping Scan"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.PostDialogCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "item") + :SetLayout("HORIZONTAL") + :SetPadding(6) + :SetMargin(0, 0, 0, 16) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Button", "icon") + :SetSize(36, 36) + :SetMargin(0, 8, 0, 0) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(36) + :SetFont("ITEM_BODY1") + ) + :AddChild(UIElements.New("Button", "editBtn") + :SetMargin(8, 0, 0, 0) + :SetBackgroundAndSize("iconPack.18x18/Edit") + :SetScript("OnClick", private.ItemBtnOnClick) + ) + ) + :AddChildIf(TSM.IsWowClassic(), UIElements.New("Frame", "numStacks") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Text", "desc") + :SetFont("BODY_BODY2") + :SetText(L["Stack(s)"]..":") + ) + :AddChild(UIElements.New("Input", "input") + :SetWidth(140) + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetJustifyH("RIGHT") + :SetValidateFunc("NUMBER", "0:5000") + :SetValue(1) + :SetScript("OnValueChanged", private.StackNumInputOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "maxBtn") + :SetWidth(64) + :SetText(L["Max"]) + :SetScript("OnClick", private.MaxStackNumBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "quantity") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Text", "desc") + :SetFont("BODY_BODY2") + :SetText(L["Quantity"]..":") + ) + :AddChild(UIElements.New("Input", "input") + :SetWidth(140) + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetJustifyH("RIGHT") + :SetValidateFunc("NUMBER", "0:5000") + :SetScript("OnValueChanged", private.QuantityInputOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "maxBtn") + :SetWidth(64) + :SetText(L["Max"]) + :SetScript("OnClick", private.MaxQuantityBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "duration") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Duration"]..":") + ) + :AddChild(UIElements.New("Toggle", "toggle") + :SetMargin(0, 48, 0, 0) + :AddOption(TSM.CONST.AUCTION_DURATIONS[1]) + :AddOption(TSM.CONST.AUCTION_DURATIONS[2]) + :AddOption(TSM.CONST.AUCTION_DURATIONS[3]) + :SetOption(private.postTimeStr, true) + :SetScript("OnValueChanged", private.DurationOnValueChanged) + ) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Frame", "per") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "item") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetTextColor("INDICATOR") + :SetText(L["Per Item"]) + :SetScript("OnClick", TSM.IsWowClassic() and private.PerItemOnClick) + ) + :AddChildIf(TSM.IsWowClassic(), UIElements.New("Button", "stack") + :SetWidth("AUTO") + :SetJustifyH("RIGHT") + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor("TEXT") + :SetText(L["Per Stack"]) + :SetScript("OnClick", TSM.IsWowClassic() and private.PerStackOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "bid") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Bid Price"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Input", "input") + :SetWidth(132) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetFont("TABLE_TABLE1") + :SetValidateFunc(private.BidBuyoutValidateFunc) + :SetJustifyH("RIGHT") + :SetTabPaths("__parent.__parent.quantity.input", "__parent.__parent.buyout.input") + :SetScript("OnValidationChanged", private.BidBuyoutOnValidationChanged) + :SetScript("OnValueChanged", private.BidBuyoutInputOnValueChanged) + :SetScript("OnEnterPressed", private.BidBuyoutInputOnEnterPressed) + ) + ) + :AddChild(UIElements.New("Frame", "buyout") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 10) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Buyout Price"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Input", "input") + :SetWidth(132) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetFont("TABLE_TABLE1") + :SetValidateFunc(private.BidBuyoutValidateFunc) + :SetJustifyH("RIGHT") + :SetTabPaths("__parent.__parent.bid.input", "__parent.__parent.quantity.input") + :SetScript("OnValidationChanged", private.BidBuyoutOnValidationChanged) + :SetScript("OnValueChanged", private.BidBuyoutInputOnValueChanged) + :SetScript("OnEnterPressed", private.BidBuyoutInputOnEnterPressed) + ) + ) + :AddChild(UIElements.New("Frame", "deposit") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 15) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Deposit Cost"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("ActionButton", "confirmBtn") + :SetHeight(26) + :SetText(L["Post Auction"]) + :SetScript("OnClick", private.PostButtonOnClick) + ) + :SetScript("OnUpdate", private.PostingFrameOnUpdate) +end + +function private.GetPostSelectionFrame() + local query = BagTracking.CreateQueryBagsItemAuctionable(ItemString.GetBase(private.itemString)) + local frame = UIElements.New("Frame", "selection") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(24) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Item Selection"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.PostDialogCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetHeaderHidden(true) + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(14) + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :DisableHiding() + :Commit() + :Commit() + :SetQuery(query) + :SetAutoReleaseQuery(true) + :SetScript("OnRowClick", private.ItemQueryOnRowClick) + ) + :AddChild(UIElements.New("ActionButton", "backBtn") + :SetMargin(0, 0, 9, 0) + :SetHeight(26) + :SetText(BACK) + :SetScript("OnClick", private.ViewBackButtonOnClick) + ) + + return frame +end + +function private.PostingFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + + local postContext = frame:GetParentElement():GetContext() + if not private.itemString then + assert(postContext.itemString) + local foundItem = false + local backupItemString = nil + local query = BagTracking.CreateQueryBagsAuctionable() + :OrderBy("slotId", true) + :Select("itemString") + for _, itemString in query:Iterator() do + if itemString == postContext.itemString then + foundItem = true + elseif not backupItemString and ItemString.GetBase(itemString) == postContext.baseItemString then + backupItemString = itemString + end + end + query:Release() + private.itemString = foundItem and postContext.itemString or backupItemString + + if not private.itemString then + frame:GetBaseElement():HideDialog() + Log.PrintfUser(L["Failed to post %sx%d as the item no longer exists in your bags."], ItemInfo.GetName(postContext.itemString), postContext.quantity) + private.frame:GetElement("scan.bottom.postBtn") + :SetDisabled(true) + :Draw() + return + end + end + local undercut = PlayerInfo.IsPlayer(postContext.ownerStr, true, true, true) and 0 or 1 + local bid = postContext.itemDisplayedBid - undercut + if not TSM.IsWowClassic() then + bid = Math.Round(bid, COPPER_PER_SILVER) + end + if bid <= 0 then + bid = 1 + elseif bid > MAXIMUM_BID_PRICE then + bid = MAXIMUM_BID_PRICE + end + local buyout = nil + if TSM.IsWowClassic() then + buyout = postContext.itemBuyout - undercut + else + buyout = Math.Round(postContext.itemBuyout - undercut, COPPER_PER_SILVER) + end + if buyout < 0 then + buyout = 0 + elseif buyout > MAXIMUM_BID_PRICE then + buyout = MAXIMUM_BID_PRICE + end + + private.perItem = true + private.postStack = nil + private.postQuantity = nil + + frame:GetElement("item.icon") + :SetBackground(ItemInfo.GetTexture(private.itemString)) + :SetTooltip(private.itemString) + frame:GetElement("item.name") + :SetText(TSM.UI.GetColoredItemName(private.itemString)) + local maxPostStack = private.GetMaxPostStack(private.itemString) + local isCommodity = ItemInfo.IsCommodity(private.itemString) + frame:GetElement("quantity.input") + :SetValidateFunc("NUMBER", "0:"..maxPostStack) + :SetValue(min(postContext.quantity, maxPostStack, 5000)) + frame:GetElement("bid.input") + :SetDisabled(isCommodity) + :SetValue(Money.ToString(bid, nil, "OPT_83_NO_COPPER", isCommodity and "OPT_DISABLE" or nil)) + frame:GetElement("buyout.input") + :SetValue(Money.ToString(buyout, nil, "OPT_83_NO_COPPER")) + frame:GetElement("confirmBtn") + :SetContext(private.itemString) + + frame:Draw() + + private.UpdateDepositCostAndPostButton(frame) +end + +function private.ItemQueryOnRowClick(scrollingtable, row) + private.itemString = row:GetField("itemString") + scrollingtable:GetElement("__parent.__parent"):SetPath("posting", true) +end + +function private.ViewBackButtonOnClick(button) + button:GetElement("__parent.__parent"):SetPath("posting", true) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnItemLinked(name, itemLink, forceSearch) + local itemString = ItemString.Get(itemLink) + local baseItemString = ItemString.GetBase(itemString) + local baseName = ItemInfo.GetName(baseItemString) + if itemString == baseItemString then + baseName = baseName.."/exact" + end + if not forceSearch and private.frame:GetPath() == "selection" and private.frame:GetElement("selection.content"):GetPath() == "advanced" then + -- they are on the advanced search UI, so just populate the filter dialog instead of starting a search + private.frame:GetElement("selection.content.advanced.search.header.keyword") + :SetValue(baseName) + :Draw() + return + end + private.frame:SetPath("selection") + + local price = CustomPrice.GetValue("first(dbmarket, 100g)", itemString) + local postContext = private.postContext + wipe(postContext) + postContext.baseItemString = baseItemString + postContext.itemString = itemString + postContext.ownerStr = PLAYER_NAME + postContext.currentBid = 0 + postContext.displayedBid = price + postContext.itemDisplayedBid = price + postContext.buyout = price + postContext.itemBuyout = price + postContext.quantity = 1 + private.frame:GetBaseElement():HideDialog() + private.StartFilterSearchHelper(private.frame, baseName, nil, postContext) + return true +end + +function private.GroupSearchOnValueChanged(input) + private.groupSearch = strlower(input:GetValue()) + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(private.groupSearch) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.GroupTreeOnGroupSelectionChanged(groupTree) + local scanBtn = groupTree:GetElement("__parent.bottom.scanBtn") + scanBtn:SetDisabled(groupTree:IsSelectionCleared()) + scanBtn:Draw() +end + +function private.FrameOnHide(frame) + assert(frame == private.frame) + private.frame = nil + for _, callback in ipairs(private.updateCallbacks) do + callback() + end +end + +function private.ScanButtonOnClick(button) + wipe(private.selectedGroups) + for _, groupPath in button:GetElement("__parent.__parent.groupTree"):SelectedGroupsIterator() do + if groupPath ~= "" and not strmatch(groupPath, "^`") then + tinsert(private.selectedGroups, groupPath) + end + end + local viewContainer = button:GetParentElement():GetParentElement():GetParentElement():GetParentElement() + local searchContext = TSM.Shopping.GroupSearch.GetSearchContext(private.selectedGroups) + assert(searchContext) + private.StartSearchHelper(viewContainer, searchContext) +end + +function private.SearchListOnFavoriteChanged(_, dbRow, isFavorite) + TSM.Shopping.SavedSearches.SetSearchIsFavorite(dbRow, isFavorite) +end + +function private.SearchListOnEditClick(searchList, dbRow) + local dialog = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(600, 187) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG") + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Text", "title") + :SetHeight(44) + :SetMargin(16, 16, 24, 16) + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Rename Search"]) + ) + :AddChild(UIElements.New("Input", "nameInput") + :SetHeight(26) + :SetMargin(16, 16, 0, 25) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AllowItemInsert(true) + :SetContext(dbRow) + :SetValue(dbRow:GetField("name")) + :SetScript("OnEnterPressed", private.RenameInputOnEnterPressed) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetMargin(16, 16, 0, 16) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("ActionButton", "closeBtn") + :SetSize(126, 26) + :SetText(CLOSE) + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + searchList:GetBaseElement():ShowDialogFrame(dialog) + dialog:GetElement("nameInput"):SetFocused(true) +end + +function private.RenameInputOnEnterPressed(input) + local name = input:GetValue() + if name == "" then + return + end + local dbRow = input:GetContext() + local baseElement = input:GetBaseElement() + baseElement:HideDialog() + TSM.Shopping.SavedSearches.RenameSearch(dbRow, name) +end + +function private.DialogCloseBtnOnClick(button) + private.RenameInputOnEnterPressed(button:GetElement("__parent.__parent.nameInput")) +end + +function private.SearchListOnDelete(_, dbRow) + TSM.Shopping.SavedSearches.DeleteSearch(dbRow) +end + +function private.SearchListOnRowClick(searchList, dbRow) + local viewContainer = searchList:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement() + private.StartFilterSearchHelper(viewContainer, dbRow:GetField("filter")) +end + +function private.AdvancedButtonOnClick(button) + button:GetParentElement():GetParentElement():GetParentElement():SetPath("advanced", true) +end + +function private.AdvancedBackButtonOnClick(button) + button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():SetPath("search", true) +end + +function private.ClassDropdownOnSelectionChanged(dropdown) + local subClassDropdown = dropdown:GetElement("__parent.subClassDropdown") + local selection = dropdown:GetSelectedItem() + if selection then + local subClasses = TempTable.Acquire() + for _, v in pairs(ItemClass.GetSubClasses(selection)) do + tinsert(subClasses, v) + end + if dropdown:GetSelectedItem() == GetItemClassInfo(LE_ITEM_CLASS_ARMOR) then + for _, v in pairs(GENERIC_TYPES) do + tinsert(subClasses, v) + end + end + subClassDropdown:SetItems(subClasses) + subClassDropdown:SetDisabled(false) + subClassDropdown:SetSelectedItem(nil) + :Draw() + + TempTable.Release(subClasses) + else + subClassDropdown:SetDisabled(true) + subClassDropdown:SetSelectedItem(nil) + :Draw() + end +end + +function private.SubClassDropdownOnSelectionChanged(dropdown) + local classDropdown = dropdown:GetElement("__parent.classDropdown") + local itemSlotDropdown = dropdown:GetElement("__parent.__parent.itemSlot.frame.dropdown") + local selection = dropdown:GetSelectedItem() + if selection and classDropdown:GetSelectedItem() == GetItemClassInfo(LE_ITEM_CLASS_ARMOR) and ARMOR_TYPES[selection] then + itemSlotDropdown:SetItems(INVENTORY_TYPES) + itemSlotDropdown:SetDisabled(false) + itemSlotDropdown:SetSelectedItem(nil) + :Draw() + else + itemSlotDropdown:SetDisabled(true) + itemSlotDropdown:SetSelectedItem(nil) + :Draw() + end +end + +function private.ResetButtonOnClick(button) + local headerFrame = button:GetElement("__parent.__parent.search.header") + headerFrame:GetElement("keyword"):SetText("") + headerFrame:Draw() + local searchFrame = button:GetElement("__parent.__parent.search.body") + searchFrame:GetElement("level.slider"):SetValue(0, MAX_LEVEL) + searchFrame:GetElement("itemLevel.slider"):SetValue(0, MAX_ITEM_LEVEL) + searchFrame:GetElement("classAndSubClass.classDropdown"):SetSelectedItem(nil) + searchFrame:GetElement("classAndSubClass.subClassDropdown"):SetSelectedItem(nil):SetDisabled(true) + searchFrame:GetElement("itemSlot.frame.dropdown"):SetSelectedItem(nil):SetDisabled(true) + searchFrame:GetElement("content.minRarity.dropdown"):SetSelectedItem(nil) + searchFrame:GetElement("content.frame.maxQty.input"):SetValue(0) + searchFrame:GetElement("filters.uncollected.checkbox"):SetChecked(false) + searchFrame:GetElement("filters.upgrades.checkbox"):SetChecked(false) + searchFrame:GetElement("filters.usable.checkbox"):SetChecked(false) + searchFrame:GetElement("filters2.exact"):SetChecked(false) + searchFrame:GetElement("filters2.crafting"):SetChecked(false) + searchFrame:Draw() +end + +function private.AdvancedStartOnClick(button) + local headerFrame = button:GetElement("__parent.__parent.search.header") + local searchFrame = button:GetElement("__parent.__parent.search.body") + local filterParts = TempTable.Acquire() + + tinsert(filterParts, strtrim(headerFrame:GetElement("keyword"):GetValue())) + + local levelMin, levelMax = searchFrame:GetElement("level.slider"):GetValue() + if levelMin ~= 0 or levelMax ~= MAX_LEVEL then + tinsert(filterParts, levelMin) + tinsert(filterParts, levelMax) + end + + local itemLevelMin, itemLevelMax = searchFrame:GetElement("itemLevel.slider"):GetValue() + if itemLevelMin ~= 0 or itemLevelMax ~= MAX_ITEM_LEVEL then + tinsert(filterParts, "i"..itemLevelMin) + tinsert(filterParts, "i"..itemLevelMax) + end + + local class = searchFrame:GetElement("classAndSubClass.classDropdown"):GetSelectedItem() + if class then + tinsert(filterParts, class) + end + + local subClass = searchFrame:GetElement("classAndSubClass.subClassDropdown"):GetSelectedItem() + if subClass then + tinsert(filterParts, subClass) + end + + local itemSlot = searchFrame:GetElement("itemSlot.frame.dropdown"):GetSelectedItem() + if itemSlot then + tinsert(filterParts, itemSlot) + end + + local rarity = searchFrame:GetElement("content.minRarity.dropdown"):GetSelectedItem() + if rarity then + tinsert(filterParts, rarity) + end + + local quantity = tonumber(searchFrame:GetElement("content.frame.maxQty.input"):GetValue()) + if quantity > 0 then + tinsert(filterParts, "x"..quantity) + end + + if not TSM.IsWowClassic() and searchFrame:GetElement("filters.uncollected.checkbox"):IsChecked() then + tinsert(filterParts, "uncollected") + end + + if not TSM.IsWowClassic() and searchFrame:GetElement("filters.upgrades.checkbox"):IsChecked() then + tinsert(filterParts, "upgrades") + end + + if searchFrame:GetElement("filters.usable.checkbox"):IsChecked() then + tinsert(filterParts, "usable") + end + + if searchFrame:GetElement("filters2.exact"):IsChecked() then + tinsert(filterParts, "exact") + end + + if searchFrame:GetElement("filters2.crafting"):IsChecked() then + tinsert(filterParts, "crafting") + end + + local filter = table.concat(filterParts, "/") + TempTable.Release(filterParts) + local viewContainer = searchFrame:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement() + private.StartFilterSearchHelper(viewContainer, filter) +end + +function private.FilterInputOnValueChanged(input) + local text = input:GetValue() + if text == private.filterText then + return + end + private.filterText = text + input:GetElement("__parent.search"):SetDisabled(TSM.IsWowClassic() and text == "") + :Draw() +end + +function private.FilterInputOnEnterPressed(input) + local filter = input:GetValue() + if TSM.IsWowClassic() and filter == "" then + return + end + local viewContainer = input:GetElement("__parent.__parent.__parent.__parent.__parent") + private.StartFilterSearchHelper(viewContainer, filter) +end + +function private.SearchButtonOnClick(button) + private.FilterInputOnEnterPressed(button:GetElement("__parent.filterInput")) +end + +function private.StartSearchHelper(viewContainer, searchContext, filter, errMsg) + if not TSM.UI.AuctionUI.StartingScan(L["Browse"]) then + return + end + if searchContext then + viewContainer:SetPath("scan", true) + local name = searchContext:GetName() + assert(name) + private.searchName = name + viewContainer:GetElement("scan.searchFrame.filterInput") + :SetValue(name) + viewContainer:GetElement("scan.searchFrame.rescanBtn") + :SetDisabled(name == L["Gathering Search"]) + private.fsm:ProcessEvent("EV_START_SCAN", searchContext) + else + viewContainer:SetPath("selection", true) + if type(filter) == "string" then + Log.PrintUser(format(L["Invalid search filter (%s)."], filter).." "..errMsg) + end + end +end + +function private.StartFilterSearchHelper(viewContainer, filter, isGreatDeals, postContext) + local searchContext, errMsg = nil, nil + if isGreatDeals then + searchContext = TSM.Shopping.FilterSearch.GetGreatDealsSearchContext(filter) + else + searchContext, errMsg = TSM.Shopping.FilterSearch.GetSearchContext(filter, postContext) + end + private.StartSearchHelper(viewContainer, searchContext, filter, errMsg) +end + +function private.StartGatheringSearchHelper(viewContainer, items, stateCallback, buyCallback, mode) + local filterList = TempTable.Acquire() + for itemString, quantity in pairs(items) do + tinsert(filterList, itemString.."/x"..quantity) + end + local filter = table.concat(filterList, ";") + TempTable.Release(filterList) + local searchContext = TSM.Shopping.FilterSearch.GetGatheringSearchContext(filter, mode) + assert(searchContext) + searchContext:SetCallbacks(buyCallback, stateCallback) + private.StartSearchHelper(viewContainer, searchContext, filter) +end + +function private.DealsButtonOnClick(button) + local viewContainer = button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement() + private.StartFilterSearchHelper(viewContainer, TSM.Shopping.GreatDealsSearch.GetFilter(), true) +end + +function private.VendorButtonOnClick(button) + local viewContainer = button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement() + local searchContext = TSM.Shopping.VendorSearch.GetSearchContext() + assert(searchContext) + private.StartSearchHelper(viewContainer, searchContext) +end + +function private.DisenchantButtonOnClick(button) + local viewContainer = button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement() + local searchContext = TSM.Shopping.DisenchantSearch.GetSearchContext() + assert(searchContext) + private.StartSearchHelper(viewContainer, searchContext) +end + +function private.ScanBackButtonOnClick(button) + private.searchName = "" + button:GetElement("__parent.__parent.__parent.__parent"):SetPath("selection", true) + private.fsm:ProcessEvent("EV_SCAN_BACK_BUTTON_CLICKED") +end + +function private.AuctionsOnSelectionChanged() + private.fsm:ProcessEvent("EV_AUCTION_SELECTION_CHANGED") +end + +function private.PauseResumeOnClick() + private.fsm:ProcessEvent("EV_PAUSE_RESUME_CLICKED") +end + +function private.AuctionsOnPostButtonClick() + private.fsm:ProcessEvent("EV_POST_BUTTON_CLICK") +end + +function private.ScanFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.fsm:ProcessEvent("EV_SCAN_FRAME_SHOWN", frame) +end + +function private.ScanFrameOnHide(frame) + private.fsm:ProcessEvent("EV_SCAN_FRAME_HIDDEN") +end + +function private.PerItemOnClick(button) + if private.perItem then + return + end + + private.perItem = true + button:GetElement("__parent.stack") + :SetTextColor("TEXT") + :Draw() + button:SetTextColor("INDICATOR") + :Draw() + + local frame = button:GetElement("__parent.__parent") + local postContext = frame:GetElement("__parent"):GetContext() + local undercut = (not TSM.IsWowClassic() or PlayerInfo.IsPlayer(postContext.ownerStr, true, true, true)) and 0 or 1 + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + buyoutInput:SetFocused(false) + bidInput:SetFocused(false) + local numStacksInput = frame:GetElement("numStacks.input") + numStacksInput:SetFocused(false) + local quantityInput = frame:GetElement("quantity.input") + quantityInput:SetFocused(false) + local stackSizeEdit = tonumber(quantityInput:GetValue()) + local isCommodity = ItemInfo.IsCommodity(private.itemString) + if postContext.quantity == stackSizeEdit then + local newBid = private.ParseBidBuyout(bidInput:GetValue()) + newBid = newBid + undercut == postContext.displayedBid and floor(postContext.displayedBid / postContext.quantity) - undercut or floor(newBid / postContext.quantity) + local newBuyout = private.ParseBidBuyout(buyoutInput:GetValue()) + newBuyout = newBuyout + undercut == postContext.buyout and postContext.itemBuyout - undercut or floor(newBuyout / postContext.quantity) + buyoutInput:SetValue(Money.ToString(newBuyout, nil, "OPT_83_NO_COPPER")) + bidInput:SetValue(Money.ToString(newBid, nil, "OPT_83_NO_COPPER", isCommodity and "OPT_DISABLE" or nil)) + else + local newBid = private.ParseBidBuyout(bidInput:GetValue()) + newBid = newBid + undercut == postContext.displayedBid and floor(postContext.displayedBid / stackSizeEdit) - undercut or floor(newBid / stackSizeEdit) + local newBuyout = private.ParseBidBuyout(buyoutInput:GetValue()) + newBuyout = newBuyout + undercut == postContext.buyout and postContext.itemBuyout - undercut or floor(newBuyout / stackSizeEdit) + buyoutInput:SetValue(Money.ToString(newBuyout, nil, "OPT_83_NO_COPPER")) + bidInput:SetValue(Money.ToString(newBid, nil, "OPT_83_NO_COPPER", isCommodity and "OPT_DISABLE" or nil)) + end + frame:Draw() +end + +function private.PerStackOnClick(button) + if not private.perItem then + return + end + + private.perItem = false + button:GetElement("__parent.item") + :SetTextColor("TEXT") + :Draw() + button:SetTextColor("INDICATOR") + :Draw() + + local frame = button:GetElement("__parent.__parent") + local postContext = frame:GetElement("__parent"):GetContext() + local undercut = (not TSM.IsWowClassic() or PlayerInfo.IsPlayer(postContext.ownerStr, true, true, true)) and 0 or 1 + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + buyoutInput:SetFocused(false) + bidInput:SetFocused(false) + local numStacksInput = frame:GetElement("numStacks.input") + numStacksInput:SetFocused(false) + local quantityInput = frame:GetElement("quantity.input") + quantityInput:SetFocused(false) + local stackSizeEdit = tonumber(quantityInput:GetValue()) + local newBuyout, newBid = nil, nil + local isCommodity = ItemInfo.IsCommodity(private.itemString) + if postContext.quantity == stackSizeEdit then + newBid = private.ParseBidBuyout(bidInput:GetValue()) + newBid = ((newBid + undercut) * postContext.quantity) == postContext.displayedBid and (postContext.displayedBid - undercut) or (newBid * postContext.quantity) + newBuyout = private.ParseBidBuyout(buyoutInput:GetValue()) + newBuyout = ((newBuyout + undercut) * postContext.quantity) == postContext.buyout and (postContext.buyout - undercut) or (newBuyout * postContext.quantity) + else + newBid = private.ParseBidBuyout(bidInput:GetValue()) + newBid = ((newBid + undercut) * postContext.quantity) == postContext.displayedBid and floor(postContext.displayedBid / postContext.quantity) * stackSizeEdit - undercut or newBid * stackSizeEdit + newBuyout = private.ParseBidBuyout(buyoutInput:GetValue()) + newBuyout = ((newBuyout + undercut) * postContext.quantity) == postContext.buyout and postContext.itemBuyout * stackSizeEdit - undercut or newBuyout * stackSizeEdit + end + buyoutInput:SetValue(Money.ToString(newBuyout, nil, "OPT_83_NO_COPPER")) + bidInput:SetValue(Money.ToString(newBid, nil, "OPT_83_NO_COPPER", isCommodity and "OPT_DISABLE" or nil)) + frame:Draw() +end + +function private.ParseBidBuyout(value) + value = Money.FromString(value) or tonumber(value) + if not value then + return nil + end + if not TSM.IsWowClassic() and value % COPPER_PER_SILVER ~= 0 then + return nil + end + return (value or 0) > 0 and value <= MAXIMUM_BID_PRICE and value or nil +end + +function private.BidBuyoutValidateFunc(input, value) + value = private.ParseBidBuyout(value) + if not value then + return false, L["Invalid price."] + end + return true +end + +function private.BidBuyoutOnValidationChanged(input) + private.UpdateDepositCostAndPostButton(input:GetElement("__parent.__parent")) +end + +function private.BidBuyoutInputOnValueChanged(input) + local frame = input:GetElement("__parent.__parent") + local itemString = frame:GetElement("confirmBtn"):GetContext() + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local bid = private.ParseBidBuyout(bidInput:GetValue()) + local buyout = private.ParseBidBuyout(buyoutInput:GetValue()) + if input == buyoutInput and not TSM.IsWowClassic() and ItemInfo.IsCommodity(itemString) then + -- update the bid to match + bidInput:SetValue(Money.ToString(buyout, nil, "OPT_83_NO_COPPER", "OPT_DISABLE")) + :Draw() + elseif input == bidInput and private.ParseBidBuyout(input:GetValue()) > private.ParseBidBuyout(buyoutInput:GetValue()) then + -- update the buyout to match + buyoutInput:SetValue(Money.ToString(bid, nil, "OPT_83_NO_COPPER")) + :Draw() + end + private.UpdateDepositCostAndPostButton(frame) +end + +function private.BidBuyoutInputOnEnterPressed(input) + local frame = input:GetElement("__parent.__parent") + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local value = private.ParseBidBuyout(input:GetValue()) + input:SetValue(Money.ToString(value, nil, "OPT_83_NO_COPPER")) + input:Draw() + if input == buyoutInput and private.ParseBidBuyout(buyoutInput:GetValue()) < private.ParseBidBuyout(bidInput:GetValue()) then + -- update the bid to match + bidInput:SetValue(Money.ToString(value, nil, "OPT_83_NO_COPPER")) + :Draw() + end + private.UpdateDepositCostAndPostButton(frame) +end + +function private.ItemBtnOnClick(button) + button:GetElement("__parent.__parent.__parent"):SetPath("selection", true) +end + +function private.StackNumInputOnValueChanged(input) + local value = tonumber(input:GetValue()) + assert(value) + if value == private.postStack then + return + end + private.postStack = value + private.UpdateDepositCostAndPostButton(input:GetParentElement():GetParentElement()) +end + +function private.QuantityInputOnValueChanged(input) + local value = tonumber(input:GetValue()) + if value == private.postQuantity then + return + end + private.postQuantity = value + private.UpdateDepositCostAndPostButton(input:GetParentElement():GetParentElement()) + + if private.perItem then + return + end + + local frame = input:GetElement("__parent.__parent") + local postContext = frame:GetElement("__parent"):GetContext() + local undercut = (not TSM.IsWowClassic() or PlayerInfo.IsPlayer(postContext.ownerStr, true, true, true)) and 0 or 1 + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local stackSizeEdit = tonumber(frame:GetElement("quantity.input"):GetValue()) + stackSizeEdit = tonumber(stackSizeEdit) + local newBuyout, newBid = nil, nil + if postContext.quantity == stackSizeEdit then + newBuyout = postContext.buyout - undercut + newBid = postContext.displayedBid - undercut + else + newBuyout = postContext.itemBuyout * stackSizeEdit - undercut + newBid = floor(postContext.displayedBid / postContext.quantity) * stackSizeEdit - undercut + end + buyoutInput:SetValue(Money.ToString(newBuyout, nil, "OPT_83_NO_COPPER")) + bidInput:SetValue(Money.ToString(newBid, nil, "OPT_83_NO_COPPER", ItemInfo.IsCommodity(private.itemString) and "OPT_DISABLE" or nil)) + frame:Draw() +end + +function private.GetBagQuantity(itemString, useSpecificItem) + return BagTracking.CreateQueryBagsItemAuctionable(useSpecificItem and itemString or ItemString.GetBase(itemString)) + :SumAndRelease("quantity") or 0 +end + +function private.GetMaxPostStack(itemString) + local numHave = private.GetBagQuantity(itemString, not TSM.IsWowClassic()) + if TSM.IsWowClassic() then + return min(ItemInfo.GetMaxStack(itemString), numHave) + else + return numHave + end +end + +function private.MaxStackNumBtnOnClick(button) + button:GetElement("__parent.__parent.quantity.input"):SetFocused(false) + button:GetElement("__parent.input"):SetFocused(false) + local itemString = button:GetElement("__parent.__parent.confirmBtn"):GetContext() + local stackSize = tonumber(button:GetElement("__parent.__parent.quantity.input"):GetValue()) + local num = min(floor(private.GetBagQuantity(itemString) / stackSize), 5000) + if num == 0 then + return + end + button:GetElement("__parent.input") + :SetValue(num) + :Draw() + private.StackNumInputOnValueChanged(button:GetElement("__parent.input")) +end + +function private.MaxQuantityBtnOnClick(button) + if TSM.IsWowClassic() then + button:GetElement("__parent.__parent.numStacks.input"):SetFocused(false) + end + button:GetElement("__parent.input"):SetFocused(false) + local itemString = button:GetElement("__parent.__parent.confirmBtn"):GetContext() + local numHave = private.GetBagQuantity(itemString) + local stackSize = min(private.GetMaxPostStack(itemString), 5000) + assert(stackSize > 0) + button:GetElement("__parent.input") + :SetValue(stackSize) + :Draw() + if TSM.IsWowClassic() then + local numStacks = tonumber(button:GetElement("__parent.__parent.numStacks.input"):GetValue()) + local newStackSize = min(floor(numHave / stackSize), 5000) + if numStacks > newStackSize then + button:GetElement("__parent.__parent.numStacks.input") + :SetValue(newStackSize) + :Draw() + private.StackNumInputOnValueChanged(button:GetElement("__parent.__parent.numStacks.input")) + end + end + private.QuantityInputOnValueChanged(button:GetElement("__parent.input")) +end + +function private.DurationOnValueChanged(toggle) + private.UpdateDepositCostAndPostButton(toggle:GetParentElement():GetParentElement()) +end + +function private.UpdateDepositCostAndPostButton(frame) + local itemString = frame:GetElement("confirmBtn"):GetContext() + if not itemString then + return + end + + local bidInput = frame:GetElement("bid.input") + local buyoutInput = frame:GetElement("buyout.input") + local bid = private.ParseBidBuyout(bidInput:GetValue()) + local buyout = private.ParseBidBuyout(buyoutInput:GetValue()) + local stackSize = tonumber(frame:GetElement("quantity.input"):GetValue()) + local numAuctions = TSM.IsWowClassic() and tonumber(frame:GetElement("numStacks.input"):GetValue()) or 1 + if bid > buyout or not bidInput:IsValid() or not buyoutInput:IsValid() or (stackSize * numAuctions) > private.GetBagQuantity(itemString) then + frame:GetElement("deposit.text") + :SetText(Money.ToString(0, nil, "OPT_83_NO_COPPER")) + :Draw() + frame:GetElement("confirmBtn") + :SetDisabled(true) + :Draw() + return + end + + local postBag, postSlot = BagTracking.CreateQueryBagsAuctionable() + :OrderBy("slotId", true) + :Select("bag", "slot") + :Equal("itemString", itemString) + :GetFirstResultAndRelease() + if not postBag or not postSlot then + frame:GetElement("deposit.text") + :SetText(Money.ToString(0, nil, "OPT_83_NO_COPPER")) + :Draw() + frame:GetElement("confirmBtn") + :SetDisabled(true) + :Draw() + return + end + + private.postTimeStr = frame:GetElement("duration.toggle"):GetValue() + local postTime = Table.GetDistinctKey(TSM.CONST.AUCTION_DURATIONS, private.postTimeStr) + local depositCost = nil + if not TSM.IsWowClassic() then + local isCommodity = ItemInfo.IsCommodity(itemString) + depositCost = max(floor(0.15 * (ItemInfo.GetVendorSell(itemString) or 0) * (isCommodity and stackSize or 1) * (postTime == 3 and 4 or postTime)), 100) * (isCommodity and 1 or stackSize) + else + if private.perItem then + bid = bid * stackSize + buyout = buyout * stackSize + end + ClearCursor() + PickupContainerItem(postBag, postSlot) + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + ClearCursor() + depositCost = GetAuctionDeposit(postTime, bid, buyout, stackSize, numAuctions) + ClearCursor() + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + ClearCursor() + end + + local noMoney = depositCost > GetMoney() + frame:GetElement("deposit.text") + :SetText(Money.ToString(depositCost)) + :Draw() + frame:GetElement("confirmBtn") + :SetText(noMoney and L["Not Enough Money"] or L["Post Auction"]) + :SetDisabled(noMoney) + :Draw() +end + +function private.PostButtonOnClick(button) + local frame = button:GetParentElement() + local stackSize = tonumber(frame:GetElement("quantity.input"):GetValue()) + local bid = private.ParseBidBuyout(frame:GetElement("bid.input"):GetValue()) + local buyout = private.ParseBidBuyout(frame:GetElement("buyout.input"):GetValue()) + local itemString = button:GetContext() + local postBag, postSlot = BagTracking.CreateQueryBagsAuctionable() + :OrderBy("slotId", true) + :Select("bag", "slot") + :Equal("itemString", itemString) + :GetFirstResultAndRelease() + if postBag and postSlot then + local postTime = Table.GetDistinctKey(TSM.CONST.AUCTION_DURATIONS, frame:GetElement("duration.toggle"):GetValue()) + if not TSM.IsWowClassic() then + bid = Math.Round(bid, COPPER_PER_SILVER) + buyout = Math.Round(buyout, COPPER_PER_SILVER) + private.itemLocation:Clear() + private.itemLocation:SetBagAndSlot(postBag, postSlot) + local commodityStatus = C_AuctionHouse.GetItemCommodityStatus(private.itemLocation) + local future = nil + if commodityStatus == Enum.ItemCommodityStatus.Item then + future = AuctionHouseWrapper.PostItem(private.itemLocation, postTime, stackSize, bid < buyout and bid or nil, buyout) + elseif commodityStatus == Enum.ItemCommodityStatus.Commodity then + future = AuctionHouseWrapper.PostCommodity(private.itemLocation, postTime, stackSize, buyout) + else + error("Unknown commodity status: "..tostring(itemString)) + end + if future then + -- TODO: wait for the future + future:Cancel() + AuctionTracking.QueryOwnedAuctions() + end + else + local num = tonumber(frame:GetElement("numStacks.input"):GetValue()) + if strfind(button:GetContext(), "^p") then + stackSize = 1 + num = 1 + end + if private.perItem then + bid = bid * stackSize + buyout = buyout * stackSize + end + -- need to set the duration in the default UI to avoid Blizzard errors + AuctionFrameAuctions.duration = postTime + ClearCursor() + PickupContainerItem(postBag, postSlot) + ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") + PostAuction(bid, buyout, postTime, stackSize, num) + ClearCursor() + end + end + frame:GetBaseElement():HideDialog() +end + +function private.PostDialogCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.ScanFilterInputOnEnterPressed(input) + local filter = input:GetValue() + if TSM.IsWowClassic() and filter == "" then + return + end + local viewContainer = input:GetParentElement():GetParentElement():GetParentElement() + viewContainer:SetPath("selection") + private.StartFilterSearchHelper(viewContainer, filter) +end + +function private.RescanBtnOnClick(button) + if not TSM.UI.AuctionUI.StartingScan(L["Browse"]) then + return + end + private.fsm:ProcessEvent("EV_RESCAN_CLICKED") +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + scanFrame = nil, + auctionScan = nil, + progress = 0, + progressText = L["Starting Scan..."], + progressPaused = false, + postDisabled = true, + bidDisabled = true, + buyoutDisabled = true, + cancelShown = false, + findHash = nil, + findAuction = nil, + findResult = nil, + numFound = 0, + maxQuantity = 0, + defaultBuyQuantity = 0, + numBought = 0, + lastBuyQuantity = 0, + numBid = 0, + numConfirmed = 0, + searchContext = nil, + postContextTemp = {}, + pausePending = nil, + cancelFuture = nil, + } + + Event.Register("CHAT_MSG_SYSTEM", private.FSMMessageEventHandler) + Event.Register("UI_ERROR_MESSAGE", private.FSMMessageEventHandler) + if not TSM.IsWowClassic() then + Event.Register("COMMODITY_PURCHASE_SUCCEEDED", private.FSMBuyoutSuccess) + end + Event.Register("AUCTION_HOUSE_CLOSED", function() + private.fsm:ProcessEvent("EV_AUCTION_HOUSE_CLOSED") + end) + Event.Register("BAG_UPDATE_DELAYED", function() + private.fsm:ProcessEvent("EV_BAG_UPDATE_DELAYED") + end) + AuctionHouseWrapper.RegisterAuctionIdUpdateCallback(function(...) + private.fsm:ProcessEvent("EV_AUCTION_ID_UPDATE", ...) + end) + local function UpdateScanFrame(context) + if not context.scanFrame then + return + end + local isCanceling = context.cancelFuture and true or false + local bottom = context.scanFrame:GetElement("bottom") + bottom:GetElement("postBtn"):SetDisabled(isCanceling or context.postDisabled) + bottom:GetElement("bidBtn"):SetDisabled(isCanceling or context.bidDisabled) + bottom:GetElement("buyoutBtn"):SetDisabled(isCanceling or context.buyoutDisabled) + if context.cancelShown then + assert(context.buyoutDisabled) + bottom:GetElement("buyoutBtn"):Hide() + bottom:GetElement("cancelBtn") + :SetDisabled(isCanceling) + :Show() + else + bottom:GetElement("buyoutBtn"):Show() + bottom:GetElement("cancelBtn") + :SetDisabled(true) + :Hide() + end + local progress, isPaused = context.auctionScan:GetProgress() + bottom:GetElement("pauseResumeBtn") + :SetDisabled((not isPaused and progress == 1) or context.pausePending ~= nil) + :SetHighlightLocked(context.pausePending ~= nil) + bottom:GetElement("progressBar"):SetProgress(context.progress) + :SetText(isCanceling and L["Cancelling..."] or context.progressText or "") + :SetProgressIconHidden(context.progress == 1 or (context.findResult and context.numBought + context.numBid == context.numConfirmed) or context.progressPaused) + local auctionList = context.scanFrame:GetElement("auctions") + :SetContext(context.auctionScan) + :SetAuctionScan(context.auctionScan) + :SetMarketValueFunction(context.searchContext:GetMarketValueFunc()) + :SetSelectionDisabled(context.numBought + context.numBid ~= context.numConfirmed) + :SetPctTooltip(context.searchContext:GetPctTooltip()) + if context.findAuction and not auctionList:GetSelection() then + auctionList:SetSelection(context.findAuction) + end + context.scanFrame:Draw() + end + private.fsm = FSM.New("SHOPPING") + :AddState(FSM.NewState("ST_INIT") + :SetOnEnter(function(context, searchContext) + private.hasLastScan = false + if context.searchContext then + context.searchContext:KillThread() + context.searchContext:OnStateChanged("DONE") + context.searchContext = nil + end + if context.cancelFuture then + context.cancelFuture:Cancel() + context.cancelFuture = nil + end + context.progress = 0 + context.progressText = L["Starting Scan..."] + context.progressPaused = false + context.pausePending = nil + context.postDisabled = true + context.bidDisabled = true + context.buyoutDisabled = true + context.cancelShown = false + context.findHash = nil + context.findAuction = nil + context.findResult = nil + context.numFound = 0 + context.maxQuantity = 0 + context.defaultBuyQuantity = 0 + context.numBought = 0 + context.lastBuyQuantity = 0 + context.numBid = 0 + context.numConfirmed = 0 + if context.auctionScan then + context.auctionScan:Release() + context.auctionScan = nil + end + if searchContext then + return "ST_STARTING_SCAN", searchContext + elseif context.scanFrame then + context.scanFrame:GetParentElement():SetPath("selection", true) + context.scanFrame = nil + end + TSM.UI.AuctionUI.EndedScan(L["Browse"]) + end) + :AddTransition("ST_INIT") + :AddTransition("ST_STARTING_SCAN") + ) + :AddState(FSM.NewState("ST_STARTING_SCAN") + :SetOnEnter(function(context, searchContext) + context.searchContext = searchContext + private.hasLastScan = true + context.auctionScan = AuctionScan.GetManager() + :SetResolveSellers(true) + :SetScript("OnProgressUpdate", private.FSMAuctionScanOnProgressUpdate) + UpdateScanFrame(context) + context.searchContext:StartThread(private.FSMScanCallback, context.auctionScan) + context.searchContext:OnStateChanged("SCANNING") + return "ST_SCANNING" + end) + :AddTransition("ST_SCANNING") + ) + :AddState(FSM.NewState("ST_SCANNING") + :SetOnEnter(function(context) + UpdateScanFrame(context) + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if context.pausePending == nil and selection and selection:IsSubRow() then + -- pause the scan so the selected auction can be bought + context.pausePending = true + context.auctionScan:SetPaused(true) + return "ST_UPDATING_SCAN_PROGRESS" + end + end) + :AddTransition("ST_UPDATING_SCAN_PROGRESS") + :AddTransition("ST_RESULTS") + :AddTransition("ST_INIT") + :AddEventTransition("EV_SCAN_PROGRESS_UPDATE", "ST_UPDATING_SCAN_PROGRESS") + :AddEvent("EV_SCAN_COMPLETE", function(context) + TSM.UI.AuctionUI.EndedScan(L["Browse"]) + if context.scanFrame then + context.scanFrame:GetElement("auctions"):ExpandSingleResult() + end + context.searchContext:OnStateChanged("RESULTS") + return "ST_RESULTS" + end) + :AddEvent("EV_SCAN_FAILED", function(context) + context.searchContext:OnStateChanged("RESULTS") + return "ST_RESULTS" + end) + :AddEvent("EV_RESCAN_CLICKED", function(context) + if context.scanFrame then + local viewContainer = context.scanFrame:GetParentElement() + viewContainer:SetPath("selection", true) + viewContainer:SetPath("scan", true) + context.scanFrame = viewContainer:GetElement("scan") + local name = context.searchContext:GetName() + assert(name) + context.scanFrame:GetElement("searchFrame.filterInput") + :SetValue(name) + context.scanFrame:GetElement("searchFrame.rescanBtn") + :SetDisabled(name == L["Gathering Search"]) + end + return "ST_INIT", context.searchContext + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + assert(context.pausePending == nil) + context.pausePending = true + context.auctionScan:SetPaused(true) + return "ST_UPDATING_SCAN_PROGRESS" + end) + :AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context) + if context.pausePending ~= nil then + return + end + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + if selection and selection:IsSubRow() then + -- pause the scan so the selected auction can be bought + assert(context.pausePending == nil) + context.pausePending = true + context.auctionScan:SetPaused(true) + return "ST_UPDATING_SCAN_PROGRESS" + end + end) + ) + :AddState(FSM.NewState("ST_UPDATING_SCAN_PROGRESS") + :SetOnEnter(function(context) + local progress, isPaused = context.auctionScan:GetProgress() + local text, progressPaused = nil, false + if context.pausePending ~= nil and context.pausePending == isPaused then + context.pausePending = nil + end + if context.pausePending == true then + text = L["Pausing Scan..."] + elseif context.pausePending == false then + text = L["Resuming Scan..."] + elseif isPaused then + text = L["Scan Paused"] + progressPaused = true + elseif progress == 1 then + text = L["Done Scanning"] + else + local numItems = context.auctionScan:GetNumItems() + text = numItems and format(L["Scanning (%d Items)"], numItems) or L["Scanning"] + end + context.progress = progress + context.progressText = text + context.progressPaused = progressPaused + UpdateScanFrame(context) + if isPaused then + return "ST_RESULTS" + else + return "ST_SCANNING" + end + end) + :AddTransition("ST_SCANNING") + :AddTransition("ST_RESULTS") + ) + :AddState(FSM.NewState("ST_RESULTS") + :SetOnEnter(function(context, didBuy) + TSM.UI.AuctionUI.EndedScan(L["Browse"]) + local _, isPaused = context.auctionScan:GetProgress() + if not isPaused then + context.searchContext:KillThread() + context.progress = 1 + end + context.progressText = isPaused and L["Scan Paused"] or L["Done Scanning"] + context.progressPaused = isPaused + context.findAuction = nil + context.findResult = nil + context.numFound = 0 + context.defaultBuyQuantity = 0 + context.numBought = 0 + context.lastBuyQuantity = 0 + context.numBid = 0 + context.numConfirmed = 0 + local postContext = context.searchContext:GetPostContext() + if postContext then + for _, query in context.auctionScan:QueryIterator() do + for _, subRow in query:ItemSubRowIterator(postContext.itemString) do + local buyout, itemBuyout = subRow:GetBuyouts() + if itemBuyout > 0 and itemBuyout < postContext.itemBuyout then + postContext.ownerStr = subRow:GetOwnerInfo() + local _, _, currentBid = subRow:GetBidInfo() + postContext.currentBid = currentBid + postContext.displayedBid, postContext.itemDisplayedBid = subRow:GetDisplayedBids() + postContext.buyout = buyout + postContext.itemBuyout = itemBuyout + end + end + end + end + context.postDisabled = not postContext or private.GetBagQuantity(postContext.itemString) == 0 + context.bidDisabled = true + context.buyoutDisabled = true + context.cancelShown = false + UpdateScanFrame(context) + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() or nil + if selection and selection:IsSubRow() then + if TSM.UI.AuctionUI.StartingScan(L["Browse"]) then + return "ST_FINDING_AUCTION" + end + elseif selection and isPaused and context.pausePending == nil then + -- resume the scan to search for the item + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_UPDATING_SCAN_PROGRESS" + elseif didBuy and not selection and isPaused and context.pausePending == nil then + -- we bought something and should now resume the scan + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_UPDATING_SCAN_PROGRESS" + end + end) + :AddTransition("ST_UPDATING_SCAN_PROGRESS") + :AddTransition("ST_FINDING_AUCTION") + :AddTransition("ST_INIT") + :AddEventTransition("EV_SCAN_PROGRESS_UPDATE", "ST_UPDATING_SCAN_PROGRESS") + :AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context) + assert(context.scanFrame) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + if not selection then + return + end + if selection:IsSubRow() then + if TSM.UI.AuctionUI.StartingScan(L["Browse"]) then + -- find the auction + return "ST_FINDING_AUCTION" + end + elseif select(2, context.auctionScan:GetProgress()) then + -- resume the scan to search for the item + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_UPDATING_SCAN_PROGRESS" + end + end) + :AddEvent("EV_POST_BUTTON_CLICK", function(context) + local postContext = nil + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + if selection then + wipe(context.postContextTemp) + private.PopulatePostContextFromRow(context.postContextTemp, selection) + postContext = context.postContextTemp + else + postContext = context.searchContext:GetPostContext() + end + private.PostDialogShow(context.scanFrame:GetBaseElement(), postContext) + end) + :AddEvent("EV_BAG_UPDATE_DELAYED", function(context) + if not context.scanFrame then + return + end + local postContext = context.searchContext:GetPostContext() + context.postDisabled = not postContext or private.GetBagQuantity(postContext.itemString) == 0 + context.scanFrame:GetElement("bottom.postBtn") + :SetDisabled(context.postDisabled) + :Draw() + end) + :AddEvent("EV_RESCAN_CLICKED", function(context) + if context.scanFrame then + local viewContainer = context.scanFrame:GetParentElement() + viewContainer:SetPath("selection", true) + viewContainer:SetPath("scan", true) + context.scanFrame = viewContainer:GetElement("scan") + local name = context.searchContext:GetName() + assert(name) + context.scanFrame:GetElement("searchFrame.filterInput") + :SetValue(name) + context.scanFrame:GetElement("searchFrame.rescanBtn") + :SetDisabled(name == L["Gathering Search"]) + end + return "ST_INIT", context.searchContext + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_UPDATING_SCAN_PROGRESS" + end) + ) + :AddState(FSM.NewState("ST_FINDING_AUCTION") + :SetOnEnter(function(context) + assert(context.scanFrame) + context.findAuction = context.scanFrame:GetElement("auctions"):GetSelection() + assert(context.findAuction and context.findAuction:IsSubRow()) + context.findHash = context.findAuction:GetHashes() + context.progress = 0 + context.progressText = L["Finding Selected Auction"] + context.progressPaused = false + context.postDisabled = true + context.bidDisabled = true + context.buyoutDisabled = true + context.cancelShown = false + UpdateScanFrame(context) + TSM.Shopping.SearchCommon.StartFindAuction(context.auctionScan, context.findAuction, private.FSMFindAuctionCallback, false) + end) + :SetOnExit(function(context) + context.auctionScan:Cancel() + TSM.Shopping.SearchCommon.StopFindAuction(true) + end) + :AddTransition("ST_FINDING_AUCTION") + :AddTransition("ST_RESULTS") + :AddTransition("ST_UPDATING_SCAN_PROGRESS") + :AddTransition("ST_AUCTION_FOUND") + :AddTransition("ST_AUCTION_NOT_FOUND") + :AddTransition("ST_INIT") + :AddEventTransition("EV_AUCTION_FOUND", "ST_AUCTION_FOUND") + :AddEventTransition("EV_AUCTION_NOT_FOUND", "ST_AUCTION_NOT_FOUND") + :AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context) + assert(context.scanFrame) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + if not selection then + return + end + if selection:IsSubRow() then + if TSM.UI.AuctionUI.StartingScan(L["Browse"]) then + return "ST_FINDING_AUCTION" + end + elseif select(2, context.auctionScan:GetProgress()) then + -- resume the scan to search for the item + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_UPDATING_SCAN_PROGRESS" + else + return "ST_RESULTS" + end + end) + :AddEvent("EV_POST_BUTTON_CLICK", function(context) + wipe(context.postContextTemp) + private.PopulatePostContextFromRow(context.postContextTemp, context.scanFrame:GetElement("auctions"):GetSelection()) + private.PostDialogShow(context.scanFrame:GetBaseElement(), context.postContextTemp) + end) + :AddEvent("EV_RESCAN_CLICKED", function(context) + if context.scanFrame then + local viewContainer = context.scanFrame:GetParentElement() + viewContainer:SetPath("selection", true) + viewContainer:SetPath("scan", true) + context.scanFrame = viewContainer:GetElement("scan") + local name = context.searchContext:GetName() + assert(name) + context.scanFrame:GetElement("searchFrame.filterInput") + :SetValue(name) + context.scanFrame:GetElement("searchFrame.rescanBtn") + :SetDisabled(name == L["Gathering Search"]) + end + return "ST_INIT", context.searchContext + end) + :AddEvent("EV_SCAN_FRAME_HIDDEN", function(context) + context.scanFrame = nil + context.findAuction = nil + return "ST_RESULTS" + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + context.findAuction = nil + context.scanFrame:GetElement("auctions"):SetSelection(nil) + return "ST_RESULTS" + end) + ) + :AddState(FSM.NewState("ST_AUCTION_FOUND") + :SetOnEnter(function(context, result) + TSM.UI.AuctionUI.EndedScan(L["Browse"]) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + -- update the selection in case the result rows changed + if context.findHash == selection:GetHashes() then + context.findAuction = selection + end + local itemString = context.findAuction:GetItemString() + local maxQuantity = context.searchContext:GetMaxCanBuy(itemString) + if TSM.IsWowClassic() then + if maxQuantity then + maxQuantity = maxQuantity / context.findAuction:GetQuantities() + end + context.findResult = result + context.numFound = min(#result, maxQuantity or math.huge) + context.maxQuantity = maxQuantity or 1 + context.defaultBuyQuantity = context.numFound + else + local maxCommodity = context.findAuction:IsCommodity() and context.findAuction:GetResultRow():GetMaxQuantities() + local numCanBuy = min(maxCommodity or result, maxQuantity or math.huge) + context.findResult = numCanBuy > 0 + context.numFound = numCanBuy + context.maxQuantity = maxCommodity or 1 + context.defaultBuyQuantity = maxQuantity and min(numCanBuy, maxQuantity) or 1 + end + assert(context.numBought == 0 and context.numBid == 0 and context.numConfirmed == 0) + return "ST_BUYING" + end) + :AddTransition("ST_BUYING") + ) + :AddState(FSM.NewState("ST_AUCTION_NOT_FOUND") + :SetOnEnter(function(context) + context.scanFrame:GetBaseElement():HideDialog() + TSM.UI.AuctionUI.EndedScan(L["Browse"]) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + if not selection or not selection:IsSubRow() then + return "ST_RESULTS" + end + -- update the selection in case the result rows changed + if context.findHash == selection:GetHashes() then + context.findAuction = selection + end + local _, rawLink = context.findAuction:GetLinks() + context.findAuction:GetResultRow():RemoveSubRow(context.findAuction) + context.scanFrame:GetElement("auctions"):UpdateData() + Log.PrintfUser(L["Failed to find auction for %s, so removing it from the results."], rawLink) + return "ST_RESULTS" + end) + :AddTransition("ST_RESULTS") + ) + :AddState(FSM.NewState("ST_BUYING") + :SetOnEnter(function(context, numToRemove) + if numToRemove then + -- remove the one we just bought + local itemString = context.findAuction:GetItemString() + assert(itemString) + context.findAuction:DecrementQuantity(numToRemove) + context.searchContext:OnBuy(itemString, context.lastBuyQuantity) + context.scanFrame:GetElement("auctions"):UpdateData() + context.findAuction = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if context.findAuction and not context.findAuction:IsSubRow() then + context.findAuction = nil + else + local maxQuantity = context.searchContext:GetMaxCanBuy(itemString) + if maxQuantity then + if TSM.IsWowClassic() and context.findAuction then + maxQuantity = maxQuantity / context.findAuction:GetQuantities() + end + context.defaultBuyQuantity = min(context.defaultBuyQuantity, maxQuantity) + end + end + end + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if selection and not selection:IsSubRow() then + selection = nil + end + local itemString, isPlayer = nil, false + if selection then + assert(selection:IsSubRow()) + itemString = selection:GetItemString() + local ownerStr = selection and selection:GetOwnerInfo() or nil + isPlayer = PlayerInfo.IsPlayer(ownerStr, true, true, true) + end + local auctionSelected = selection and context.findHash == selection:GetHashes() + local numCanBuy = not auctionSelected and 0 or (context.numFound - context.numBought - context.numBid) + local numConfirming = context.numBought + context.numBid - context.numConfirmed + local canPost = selection and private.GetBagQuantity(itemString) > 0 and numConfirming == 0 + local progressText = nil + if numConfirming == 0 and (numCanBuy == 0 and (not isPlayer or context.scanFrame:GetElement("auctions"):GetSelection() ~= context.findAuction)) then + -- we're done buying and confirming this batch + return "ST_RESULTS", true + elseif isPlayer and canPost then + progressText = TSM.IsWowClassic() and L["Post"] or L["Cancel or Post"] + elseif isPlayer then + progressText = TSM.IsWowClassic() and L["Select an Auction to Buy"] or L["Cancel Auction"] + elseif numConfirming == 0 then + -- we can still buy more + progressText = format(isPlayer and not TSM.IsWowClassic() and L["Cancel %d / %d"] or L["Buy %d / %d"], context.numBought + context.numBid + 1, context.numFound) + elseif numCanBuy == 0 then + -- we're just confirming + progressText = format(L["Confirming %d / %d"], context.numConfirmed + 1, context.numFound) + else + -- we can buy more while confirming + progressText = format(L["Buy %d / %d (Confirming %d / %d)"], context.numBought + context.numBid + 1, context.numFound, context.numConfirmed + 1, context.numFound) + end + local _, isPaused = context.auctionScan:GetProgress() + if isPaused then + progressText = L["Scan Paused"].." | "..progressText + end + context.progress = context.numConfirmed / context.numFound + context.progressText = progressText + context.progressPaused = false + context.postDisabled = not canPost + if numCanBuy == 0 or isPlayer or (not TSM.IsWowClassic() and numConfirming > 0) then + context.bidDisabled = true + context.buyoutDisabled = true + context.cancelShown = isPlayer and not TSM.IsWowClassic() + if context.cancelShown then + AuctionTracking.QueryOwnedAuctions() + end + else + context.bidDisabled = not selection or not AuctionScan.CanBid(selection) + context.buyoutDisabled = not selection or not AuctionScan.CanBuyout(selection, context.auctionScan) + context.cancelShown = false + end + UpdateScanFrame(context) + end) + :AddTransition("ST_BUYING") + :AddTransition("ST_UPDATING_SCAN_PROGRESS") + :AddTransition("ST_BUY_CONFIRMATION") + :AddTransition("ST_BID_CONFIRMATION") + :AddTransition("ST_CANCELING") + :AddTransition("ST_PLACING_BUY") + :AddTransition("ST_PLACING_BID") + :AddTransition("ST_CONFIRMING_BUY") + :AddTransition("ST_RESULTS") + :AddTransition("ST_INIT") + :AddEventTransition("EV_AUCTION_SELECTION_CHANGED", "ST_BUYING") + :AddEventTransition("EV_BUYOUT_CLICKED", "ST_BUY_CONFIRMATION") + :AddEventTransition("EV_BID_CLICKED", "ST_BID_CONFIRMATION") + :AddEventTransition("EV_CANCEL_CLICKED", "ST_CANCELING") + :AddEvent("EV_CONFIRMED", function(context, isBuy, quantity) + return isBuy and "ST_PLACING_BUY" or "ST_PLACING_BID", quantity + end) + :AddEvent("EV_MSG", function(context, msg) + if not context.findAuction then + return + end + local _, rawLink = context.findAuction:GetLinks() + if msg == LE_GAME_ERR_AUCTION_HIGHER_BID or msg == LE_GAME_ERR_ITEM_NOT_FOUND or msg == LE_GAME_ERR_AUCTION_BID_OWN or msg == LE_GAME_ERR_NOT_ENOUGH_MONEY or msg == LE_GAME_ERR_ITEM_MAX_COUNT then + -- failed to buy an auction + return "ST_CONFIRMING_BUY", false + elseif msg == format(ERR_AUCTION_WON_S, ItemInfo.GetName(rawLink)) or (context.numBid > 0 and msg == ERR_AUCTION_BID_PLACED) then + -- bought an auction + return "ST_CONFIRMING_BUY", true + end + end) + :AddEvent("EV_BUYOUT_SUCCESS", function(context) + if not context.findAuction then + return + end + return "ST_CONFIRMING_BUY", true + end) + :AddEvent("EV_POST_BUTTON_CLICK", function(context) + wipe(context.postContextTemp) + private.PopulatePostContextFromRow(context.postContextTemp, context.scanFrame:GetElement("auctions"):GetSelection()) + private.PostDialogShow(context.scanFrame:GetBaseElement(), context.postContextTemp) + end) + :AddEvent("EV_RESCAN_CLICKED", function(context) + if context.scanFrame then + local viewContainer = context.scanFrame:GetParentElement() + viewContainer:SetPath("selection", true) + viewContainer:SetPath("scan", true) + context.scanFrame = viewContainer:GetElement("scan") + local name = context.searchContext:GetName() + assert(name) + context.scanFrame:GetElement("searchFrame.filterInput") + :SetValue(name) + context.scanFrame:GetElement("searchFrame.rescanBtn") + :SetDisabled(name == L["Gathering Search"]) + end + return "ST_INIT", context.searchContext + end) + :AddEvent("EV_SCAN_FRAME_HIDDEN", function(context) + context.scanFrame = nil + context.findAuction = nil + return "ST_RESULTS" + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + context.scanFrame:GetElement("auctions"):SetSelection(nil) + context.findAuction = nil + assert(context.pausePending == nil) + context.pausePending = false + context.auctionScan:SetPaused(false) + return "ST_UPDATING_SCAN_PROGRESS" + end) + :AddEvent("EV_AUCTION_ID_UPDATE", function(context, oldAuctionId, newAuctionId, newResultInfo) + if not context.findAuction or select(2, context.findAuction:GetListingInfo()) ~= oldAuctionId then + return + end + context.findAuction:UpdateResultInfo(newAuctionId, newResultInfo) + context.findHash = context.findAuction:GetHashes() + end) + :AddEvent("EV_BAG_UPDATE_DELAYED", function(context) + local postContext = context.searchContext:GetPostContext() + local prevDisabled = context.postDisabled + context.postDisabled = not postContext or private.GetBagQuantity(postContext.itemString) == 0 + context.scanFrame:GetElement("bottom.postBtn") + :SetDisabled(context.postDisabled) + :Draw() + if not prevDisabled and context.postDisabled then + -- hide any visible dialog in case the post dialog is visible + context.scanFrame:GetBaseElement():HideDialog() + end + end) + ) + :AddState(FSM.NewState("ST_BUY_CONFIRMATION") + :SetOnEnter(function(context) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + local index = TSM.IsWowClassic() and context.findResult[#context.findResult] or nil + if TSM.UI.AuctionUI.BuyUtil.ShowConfirmation(context.scanFrame, selection, true, context.numConfirmed + 1, context.defaultBuyQuantity, context.maxQuantity, private.FSMConfirmationCallback, context.auctionScan, index, false, context.searchContext:GetMarketValueFunc()) then + return "ST_BUYING" + else + return "ST_PLACING_BUY", selection:GetQuantities() + end + end) + :AddTransition("ST_PLACING_BUY") + :AddTransition("ST_BUYING") + ) + :AddState(FSM.NewState("ST_BID_CONFIRMATION") + :SetOnEnter(function(context) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + local index = TSM.IsWowClassic() and context.findResult[#context.findResult] or nil + if TSM.UI.AuctionUI.BuyUtil.ShowConfirmation(context.scanFrame, selection, false, context.numConfirmed + 1, context.defaultBuyQuantity, context.maxQuantity, private.FSMConfirmationCallback, context.auctionScan, index, false, context.searchContext:GetMarketValueFunc()) then + return "ST_BUYING" + else + local quantity = selection:GetQuantities() + return "ST_PLACING_BID", quantity + end + end) + :AddTransition("ST_PLACING_BID") + :AddTransition("ST_BUYING") + ) + :AddState(FSM.NewState("ST_PLACING_BUY") + :SetOnEnter(function(context, quantity) + local index = TSM.IsWowClassic() and tremove(context.findResult, #context.findResult) or nil + assert(not TSM.IsWowClassic() or index) + -- buy the auction + local buyout = context.findAuction:GetBuyouts() + local result = context.auctionScan:PlaceBidOrBuyout(index, buyout, context.findAuction, quantity) + if result then + MailTracking.RecordAuctionBuyout(ItemString.GetBaseFast(context.findAuction:GetItemString()), quantity) + context.numBought = context.numBought + (TSM.IsWowClassic() and 1 or quantity) + context.lastBuyQuantity = quantity + else + local _, rawLink = context.findAuction:GetLinks() + Log.PrintfUser(L["Failed to buy auction of %s."], rawLink) + end + return "ST_BUYING" + end) + :AddTransition("ST_BUYING") + ) + :AddState(FSM.NewState("ST_CONFIRMING_BUY") + :SetOnEnter(function(context, success) + if not success then + local _, rawLink = context.findAuction:GetLinks() + Log.PrintfUser(L["Failed to buy auction of %s."], rawLink) + end + context.numConfirmed = context.numConfirmed + (TSM.IsWowClassic() and 1 or context.lastBuyQuantity) + return "ST_BUYING", context.lastBuyQuantity + end) + :AddTransition("ST_BUYING") + ) + :AddState(FSM.NewState("ST_PLACING_BID") + :SetOnEnter(function(context, quantity) + local index = TSM.IsWowClassic() and tremove(context.findResult, #context.findResult) or nil + assert(not TSM.IsWowClassic() or index) + -- bid on the auction + local result, future = context.auctionScan:PrepareForBidOrBuyout(index, context.findAuction, false, quantity) + assert(not future) + result = result and context.auctionScan:PlaceBidOrBuyout(index, context.findAuction:GetRequiredBid(), context.findAuction, quantity) + if result then + MailTracking.RecordAuctionBuyout(ItemString.GetBaseFast(context.findAuction:GetItemString()), quantity) + context.numBid = context.numBid + (TSM.IsWowClassic() and 1 or quantity) + context.lastBuyQuantity = quantity + else + local _, rawLink = context.findAuction:GetLinks() + Log.PrintfUser(L["Failed to bid on auction of %s."], rawLink) + end + return "ST_BUYING" + end) + :AddTransition("ST_BUYING") + ) + :AddState(FSM.NewState("ST_CANCELING") + :SetOnEnter(function(context) + assert(not TSM.IsWowClassic() and context.findAuction and context.findAuction:IsSubRow()) + local _, auctionId = context.findAuction:GetListingInfo() + Log.Info("Canceling (auctionId=%d)", auctionId) + local future = AuctionHouseWrapper.CancelAuction(auctionId) + if future then + future:SetScript("OnDone", private.FSMCancelFutureOnDone) + context.cancelFuture = future + UpdateScanFrame(context) + else + Log.PrintUser(L["Failed to cancel auction due to the auction house being busy. Ensure no other addons are scanning the AH and try again."]) + return "ST_BUYING" + end + end) + :AddTransition("ST_BUYING") + :AddTransition("ST_INIT") + :AddEvent("EV_CANCEL_DONE", function(context) + assert(context.cancelFuture) + local result = context.cancelFuture:GetValue() + context.cancelFuture = nil + if result then + context.findAuction:GetResultRow():RemoveSubRow(context.findAuction) + context.scanFrame:GetElement("auctions"):UpdateData() + else + Log.PrintUser(L["Failed to cancel auction due to the auction house being busy. Ensure no other addons are scanning the AH and try again."]) + end + return "ST_BUYING" + end) + ) + :AddDefaultEvent("EV_START_SCAN", function(context, searchContext) + return "ST_INIT", searchContext + end) + :AddDefaultEvent("EV_SCAN_FRAME_SHOWN", function(context, scanFrame) + context.scanFrame = scanFrame + UpdateScanFrame(context) + context.scanFrame:GetElement("auctions") + :UpdateData(true) + :ExpandSingleResult() + end) + :AddDefaultEvent("EV_SCAN_FRAME_HIDDEN", function(context) + context.scanFrame = nil + context.findAuction = nil + end) + :AddDefaultEventTransition("EV_AUCTION_HOUSE_CLOSED", "ST_INIT") + :AddDefaultEventTransition("EV_SCAN_BACK_BUTTON_CLICKED", "ST_INIT") + :Init("ST_INIT", fsmContext) +end + +function private.FSMMessageEventHandler(_, msg) + private.fsm:SetLoggingEnabled(false) + private.fsm:ProcessEvent("EV_MSG", msg) + private.fsm:SetLoggingEnabled(true) +end + +function private.FSMBuyoutSuccess() + private.fsm:ProcessEvent("EV_BUYOUT_SUCCESS") +end + +function private.FSMAuctionScanOnProgressUpdate(auctionScan) + private.fsm:ProcessEvent("EV_SCAN_PROGRESS_UPDATE") +end + +function private.FSMScanCallback(success) + if success then + private.fsm:ProcessEvent("EV_SCAN_COMPLETE") + else + private.fsm:ProcessEvent("EV_SCAN_FAILED") + end +end + +function private.FSMFindAuctionCallback(result) + if result then + private.fsm:ProcessEvent("EV_AUCTION_FOUND", result) + else + private.fsm:ProcessEvent("EV_AUCTION_NOT_FOUND") + end +end + +function private.FSMConfirmationCallback(isBuy, quantity) + private.fsm:ProcessEvent("EV_CONFIRMED", isBuy, quantity) +end + +function private.PopulatePostContextFromRow(postContext, row) + postContext.baseItemString = row:GetBaseItemString() + postContext.itemString = row:GetItemString() + postContext.ownerStr = row:GetOwnerInfo() + local _, _, currentBid = row:GetBidInfo() + postContext.currentBid = currentBid + postContext.displayedBid, postContext.itemDisplayedBid = row:GetDisplayedBids() + postContext.buyout, postContext.itemBuyout = row:GetBuyouts() + postContext.quantity = row:GetQuantities() +end + +function private.FSMCancelFutureOnDone() + private.fsm:ProcessEvent("EV_CANCEL_DONE") +end diff --git a/Core/UI/AuctionUI/Sniper.lua b/Core/UI/AuctionUI/Sniper.lua new file mode 100644 index 0000000..b52efce --- /dev/null +++ b/Core/UI/AuctionUI/Sniper.lua @@ -0,0 +1,818 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sniper = TSM.UI.AuctionUI:NewPackage("Sniper") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Event = TSM.Include("Util.Event") +local FSM = TSM.Include("Util.FSM") +local Sound = TSM.Include("Util.Sound") +local Money = TSM.Include("Util.Money") +local Log = TSM.Include("Util.Log") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local AuctionScan = TSM.Include("Service.AuctionScan") +local MailTracking = TSM.Include("Service.MailTracking") +local Settings = TSM.Include("Service.Settings") +local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + fsm = nil, + selectionFrame = nil, + hasLastScan = nil, + contentPath = "selection", +} +local PHASED_TIME = 60 +local RETAIL_RESCAN_DELAY = 30 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Sniper.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "auctionUIContext", "sniperScrollingTable") + :AddKey("global", "sniperOptions", "sniperSound") + TSM.UI.AuctionUI.RegisterTopLevelPage(L["Sniper"], private.GetSniperFrame, private.OnItemLinked) + private.FSMCreate() +end + + + +-- ============================================================================ +-- Sniper UI +-- ============================================================================ + +function private.GetSniperFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "sniper") + if not private.hasLastScan then + private.contentPath = "selection" + end + return UIElements.New("ViewContainer", "sniper") + :SetNavCallback(private.GetSniperContentFrame) + :AddPath("selection") + :AddPath("scan") + :SetPath(private.contentPath) +end + +function private.GetSniperContentFrame(viewContainer, path) + private.contentPath = path + if path == "selection" then + return private.GetSelectionFrame() + elseif path == "scan" then + return private.GetScanFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetSelectionFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "sniper", "selection") + local frame = UIElements.New("Frame", "selection") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChildIf(TSM.IsWowClassic(), UIElements.New("Text", "text") + :SetHeight(20) + :SetMargin(8, 8, 12, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Start either a 'Buyout' or 'Bid' sniper using the buttons above."]) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 12, 12) + :AddChild(UIElements.New("ActionButton", "buyoutScanBtn") + :SetMargin(0, TSM.IsWowClassic() and 8 or 0, 0, 0) + :SetText(L["Run Buyout Sniper"]) + :SetScript("OnClick", private.BuyoutScanButtonOnClick) + ) + :AddChildIf(TSM.IsWowClassic(), UIElements.New("ActionButton", "bidScanBtn") + :SetText(L["Run Bid Sniper"]) + :SetScript("OnClick", private.BidScanButtonOnClick) + ) + ) + :AddChild(UIElements.New("SniperScrollingTable", "auctions") + :SetSettingsContext(private.settings, "sniperScrollingTable") + ) + :SetScript("OnHide", private.SelectionFrameOnHide) + private.selectionFrame = frame + return frame +end + +function private.GetScanFrame() + TSM.UI.AnalyticsRecordPathChange("auction", "sniper", "scan") + return UIElements.New("Frame", "scan") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(48) + :SetPadding(8, 8, 14, 14) + :AddChild(UIElements.New("ActionButton", "backBtn") + :SetSize(64, 24) + :SetMargin(0, 16, 0, 0) + :SetIcon("iconPack.14x14/Chevron/Right@180") + :SetText(BACK) + :SetScript("OnClick", private.BackButtonOnClick) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + ) + :AddChild(UIElements.New("ActionButton", "restartBtn") + :SetSize(80, 24) + :SetText(L["Restart"]) + :SetScript("OnClick", private.RestartButtonOnClick) + ) + ) + :AddChild(UIElements.New("SniperScrollingTable", "auctions") + :SetSettingsContext(private.settings, "sniperScrollingTable") + :SetScript("OnSelectionChanged", private.AuctionsOnSelectionChanged) + :SetScript("OnRowRemoved", private.AuctionsOnRowRemoved) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "pauseResumeBtn") + :SetSize(24, 24) + :SetMargin(0, 8, 0, 0) + :SetIcon("iconPack.18x18/PlayPause") + :SetScript("OnClick", private.PauseResumeBtnOnClick) + ) + :AddChild(UIElements.New("ProgressBar", "progressBar") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetProgress(0) + :SetText(L["Starting Scan..."]) + ) + :AddChild(UIElements.NewNamed("ActionButton", "actionBtn", "TSMSniperBtn") + :SetSize(165, 24) + :SetText(BID) + :SetDisabled(true) + :DisableClickCooldown(true) + :SetScript("OnClick", private.ActionButtonOnClick) + ) + ) + :SetScript("OnUpdate", private.ScanFrameOnUpdate) + :SetScript("OnHide", private.ScanFrameOnHide) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnItemLinked(name, itemLink) + if private.selectionFrame then + return false + end + private.fsm:ProcessEvent("EV_STOP_CLICKED") + TSM.UI.AuctionUI.SetOpenPage(L["Browse"]) + TSM.UI.AuctionUI.Shopping.StartItemSearch(itemLink) + return true +end + +function private.SelectionFrameOnHide(frame) + assert(frame == private.selectionFrame) + private.selectionFrame = nil +end + +function private.StartScanHelper(viewContainer, searchContext) + if not TSM.UI.AuctionUI.StartingScan(L["Sniper"]) then + return + end + viewContainer:SetPath("scan", true) + private.fsm:ProcessEvent("EV_START_SCAN", searchContext) +end + +function private.BuyoutScanButtonOnClick(button) + local viewContainer = button:GetParentElement():GetParentElement():GetParentElement() + local searchContext = TSM.Sniper.BuyoutSearch.GetSearchContext() + private.StartScanHelper(viewContainer, searchContext) +end + +function private.BidScanButtonOnClick(button) + local viewContainer = button:GetParentElement():GetParentElement():GetParentElement() + local searchContext = TSM.Sniper.BidSearch.GetSearchContext() + private.StartScanHelper(viewContainer, searchContext) +end + +function private.AuctionsOnSelectionChanged() + private.fsm:ProcessEvent("EV_AUCTION_SELECTION_CHANGED") +end + +function private.AuctionsOnRowRemoved(_, row) + private.fsm:ProcessEvent("EV_AUCTION_ROW_REMOVED", row) +end + +function private.BackButtonOnClick() + private.fsm:ProcessEvent("EV_STOP_CLICKED") +end + +function private.PauseResumeBtnOnClick(button) + private.fsm:ProcessEvent("EV_PAUSE_RESUME_CLICKED") +end + +function private.ActionButtonOnClick(button) + private.fsm:ProcessEvent("EV_ACTION_CLICKED") +end + +function private.RestartButtonOnClick(button) + if not TSM.UI.AuctionUI.StartingScan(L["Sniper"]) then + return + end + local lastScanType = private.hasLastScan + local sniperFrame = button:GetParentElement():GetParentElement():GetParentElement() + private.fsm:ProcessEvent("EV_STOP_CLICKED") + if lastScanType == "bid" then + sniperFrame:GetElement("selection.buttons.bidScanBtn"):Click() + elseif lastScanType == "buyout" then + sniperFrame:GetElement("selection.buttons.buyoutScanBtn"):Click() + else + error("Invalid last scan type: "..tostring(lastScanType)) + end +end + +function private.ScanFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.fsm:ProcessEvent("EV_SCAN_FRAME_SHOWN", frame) +end + +function private.ScanFrameOnHide(frame) + private.fsm:ProcessEvent("EV_SCAN_FRAME_HIDDEN") +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + scanFrame = nil, + auctionScan = nil, + query = nil, + progress = 0, + progressText = L["Running Sniper Scan"], + buttonsDisabled = true, + findHash = nil, + findAuction = nil, + findResult = nil, + numFound = 0, + maxQuantity = 0, + numActioned = 0, + lastBuyQuantity = 0, + numConfirmed = 0, + searchContext = nil, + scanDone = false, + } + Event.Register("CHAT_MSG_SYSTEM", private.FSMMessageEventHandler) + Event.Register("UI_ERROR_MESSAGE", private.FSMMessageEventHandler) + if not TSM.IsWowClassic() then + Event.Register("COMMODITY_PURCHASE_SUCCEEDED", private.FSMBuyoutSuccess) + end + Event.Register("AUCTION_HOUSE_CLOSED", function() + private.fsm:ProcessEvent("EV_AUCTION_HOUSE_CLOSED") + end) + AuctionHouseWrapper.RegisterAuctionIdUpdateCallback(function(...) + private.fsm:ProcessEvent("EV_AUCTION_ID_UPDATE", ...) + end) + local function UpdateScanFrame(context) + if not context.scanFrame or not context.searchContext then + return + end + local actionText = nil + if context.searchContext:IsBuyoutScan() then + actionText = BUYOUT + elseif context.searchContext:IsBidScan() then + actionText = BID + else + error("Invalid scan type") + end + local bottom = context.scanFrame:GetElement("bottom") + bottom:GetElement("actionBtn") + :SetText(actionText) + :SetDisabled(context.buttonsDisabled) + bottom:GetElement("progressBar") + :SetProgress(context.progress) + :SetText(context.progressText or "") + local auctionList = context.scanFrame:GetElement("auctions") + :SetContext(context.auctionScan) + :SetAuctionScan(context.auctionScan) + :SetMarketValueFunction(context.searchContext:GetMarketValueFunc()) + if context.findAuction and not auctionList:GetSelection() then + auctionList:SetSelection(context.findAuction) + end + local title = context.scanFrame:GetElement("header.title") + if context.scanPausing or auctionList:GetSelection() then + if context.searchContext:IsBuyoutScan() then + title:SetText(L["Buyout Sniper Paused"]) + elseif context.searchContext:IsBidScan() then + title:SetText(L["Bid Sniper Paused"]) + else + error("Invalid scan type") + end + else + if context.searchContext:IsBuyoutScan() then + title:SetText(L["Buyout Sniper Running"]) + elseif context.searchContext:IsBidScan() then + title:SetText(L["Bid Sniper Running"]) + else + error("Invalid scan type") + end + end + context.scanFrame:Draw() + end + local function ScanOnFilterDone(self, filter, numNewResults) + if numNewResults > 0 then + Sound.PlaySound(private.settings.sniperSound) + end + end + private.fsm = FSM.New("SNIPER") + :AddState(FSM.NewState("ST_INIT") + :SetOnEnter(function(context, searchContext) + private.hasLastScan = nil + if context.searchContext then + context.searchContext:KillThread() + context.searchContext = nil + end + context.progress = 0 + context.progressText = L["Running Sniper Scan"] + context.buttonsDisabled = true + context.findHash = nil + context.findAuction = nil + context.findResult = nil + context.numFound = 0 + context.numActioned = 0 + context.lastBuyQuantity = 0 + context.numConfirmed = 0 + if context.auctionScan then + context.auctionScan:Release() + context.auctionScan = nil + end + if searchContext then + context.searchContext = searchContext + return "ST_RUNNING_SCAN" + elseif context.scanFrame then + context.scanFrame:GetParentElement():SetPath("selection", true) + context.scanFrame = nil + end + TSM.UI.AuctionUI.EndedScan(L["Sniper"]) + end) + :AddTransition("ST_INIT") + :AddTransition("ST_RUNNING_SCAN") + :AddEventTransition("EV_START_SCAN", "ST_INIT") + ) + :AddState(FSM.NewState("ST_RUNNING_SCAN") + :SetOnEnter(function(context) + context.scanDone = false + if not context.searchContext then + private.hasLastScan = nil + elseif context.searchContext:IsBuyoutScan() then + private.hasLastScan = "buyout" + elseif context.searchContext:IsBidScan() then + private.hasLastScan = "bid" + else + error("Invalid scan type") + end + if not context.auctionScan then + context.auctionScan = AuctionScan.GetManager() + :SetResolveSellers(false) + :SetScript("OnQueryDone", ScanOnFilterDone) + end + if context.scanFrame then + context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(false) + end + UpdateScanFrame(context) + context.searchContext:StartThread(private.FSMScanCallback, context.auctionScan) + if TSM.IsWowClassic() then + Delay.AfterTime("sniperPhaseDetect", PHASED_TIME, private.FSMPhasedCallback) + end + end) + :SetOnExit(function(context) + Delay.Cancel("sniperPhaseDetect") + end) + :AddTransition("ST_RESULTS") + :AddTransition("ST_WAITING_FOR_PAUSE") + :AddTransition("ST_FINDING_AUCTION") + :AddTransition("ST_INIT") + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + return "ST_WAITING_FOR_PAUSE" + end) + :AddEvent("EV_SCAN_COMPLETE", function(context) + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if selection and selection:IsSubRow() then + return "ST_FINDING_AUCTION" + else + if TSM.IsWowClassic() then + return "ST_RESULTS" + else + -- wait 30 seconds before rescanning to avoid spamming the server with API calls + context.scanDone = true + Delay.AfterTime("SNIPER_RESCAN_DELAY", RETAIL_RESCAN_DELAY, private.FSMRescanDelayed) + end + end + end) + :AddEventTransition("EV_RESCAN_DELAYED", "ST_RESULTS") + :AddEventTransition("EV_SCAN_FAILED", "ST_INIT") + :AddEvent("EV_PHASED", function() + Log.PrintUser(L["You've been phased which has caused the AH to stop working due to a bug on Blizzard's end. Please close and reopen the AH and restart Sniper."]) + return "ST_INIT" + end) + :AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context) + assert(context.scanFrame) + if context.scanFrame:GetElement("auctions"):GetSelection() then + if context.scanDone then + return "ST_RESULTS" + else + -- the user selected something, so cancel the current scan + context.auctionScan:Cancel() + end + end + end) + ) + :AddState(FSM.NewState("ST_WAITING_FOR_PAUSE") + :SetOnEnter(function(context) + context.scanPausing = true + context.progressText = L["Scan Paused"] + if context.scanFrame then + context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(true) + end + UpdateScanFrame(context) + end) + :SetOnExit(function(context) + context.scanPausing = false + context.progressText = L["Running Sniper Scan"] + if context.scanFrame then + context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(false) + end + UpdateScanFrame(context) + end) + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + return "ST_RESULTS" + end) + :AddTransition("ST_RESULTS") + :AddTransition("ST_INIT") + ) + :AddState(FSM.NewState("ST_RESULTS") + :SetOnEnter(function(context) + context.searchContext:KillThread() + context.findAuction = nil + context.findResult = nil + context.numFound = 0 + context.numActioned = 0 + context.lastBuyQuantity = 0 + context.numConfirmed = 0 + context.progress = 0 + context.progressText = L["Running Sniper Scan"] + context.buttonsDisabled = true + UpdateScanFrame(context) + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if selection and selection:IsSubRow() then + return "ST_FINDING_AUCTION" + else + return "ST_RUNNING_SCAN" + end + end) + :AddTransition("ST_RUNNING_SCAN") + :AddTransition("ST_AUCTION_FOUND") + :AddTransition("ST_FINDING_AUCTION") + :AddTransition("ST_INIT") + ) + :AddState(FSM.NewState("ST_FINDING_AUCTION") + :SetOnEnter(function(context) + assert(context.scanFrame) + context.findAuction = context.scanFrame:GetElement("auctions"):GetSelection() + assert(context.findAuction:IsSubRow()) + context.findHash = context.findAuction:GetHashes() + context.progress = 0 + context.progressText = L["Finding Selected Auction"] + context.buttonsDisabled = true + if context.scanFrame then + context.scanFrame:GetElement("bottom.progressBar"):SetProgressIconHidden(false) + end + UpdateScanFrame(context) + TSM.Shopping.SearchCommon.StartFindAuction(context.auctionScan, context.findAuction, private.FSMFindAuctionCallback, true) + end) + :SetOnExit(function(context) + TSM.Shopping.SearchCommon.StopFindAuction() + end) + :AddTransition("ST_RESULTS") + :AddTransition("ST_FINDING_AUCTION") + :AddTransition("ST_AUCTION_FOUND") + :AddTransition("ST_AUCTION_NOT_FOUND") + :AddTransition("ST_INIT") + :AddEventTransition("EV_AUCTION_FOUND", "ST_AUCTION_FOUND") + :AddEventTransition("EV_AUCTION_NOT_FOUND", "ST_AUCTION_NOT_FOUND") + :AddEvent("EV_AUCTION_SELECTION_CHANGED", function(context) + assert(context.scanFrame) + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if selection and selection:IsSubRow() then + return "ST_FINDING_AUCTION" + else + return "ST_RESULTS" + end + end) + :AddEvent("EV_AUCTION_ROW_REMOVED", function(context, row) + if not row:IsSubRow() then + return + end + local removingFindAuction = context.findAuction == row + row:GetResultRow():RemoveSubRow(row) + context.scanFrame:GetElement("auctions"):UpdateData(true) + if removingFindAuction then + return "ST_RESULTS" + end + end) + :AddEvent("EV_SCAN_FRAME_HIDDEN", function(context) + context.scanFrame = nil + context.findAuction = nil + return "ST_RESULTS" + end) + ) + :AddState(FSM.NewState("ST_AUCTION_FOUND") + :SetOnEnter(function(context, result) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + -- update the selection in case the result rows changed + if context.findHash == selection:GetHashes() then + context.findAuction = selection + end + if TSM.IsWowClassic() then + context.findResult = result + context.numFound = #result + else + local maxCommodity = context.findAuction:IsCommodity() and context.findAuction:GetResultRow():GetMaxQuantities() + local numCanBuy = maxCommodity or result + context.findResult = numCanBuy > 0 + context.numFound = numCanBuy + context.maxQuantity = maxCommodity or 1 + end + assert(context.numActioned == 0 and context.numConfirmed == 0) + return "ST_BIDDING_BUYING" + end) + :AddTransition("ST_BIDDING_BUYING") + ) + :AddState(FSM.NewState("ST_AUCTION_NOT_FOUND") + :SetOnEnter(function(context) + local _, rawLink = context.findAuction:GetLinks() + context.findAuction:GetResultRow():RemoveSubRow(context.findAuction) + Log.PrintfUser(L["Failed to find auction for %s, so removing it from the results."], rawLink) + return "ST_RESULTS" + end) + :AddTransition("ST_RESULTS") + ) + :AddState(FSM.NewState("ST_BIDDING_BUYING") + :SetOnEnter(function(context, numToRemove) + if numToRemove then + -- remove the one we just bought + context.findAuction:DecrementQuantity(numToRemove) + context.scanFrame:GetElement("auctions"):UpdateData() + context.findAuction = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if context.findAuction and not context.findAuction:IsSubRow() then + context.findAuction = nil + end + end + local selection = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + if selection and not selection:IsSubRow() then + selection = nil + end + local isPlayer, isHighBidder = false, false + if selection then + assert(selection:IsSubRow()) + local ownerStr = selection and selection:GetOwnerInfo() or nil + isPlayer = PlayerInfo.IsPlayer(ownerStr, true, true, true) + isHighBidder = select(4, selection:GetBidInfo()) + end + local auctionSelected = selection and context.findHash == selection:GetHashes() + local numCanAction = not auctionSelected and 0 or (context.numFound - context.numActioned) + local numConfirming = context.numActioned - context.numConfirmed + local progressText = nil + local actionFormatStr = nil + if context.searchContext:IsBuyoutScan() then + actionFormatStr = L["Buy %d / %d"] + elseif context.searchContext:IsBidScan() then + actionFormatStr = L["Bid %d / %d"] + else + error("Invalid scan type") + end + if numConfirming == 0 and numCanAction == 0 then + -- we're done bidding/buying and confirming this batch + return "ST_RESULTS" + elseif numConfirming == 0 then + -- we can still bid/buy more + progressText = format(actionFormatStr, context.numActioned + 1, context.numFound) + elseif numCanAction == 0 then + -- we're just confirming + progressText = format(L["Confirming %d / %d"], context.numConfirmed + 1, context.numFound) + else + -- we can bid/buy more while confirming + progressText = format(actionFormatStr.." ("..L["Confirming %d / %d"]..")", context.numActioned + 1, context.numFound, context.numConfirmed + 1, context.numFound) + end + context.progress = context.numConfirmed / context.numFound + context.progressText = L["Scan Paused"].." - "..progressText + if numCanAction == 0 or isPlayer or (not TSM.IsWowClassic() and numConfirming > 0) then + context.buttonsDisabled = true + else + if context.searchContext:IsBuyoutScan() then + context.buttonsDisabled = not AuctionScan.CanBuyout(selection, context.auctionScan) + elseif context.searchContext:IsBidScan() then + context.buttonsDisabled = not AuctionScan.CanBid(selection) + else + error("Invalid scan type") + end + end + if context.scanFrame then + context.scanFrame:GetElement("bottom.progressBar") + :SetProgressIconHidden(context.numConfirmed == context.numActioned) + context.scanFrame:GetElement("bottom.actionBtn") + :SetDisabled(isPlayer or (isHighBidder and context.searchContext:IsBidScan())) + :Draw() + end + UpdateScanFrame(context) + end) + :AddTransition("ST_BID_BUY_CONFIRMATION") + :AddTransition("ST_BIDDING_BUYING") + :AddTransition("ST_PLACING_BID_BUY") + :AddTransition("ST_CONFIRMING_BID_BUY") + :AddTransition("ST_RESULTS") + :AddTransition("ST_INIT") + :AddEvent("EV_PAUSE_RESUME_CLICKED", function(context) + context.scanFrame:GetElement("auctions"):SetSelection(nil) + return "ST_RESULTS" + end) + :AddEventTransition("EV_AUCTION_SELECTION_CHANGED", "ST_RESULTS") + :AddEventTransition("EV_ACTION_CLICKED", "ST_BID_BUY_CONFIRMATION") + :AddEvent("EV_CONFIRMED", function(context, isBuy, quantity) + assert(isBuy == context.searchContext:IsBuyoutScan()) + return "ST_PLACING_BID_BUY", quantity + end) + :AddEvent("EV_MSG", function(context, msg) + local _, rawLink = context.findAuction:GetLinks() + if msg == LE_GAME_ERR_AUCTION_HIGHER_BID or msg == LE_GAME_ERR_ITEM_NOT_FOUND or msg == LE_GAME_ERR_AUCTION_BID_OWN or msg == LE_GAME_ERR_NOT_ENOUGH_MONEY or msg == LE_GAME_ERR_ITEM_MAX_COUNT then + -- failed to bid/buy an auction + return "ST_CONFIRMING_BID_BUY", false + elseif context.searchContext:IsBidScan() and msg == ERR_AUCTION_BID_PLACED then + -- bid on an auction + return "ST_CONFIRMING_BID_BUY", true + elseif context.searchContext:IsBuyoutScan() and msg == format(ERR_AUCTION_WON_S, ItemInfo.GetName(rawLink)) then + -- bought an auction + return "ST_CONFIRMING_BID_BUY", true + end + end) + :AddEvent("EV_BUYOUT_SUCCESS", function(context) + return "ST_CONFIRMING_BID_BUY", true + end) + :AddEvent("EV_AUCTION_ID_UPDATE", function(context, oldAuctionId, newAuctionId, newResultInfo) + if not context.findAuction or select(2, context.findAuction:GetListingInfo()) ~= oldAuctionId then + return + end + context.findAuction:UpdateResultInfo(newAuctionId, newResultInfo) + context.findHash = context.findAuction:GetHashes() + end) + ) + :AddState(FSM.NewState("ST_BID_BUY_CONFIRMATION") + :SetOnEnter(function(context) + local selection = context.scanFrame:GetElement("auctions"):GetSelection() + local index = TSM.IsWowClassic() and context.findResult[#context.findResult] or nil + if TSM.UI.AuctionUI.BuyUtil.ShowConfirmation(context.scanFrame, selection, context.searchContext:IsBuyoutScan(), context.numConfirmed + 1, context.numFound, context.maxQuantity, private.FSMConfirmationCallback, context.auctionScan, index, true, context.searchContext:GetMarketValueFunc()) then + return "ST_BIDDING_BUYING" + else + local quantity = selection:GetQuantities() + return "ST_PLACING_BID_BUY", quantity + end + end) + :AddTransition("ST_PLACING_BID_BUY") + :AddTransition("ST_BIDDING_BUYING") + ) + :AddState(FSM.NewState("ST_PLACING_BID_BUY") + :SetOnEnter(function(context, quantity) + local index = TSM.IsWowClassic() and tremove(context.findResult, #context.findResult) or nil + assert(not TSM.IsWowClassic() or index) + local bidBuyout = nil + if context.searchContext:IsBuyoutScan() then + bidBuyout = context.findAuction:GetBuyouts() + elseif context.searchContext:IsBidScan() then + bidBuyout = context.findAuction:GetRequiredBid() + else + error("Invalid scan type") + end + local result = context.auctionScan:PlaceBidOrBuyout(index, bidBuyout, context.findAuction, quantity) + if result then + MailTracking.RecordAuctionBuyout(ItemString.GetBaseFast(context.findAuction:GetItemString()), quantity) + context.numActioned = context.numActioned + (TSM.IsWowClassic() and 1 or quantity) + context.lastBuyQuantity = quantity + else + local _, rawLink = context.findAuction:GetLinks() + if context.searchContext:IsBuyoutScan() then + Log.PrintfUser(L["Failed to buy auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER")) + elseif context.searchContext:IsBidScan() then + Log.PrintfUser(L["Failed to bid on auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER")) + else + error("Invalid scan type") + end + end + return "ST_BIDDING_BUYING" + end) + :AddTransition("ST_BIDDING_BUYING") + ) + :AddState(FSM.NewState("ST_CONFIRMING_BID_BUY") + :SetOnEnter(function(context, success) + if not success then + local _, rawLink = context.findAuction:GetLinks() + local quantity = context.findAuction:GetQuantities() + local bidBuyout = nil + if context.searchContext:IsBuyoutScan() then + bidBuyout = context.findAuction:GetBuyouts() + elseif context.searchContext:IsBidScan() then + bidBuyout = context.findAuction:GetRequiredBid() + else + error("Invalid scan type") + end + if context.searchContext:IsBuyoutScan() then + Log.PrintfUser(L["Failed to buy auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER")) + elseif context.searchContext:IsBidScan() then + Log.PrintfUser(L["Failed to bid on auction of %s (x%s) for %s."], rawLink, quantity, Money.ToString(bidBuyout, nil, "OPT_83_NO_COPPER")) + else + error("Invalid scan type") + end + end + context.numConfirmed = context.numConfirmed + (TSM.IsWowClassic() and 1 or context.lastBuyQuantity) + context.findAuction = context.scanFrame and context.scanFrame:GetElement("auctions"):GetSelection() + return "ST_BIDDING_BUYING", context.lastBuyQuantity + end) + :AddTransition("ST_BIDDING_BUYING") + ) + :AddDefaultEvent("EV_SCAN_FRAME_SHOWN", function(context, scanFrame) + context.scanFrame = scanFrame + UpdateScanFrame(context) + end) + :AddDefaultEvent("EV_SCAN_FRAME_HIDDEN", function(context) + context.scanFrame = nil + context.findAuction = nil + end) + :AddDefaultEventTransition("EV_AUCTION_HOUSE_CLOSED", "ST_INIT") + :AddDefaultEventTransition("EV_STOP_CLICKED", "ST_INIT") + :AddDefaultEvent("EV_AUCTION_ROW_REMOVED", function(context, row) + if not row:IsSubRow() then + return + end + row:GetResultRow():RemoveSubRow(row) + context.scanFrame:GetElement("auctions"):UpdateData(true) + end) + :Init("ST_INIT", fsmContext) +end + +function private.FSMMessageEventHandler(_, msg) + private.fsm:SetLoggingEnabled(false) + private.fsm:ProcessEvent("EV_MSG", msg) + private.fsm:SetLoggingEnabled(true) +end + +function private.FSMBuyoutSuccess() + private.fsm:ProcessEvent("EV_BUYOUT_SUCCESS") +end + +function private.FSMRescanDelayed() + private.fsm:ProcessEvent("EV_RESCAN_DELAYED") +end + +function private.FSMScanCallback(success) + if success then + private.fsm:ProcessEvent("EV_SCAN_COMPLETE") + else + private.fsm:ProcessEvent("EV_SCAN_FAILED") + end +end + +function private.FSMPhasedCallback() + private.fsm:ProcessEvent("EV_PHASED") +end + +function private.FSMFindAuctionCallback(result) + if result then + private.fsm:ProcessEvent("EV_AUCTION_FOUND", result) + else + private.fsm:ProcessEvent("EV_AUCTION_NOT_FOUND") + end +end + +function private.FSMConfirmationCallback(isBuy, quantity) + private.fsm:ProcessEvent("EV_CONFIRMED", isBuy, quantity) +end diff --git a/Core/UI/BankingUI/Core.lua b/Core/UI/BankingUI/Core.lua new file mode 100644 index 0000000..5e4ed5a --- /dev/null +++ b/Core/UI/BankingUI/Core.lua @@ -0,0 +1,556 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local BankingUI = TSM.UI:NewPackage("BankingUI") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local TempTable = TSM.Include("Util.TempTable") +local Log = TSM.Include("Util.Log") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + fsm = nil, + groupSearch = "", +} +local MIN_FRAME_SIZE = { width = 325, height = 600 } +local MODULE_LIST = { + "Warehousing", + "Auctioning", + "Mailing", +} +local BUTTON_TEXT_LOOKUP = { + Warehousing = L["Warehousing"], + Auctioning = L["Auctioning"], + Mailing = L["Mailing"], +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function BankingUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "bankingUIContext", "frame") + :AddKey("global", "bankingUIContext", "isOpen") + :AddKey("global", "bankingUIContext", "tab") + :AddKey("char", "bankingUIContext", "warehousingGroupTree") + :AddKey("char", "bankingUIContext", "auctioningGroupTree") + :AddKey("char", "bankingUIContext", "mailingGroupTree") + private.FSMCreate() +end + +function BankingUI.OnDisable() + -- hide the frame + private.fsm:ProcessEvent("EV_BANK_CLOSED") +end + +function BankingUI.Toggle() + private.fsm:ProcessEvent("EV_TOGGLE") +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("banking") + local frame = UIElements.New("ApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :SetTitle(L["Banking"]) + :SetScript("OnHide", private.BaseFrameOnHide) + :SetContentFrame(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "navButtons") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(8) + :SetPadding(-4, 0, 0, 0) -- account for the left margin of the first button + ) + :AddChild(UIElements.New("Frame", "search") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetIconTexture("iconPack.18x18/Search") + :AllowItemInsert(true) + :SetClearButtonEnabled(true) + :SetValue(private.groupSearch) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetSettingsContext(private.settings, private.GetSettingsContextKey()) + :SetQuery(TSM.Groups.CreateQuery(), private.settings.tab) + :SetSearchString(private.groupSearch) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("VERTICAL") + :SetHeight(170) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ProgressBar", "progressBar") + :SetHeight(24) + :SetProgress(0) + :SetProgressIconHidden(true) + :SetText(L["Select Action"]) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("VERTICAL") + ) + ) + ) + frame:GetElement("titleFrame.closeBtn"):SetScript("OnClick", private.CloseBtnOnClick) + + for _, module in ipairs(MODULE_LIST) do + frame:GetElement("content.navButtons"):AddChild(UIElements.New("ActionButton", "navBtn_"..module) + :SetHeight(20) + :SetMargin(4, 0, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetContext(module) + :SetText(BUTTON_TEXT_LOOKUP[module]) + :SetScript("OnClick", private.NavBtnOnClick) + ) + end + + private.UpdateCurrentModule(frame) + + return frame +end + +function private.GetSettingsContextKey() + if private.settings.tab == "Warehousing" then + return "warehousingGroupTree" + elseif private.settings.tab == "Auctioning" then + return "auctioningGroupTree" + elseif private.settings.tab == "Mailing" then + return "mailingGroupTree" + else + error("Unexpected tab: "..tostring(private.settings.tab)) + end +end + +function private.UpdateCurrentModule(frame) + if not TSM.IsWowClassic() then + ReagentBankFrame_OnShow(ReagentBankFrame) + end + -- update nav buttons + local navButtonsFrame = frame:GetElement("content.navButtons") + for _, module in ipairs(MODULE_LIST) do + navButtonsFrame:GetElement("navBtn_"..module) + :SetPressed(module == private.settings.tab) + end + navButtonsFrame:Draw() + + -- update group tree + frame:GetElement("content.groupTree") + :SetSettingsContext(private.settings, private.GetSettingsContextKey()) + :SetQuery(TSM.Groups.CreateQuery(), private.settings.tab) + :UpdateData(true) + :Draw() + + -- update footer buttons + local footerButtonsFrame = frame:GetElement("content.footer.buttons") + footerButtonsFrame:ReleaseAllChildren() + if private.settings.tab == "Warehousing" then + footerButtonsFrame:AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("ActionButton", "moveBankBtn") + :SetHeight(24) + :SetMargin(0, 8, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Move to bank"]) + :SetContext(TSM.Banking.Warehousing.MoveGroupsToBank) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "moveBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Move to bags"]) + :SetContext(TSM.Banking.Warehousing.MoveGroupsToBags) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "restockBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Restock bags"]) + :SetContext(TSM.Banking.Warehousing.RestockBags) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "depositReagentsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetDisabled(TSM.IsWowClassic()) + :SetText(L["Deposit reagents"]) + :SetScript("OnClick", private.WarehousingDepositReagentsBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("Frame", "row4") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("ActionButton", "emptyBagsBtn") + :SetHeight(24) + :SetMargin(0, 8, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Empty bags"]) + :SetContext(TSM.Banking.EmptyBags) + :SetScript("OnClick", private.SimpleBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "restoreBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Restore bags"]) + :SetContext(TSM.Banking.RestoreBags) + :SetScript("OnClick", private.SimpleBtnOnClick) + ) + ) + elseif private.settings.tab == "Auctioning" then + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "moveBankBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Move to bank"]) + :SetContext(TSM.Banking.Auctioning.MoveGroupsToBank) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "postCapBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Post cap to bags"]) + :SetContext(TSM.Banking.Auctioning.PostCapToBags) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "shortfallBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Shortfall to bags"]) + :SetContext(TSM.Banking.Auctioning.ShortfallToBags) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "maxExpBankBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Max expires to bank"]) + :SetContext(TSM.Banking.Auctioning.MaxExpiresToBank) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + elseif private.settings.tab == "Mailing" then + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "moveBankBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Move to bank"]) + :SetContext(TSM.Banking.Mailing.MoveGroupsToBank) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "nongroupBankBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Nongroup to bank"]) + :SetContext(TSM.Banking.Mailing.NongroupToBank) + :SetScript("OnClick", private.SimpleBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("ActionButton", "targetShortfallBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Target shortfall to bags"]) + :SetContext(TSM.Banking.Mailing.TargetShortfallToBags) + :SetScript("OnClick", private.GroupBtnOnClick) + ) + footerButtonsFrame:AddChild(UIElements.New("Frame", "row4") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("ActionButton", "emptyBagsBtn") + :SetHeight(24) + :SetMargin(0, 8, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Empty bags"]) + :SetContext(TSM.Banking.EmptyBags) + :SetScript("OnClick", private.SimpleBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "restoreBagsBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Restore bags"]) + :SetContext(TSM.Banking.RestoreBags) + :SetScript("OnClick", private.SimpleBtnOnClick) + ) + ) + else + error("Unexpected module: "..tostring(private.settings.tab)) + end + footerButtonsFrame:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide() + TSM.UI.AnalyticsRecordClose("banking") +end + +function private.CloseBtnOnClick(button) + Log.PrintUser(L["Hiding the TSM Banking UI. Type '/tsm bankui' to reopen it."]) + button:GetParentElement():Hide() + private.fsm:ProcessEvent("EV_FRAME_HIDDEN") +end + +function private.GroupSearchOnValueChanged(input) + private.groupSearch = strlower(input:GetValue()) + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(private.groupSearch) + :Draw() +end + +function private.NavBtnOnClick(button) + private.settings.tab = button:GetContext() + private.UpdateCurrentModule(button:GetBaseElement()) + private.fsm:ProcessEvent("EV_NAV_CHANGED") +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.WarehousingDepositReagentsBtnOnClick() + DepositReagentBank() +end + +function private.SimpleBtnOnClick(button) + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", button, button:GetContext()) +end + +function private.GroupBtnOnClick(button) + local groups = TempTable.Acquire() + for _, groupPath in button:GetElement("__base.content.groupTree"):SelectedGroupsIterator() do + groups[groupPath] = true + end + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", button, button:GetContext(), groups) + TempTable.Release(groups) +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + TSM.Banking.RegisterFrameCallback(function(openFrame) + private.fsm:ProcessEvent(openFrame and "EV_BANK_OPENED" or "EV_BANK_CLOSED") + end) + + local fsmContext = { + frame = nil, + progress = nil, + activeButton = nil, + } + local function UpdateFrame(context) + if context.activeButton and not context.progress then + context.activeButton + :SetPressed(false) + :Draw() + context.activeButton = nil + end + + -- update the nav button state + local navButtonsFrame = context.frame:GetElement("content.navButtons") + for _, module in ipairs(MODULE_LIST) do + navButtonsFrame:GetElement("navBtn_"..module) + :SetDisabled(context.progress) + end + navButtonsFrame:Draw() + + -- update the progress bar + context.frame:GetElement("content.footer.progressBar") + :SetProgress(context.progress or 0) + :SetProgressIconHidden(not context.progress) + :SetText(context.progress and L["Moving"] or L["Select Action"]) + :Draw() + + -- update the action button state + local footerButtonsFrame = context.frame:GetElement("content.footer.buttons") + if private.settings.tab == "Warehousing" then + footerButtonsFrame:GetElement("row1.moveBankBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("row1.moveBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("restockBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("depositReagentsBtn") + :SetDisabled(context.progress or TSM.IsWowClassic()) + footerButtonsFrame:GetElement("row4.emptyBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("row4.restoreBagsBtn") + :SetDisabled(context.progress or not TSM.Banking.CanRestoreBags()) + elseif private.settings.tab == "Auctioning" then + footerButtonsFrame:GetElement("moveBankBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("postCapBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("shortfallBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("maxExpBankBtn") + :SetDisabled(context.progress) + elseif private.settings.tab == "Mailing" then + footerButtonsFrame:GetElement("moveBankBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("nongroupBankBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("targetShortfallBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("row4.emptyBagsBtn") + :SetDisabled(context.progress) + footerButtonsFrame:GetElement("row4.restoreBagsBtn") + :SetDisabled(context.progress or not TSM.Banking.CanRestoreBags()) + else + error("Unexpected module: "..tostring(private.settings.tab)) + end + footerButtonsFrame:Draw() + end + private.fsm = FSM.New("BANKING_UI") + :AddState(FSM.NewState("ST_CLOSED") + :SetOnEnter(function(context) + if context.frame then + context.frame:Hide() + context.frame:Release() + context.frame = nil + end + context.activeButton = nil + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_FRAME_HIDDEN") + :AddEvent("EV_BANK_OPENED", function(context) + assert(not context.frame) + if not private.settings.isOpen then + return "ST_FRAME_HIDDEN" + end + return "ST_FRAME_OPEN" + end) + ) + :AddState(FSM.NewState("ST_FRAME_HIDDEN") + :SetOnEnter(function(context) + private.settings.isOpen = false + if context.frame then + context.frame:Hide() + context.frame:Release() + context.frame = nil + end + context.activeButton = nil + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_CLOSED") + :AddEvent("EV_TOGGLE", function() + private.settings.isOpen = true + return "ST_FRAME_OPEN" + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPEN") + :SetOnEnter(function(context) + if not context.frame then + context.frame = private.CreateMainFrame() + context.frame:Show() + context.frame:Draw() + end + UpdateFrame(context) + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_FRAME_HIDDEN") + :AddTransition("ST_PROCESSING") + :AddTransition("ST_CLOSED") + :AddEventTransition("EV_BUTTON_CLICKED", "ST_PROCESSING") + :AddEventTransition("EV_TOGGLE", "ST_FRAME_HIDDEN") + :AddEventTransition("EV_FRAME_HIDDEN", "ST_FRAME_HIDDEN") + :AddEventTransition("EV_NAV_CHANGED", "ST_FRAME_OPEN") + ) + :AddState(FSM.NewState("ST_PROCESSING") + :SetOnEnter(function(context, button, startFunc, ...) + context.activeButton = button + context.activeButton + :SetPressed(true) + :Draw() + context.progress = 0 + startFunc(private.FSMThreadCallback, ...) + UpdateFrame(context) + end) + :SetOnExit(function(context) + context.progress = nil + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_FRAME_HIDDEN") + :AddTransition("ST_CLOSED") + :AddEvent("EV_THREAD_PROGRESS", function(context, progress) + context.progress = progress + UpdateFrame(context) + end) + :AddEvent("EV_THREAD_DONE", function(context) + if context.progress == 0 then + Log.PrintUser(L["Nothing to move."]) + end + return "ST_FRAME_OPEN" + end) + :AddEventTransition("EV_TOGGLE", "ST_FRAME_HIDDEN") + ) + :AddDefaultEventTransition("EV_BANK_CLOSED", "ST_CLOSED") + :Init("ST_CLOSED", fsmContext) +end + +function private.FSMThreadCallback(event, ...) + if event == "PROGRESS" then + private.fsm:ProcessEvent("EV_THREAD_PROGRESS", ...) + elseif event == "DONE" then + private.fsm:ProcessEvent("EV_THREAD_DONE") + elseif event == "MOVED" then + -- ignore this event + else + error("Unexpected event: "..tostring(event)) + end +end diff --git a/Core/UI/CraftingUI/Core.lua b/Core/UI/CraftingUI/Core.lua new file mode 100644 index 0000000..14ac155 --- /dev/null +++ b/Core/UI/CraftingUI/Core.lua @@ -0,0 +1,340 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CraftingUI = TSM.UI:NewPackage("CraftingUI") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local Event = TSM.Include("Util.Event") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + topLevelPages = {}, + fsm = nil, + craftOpen = nil, + tradeSkillOpen = nil, + defaultUISwitchBtn = nil, + isVisible = false, +} +local MIN_FRAME_SIZE = { width = 650, height = 587 } +local BEAST_TRAINING_DE = "Bestienausbildung" +local BEAST_TRAINING_ES = "Entrenamiento de bestias" +local BEAST_TRAINING_RUS = "Воспитание питомца" +local IGNORED_PROFESSIONS = { + [53428] = true, -- Runeforging + [158756] = true, -- Skinning Skills + [193290] = true, -- Herbalism Skills + [7620] = true, -- Fishing Skills (shows up as Fishing) +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function CraftingUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "craftingUIContext", "showDefault") + :AddKey("global", "craftingUIContext", "frame") + private.FSMCreate() + TSM.Crafting.ProfessionScanner.SetDisabled(private.settings.showDefault) +end + +function CraftingUI.OnDisable() + -- hide the frame + if private.isVisible then + TSM.Crafting.ProfessionScanner.SetDisabled(false) + private.fsm:ProcessEvent("EV_FRAME_TOGGLE") + end +end + +function CraftingUI.RegisterTopLevelPage(name, callback) + tinsert(private.topLevelPages, { name = name, callback = callback }) +end + +function CraftingUI.Toggle() + private.settings.showDefault = false + TSM.Crafting.ProfessionScanner.SetDisabled(false) + private.fsm:ProcessEvent("EV_FRAME_TOGGLE") +end + +function CraftingUI.IsProfessionIgnored(name) + if TSM.IsWowClassic() then + if name == GetSpellInfo(5149) or name == BEAST_TRAINING_DE or name == BEAST_TRAINING_ES or name == BEAST_TRAINING_RUS then -- Beast Training + return true + elseif name == GetSpellInfo(7620) then -- Fishing + return true + elseif name == GetSpellInfo(2366) then -- Herb Gathering + return true + elseif name == GetSpellInfo(8613) then -- Skinning + return true + end + end + for i in pairs(IGNORED_PROFESSIONS) do + local ignoredName = GetSpellInfo(i) + if ignoredName == name then + return true + end + end +end + +function CraftingUI.IsVisible() + return private.isVisible +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("crafting") + local frame = UIElements.New("LargeApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :AddPlayerGold() + :AddAppStatusIcon() + :AddSwitchButton(private.SwitchBtnOnClick) + :SetScript("OnHide", private.BaseFrameOnHide) + + for _, info in ipairs(private.topLevelPages) do + frame:AddNavButton(info.name, info.callback) + end + + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide() + TSM.UI.AnalyticsRecordClose("crafting") + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.SwitchBtnOnClick(button) + private.settings.showDefault = button ~= private.defaultUISwitchBtn + TSM.Crafting.ProfessionScanner.SetDisabled(private.settings.showDefault) + private.fsm:ProcessEvent("EV_SWITCH_BTN_CLICKED") +end + +function private.SwitchButtonOnEnter(button) + button:SetTextColor("TEXT") + :Draw() +end + +function private.SwitchButtonOnLeave(button) + button:SetTextColor("TEXT_ALT") + :Draw() +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + if TSM.IsWowClassic() then + Event.Register("CRAFT_SHOW", function() + CloseTradeSkill() + private.craftOpen = true + TSM.Crafting.ProfessionState.SetCraftOpen(true) + private.fsm:ProcessEvent("EV_TRADE_SKILL_SHOW") + end) + Event.Register("CRAFT_CLOSE", function() + private.craftOpen = false + TSM.Crafting.ProfessionState.SetCraftOpen(false) + if not private.tradeSkillOpen then + private.fsm:ProcessEvent("EV_TRADE_SKILL_CLOSED") + end + end) + end + Event.Register("TRADE_SKILL_SHOW", function() + if TSM.IsWowClassic() then + CloseCraft() + end + private.tradeSkillOpen = true + private.fsm:ProcessEvent("EV_TRADE_SKILL_SHOW") + end) + Event.Register("TRADE_SKILL_CLOSE", function() + private.tradeSkillOpen = false + if not private.craftOpen then + private.fsm:ProcessEvent("EV_TRADE_SKILL_CLOSED") + end + end) + -- we'll implement UIParent's event handler directly when necessary for TRADE_SKILL_SHOW + if TSM.IsWowClassic() then + UIParent:UnregisterEvent("CRAFT_SHOW") + end + UIParent:UnregisterEvent("TRADE_SKILL_SHOW") + + local fsmContext = { + frame = nil, + } + local function UpdateDefaultCraftButton() + if CraftFrame and CraftCreateButton and private.craftOpen then + CraftCreateButton:SetParent(CraftFrame) + CraftCreateButton:ClearAllPoints() + CraftCreateButton:SetPoint("CENTER", CraftFrame, "TOPLEFT", 224, -422) + CraftCreateButton:SetFrameLevel(2) + CraftCreateButton:EnableDrawLayer("BACKGROUND") + CraftCreateButton:EnableDrawLayer("ARTWORK") + CraftCreateButton:SetHighlightTexture("Interface\\Buttons\\UI-Panel-Button-Highlight") + CraftCreateButton:GetHighlightTexture():SetTexCoord(0, 0.625, 0, 0.6875) + end + end + local function DefaultFrameOnHide() + private.fsm:ProcessEvent("EV_FRAME_HIDE") + end + private.fsm = FSM.New("CRAFTING_UI") + :AddState(FSM.NewState("ST_CLOSED") + :AddTransition("ST_DEFAULT_OPEN") + :AddTransition("ST_FRAME_OPEN") + :AddEvent("EV_FRAME_TOGGLE", function(context) + assert(not private.settings.showDefault) + TSM.Crafting.ProfessionScanner.SetDisabled(false) + return "ST_FRAME_OPEN" + end) + :AddEvent("EV_TRADE_SKILL_SHOW", function(context) + TSM.Crafting.ProfessionScanner.SetDisabled(private.settings.showDefault) + local name = TSM.Crafting.ProfessionUtil.GetCurrentProfessionName() + if CraftingUI.IsProfessionIgnored(name) then + return "ST_DEFAULT_OPEN", true + elseif private.settings.showDefault then + return "ST_DEFAULT_OPEN" + else + return "ST_FRAME_OPEN" + end + end) + ) + :AddState(FSM.NewState("ST_DEFAULT_OPEN") + :SetOnEnter(function(context, isIgnored) + if private.craftOpen then + UIParent_OnEvent(UIParent, "CRAFT_SHOW") + UpdateDefaultCraftButton() + else + UIParent_OnEvent(UIParent, "TRADE_SKILL_SHOW") + end + if not private.defaultUISwitchBtn then + private.defaultUISwitchBtn = UIElements.New("ActionButton", "switchBtn") + :SetSize(60, TSM.IsWowClassic() and 16 or 15) + :AddAnchor("TOPRIGHT", TSM.IsWowClassic() and -60 or -27, TSM.IsWowClassic() and -16 or -4) + :SetRelativeLevel(3) + :DisableClickCooldown() + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["TSM4"]) + :SetScript("OnClick", private.SwitchBtnOnClick) + :SetScript("OnEnter", private.SwitchButtonOnEnter) + :SetScript("OnLeave", private.SwitchButtonOnLeave) + private.defaultUISwitchBtn:_GetBaseFrame():SetParent(TradeSkillFrame) + end + private.defaultUISwitchBtn:_GetBaseFrame():SetParent(private.craftOpen and CraftFrame or TradeSkillFrame) + if isIgnored then + TSM.Crafting.ProfessionScanner.SetDisabled(true) + private.defaultUISwitchBtn:Hide() + else + private.defaultUISwitchBtn:Show() + private.defaultUISwitchBtn:Draw() + end + if private.craftOpen then + ScriptWrapper.Set(CraftFrame, "OnHide", DefaultFrameOnHide) + else + ScriptWrapper.Set(TradeSkillFrame, "OnHide", DefaultFrameOnHide) + end + if not TSM.IsWowClassic() then + local linked, linkedName = TSM.Crafting.ProfessionUtil.IsLinkedProfession() + if TSM.Crafting.ProfessionUtil.IsDataStable() and not TSM.Crafting.ProfessionUtil.IsGuildProfession() and (not linked or (linked and linkedName == UnitName("player"))) then + TradeSkillFrame:OnEvent("TRADE_SKILL_DATA_SOURCE_CHANGED") + TradeSkillFrame:OnEvent("TRADE_SKILL_LIST_UPDATE") + end + end + end) + :SetOnExit(function(context) + if private.craftOpen then + if CraftFrame then + ScriptWrapper.Clear(CraftFrame, "OnHide") + HideUIPanel(CraftFrame) + end + else + if TradeSkillFrame then + ScriptWrapper.Clear(TradeSkillFrame, "OnHide") + HideUIPanel(TradeSkillFrame) + end + end + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_DEFAULT_OPEN") + :AddEvent("EV_FRAME_HIDE", function(context) + TSM.Crafting.ProfessionUtil.CloseTradeSkill(false, private.craftOpen) + return "ST_CLOSED" + end) + :AddEvent("EV_TRADE_SKILL_SHOW", function(context) + if CraftingUI.IsProfessionIgnored(TSM.Crafting.ProfessionUtil.GetCurrentProfessionName()) then + return "ST_DEFAULT_OPEN", true + else + if private.settings.showDefault then + return "ST_DEFAULT_OPEN" + else + TSM.Crafting.ProfessionScanner.SetDisabled(private.settings.showDefault) + return "ST_FRAME_OPEN" + end + end + end) + :AddEventTransition("EV_TRADE_SKILL_CLOSED", "ST_CLOSED") + :AddEventTransition("EV_SWITCH_BTN_CLICKED", "ST_FRAME_OPEN") + ) + :AddState(FSM.NewState("ST_FRAME_OPEN") + :SetOnEnter(function(context) + assert(not context.frame) + context.frame = private.CreateMainFrame() + context.frame:Show() + if TSM.Crafting.ProfessionUtil.GetCurrentProfessionName() then + context.frame:GetElement("titleFrame.switchBtn"):Show() + else + context.frame:GetElement("titleFrame.switchBtn"):Hide() + end + context.frame:Draw() + private.isVisible = true + end) + :SetOnExit(function(context) + context.frame:Hide() + context.frame:Release() + context.frame = nil + private.isVisible = false + if TSM.IsWowClassic() then + UpdateDefaultCraftButton() + end + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_DEFAULT_OPEN") + :AddEvent("EV_FRAME_HIDE", function(context) + TSM.Crafting.ProfessionUtil.CloseTradeSkill(true) + return "ST_CLOSED" + end) + :AddEvent("EV_TRADE_SKILL_SHOW", function(context) + if CraftingUI.IsProfessionIgnored(TSM.Crafting.ProfessionUtil.GetCurrentProfessionName()) then + return "ST_DEFAULT_OPEN", true + end + context.frame:GetElement("titleFrame.switchBtn"):Show() + context.frame:GetElement("titleFrame"):Draw() + end) + :AddEventTransition("EV_TRADE_SKILL_CLOSED", "ST_CLOSED") + :AddEventTransition("EV_SWITCH_BTN_CLICKED", "ST_DEFAULT_OPEN") + :AddEventTransition("EV_FRAME_TOGGLE", "ST_CLOSED") + ) + :Init("ST_CLOSED", fsmContext) +end diff --git a/Core/UI/CraftingUI/Crafting.lua b/Core/UI/CraftingUI/Crafting.lua new file mode 100644 index 0000000..86990fe --- /dev/null +++ b/Core/UI/CraftingUI/Crafting.lua @@ -0,0 +1,1294 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Crafting = TSM.UI.CraftingUI:NewPackage("Crafting") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local FSM = TSM.Include("Util.FSM") +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local Wow = TSM.Include("Util.Wow") +local Theme = TSM.Include("Util.Theme") +local ItemString = TSM.Include("Util.ItemString") +local BagTracking = TSM.Include("Service.BagTracking") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local ItemLinked = TSM.Include("Service.ItemLinked") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + fsm = nil, + professions = {}, + professionsKeys = {}, + groupSearch = "", + showDelayFrame = 0, + filterText = "", + haveSkillUp = false, + haveMaterials = false, + professionFrame = nil, +} +local SHOW_DELAY_FRAMES = 2 +local KEY_SEP = "\001" + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Crafting.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "craftingUIContext", "professionScrollingTable") + :AddKey("global", "craftingUIContext", "professionDividedContainer") + :AddKey("char", "craftingUIContext", "groupTree") + :AddKey("factionrealm", "internalData", "isCraftFavorite") + ItemLinked.RegisterCallback(private.ItemLinkedCallback) + TSM.UI.CraftingUI.RegisterTopLevelPage(L["Crafting"], private.GetCraftingFrame) + private.FSMCreate() +end + +function Crafting.GatherCraftNext(spellId, quantity) + private.fsm:ProcessEvent("EV_CRAFT_NEXT_BUTTON_CLICKED", spellId, quantity) +end + + + +-- ============================================================================ +-- Crafting UI +-- ============================================================================ + +function private.GetCraftingFrame() + TSM.UI.AnalyticsRecordPathChange("crafting", "crafting") + return UIElements.New("DividedContainer", "crafting") + :SetMinWidth(400, 200) + :SetBackgroundColor("PRIMARY_BG") + :SetSettingsContext(private.settings, "professionDividedContainer") + :SetLeftChild(UIElements.New("Frame", "left") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ViewContainer", "viewContainer") + :SetNavCallback(private.GetCraftingMainScreen) + :AddPath("main", true) + ) + ) + :SetRightChild(UIElements.New("Frame", "queue") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "title") + :SetHeight(24) + :SetMargin(8, 8, 6, 0) + :SetFont("BODY_BODY1_BOLD") + :SetText(format(L["Crafting Queue (%d)"], 0)) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetMargin(0, 0, 4, 4) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("CraftingQueueList", "queueList") + :SetQuery(TSM.Crafting.CreateCraftsQuery() + :IsNotNil("num") + :GreaterThan("num", 0) + ) + :SetScript("OnRowMouseDown", private.QueueOnRowMouseDown) + :SetScript("OnRowClick", private.QueueOnRowClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "queueTime") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(8, 8, 8, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetMargin(0, 4, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Time to Craft:"]) + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "queueCost") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(8, 8, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetMargin(0, 4, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Estimated Cost:"]) + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "queueProfit") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(8, 8, 0, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetMargin(0, 4, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Estimated Profit:"]) + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "craft") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.NewNamed("ActionButton", "craftNextBtn", "TSMCraftingBtn") + :SetMargin(0, 8, 0, 0) + :SetText(L["Craft Next"]) + :SetScript("OnClick", private.CraftNextOnClick) + ) + :AddChild(UIElements.New("Button", "clearBtn") + :SetWidth(70) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Clear All"]) + :SetScript("OnClick", private.ClearOnClick) + ) + ) + ) + ) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) +end + +function private.GetCraftingMainScreen(self, button) + if button == "main" then + return UIElements.New("Frame", "main") + :SetLayout("VERTICAL") + :SetPadding(0, 0, 6, 0) + :AddChild(UIElements.New("TabGroup", "content") + :SetNavCallback(private.GetCraftingElements) + :AddPath(L["Crafting List"], true) + :AddPath(L["TSM Groups"]) + ) + end +end + +function private.GetCraftingElements(self, button) + if button == L["Crafting List"] then + private.filterText = "" + private.professionFrame = UIElements.New("Frame", "profession") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("SelectionDropdown", "professionDropdown") + :SetMargin(0, 8, 0, 0) + :SetHintText(L["No Profession Opened"]) + :SetScript("OnSelectionChanged", private.ProfessionDropdownOnSelectionChanged) + ) + :AddChild(UIElements.New("Input", "filterInput") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Search Patterns"]) + :SetScript("OnValueChanged", private.FilterInputOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "filterBtn", "FILTER") + :SetSize(24, 24) + :SetIcon("iconPack.18x18/Filter") + :SetDefaultNoBackground() + :DisableClickCooldown() + :SetHighlightLocked(private.haveSkillUp or private.haveMaterials, "INDICATOR") + :SetScript("OnClick", private.FilterButtonOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "recipeContent") + :SetLayout("VERTICAL") + :SetMargin(0, 0, 0, 1) + :AddChild(UIElements.New("ProfessionScrollingTable", "recipeList") + :SetFavoritesContext(private.settings.isCraftFavorite) + :SetSettingsContext(private.settings, "professionScrollingTable") + :SetScript("OnSelectionChanged", private.RecipeListOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "details") + :SetLayout("HORIZONTAL") + :SetHeight(120) + :SetPadding(8, 0, 8, 8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "left") + :SetLayout("VERTICAL") + :SetWidth(234) + :SetMargin(0, 8, 0, 0) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Button", "icon") + :SetSize(20, 20) + :SetMargin(0, 8, 0, 0) + :SetScript("OnClick", private.ItemOnClick) + ) + :AddChild(UIElements.New("Button", "name") + :SetHeight(24) + :SetFont("ITEM_BODY1") + :SetJustifyH("LEFT") + :SetScript("OnClick", private.ItemOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "cost") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetMargin(0, 4, 0, 0) + :SetFont("BODY_BODY3") + :SetText(L["Crafting Cost"]..":") + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("TABLE_TABLE1") + ) + ) + :AddChild(UIElements.New("Frame", "craft") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "num") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + ) + :AddChild(UIElements.New("Text", "error") + :SetFont("BODY_BODY3") + :SetTextColor(Theme.GetFeedbackColor("RED")) + ) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Texture", "line") + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("CraftingMatList", "matList") + :SetBackgroundColor("PRIMARY_BG_ALT") + ) + ) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :AddChild(UIElements.New("ActionButton", "craftAllBtn") + :SetWidth(155) + :SetMargin(0, 8, 0, 0) + :SetText(L["Craft All"]) + :SetScript("OnMouseDown", private.CraftAllBtnOnMouseDown) + :SetScript("OnClick", private.CraftAllBtnOnClick) + ) + :AddChild(UIElements.New("Input", "craftInput") + :SetWidth(75) + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetJustifyH("CENTER") + :SetSubAddEnabled(true) + :SetValidateFunc("NUMBER", "1:9999") + :SetValue(1) + ) + :AddChild(UIElements.New("ActionButton", "craftBtn") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetText(L["Craft"]) + :SetScript("OnMouseDown", private.CraftBtnOnMouseDown) + :SetScript("OnClick", private.CraftBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "queueBtn") + :SetHeight(24) + :SetText(L["Queue"]) + :SetScript("OnClick", private.QueueBtnOnClick) + ) + ) + :AddChild(UIElements.New("Text", "recipeListLoadingText") + :SetJustifyH("CENTER") + :SetFont("HEADING_H5") + :SetText(L["Loading..."]) + ) + :SetScript("OnUpdate", private.ProfessionFrameOnUpdate) + :SetScript("OnHide", private.ProfessionFrameOnHide) + private.professionFrame:GetElement("recipeContent"):Hide() + private.professionFrame:GetElement("buttons"):Hide() + return private.professionFrame + elseif button == L["TSM Groups"] then + local frame = UIElements.New("Frame", "group") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "search") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "input") + :SetIconTexture("iconPack.18x18/Search") + :AllowItemInsert(true) + :SetClearButtonEnabled(true) + :SetValue(private.groupSearch) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetMargin(0, 4, 0, 0) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + :AddChild(UIElements.New("Button", "groupsBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Groups") + :SetScript("OnClick", private.CreateProfessionBtnOnClick) + :SetTooltip(L["Create Profession Groups"]) + ) + ) + :AddChild(UIElements.New("Texture", "lineTop") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetSettingsContext(private.settings, "groupTree") + :SetQuery(TSM.Groups.CreateQuery(), "Crafting") + :SetSearchString(private.groupSearch) + :SetScript("OnGroupSelectionChanged", private.GroupTreeOnGroupSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "lineBottom") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ActionButton", "addBtn") + :SetHeight(24) + :SetMargin(6, 6, 8, 8) + :SetText(L["Restock Selected Groups"]) + :SetScript("OnClick", private.QueueAddBtnOnClick) + ) + :SetScript("OnUpdate", private.GroupFrameOnUpdate) + frame:GetElement("addBtn"):SetDisabled(frame:GetElement("groupTree"):IsSelectionCleared()) + return frame + else + error("Unexpected button: "..tostring(button)) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnUpdate(frame) + -- delay the FSM event by a few frames to give textures a chance to load + if private.showDelayFrame == SHOW_DELAY_FRAMES then + frame:SetScript("OnUpdate", nil) + private.fsm:ProcessEvent("EV_FRAME_SHOW", frame) + else + private.showDelayFrame = private.showDelayFrame + 1 + end +end + +function private.FrameOnHide() + private.showDelayFrame = 0 + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.ProfessionFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + TSM.UI.AnalyticsRecordPathChange("crafting", "crafting", "profession") + private.fsm:ProcessEvent("EV_PAGE_CHANGED", "profession") + private.fsm:ProcessEvent("EV_RECIPE_FILTER_CHANGED", private.filterText) +end + +function private.ProfessionFrameOnHide(frame) + assert(private.professionFrame == frame) + private.professionFrame = nil +end + +function private.GroupFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + TSM.UI.AnalyticsRecordPathChange("crafting", "crafting", "group") + private.fsm:ProcessEvent("EV_PAGE_CHANGED", "group") +end + +function private.GroupSearchOnValueChanged(input) + private.groupSearch = strlower(input:GetValue()) + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(private.groupSearch) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.CreateProfessionBtnOnClick(button) + local baseFrame = button:GetBaseElement() + local profName = TSM.Crafting.ProfessionState.GetCurrentProfession() + if not profName then + Log.PrintUser(L["There is currently no profession open, so cannot create profession groups."]) + return + end + local items = profName..TSM.CONST.GROUP_SEP..L["Items"] + local mats = profName..TSM.CONST.GROUP_SEP..L["Materials"] + if TSM.Groups.Exists(profName) then + if not TSM.Groups.Exists(items) then + TSM.Groups.Create(items) + end + if not TSM.Groups.Exists(mats) then + TSM.Groups.Create(mats) + end + else + TSM.Groups.Create(profName) + TSM.Groups.Create(items) + TSM.Groups.Create(mats) + end + + local numMats, numItems = 0, 0 + local query = TSM.Crafting.CreateRawMatItemQuery() + :Matches("professions", profName) + :Select("itemString") + + for _, itemString in query:IteratorAndRelease() do + local classId = ItemInfo.GetClassId(itemString) + if itemString and not TSM.Groups.IsItemInGroup(itemString) and not ItemInfo.IsSoulbound(itemString) and classId ~= LE_ITEM_CLASS_WEAPON and classId ~= LE_ITEM_CLASS_ARMOR then + TSM.Groups.SetItemGroup(itemString, mats) + numMats = numMats + 1 + end + end + + query = TSM.Crafting.ProfessionScanner.CreateQuery() + :Select("spellId") + + for _, spellId in query:IteratorAndRelease() do + local itemString = TSM.Crafting.GetItemString(spellId) + if itemString and not TSM.Groups.IsItemInGroup(itemString) and not ItemInfo.IsSoulbound(itemString) then + TSM.Groups.SetItemGroup(itemString, items) + numItems = numItems + 1 + end + end + + if numMats > 0 or numItems > 0 then + Log.PrintfUser(L["%s group updated with %d items and %d materials."], profName, numItems, numMats) + else + Log.PrintfUser(L["%s group is already up to date."], profName) + end + + baseFrame:GetElement("content.crafting.left.viewContainer.main.content.group.groupTree"):UpdateData(true) + baseFrame:HideDialog() +end + +function private.GroupTreeOnGroupSelectionChanged(groupTree) + local addBtn = groupTree:GetElement("__parent.addBtn") + addBtn:SetDisabled(groupTree:IsSelectionCleared()) + addBtn:Draw() +end + +function private.ProfessionDropdownOnSelectionChanged(dropdown) + local key = dropdown:GetSelectedItemKey() + if not key then + -- nothing selected + return + end + local player, profession, skillId = strsplit(KEY_SEP, key) + if not profession then + -- the current linked / guild / NPC profession was re-selected, so just ignore this change + return + end + -- TODO: support showing of other player's professions? + assert(player == UnitName("player")) + TSM.Crafting.ProfessionUtil.OpenProfession(profession, skillId) +end + +function private.FilterInputOnValueChanged(input) + local text = input:GetValue() + if text == private.filterText then + return + end + private.filterText = text + + private.fsm:ProcessEvent("EV_RECIPE_FILTER_CHANGED", private.filterText) +end + +function private.RecipeListOnSelectionChanged(list) + local selection = list:GetSelection() + if selection and CraftFrame_SetSelection and TSM.Crafting.ProfessionState.IsClassicCrafting() then + CraftFrame_SetSelection(TSM.Crafting.ProfessionScanner.GetIndexBySpellId(selection)) + end + + private.fsm:ProcessEvent("EV_RECIPE_SELECTION_CHANGED") + if selection and IsShiftKeyDown() then + local item = TSM.Crafting.ProfessionUtil.GetRecipeInfo(selection) + ChatEdit_InsertLink(item) + end +end + +function private.QueueBtnOnClick(button) + local value = max(tonumber(button:GetElement("__parent.craftInput"):GetValue()), 1) + private.fsm:ProcessEvent("EV_QUEUE_BUTTON_CLICKED", value) +end + +function private.ItemOnClick(text) + local spellId = tonumber(text:GetElement("__parent.name"):GetContext()) + if spellId then + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + if IsShiftKeyDown() and ChatEdit_GetActiveWindow() then + ChatEdit_InsertLink(GetCraftItemLink(TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId))) + end + else + if IsShiftKeyDown() and ChatEdit_GetActiveWindow() then + if TSM.IsWowClassic() then + ChatEdit_InsertLink(GetTradeSkillItemLink(TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId))) + else + ChatEdit_InsertLink(C_TradeSkillUI.GetRecipeItemLink(spellId)) + end + end + end + else + Wow.SafeItemRef(ItemInfo.GetLink(text:GetElement("__parent.name"):GetContext())) + end +end + +function private.CraftBtnOnMouseDown(button) + local quantity = max(tonumber(button:GetElement("__parent.craftInput"):GetValue()), 1) + private.fsm:ProcessEvent("EV_CRAFT_BUTTON_MOUSE_DOWN", quantity) +end + +function private.CraftBtnOnClick(button) + button:SetPressed(true) + button:Draw() + local quantity = max(tonumber(button:GetElement("__parent.craftInput"):GetValue()), 1) + private.fsm:ProcessEvent("EV_CRAFT_BUTTON_CLICKED", quantity) +end + +function private.CraftAllBtnOnMouseDown(button) + private.fsm:ProcessEvent("EV_CRAFT_BUTTON_MOUSE_DOWN", math.huge) +end + +function private.CraftAllBtnOnClick(button) + button:SetPressed(true) + button:Draw() + private.fsm:ProcessEvent("EV_CRAFT_BUTTON_CLICKED", math.huge) +end + +function private.QueueOnRowMouseDown(button, data, mouseButton) + if not private.IsPlayerProfession() or mouseButton ~= "LeftButton" then + return + end + local spellId = data:GetField("spellId") + if TSM.Crafting.ProfessionScanner.HasSpellId(spellId) then + TSM.Crafting.ProfessionUtil.PrepareToCraft(spellId, TSM.Crafting.Queue.GetNum(spellId)) + end +end + +function private.QueueOnRowClick(button, data, mouseButton) + if not private.IsPlayerProfession() then + return + end + local spellId = data:GetField("spellId") + if mouseButton == "RightButton" then + private.fsm:ProcessEvent("EV_QUEUE_RIGHT_CLICKED", spellId) + elseif TSM.Crafting.ProfessionScanner.HasSpellId(spellId) then + private.fsm:ProcessEvent("EV_CRAFT_NEXT_BUTTON_CLICKED", spellId, TSM.Crafting.Queue.GetNum(spellId)) + end +end + +function private.CraftNextOnClick(button) + button:SetPressed(true) + button:Draw() + local spellId = button:GetElement("__parent.__parent.__parent.queueList"):GetFirstData():GetField("spellId") + private.fsm:ProcessEvent("EV_CRAFT_NEXT_BUTTON_CLICKED", spellId, TSM.Crafting.Queue.GetNum(spellId)) +end + +function private.ClearOnClick(button) + TSM.Crafting.Queue.Clear() + button:GetElement("__parent.__parent.__parent.title") + :SetText(format(L["Crafting Queue (%d)"], 0)) + :Draw() + button:GetElement("__parent.__parent.queueTime.text") + :SetText("") + :Draw() + button:GetElement("__parent.__parent.queueCost.text") + :SetText("") + :Draw() + button:GetElement("__parent.__parent.queueProfit.text") + :SetText("") + :Draw() + button:GetElement("__parent.craftNextBtn") + :SetDisabled(true) + :Draw() +end + +function private.QueueAddBtnOnClick(button) + local groups = TempTable.Acquire() + for _, groupPath in button:GetElement("__parent.groupTree"):SelectedGroupsIterator() do + tinsert(groups, groupPath) + end + TSM.Crafting.Queue.RestockGroups(groups) + TempTable.Release(groups) +end + +function private.FilterButtonOnClick(button) + button:GetBaseElement():ShowMenuDialog(button._frame, private.FilterDialogIterator, button:GetBaseElement(), private.FilterDialogButtonOnClick, true) +end + +function private.FilterDialogButtonOnClick(button, self, index1, index2, extra) + assert(not extra and index1) + if index1 == "SKILLUP" then + private.haveSkillUp = not private.haveSkillUp + button:SetText(Theme.GetColor(private.haveSkillUp and "TEXT" or "TEXT+DISABLED"):ColorText(L["Have Skill Ups"])) + :Draw() + private.fsm:ProcessEvent("EV_RECIPE_FILTER_CHANGED", private.filterText) + elseif index1 == "MATS" then + private.haveMaterials = not private.haveMaterials + button:SetText(Theme.GetColor(private.haveMaterials and "TEXT" or "TEXT+DISABLED"):ColorText(L["Have Mats"])) + :Draw() + private.fsm:ProcessEvent("EV_RECIPE_FILTER_CHANGED", private.filterText) + elseif index1 == "RESET" then + self:GetBaseElement():HideDialog() + private.haveSkillUp = false + private.haveMaterials = false + private.fsm:ProcessEvent("EV_RECIPE_FILTER_CHANGED", private.filterText) + else + error("Unexpected index1: "..tostring(index1)) + end +end + +function private.FilterDialogIterator(self, prevIndex) + if prevIndex == nil then + return "SKILLUP", Theme.GetColor(private.haveSkillUp and "TEXT" or "TEXT+DISABLED"):ColorText(L["Have Skill Ups"]) + elseif prevIndex == "SKILLUP" then + return "MATS", Theme.GetColor(private.haveMaterials and "TEXT" or "TEXT+DISABLED"):ColorText(L["Have Mats"]) + elseif prevIndex == "MATS" then + return "RESET", L["Reset Filters"] + end +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + frame = nil, + recipeQuery = nil, + professionQuery = nil, + page = "profession", + selectedRecipeSpellId = nil, + queueQuery = nil, + craftingSpellId = nil, + craftingType = nil, + craftingQuantity = nil, + } + + TSM.Crafting.ProfessionState.RegisterUpdateCallback(function() + private.fsm:ProcessEvent("EV_PROFESSION_STATE_UPDATE") + end) + TSM.Crafting.ProfessionScanner.RegisterHasScannedCallback(function() + private.fsm:ProcessEvent("EV_PROFESSION_STATE_UPDATE") + end) + + local fsmPrivate = { + success = nil, + isDone = nil, + } + local function BagTrackingCallback() + private.fsm:ProcessEvent("EV_BAG_UPDATE_DELAYED") + end + BagTracking.RegisterCallback(BagTrackingCallback) + local function CraftCallback() + private.fsm:ProcessEvent("EV_SPELLCAST_COMPLETE", fsmPrivate.success, fsmPrivate.isDone) + fsmPrivate.success = nil + fsmPrivate.isDone = nil + end + function fsmPrivate.CraftCallback(success, isDone) + fsmPrivate.success = success + fsmPrivate.isDone = isDone + Delay.AfterFrame(1, CraftCallback) + end + function fsmPrivate.QueueUpdateCallback() + private.fsm:ProcessEvent("EV_QUEUE_UPDATE") + end + function fsmPrivate.SkillUpdateCallback() + private.fsm:ProcessEvent("EV_SKILL_UPDATE") + end + function fsmPrivate.UpdateMaterials(context) + if context.page == "profession" then + context.frame:GetElement("left.viewContainer.main.content.profession.recipeContent.recipeList"):UpdateData(true) + context.frame:GetElement("left.viewContainer.main.content.profession.recipeContent.details.matList"):UpdateData(true) + end + fsmPrivate.UpdateCraftButtons(context) + end + function fsmPrivate.UpdateProfessionsDropdown(context) + -- update the professions dropdown info + local dropdownSelection = nil + local currentProfession = TSM.Crafting.ProfessionState.GetCurrentProfession() + local isCurrentProfessionPlayer = private.IsPlayerProfession() + wipe(private.professions) + wipe(private.professionsKeys) + if currentProfession and not isCurrentProfessionPlayer then + assert(not TSM.IsWowClassic()) + local playerName = nil + local linked, linkedName = TSM.Crafting.ProfessionUtil.IsLinkedProfession() + if linked then + playerName = linkedName or "?" + elseif TSM.Crafting.ProfessionUtil.IsNPCProfession() then + playerName = L["NPC"] + elseif TSM.Crafting.ProfessionUtil.IsGuildProfession() then + playerName = L["Guild"] + end + assert(playerName) + tinsert(private.professions, currentProfession) + local key = currentProfession + tinsert(private.professionsKeys, key) + dropdownSelection = key + end + + for _, player, profession, skillId, level, maxLevel in TSM.Crafting.PlayerProfessions.Iterator() do + if player == UnitName("player") then + tinsert(private.professions, format("%s (%d/%d)", profession, level, maxLevel)) + local key = player..KEY_SEP..profession..KEY_SEP..skillId + tinsert(private.professionsKeys, key) + if isCurrentProfessionPlayer and profession == currentProfession then + assert(not dropdownSelection) + dropdownSelection = key + end + end + end + + context.frame:GetElement("left.viewContainer.main.content.profession.header.professionDropdown") + :SetItems(private.professions, private.professionsKeys) + :SetSelectedItemByKey(dropdownSelection, true) + :Draw() + end + function fsmPrivate.UpdateSkills(context) + if context.page ~= "profession" then + return + end + + fsmPrivate.UpdateProfessionsDropdown(context) + end + function fsmPrivate.UpdateFilter(context) + if context.frame:GetElement("left.viewContainer.main.content"):GetPath() ~= L["Crafting List"] then + return + end + context.frame:GetElement("left.viewContainer.main.content.profession.header.filterBtn"):SetHighlightLocked(private.haveSkillUp or private.haveMaterials, "INDICATOR") + :Draw() + end + function fsmPrivate.UpdateContentPage(context) + if context.page ~= "profession" and context.page ~= "filters" then + -- nothing to update + return + end + + fsmPrivate.UpdateProfessionsDropdown(context) + + local craftingContentFrame = context.frame:GetElement("left.viewContainer.main.content.profession") + if not private.IsProfessionLoaded() then + local text = nil + if private.IsProfessionClosed() then + text = L["No Profession Selected"] + elseif private.IsProfessionLoadedNoSkills() then + text = L["No Crafts"] + else + text = L["Loading..."] + end + craftingContentFrame:GetElement("recipeContent"):Hide() + craftingContentFrame:GetElement("buttons"):Hide() + craftingContentFrame:GetElement("recipeListLoadingText") + :SetText(text) + :Show() + craftingContentFrame:Draw() + return + end + + local recipeContent = craftingContentFrame:GetElement("recipeContent") + craftingContentFrame:GetElement("buttons"):Show() + local recipeList = recipeContent:GetElement("recipeList") + recipeContent:Show() + craftingContentFrame:GetElement("recipeListLoadingText"):Hide() + + recipeList:SetQuery(fsmContext.recipeQuery) + context.selectedRecipeSpellId = recipeList:GetSelection() + local buttonFrame = recipeContent:GetElement("__parent.buttons") + local detailsFrame = recipeContent:GetElement("details") + if not context.selectedRecipeSpellId then + buttonFrame:GetElement("craftBtn") + :SetDisabled(true) + buttonFrame:GetElement("queueBtn") + :SetDisabled(true) + buttonFrame:GetElement("craftInput") + :Hide() + buttonFrame:Draw() + detailsFrame:GetElement("left.content.icon") + :Hide() + detailsFrame:GetElement("left.content.name") + :SetText("") + :SetContext(nil) + :SetTooltip(nil) + detailsFrame:GetElement("left.cost.text") + :SetText("") + detailsFrame:GetElement("left.cost.label") + :Hide() + detailsFrame:GetElement("left.craft.num") + :SetText("") + detailsFrame:GetElement("left.craft.error") + :SetText(L["No receipe selected"]) + buttonFrame:GetElement("craftAllBtn") + :SetDisabled(true) + detailsFrame:GetElement("matList") + :SetRecipe(nil) + :SetContext(nil) + craftingContentFrame:Draw() + return + end + local resultName, resultItemString, resultTexture = TSM.Crafting.ProfessionUtil.GetResultInfo(context.selectedRecipeSpellId) + -- engineer tinkers can't be crafted, multi-crafted or queued + local currentProfession = TSM.Crafting.ProfessionState.GetCurrentProfession() + if not resultItemString then + buttonFrame:GetElement("craftBtn") + :SetText(currentProfession == GetSpellInfo(7411) and L["Enchant"] or L["Tinker"]) + buttonFrame:GetElement("queueBtn") + :SetDisabled(true) + buttonFrame:GetElement("craftInput") + :Hide() + else + buttonFrame:GetElement("craftBtn") + :SetText(L["Craft"]) + buttonFrame:GetElement("queueBtn") + :SetDisabled(false) + buttonFrame:GetElement("craftInput") + :Show() + end + local nameTooltip = resultItemString + if not nameTooltip then + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + nameTooltip = "craft:"..(TSM.Crafting.ProfessionScanner.GetIndexBySpellId(context.selectedRecipeSpellId) or context.selectedRecipeSpellId) + else + nameTooltip = "enchant:"..context.selectedRecipeSpellId + end + end + detailsFrame:GetElement("left.content.icon") + :SetBackground(resultTexture) + :SetTooltip(nameTooltip) + :Show() + detailsFrame:GetElement("left.content.name") + :SetText(resultName or "?") + :SetContext(resultItemString or tostring(context.selectedRecipeSpellId)) + :SetTooltip(nameTooltip) + local craftingCost = TSM.Crafting.Cost.GetCostsBySpellId(context.selectedRecipeSpellId) + detailsFrame:GetElement("left.cost.label") + :Show() + detailsFrame:GetElement("left.cost.text") + :SetText(Money.ToString(craftingCost) or "") + detailsFrame:GetElement("left.craft.num") + :SetFormattedText(L["Crafts %d"], TSM.Crafting.GetNumResult(context.selectedRecipeSpellId)) + local _, _, _, toolsStr, hasTools = TSM.Crafting.ProfessionUtil.GetRecipeInfo(context.selectedRecipeSpellId) + local errorText = detailsFrame:GetElement("left.craft.error") + local canCraft, errStr = false, nil + if toolsStr and not hasTools then + errStr = REQUIRES_LABEL.." "..toolsStr + elseif TSM.Crafting.ProfessionUtil.GetRemainingCooldown(context.selectedRecipeSpellId) then + errStr = L["On Cooldown"] + elseif TSM.Crafting.ProfessionUtil.GetNumCraftable(context.selectedRecipeSpellId) == 0 then + errStr = L["Missing Materials"] + else + canCraft = true + end + errorText:SetText(errStr and "("..errStr..")" or "") + local isEnchant = TSM.Crafting.ProfessionUtil.IsEnchant(context.selectedRecipeSpellId) + buttonFrame:GetElement("craftBtn") + :SetDisabled(not canCraft or context.craftingSpellId) + :SetPressed(context.craftingSpellId and context.craftingType == "craft") + buttonFrame:GetElement("craftAllBtn") + :SetText(isEnchant and L["Enchant Vellum"] or L["Craft All"]) + :SetDisabled(not resultItemString or not canCraft or context.craftingSpellId) + :SetPressed(context.craftingSpellId and context.craftingType == "all") + detailsFrame:GetElement("matList") + :SetRecipe(context.selectedRecipeSpellId) + :SetContext(context.selectedRecipeSpellId) + craftingContentFrame:Draw() + if TSM.Crafting.ProfessionState.IsClassicCrafting() and CraftCreateButton then + CraftCreateButton:SetParent(buttonFrame:GetElement("craftBtn"):_GetBaseFrame()) + CraftCreateButton:ClearAllPoints() + CraftCreateButton:SetAllPoints(buttonFrame:GetElement("craftBtn"):_GetBaseFrame()) + CraftCreateButton:SetFrameLevel(200) + CraftCreateButton:DisableDrawLayer("BACKGROUND") + CraftCreateButton:DisableDrawLayer("ARTWORK") + CraftCreateButton:SetHighlightTexture(nil) + if canCraft then + CraftCreateButton:Enable() + else + CraftCreateButton:Disable() + end + end + end + function fsmPrivate.UpdateQueueFrame(context, noDataUpdate) + local queueFrame = context.frame:GetElement("queue") + local totalCost, totalProfit, totalCastTime, totalNumQueued = TSM.Crafting.Queue.GetTotals() + queueFrame:GetElement("title"):SetText(format(L["Crafting Queue (%d)"], totalNumQueued)) + queueFrame:GetElement("footer.queueTime.text"):SetText(totalCastTime and SecondsToTime(totalCastTime) or "") + queueFrame:GetElement("footer.queueCost.text"):SetText(totalCost and Money.ToString(totalCost) or "") + local color = totalProfit and (totalProfit >= 0 and Theme.GetFeedbackColor("GREEN") or Theme.GetFeedbackColor("RED")) or nil + local totalProfitText = totalProfit and Money.ToString(totalProfit, color:GetTextColorPrefix()) or "" + queueFrame:GetElement("footer.queueProfit.text"):SetText(totalProfitText) + if not noDataUpdate then + queueFrame:GetElement("queueList"):UpdateData() + end + + local professionLoaded = private.IsProfessionLoaded() + local nextCraftRecord = queueFrame:GetElement("queueList"):GetFirstData() + local nextCraftSpellId = nextCraftRecord and nextCraftRecord:GetField("spellId") + if nextCraftRecord and (not professionLoaded or not TSM.Crafting.ProfessionScanner.HasSpellId(nextCraftSpellId) or TSM.Crafting.ProfessionUtil.GetNumCraftable(nextCraftSpellId) == 0) then + nextCraftRecord = nil + end + local canCraftFromQueue = professionLoaded and private.IsPlayerProfession() + queueFrame:GetElement("footer.craft.craftNextBtn") + :SetDisabled(not canCraftFromQueue or not nextCraftRecord or context.craftingSpellId) + :SetPressed(context.craftingSpellId and context.craftingType == "queue") + if nextCraftRecord and canCraftFromQueue then + TSM.Crafting.ProfessionUtil.PrepareToCraft(nextCraftSpellId, nextCraftRecord:GetField("num")) + end + queueFrame:Draw() + end + function fsmPrivate.UpdateCraftButtons(context) + if context.page == "profession" and private.IsProfessionLoaded() and context.selectedRecipeSpellId then + local _, _, _, toolsStr, hasTools = TSM.Crafting.ProfessionUtil.GetRecipeInfo(context.selectedRecipeSpellId) + local detailsFrame = context.frame:GetElement("left.viewContainer.main.content.profession.recipeContent.details") + local errorText = detailsFrame:GetElement("left.craft.error") + local canCraft, errStr = false, nil + if toolsStr and not hasTools then + errStr = REQUIRES_LABEL.." "..toolsStr + elseif TSM.Crafting.ProfessionUtil.GetRemainingCooldown(context.selectedRecipeSpellId) then + errStr = L["On Cooldown"] + elseif TSM.Crafting.ProfessionUtil.GetNumCraftable(context.selectedRecipeSpellId) == 0 then + errStr = L["Missing Materials"] + else + canCraft = true + end + errorText:SetText(errStr and "("..errStr..")" or "") + :Draw() + local isEnchant = TSM.Crafting.ProfessionUtil.IsEnchant(context.selectedRecipeSpellId) + local _, resultItemString = TSM.Crafting.ProfessionUtil.GetResultInfo(context.selectedRecipeSpellId) + detailsFrame:GetElement("__parent.__parent.buttons.craftBtn") + :SetPressed(context.craftingSpellId and context.craftingType == "craft") + :SetDisabled(not canCraft or context.craftingSpellId) + :Draw() + detailsFrame:GetElement("__parent.__parent.buttons.craftAllBtn") + :SetText(isEnchant and L["Enchant Vellum"] or L["Craft All"]) + :SetPressed(context.craftingSpellId and context.craftingType == "all") + :SetDisabled(not resultItemString or not canCraft or context.craftingSpellId) + :Draw() + if context.craftingQuantity and context.craftingSpellId == context.selectedRecipeSpellId then + detailsFrame:GetElement("__parent.__parent.buttons.craftInput") + :SetValue(context.craftingQuantity) + :Draw() + end + if TSM.IsWowClassic() and CraftCreateButton then + if canCraft then + CraftCreateButton:Enable() + else + CraftCreateButton:Disable() + end + end + end + + local nextCraftRecord = context.frame:GetElement("queue.queueList"):GetFirstData() + if nextCraftRecord and (TSM.Crafting.GetProfession(nextCraftRecord:GetField("spellId")) ~= TSM.Crafting.ProfessionState.GetCurrentProfession() or TSM.Crafting.ProfessionUtil.GetNumCraftable(nextCraftRecord:GetField("spellId")) == 0) then + nextCraftRecord = nil + end + local canCraftFromQueue = private.IsProfessionLoaded() and private.IsPlayerProfession() + context.frame:GetElement("queue.footer.craft.craftNextBtn") + :SetPressed(context.craftingSpellId and context.craftingType == "queue") + :SetDisabled(not canCraftFromQueue or not nextCraftRecord or context.craftingSpellId) + :Draw() + end + function fsmPrivate.StartCraft(context, spellId, quantity) + local numCrafted = TSM.Crafting.ProfessionUtil.Craft(spellId, quantity, context.craftingType ~= "craft", fsmPrivate.CraftCallback) + Log.Info("Crafting %d (requested %s) of %d", numCrafted, quantity == math.huge and "all" or quantity, spellId) + if numCrafted == 0 then + return + end + context.craftingSpellId = spellId + context.craftingQuantity = numCrafted + fsmPrivate.UpdateCraftButtons(context) + end + + private.fsm = FSM.New("CRAFTING_UI_CRAFTING") + :AddState(FSM.NewState("ST_FRAME_CLOSED") + :SetOnEnter(function(context) + context.page = "profession" + context.frame = nil + context.craftingSpellId = nil + context.craftingQuantity = nil + context.craftingType = nil + end) + :AddTransition("ST_FRAME_CLOSED") + :AddTransition("ST_FRAME_OPEN_NO_PROFESSION") + :AddTransition("ST_FRAME_OPEN_WITH_PROFESSION") + :AddEvent("EV_FRAME_SHOW", function(context, frame) + context.frame = frame + if private.IsProfessionLoaded() then + return "ST_FRAME_OPEN_WITH_PROFESSION" + else + return "ST_FRAME_OPEN_NO_PROFESSION" + end + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPEN_NO_PROFESSION") + :SetOnEnter(function(context) + context.craftingSpellId = nil + context.craftingQuantity = nil + context.craftingType = nil + if not context.queueQuery then + context.queueQuery = TSM.Crafting.Queue.CreateQuery() + context.queueQuery:SetUpdateCallback(fsmPrivate.QueueUpdateCallback) + end + fsmPrivate.UpdateContentPage(context) + fsmPrivate.UpdateQueueFrame(context) + end) + :AddTransition("ST_FRAME_OPEN_NO_PROFESSION") + :AddTransition("ST_FRAME_OPEN_WITH_PROFESSION") + :AddTransition("ST_FRAME_CLOSED") + :AddEvent("EV_PROFESSION_STATE_UPDATE", function(context) + if private.IsProfessionLoaded() then + return "ST_FRAME_OPEN_WITH_PROFESSION" + end + fsmPrivate.UpdateContentPage(context) + end) + :AddEvent("EV_PAGE_CHANGED", function(context, page) + context.page = page + fsmPrivate.UpdateContentPage(context) + end) + :AddEvent("EV_QUEUE_UPDATE", function(context) + fsmPrivate.UpdateQueueFrame(context, true) + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPEN_WITH_PROFESSION") + :SetOnEnter(function(context) + context.recipeQuery = TSM.Crafting.ProfessionScanner.CreateQuery() + :Select("spellId", "categoryId") + :OrderBy("index", true) + :VirtualField("matNames", "string", TSM.Crafting.GetMatNames, "spellId") + context.professionQuery = TSM.Crafting.PlayerProfessions.CreateQuery() + context.professionQuery:SetUpdateCallback(fsmPrivate.SkillUpdateCallback) + if context.page == "profession" then + context.frame:GetElement("left.viewContainer.main.content.profession.header.filterInput") + :SetValue("") + :Draw() + private.filterText = "" + end + if context.selectedRecipeSpellId and context.page == "profession" then + local recipeList = context.frame:GetElement("left.viewContainer.main.content.profession.recipeContent.recipeList") + recipeList:SetQuery(context.recipeQuery) + if TSM.Crafting.ProfessionScanner.GetIndexBySpellId(context.selectedRecipeSpellId) then + recipeList:SetSelection(context.selectedRecipeSpellId) + end + end + fsmPrivate.UpdateContentPage(context) + fsmPrivate.UpdateFilter(context) + fsmPrivate.UpdateQueueFrame(context) + if not context.queueQuery then + context.queueQuery = TSM.Crafting.Queue.CreateQuery() + context.queueQuery:SetUpdateCallback(fsmPrivate.QueueUpdateCallback) + end + end) + :SetOnExit(function(context) + private.haveSkillUp = false + private.haveMaterials = false + context.recipeQuery:Release() + context.recipeQuery = nil + context.professionQuery:Release() + context.professionQuery = nil + end) + :AddTransition("ST_FRAME_OPEN_NO_PROFESSION") + :AddTransition("ST_FRAME_CLOSED") + :AddEvent("EV_PROFESSION_STATE_UPDATE", function(context) + if not private.IsProfessionLoaded() then + return "ST_FRAME_OPEN_NO_PROFESSION" + end + fsmPrivate.UpdateContentPage(context) + end) + :AddEvent("EV_RECIPE_FILTER_CHANGED", function(context, filter) + local recipeList = context.frame:GetElement("left.viewContainer.main.content.profession.recipeContent.recipeList") + local prevSelection = recipeList:GetSelection() + context.recipeQuery:Reset() + :Select("spellId", "categoryId") + :OrderBy("index", true) + :VirtualField("matNames", "string", TSM.Crafting.GetMatNames, "spellId") + if filter ~= "" then + filter = String.Escape(filter) + context.recipeQuery + :Or() + :Matches("name", filter) + :Matches("matNames", filter) + :End() + end + if private.haveSkillUp then + context.recipeQuery:NotEqual("difficulty", "trivial") + end + if private.haveMaterials then + context.recipeQuery:Custom(private.HaveMaterialsFilterHelper) + end + recipeList:UpdateData(true) + fsmPrivate.UpdateFilter(context) + if recipeList:GetSelection() ~= prevSelection then + fsmPrivate.UpdateContentPage(context) + end + end) + :AddEvent("EV_PAGE_CHANGED", function(context, page) + context.recipeQuery:ResetFilters() + context.page = page + fsmPrivate.UpdateContentPage(context) + end) + :AddEvent("EV_QUEUE_BUTTON_CLICKED", function(context, quantity) + assert(context.selectedRecipeSpellId) + TSM.Crafting.Queue.Add(context.selectedRecipeSpellId, quantity) + fsmPrivate.UpdateQueueFrame(context, true) + end) + :AddEvent("EV_QUEUE_RIGHT_CLICKED", function(context, spellId) + if context.page ~= "profession" or TSM.Crafting.GetProfession(spellId) ~= TSM.Crafting.ProfessionState.GetCurrentProfession() then + return + end + local recipeList = context.frame:GetElement("left.viewContainer.main.content.profession.recipeContent.recipeList") + if not recipeList:IsSpellIdVisible(spellId) then + return + end + recipeList:SetSelection(spellId) + fsmPrivate.UpdateContentPage(context) + end) + :AddEvent("EV_RECIPE_SELECTION_CHANGED", function(context) + fsmPrivate.UpdateContentPage(context) + end) + :AddEvent("EV_BAG_UPDATE_DELAYED", function(context) + fsmPrivate.UpdateMaterials(context) + fsmPrivate.UpdateQueueFrame(context) + local professionLoaded = private.IsProfessionLoaded() + local nextCraftRecord = context.frame:GetElement("queue.queueList"):GetFirstData() + local nextCraftSpellId = nextCraftRecord and nextCraftRecord:GetField("spellId") + if nextCraftRecord and professionLoaded and TSM.Crafting.ProfessionScanner.HasSpellId(nextCraftSpellId) and TSM.Crafting.ProfessionUtil.GetNumCraftable(nextCraftSpellId) > 0 and private.IsPlayerProfession() then + TSM.Crafting.ProfessionUtil.PrepareToCraft(nextCraftSpellId, nextCraftRecord:GetField("num")) + end + end) + :AddEvent("EV_QUEUE_UPDATE", function(context) + fsmPrivate.UpdateQueueFrame(context, true) + end) + :AddEvent("EV_SKILL_UPDATE", function(context) + fsmPrivate.UpdateSkills(context) + end) + :AddEvent("EV_CRAFT_BUTTON_MOUSE_DOWN", function(context, quantity) + context.craftingType = quantity == math.huge and "all" or "craft" + TSM.Crafting.ProfessionUtil.PrepareToCraft(context.selectedRecipeSpellId, quantity) + end) + :AddEvent("EV_CRAFT_BUTTON_CLICKED", function(context, quantity) + context.craftingType = quantity == math.huge and "all" or "craft" + fsmPrivate.StartCraft(context, context.selectedRecipeSpellId, quantity) + end) + :AddEvent("EV_CRAFT_NEXT_BUTTON_CLICKED", function(context, spellId, quantity) + if context.craftingSpellId then + -- already crafting something + return + end + local _, _, _, toolsStr, hasTools = TSM.Crafting.ProfessionUtil.GetRecipeInfo(spellId) + if (toolsStr and not hasTools) or TSM.Crafting.ProfessionUtil.GetNumCraftable(spellId) == 0 or TSM.Crafting.ProfessionUtil.GetRemainingCooldown(spellId) then + -- can't craft this + return + end + context.craftingType = "queue" + fsmPrivate.StartCraft(context, spellId, quantity) + end) + :AddEvent("EV_SPELLCAST_COMPLETE", function(context, success, isDone) + if success and context.craftingSpellId then + Log.Info("Crafted %d", context.craftingSpellId) + TSM.Crafting.Queue.Remove(context.craftingSpellId, 1) + context.craftingQuantity = context.craftingQuantity - 1 + assert(context.craftingQuantity >= 0) + if context.craftingQuantity == 0 then + assert(isDone) + context.craftingSpellId = nil + context.craftingQuantity = nil + context.craftingType = nil + end + else + context.craftingSpellId = nil + context.craftingQuantity = nil + context.craftingType = nil + end + fsmPrivate.UpdateCraftButtons(context) + fsmPrivate.UpdateQueueFrame(context, true) + end) + ) + :AddDefaultEventTransition("EV_FRAME_HIDE", "ST_FRAME_CLOSED") + :Init("ST_FRAME_CLOSED", fsmContext) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.IsProfessionClosed() + return TSM.Crafting.ProfessionState.GetIsClosed() +end + +function private.IsProfessionLoadedNoSkills() + return not private.IsProfessionClosed() and TSM.Crafting.ProfessionState.GetCurrentProfession() and TSM.Crafting.ProfessionScanner.HasScanned() and not TSM.Crafting.ProfessionScanner.HasSkills() +end + +function private.IsProfessionLoaded() + return not private.IsProfessionClosed() and TSM.Crafting.ProfessionState.GetCurrentProfession() and TSM.Crafting.ProfessionScanner.HasScanned() and TSM.Crafting.ProfessionScanner.HasSkills() +end + +function private.IsPlayerProfession() + return not (TSM.Crafting.ProfessionUtil.IsNPCProfession() or TSM.Crafting.ProfessionUtil.IsLinkedProfession() or TSM.Crafting.ProfessionUtil.IsGuildProfession()) +end + +function private.HaveMaterialsFilterHelper(row) + return TSM.Crafting.ProfessionUtil.IsCraftable(row:GetField("spellId")) +end + +function private.ItemLinkedCallback(name, itemLink) + if not private.professionFrame then + return + end + local input = private.professionFrame:GetElement("header.filterInput") + input:SetValue(ItemInfo.GetName(ItemString.GetBase(itemLink))) + :SetFocused(false) + :Draw() + + private.FilterInputOnValueChanged(input) + return true +end diff --git a/Core/UI/CraftingUI/CraftingReports.lua b/Core/UI/CraftingUI/CraftingReports.lua new file mode 100644 index 0000000..3ee1640 --- /dev/null +++ b/Core/UI/CraftingUI/CraftingReports.lua @@ -0,0 +1,673 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CraftingReports = TSM.UI.CraftingUI:NewPackage("CraftingReports") +local L = TSM.Include("Locale").GetTable() +local Math = TSM.Include("Util.Math") +local Log = TSM.Include("Util.Log") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + craftsQuery = nil, + matsQuery = nil, + filterText = "", + craftProfessions = {}, + matProfessions = {}, +} +local MAT_PRICE_SOURCES = {ALL, L["Default Price"], L["Custom Price"]} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function CraftingReports.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "craftingUIContext", "craftsScrollingTable") + :AddKey("global", "craftingUIContext", "matsScrollingTable") + :AddKey("factionrealm", "internalData", "mats") + :AddKey("global", "craftingOptions", "defaultMatCostMethod") + TSM.UI.CraftingUI.RegisterTopLevelPage(L["Reports"], private.GetCraftingReportsFrame) +end + + + +-- ============================================================================ +-- CraftingReports UI +-- ============================================================================ + +function private.GetCraftingReportsFrame() + TSM.UI.AnalyticsRecordPathChange("crafting", "crafting_reports") + if not private.craftsQuery then + private.craftsQuery = TSM.Crafting.CreateCraftsQuery() + private.craftsQuery:VirtualField("firstOperation", "string", private.FirstOperationVirtualField, "itemString") + end + private.craftsQuery:ResetFilters() + private.craftsQuery:ResetOrderBy() + private.craftsQuery:OrderBy("itemName", true) + private.matsQuery = private.matsQuery or TSM.Crafting.CreateMatItemQuery() + private.matsQuery:ResetFilters() + private.matsQuery:ResetOrderBy() + private.matsQuery:OrderBy("name", true) + return UIElements.New("Frame", "craftingReportsContent") + :SetLayout("VERTICAL") + :SetPadding(0, 0, 6, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("TabGroup", "buttons") + :SetNavCallback(private.GetTabElements) + :AddPath(L["Crafts"], true) + :AddPath(L["Materials"]) + ) +end + +function private.GetTabElements(self, path) + if path == L["Crafts"] then + TSM.UI.AnalyticsRecordPathChange("crafting", "crafting_reports", "crafts") + private.filterText = "" + wipe(private.craftProfessions) + tinsert(private.craftProfessions, L["All Professions"]) + for _, player, profession in TSM.Crafting.PlayerProfessions.Iterator() do + tinsert(private.craftProfessions, format("%s - %s", profession, player)) + end + private.craftsQuery:ResetFilters() + + return UIElements.New("Frame", "crafts") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "filters") + :SetLayout("HORIZONTAL") + :SetHeight(72) + :SetPadding(10, 10, 8, 16) + :AddChild(UIElements.New("Frame", "search") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Filter by Keyword"]) + ) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :AllowItemInsert() + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Enter Keyword"]) + :SetScript("OnValueChanged", private.CraftsInputOnValueChanged) + ) + ) + :AddChild(UIElements.New("Frame", "profession") + :SetLayout("VERTICAL") + :SetMargin(16, 16, 0, 0) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Filter by Profession"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(private.craftProfessions) + :SetSelectedItem(private.craftProfessions[1], true) + :SetScript("OnSelectionChanged", private.CraftsDropdownOnSelectionChanged) + ) + ) + :AddChild(UIElements.New("Frame", "craftable") + :SetLayout("HORIZONTAL") + :SetSize(176, 24) + :SetMargin(0, 0, 24, 0) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetWidth(24) + :SetFont("BODY_BODY2") + :SetScript("OnValueChanged", private.CheckboxOnValueChanged) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Only show craftable"]) + ) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "crafts") + :SetSettingsContext(private.settings, "craftsScrollingTable") + :GetScrollingTableInfo() + :SetMenuInfo(private.CraftsMatsMenuIterator, private.CraftsMenuClickHandler) + :NewColumn("queued") + :SetTitleIcon("iconPack.18x18/Queue") + :SetFont("TABLE_TABLE1") + :SetJustifyH("CENTER") + :SetTextInfo("num") + :SetSortInfo("num") + :Commit() + :NewColumn("craftName") + :SetTitle(L["Name"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.CraftsGetCraftNameText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("itemName") + :DisableHiding() + :Commit() + :NewColumn("operation") + :SetTitle(L["Operation"]) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("firstOperation") + :SetSortInfo("firstOperation") + :Commit() + :NewColumn("bags") + :SetTitle(L["Bag"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("bagQuantity", private.CraftsGetBagsText) + :SetSortInfo("bagQuantity") + :Commit() + :NewColumn("ah") + :SetTitle(L["AH"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("auctionQuantity", private.CraftsGetAHText) + :SetSortInfo("auctionQuantity") + :Commit() + :NewColumn("craftingCost") + :SetTitle(L["Crafting Cost"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("craftingCost", private.CraftsGetCostItemValueText) + :SetSortInfo("craftingCost") + :Commit() + :NewColumn("itemValue") + :SetTitle(L["Item Value"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("itemValue", private.CraftsGetCostItemValueText) + :SetSortInfo("itemValue") + :Commit() + :NewColumn("profit") + :SetTitle(L["Profit"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("profit", private.CraftsGetProfitText) + :SetSortInfo("profit") + :Commit() + :NewColumn("profitPct") + :SetTitle("%") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("profitPct", private.GetProfitPctText) + :SetSortInfo("profitPct") + :Commit() + :NewColumn("saleRate") + :SetTitleIcon("iconPack.18x18/SaleRate") + :SetFont("TABLE_TABLE1") + :SetJustifyH("CENTER") + :SetTextInfo("saleRate", private.CraftsGetSaleRateText) + :SetSortInfo("saleRate") + :Commit() + :Commit() + :SetQuery(private.craftsQuery) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.CraftsOnRowClick) + ) + elseif path == L["Materials"] then + TSM.UI.AnalyticsRecordPathChange("crafting", "crafting_reports", "materials") + wipe(private.matProfessions) + tinsert(private.matProfessions, L["All Professions"]) + for _, _, profession in TSM.Crafting.PlayerProfessions.Iterator() do + if not private.matProfessions[profession] then + tinsert(private.matProfessions, profession) + private.matProfessions[profession] = true + end + end + private.matsQuery:ResetFilters() + + return UIElements.New("Frame", "materials") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "filters") + :SetLayout("HORIZONTAL") + :SetHeight(72) + :SetPadding(10, 10, 8, 16) + :AddChild(UIElements.New("Frame", "search") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Filter by Keyword"]) + ) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :AllowItemInsert() + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Enter Keyword"]) + :SetScript("OnValueChanged", private.MatsInputOnValueChanged) + ) + ) + :AddChild(UIElements.New("Frame", "profession") + :SetLayout("VERTICAL") + :SetMargin(16, 0, 0, 0) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Filter by Profession"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(private.matProfessions) + :SetSelectedItem(private.matProfessions[1], true) + :SetScript("OnSelectionChanged", private.MatsDropdownOnSelectionChanged) + ) + ) + :AddChild(UIElements.New("Frame", "priceSource") + :SetLayout("VERTICAL") + :SetMargin(16, 0, 0, 0) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Filter by Price Source"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(MAT_PRICE_SOURCES) + :SetSelectedItem(MAT_PRICE_SOURCES[1], true) + :SetScript("OnSelectionChanged", private.MatsDropdownOnSelectionChanged) + ) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "mats") + :SetSettingsContext(private.settings, "matsScrollingTable") + :GetScrollingTableInfo() + :SetMenuInfo(private.CraftsMatsMenuIterator, private.MatsMenuClickHandler) + :NewColumn("name") + :SetTitle(L["Name"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", private.MatsGetNameText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("price") + :SetTitle(L["Mat Price"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("matCost", private.MatsGetPriceText) + :SetSortInfo("matCost") + :Commit() + :NewColumn("professions") + :SetTitle(L["Professions Used In"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("professions") + :SetSortInfo("professions") + :Commit() + :NewColumn("num") + :SetTitle(L["Number Owned"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("totalQuantity", private.MatsGetNumText) + :SetSortInfo("totalQuantity") + :Commit() + :Commit() + :SetQuery(private.matsQuery) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.MatsOnRowClick) + ) + else + error("Unknown path: "..tostring(path)) + end +end + + + +-- ============================================================================ +-- ScrollingTable Functions +-- ============================================================================ + +function private.CraftsGetCraftNameText(row) + return TSM.UI.GetColoredItemName(row:GetField("itemString")) or row:GetField("name") +end + +function private.CraftsGetBagsText(bagQuantity) + return bagQuantity or "0" +end + +function private.CraftsGetAHText(bagQuantity) + return bagQuantity or "0" +end + +function private.CraftsGetCostItemValueText(costItemValue) + if Math.IsNan(costItemValue) then + return "" + end + return Money.ToString(costItemValue) +end + +function private.CraftsGetProfitText(profit) + if Math.IsNan(profit) then + return "" + end + return Money.ToString(profit, (profit >= 0 and Theme.GetFeedbackColor("GREEN") or Theme.GetFeedbackColor("RED")):GetTextColorPrefix()) +end + +function private.GetProfitPctText(profitPct) + if Math.IsNan(profitPct) then + return "" + end + local color = Theme.GetFeedbackColor(profitPct >= 0 and "GREEN" or "RED") + return color:ColorText(profitPct.."%") or "" +end + +function private.CraftsGetSaleRateText(saleRate) + if Math.IsNan(saleRate) then + return "" + end + return format("%0.2f", saleRate) +end + +function private.MatsGetNameText(itemString) + return TSM.UI.GetColoredItemName(itemString) or TSM.UI.GetColoredItemName(ItemString.GetUnknown()) +end + +function private.MatsGetPriceText(matCost) + if Math.IsNan(matCost) then + return "" + end + return Money.ToString(matCost) +end + +function private.MatsGetNumText(totalQuantity) + return totalQuantity or "0" +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.CraftsInputOnValueChanged(input) + local text = input:GetValue() + if text == private.filterText then + return + end + private.filterText = text + private.UpdateCraftsQueryWithFilters(input:GetParentElement():GetParentElement()) +end + +function private.CraftsDropdownOnSelectionChanged(dropdown) + private.UpdateCraftsQueryWithFilters(dropdown:GetParentElement():GetParentElement()) +end + +function private.CheckboxOnValueChanged(checkbox) + private.UpdateCraftsQueryWithFilters(checkbox:GetParentElement():GetParentElement()) +end + +function private.CraftsOnRowClick(scrollingTable, record, mouseButton) + if mouseButton == "LeftButton" then + TSM.Crafting.Queue.Add(record:GetField("spellId"), 1) + elseif mouseButton == "RightButton" then + TSM.Crafting.Queue.Remove(record:GetField("spellId"), 1) + end + scrollingTable:Draw() +end + +function private.MatsInputOnValueChanged(input) + private.UpdateMatsQueryWithFilters(input:GetParentElement():GetParentElement()) +end + +function private.MatsDropdownOnSelectionChanged(dropdown) + private.UpdateMatsQueryWithFilters(dropdown:GetParentElement():GetParentElement()) +end + +function private.MatsOnRowClick(scrollingTable, row) + local itemString = row:GetField("itemString") + local priceStr = private.settings.mats[itemString].customValue or private.settings.defaultMatCostMethod + scrollingTable:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(478, 312) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetContext(itemString) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(20) + ) + :AddChild(UIElements.New("Text", "title") + :SetJustifyH("CENTER") + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Edit Material Price"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "item") + :SetLayout("HORIZONTAL") + :SetPadding(6) + :SetMargin(0, 0, 0, 10) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Button", "icon") + :SetSize(36, 36) + :SetMargin(0, 8, 0, 0) + :SetBackground(ItemInfo.GetTexture(itemString)) + :SetTooltip(itemString) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(36) + :SetFont("ITEM_BODY1") + :SetText(TSM.UI.GetColoredItemName(itemString)) + ) + ) + :AddChild(UIElements.New("Text", "desc") + :SetHeight(20) + :SetMargin(0, 0, 0, 6) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Material Price"]) + ) + :AddChild(UIElements.New("MultiLineInput", "input") + :SetMargin(0, 0, 0, 12) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetFont("BODY_BODY2_MEDIUM") + :SetValidateFunc("CUSTOM_PRICE") + :SetValue(Money.ToString(priceStr) or priceStr) + :SetScript("OnValueChanged", private.MatPriceInputOnValueChanged) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Button", "resetBtn") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetTextColor(private.settings.mats[itemString].customValue and "TEXT" or "TEXT_ALT") + :SetDisabled(not private.settings.mats[itemString].customValue) + :SetText(L["Reset to Default"]) + :SetScript("OnClick", private.ResetButtonOnClick) + ) + :AddChild(UIElements.New("Frame", "spacer")) + :AddChild(UIElements.New("ActionButton", "closeBtn") + :SetWidth(342) + :SetText(L["Save"]) + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + ) +end + +function private.DialogCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.MatPriceInputOnValueChanged(input) + local value = input:GetValue() + local itemString = input:GetParentElement():GetContext() + TSM.Crafting.SetMatCustomValue(itemString, value) + input:GetElement("__parent.buttons.resetBtn") + :SetTextColor("TEXT") + :SetDisabled(false) + :Draw() +end + +function private.ResetButtonOnClick(button) + local itemString = button:GetParentElement():GetParentElement():GetContext() + TSM.Crafting.SetMatCustomValue(itemString, nil) + assert(not private.settings.mats[itemString].customValue) + button:SetTextColor("TEXT_ALT") + button:SetDisabled(true) + button:Draw() + button:GetElement("__parent.__parent.input") + :SetValue(Money.ToString(private.settings.defaultMatCostMethod) or private.settings.defaultMatCostMethod) + :Draw() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.FirstOperationVirtualField(itemString) + return TSM.Operations.GetFirstOperationByItem("Crafting", itemString) or "" +end + +function private.UpdateCraftsQueryWithFilters(frame) + private.craftsQuery:ResetFilters() + -- apply search filter + local filter = strtrim(frame:GetElement("search.input"):GetValue()) + if filter ~= "" then + private.craftsQuery:Matches("itemName", String.Escape(filter)) + end + -- apply dropdown filter + local professionPlayer = frame:GetElement("profession.dropdown"):GetSelectedItem() + if professionPlayer ~= private.craftProfessions[1] then + local profession, player = strmatch(professionPlayer, "^(.+) %- ([^ ]+)$") + private.craftsQuery + :Equal("profession", profession) + :Or() + :Equal("players", player) + :Matches("players", "^"..player..",") + :Matches("players", ","..player..",") + :Matches("players", ","..player.."$") + :End() + end + -- apply craftable filter + local craftableOnly = frame:GetElement("craftable.checkbox"):IsChecked() + if craftableOnly then + private.craftsQuery:Custom(private.IsCraftableQueryFilter) + end + frame:GetElement("__parent.crafts"):SetQuery(private.craftsQuery, true) +end + +function private.IsCraftableQueryFilter(record) + return TSM.Crafting.ProfessionUtil.GetNumCraftableFromDB(record:GetField("spellId")) > 0 +end + +function private.UpdateMatsQueryWithFilters(frame) + private.matsQuery:ResetFilters() + -- apply search filter + local filter = strtrim(frame:GetElement("search.input"):GetValue()) + if filter ~= "" then + private.matsQuery:Custom(private.MatItemNameQueryFilter, strlower(String.Escape(filter))) + end + -- apply dropdown filters + local profession = frame:GetElement("profession.dropdown"):GetSelectedItem() + if profession ~= private.matProfessions[1] then + private.matsQuery + :Or() + :Equal("professions", profession) + :Matches("professions", "^"..profession..",") + :Matches("professions", ","..profession..",") + :Matches("professions", ","..profession.."$") + :End() + end + local priceSource = frame:GetElement("priceSource.dropdown"):GetSelectedItem() + if priceSource == MAT_PRICE_SOURCES[2] then + private.matsQuery:Equal("customValue", "") + elseif priceSource == MAT_PRICE_SOURCES[3] then + private.matsQuery:NotEqual("customValue", "") + end + frame:GetElement("__parent.mats"):SetQuery(private.matsQuery, true) +end + +function private.MatItemNameQueryFilter(row, filter) + local name = ItemInfo.GetName(row:GetField("itemString")) + if not name then return end + return strmatch(strlower(name), filter) +end + +function private.CraftsMatsMenuIterator(scrollingTable, prevIndex) + if prevIndex == "CREATE_GROUPS" then + -- we're done + return + else + return "CREATE_GROUPS", L["Create Groups from Table"] + end +end + +function private.CraftsMenuClickHandler(scrollingTable, index1, index2) + if index1 == "CREATE_GROUPS" then + assert(not index2) + scrollingTable:GetBaseElement():HideDialog() + local numCreated, numAdded = 0, 0 + for _, row in private.craftsQuery:Iterator() do + local itemString = row:GetField("itemString") + local groupPath = TSM.Groups.Path.Join(L["Crafted Items"], row:GetField("profession")) + if not TSM.Groups.Exists(groupPath) then + TSM.Groups.Create(groupPath) + numCreated = numCreated + 1 + end + if not TSM.Groups.IsItemInGroup(itemString) and not ItemInfo.IsSoulbound(itemString) then + TSM.Groups.SetItemGroup(itemString, groupPath) + numAdded = numAdded + 1 + end + end + Log.PrintfUser(L["%d groups were created and %d items were added from the table."], numCreated, numAdded) + else + error("Unexpected index1: "..tostring(index1)) + end +end + +function private.MatsMenuClickHandler(scrollingTable, index1, index2) + if index1 == "CREATE_GROUPS" then + assert(not index2) + scrollingTable:GetBaseElement():HideDialog() + local numCreated, numAdded = 0, 0 + for _, row in private.matsQuery:Iterator() do + local itemString = row:GetField("itemString") + local groupPath = L["Materials"] + if not TSM.Groups.Exists(groupPath) then + TSM.Groups.Create(groupPath) + numCreated = numCreated + 1 + end + if not TSM.Groups.IsItemInGroup(itemString) and not ItemInfo.IsSoulbound(itemString) then + TSM.Groups.SetItemGroup(itemString, groupPath) + numAdded = numAdded + 1 + end + end + Log.PrintfUser(L["%d groups were created and %d items were added from the table."], numCreated, numAdded) + else + error("Unexpected index1: "..tostring(index1)) + end +end diff --git a/Core/UI/CraftingUI/Gathering.lua b/Core/UI/CraftingUI/Gathering.lua new file mode 100644 index 0000000..bd48bdd --- /dev/null +++ b/Core/UI/CraftingUI/Gathering.lua @@ -0,0 +1,354 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Gathering = TSM.UI.CraftingUI:NewPackage("Gathering") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + frame = nil, + query = nil, +} +local SOURCE_LIST = { + "vendor", + "guildBank", + "alt", + "altGuildBank", + "craftProfit", + "craftNoProfit", + "auction", + "auctionDE", + "auctionCrafting" +} +local SOURCE_TEXT_LIST = { + L["Vendor"], + L["Guild Bank"], + L["Alts"], + L["Alt Guild Bank"], + L["Craft (Profitable)"], + L["Craft (Unprofitable)"], + L["AH"], + L["AH (Disenchanting)"], + L["AH (Crafting)"], +} +if TSM.IsWowClassic() then + Table.RemoveByValue(SOURCE_LIST, "guildBank") + Table.RemoveByValue(SOURCE_LIST, "altGuildBank") + Table.RemoveByValue(SOURCE_TEXT_LIST, L["Guild Bank"]) + Table.RemoveByValue(SOURCE_TEXT_LIST, L["Alt Guild Bank"]) +end +assert(#SOURCE_LIST == #SOURCE_TEXT_LIST) + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Gathering.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "craftingUIContext", "gatheringDividedContainer") + :AddKey("global", "craftingUIContext", "gatheringScrollingTable") + :AddKey("profile", "gatheringOptions", "sources") + TSM.UI.CraftingUI.RegisterTopLevelPage(L["Gathering"], private.GetGatheringFrame) + TSM.Crafting.Gathering.SetContextChangedCallback(private.ContextChangedCallback) + TSM.UI.TaskListUI.RegisterUpdateCallback(private.UpdateButtonState) +end + + + +-- ============================================================================ +-- Gathering UI +-- ============================================================================ + +function private.GetGatheringFrame() + TSM.UI.AnalyticsRecordPathChange("crafting", "gathering") + assert(not private.query) + private.query = TSM.Crafting.Gathering.CreateQuery() + :SetUpdateCallback(private.UpdateButtonState) + local frame = UIElements.New("DividedContainer", "gathering") + :SetMinWidth(284, 200) + :SetBackgroundColor("PRIMARY_BG") + :SetSettingsContext(private.settings, "gatheringDividedContainer") + :SetLeftChild(UIElements.New("ScrollFrame", "setup") + :SetPadding(12) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "title") + :SetHeight(20) + :SetMargin(0, 0, 0, 8) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Gathering Setup"]) + ) + :AddChild(UIElements.New("Text", "crafterDropdownLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 2) + :SetFont("BODY_BODY3_MEDIUM") + :SetTextColor("INDICATOR") + :SetText(L["Crafter"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "crafterDropdown") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :SetHintText(L["Select crafter"]) + :SetScript("OnSelectionChanged", private.CrafterDropdownOnSelectionChanged) + ) + :AddChild(UIElements.New("Text", "professionDropdownLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 2) + :SetFont("BODY_BODY3_MEDIUM") + :SetTextColor("INDICATOR") + :SetText(L["Profession"]) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "professionDropdown") + :SetHeight(24) + :SetMargin(0, 0, 0, 24) + :SetHintText(L["Select professions"]) + :SetSelectionText(L["No Professions"], L["%d Professions"], L["All Professions"]) + :SetScript("OnSelectionChanged", private.ProfessionDropdownOnSelectionChanged) + ) + :AddChild(UIElements.New("Text", "sourcesCategoryText") + :SetHeight(20) + :SetMargin(0, 0, 0, 2) + :SetFont("BODY_BODY3_MEDIUM") + :SetTextColor("INDICATOR") + :SetText(L["Sources"]) + ) + :AddChild(UIElements.New("Text", "sourcesDesc") + :SetHeight(28) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY3_MEDIUM") + :SetTextColor("TEXT_ALT") + :SetText(L["Define what priority Gathering gives certain sources."]) + ) + :AddChildrenWithFunction(private.CreateSourceRows) + ) + :SetRightChild(UIElements.New("Frame", "mats") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "title") + :SetHeight(20) + :SetMargin(0, 0, 8, 8) + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Materials to Gather"]) + ) + :AddChild(UIElements.New("QueryScrollingTable", "table") + :SetSettingsContext(private.settings, "gatheringScrollingTable") + :GetScrollingTableInfo() + :NewColumn("name") + :SetTitle(NAME) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("sources") + :SetTitle(L["Sources"]) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("sourcesStr", private.MatsGetSourcesStrText) + :SetSortInfo("sourcesStr") + :Commit() + :NewColumn("have") + :SetTitle(L["Have"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("numHave") + :SetSortInfo("numHave") + :Commit() + :NewColumn("need") + :SetTitle(NEED) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("numNeed") + :SetSortInfo("numNeed") + :Commit() + :Commit() + :SetQuery(TSM.Crafting.Gathering.CreateQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :OrderBy("name", true) + ) + :SetSelectionDisabled(true) + :SetAutoReleaseQuery(true) + ) + :AddChild(UIElements.New("Texture", "headerTopLine") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ActionButton", "openTaskListBtn") + :SetHeight(26) + :SetMargin(8) + :SetScript("OnClick", TSM.UI.TaskListUI.Toggle) + ) + ) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) + private.frame = frame + return frame +end + +function private.MatsGetSourcesStrText(str) + str = gsub(str, "/[^,]+", "") + for i = 1, #SOURCE_LIST do + str = gsub(str, SOURCE_LIST[i], SOURCE_TEXT_LIST[i]) + end + return str +end + +function private.CreateSourceRows(frame) + for i = 1, #SOURCE_LIST do + frame:AddChild(UIElements.New("Frame", "sourceFrame"..i) + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY3_MEDIUM") + :SetTextColor((i > #private.settings.sources + 1) and "TEXT_ALT+DISABLED" or "TEXT_ALT") + :SetFormattedText(L["SOURCE %d"], i) + ) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetWidth(188) + :SetContext(i) + :SetHintText(L["Select a Source"]) + :SetScript("OnSelectionChanged", private.SourceDropdownOnSelectionChanged) + ) + ) + end + private.UpdateSourceRows(frame) +end + +function private.UpdateSourceRows(setupFrame) + if TSM.IsWowClassic() then + Table.RemoveByValue(private.settings.sources, "guildBank") + Table.RemoveByValue(private.settings.sources, "altGuildBank") + end + local texts = TempTable.Acquire() + local sources = TempTable.Acquire() + for i = 1, #SOURCE_LIST do + wipe(texts) + wipe(sources) + for j = 1, #SOURCE_LIST do + local index = Table.KeyByValue(private.settings.sources, SOURCE_LIST[j]) + if not index or index >= i then + tinsert(texts, SOURCE_TEXT_LIST[j]) + tinsert(sources, SOURCE_LIST[j]) + end + end + if i <= #private.settings.sources then + tinsert(texts, "<"..strupper(REMOVE)..">") + tinsert(sources, "") + end + setupFrame:GetElement("sourceFrame"..i..".label") + :SetTextColor((i > #private.settings.sources + 1) and "TEXT_ALT+DISABLED" or "TEXT_ALT") + setupFrame:GetElement("sourceFrame"..i..".dropdown") + :SetItems(texts, sources) + :SetDisabled(i > #private.settings.sources + 1) + :SetHintText(L["Select a Source"]) + :SetSelectedItemByKey(private.settings.sources[i], true) + end + TempTable.Release(texts) + TempTable.Release(sources) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnUpdate(frame) + private.UpdateButtonState() + frame:SetScript("OnUpdate", nil) + private.ContextChangedCallback() +end + +function private.FrameOnHide(frame) + assert(frame == private.frame) + private.frame = nil + private.query:Release() + private.query = nil +end + +function private.CrafterDropdownOnSelectionChanged(dropdown) + TSM.Crafting.Gathering.SetCrafter(dropdown:GetSelectedItem() or "") + dropdown:GetElement("__parent.professionDropdown") + :SetItems(TSM.Crafting.Gathering.GetProfessionList()) + :SetSelectedItems(TSM.Crafting.Gathering.GetProfessions()) + :Draw() +end + +function private.ProfessionDropdownOnSelectionChanged(dropdown) + local professions = TempTable.Acquire() + dropdown:GetSelectedItems(professions) + TSM.Crafting.Gathering.SetProfessions(professions) + TempTable.Release(professions) +end + +function private.SourceDropdownOnSelectionChanged(dropdown) + local index = dropdown:GetContext() + local source = dropdown:GetSelectedItemKey() + if source == "" then + tremove(private.settings.sources, index) + else + private.settings.sources[index] = source + for i = #private.settings.sources, index + 1, -1 do + if private.settings.sources[i] == source then + tremove(private.settings.sources, i) + end + end + end + local setupFrame = dropdown:GetParentElement():GetParentElement() + private.UpdateSourceRows(setupFrame) + setupFrame:Draw() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ContextChangedCallback() + if not private.frame then + return + end + + private.frame:GetElement("setup.crafterDropdown") + :SetItems(TSM.Crafting.Gathering.GetCrafterList()) + :SetSelectedItem(TSM.Crafting.Gathering.GetCrafter()) + :Draw() + private.frame:GetElement("setup.professionDropdown") + :SetItems(TSM.Crafting.Gathering.GetProfessionList()) + :SetSelectedItems(TSM.Crafting.Gathering.GetProfessions()) + :Draw() +end + +function private.UpdateButtonState() + if not private.frame then + return + end + local button = private.frame:GetElement("mats.openTaskListBtn") + if private.query:Count() == 0 then + button:SetText(L["No Materials to Gather"]) + button:SetDisabled(true) + elseif TSM.UI.TaskListUI.IsVisible() then + button:SetText(L["Tasks Added to Task List"]) + button:SetDisabled(true) + else + button:SetText(L["Open Task List"]) + button:SetDisabled(false) + end + button:Draw() +end diff --git a/Core/UI/Debug/DBViewer.lua b/Core/UI/Debug/DBViewer.lua new file mode 100644 index 0000000..1737227 --- /dev/null +++ b/Core/UI/Debug/DBViewer.lua @@ -0,0 +1,289 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local DBViewer = TSM.UI:NewPackage("DBViewer") +local Database = TSM.Include("Util.Database") +local Log = TSM.Include("Util.Log") +local UIElements = TSM.Include("UI.UIElements") +local private = { + frame = nil, + frameContext = {}, + dividedContainerContext = {}, + selectedDBName = nil, + structureScrollingTableContext = {}, + browseScrollingTableContext = {}, + defaultBrowseScrollingTableContext = { colWidth = {}, colHidden = {} }, +} +local DEFAULT_FRAME_CONTEXT = { + width = 900, + height = 600, + centerX = 100, + centerY = 0, + scale = 1, +} +local MIN_FRAME_SIZE = { + width = 900, + height = 600 +} +local DEFAULT_DIVIDED_CONTAINER_CONTEXT = { + leftWidth = 200, +} +local DEFAULT_STRUCTURE_SCROLLING_TABLE_CONTEXT = { + colWidth = { + order = 24, + field = 450, + type = 60, + attributes = 96, + }, + colHidden = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function DBViewer.OnDisable() + -- hide the frame + if private.frame then + DBViewer.Toggle() + end +end + +function DBViewer.Toggle() + if not private.frame then + private.frame = private.CreateMainFrame() + private.frame:Draw() + private.frame:Show() + else + private.frame:Hide() + assert(not private.frame) + end +end + + + +-- ============================================================================ +-- UI Functions +-- ============================================================================ + +function private.CreateMainFrame() + private.selectedDBName = nil + return UIElements.New("ApplicationFrame", "base") + :SetParent(UIParent) + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetContextTable(private.frameContext, DEFAULT_FRAME_CONTEXT) + :SetStrata("HIGH") + :SetTitle("TSM DB Viewer") + :SetScript("OnHide", private.FrameOnHide) + :SetContentFrame(UIElements.New("DividedContainer", "container") + :SetContextTable(private.dividedContainerContext, DEFAULT_DIVIDED_CONTAINER_CONTEXT) + :SetBackgroundColor("PRIMARY_BG") + :SetMinWidth(100, 100) + :SetLeftChild(UIElements.New("ScrollFrame", "left") + :AddChildrenWithFunction(private.AddTableRows) + ) + :SetRightChild(UIElements.New("Frame", "right") + :SetLayout("VERTICAL") + ) + ) +end + +function private.AddTableRows(frame) + for _, name in Database.InfoNameIterator() do + frame:AddChild(UIElements.New("Button", "nav_"..name) + :SetHeight(20) + :SetPadding(8, 0, 0, 0) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetHighlightEnabled(true) + :SetText(name) + :SetBackground("PRIMARY_BG") + :SetScript("OnClick", private.NavButtonOnClick) + ) + end +end + +function private.CreateTableContent() + local contentFrame = private.frame:GetElement("container.right") + contentFrame:ReleaseAllChildren() + contentFrame:AddChild(UIElements.New("TabGroup", "tabs") + :SetMargin(0, 0, 4, 0) + :SetNavCallback(private.ContentNavCallback) + :AddPath("Structure", true) + :AddPath("Browse") + ) + contentFrame:Draw() +end + +function private.ContentNavCallback(_, path) + if path == "Structure" then + return private.CreateStructureFrame() + elseif path == "Browse" then + return private.CreateBrowseFrame() + else + error("Invalid path: "..tostring(path)) + end +end + +function private.CreateStructureFrame() + local query = Database.CreateInfoFieldQuery(private.selectedDBName) + :OrderBy("order", true) + return UIElements.New("Frame", "structure") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "info") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(4) + :AddChild(UIElements.New("Text", "numRows") + :SetFont("BODY_BODY2") + :SetText("Total Rows: "..Database.GetNumRows(private.selectedDBName)) + ) + :AddChild(UIElements.New("Text", "numRows") + :SetFont("BODY_BODY2") + :SetText("Active Queries: "..Database.GetNumActiveQueries(private.selectedDBName)) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "table") + :SetContextTable(private.structureScrollingTableContext, DEFAULT_STRUCTURE_SCROLLING_TABLE_CONTEXT) + :GetScrollingTableInfo() + :NewColumn("order") + :SetTitle("#") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("order") + :SetSortInfo("order") + :Commit() + :NewColumn("field") + :SetTitle("Field") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("field") + :SetSortInfo("field") + :Commit() + :NewColumn("type") + :SetTitle("Type") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("type") + :SetSortInfo("type") + :Commit() + :NewColumn("attributes") + :SetTitle("Attributes") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("attributes") + :SetSortInfo("attributes") + :Commit() + :Commit() + :SetQuery(query) + :SetAutoReleaseQuery(true) + :SetSelectionDisabled(true) + ) +end + +function private.CreateBrowseFrame() + local query = Database.CreateDBQuery(private.selectedDBName) + local fieldQuery = Database.CreateInfoFieldQuery(private.selectedDBName) + :Select("field") + :OrderBy("order", true) + wipe(private.defaultBrowseScrollingTableContext.colWidth) + for _, field in fieldQuery:Iterator() do + private.defaultBrowseScrollingTableContext.colWidth[field] = 100 + end + wipe(private.browseScrollingTableContext) + + local frame = UIElements.New("Frame", "browse") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Input", "queryInput") + :SetHeight(26) + :SetMargin(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValue("query") + :SetScript("OnEnterPressed", private.QueryInputOnEnterPressed) + ) + :AddChild(UIElements.New("QueryScrollingTable", "table") + :SetContextTable(private.browseScrollingTableContext, private.defaultBrowseScrollingTableContext) + :SetContext(query) + :SetQuery(query) + :SetAutoReleaseQuery(true) + :SetSelectionDisabled(true) + ) + + local stInfo = frame:GetElement("table"):GetScrollingTableInfo() + for _, field in fieldQuery:Iterator() do + stInfo:NewColumn(field) + :SetTitle(field) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(field, tostring) + :SetTooltipInfo(field, private.TooltipFunc) + :Commit() + end + fieldQuery:Release() + stInfo:Commit() + + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnHide(frame) + assert(frame == private.frame) + private.frame:Release() + private.frame = nil +end + +function private.NavButtonOnClick(button) + local navFrame = button:GetParentElement() + private.selectedDBName = button:GetText() + for _, name in Database.InfoNameIterator() do + navFrame:GetElement("nav_"..name) + :SetTextColor(name == private.selectedDBName and "INDICATOR" or "TEXT") + :Draw() + end + private.CreateTableContent() +end + +function private.QueryInputOnEnterPressed(input) + local func, errStr = loadstring(input:GetValue()) + if not func then + Log.PrintfUser("Failed to compile code: "..errStr) + return + end + local tableElement = input:GetElement("__parent.table") + local query = tableElement:GetContext() + query:Reset() + setfenv(func, { query = query }) + local ok, funcErrStr = pcall(func) + if not ok then + Log.PrintfUser("Failed to execute code: "..funcErrStr) + return + end + tableElement:UpdateData(true) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.TooltipFunc(value) + value = tostring(value) + if strmatch(value, "item:") or strmatch(value, "battlepet:") or strmatch(value, "[ip]:") then + -- this is an item string or item link + return value + else + return "Value: "..value + end +end diff --git a/Core/UI/DestroyingUI/Core.lua b/Core/UI/DestroyingUI/Core.lua new file mode 100644 index 0000000..31ed0df --- /dev/null +++ b/Core/UI/DestroyingUI/Core.lua @@ -0,0 +1,455 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local DestroyingUI = TSM.UI:NewPackage("DestroyingUI") +local L = TSM.Include("Locale").GetTable() +local DisenchantInfo = TSM.Include("Data.DisenchantInfo") +local FSM = TSM.Include("Util.FSM") +local ItemString = TSM.Include("Util.ItemString") +local Log = TSM.Include("Util.Log") +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local Event = TSM.Include("Util.Event") +local Delay = TSM.Include("Util.Delay") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Conversions = TSM.Include("Service.Conversions") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + enterWorldTime = nil, + settings = nil, + fsm = nil, + query = nil, +} +local MIN_FRAME_SIZE = { width = 280, height = 280 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function DestroyingUI.OnInitialize() + Event.Register("PLAYER_ENTERING_WORLD", function() + private.enterWorldTime = GetTime() + end) + private.settings = Settings.NewView() + :AddKey("global", "destroyingUIContext", "frame") + :AddKey("global", "destroyingUIContext", "itemsScrollingTable") + :AddKey("global", "destroyingOptions", "autoShow") + :AddKey("global", "destroyingOptions", "autoStack") + private.FSMCreate() +end + +function DestroyingUI.OnDisable() + -- hide the frame + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function DestroyingUI.Toggle() + private.fsm:ProcessEvent("EV_FRAME_TOGGLE") +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("destroying") + local frame = UIElements.New("ApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :SetTitle(L["Destroying"]) + :SetScript("OnHide", private.FrameOnHide) + :SetContentFrame(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "item") + :SetLayout("VERTICAL") + :SetHeight(82) + :SetMargin(8) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :SetBorderColor("FRAME_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetPadding(8, 8, 8, 4) + :SetHeight(32) + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Button", "icon") + :SetSize(20, 20) + :SetMargin(0, 5, 0, 0) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(20) + :SetFont("ITEM_BODY2") + ) + ) + -- draw a line along the bottom to hide the rounded corners at the bottom of the header frame + :AddChildNoLayout(UIElements.New("Texture", "line") + :AddAnchor("BOTTOMLEFT", "header") + :AddAnchor("BOTTOMRIGHT", "header") + :SetHeight(4) + :SetTexture("FRAME_BG") + ) + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetPadding(0, 0, 4, 4) + :AddChild(UIElements.New("ScrollFrame", "scroll") + :SetPadding(8, 8, 0, 0) + ) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetSettingsContext(private.settings, "itemsScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :SetTooltipLinkingDisabled(true) + :SetActionIconInfo(1, 12, private.GetHideIcon) + :SetActionIconClickHandler(private.OnHideIconClick) + :DisableHiding() + :Commit() + :NewColumn("num") + :SetTitle("Qty") + :SetFont("TABLE_TABLE1") + :SetJustifyH("CENTER") + :SetTextInfo("quantity") + :SetSortInfo("quantity") + :Commit() + :Commit() + :SetQuery(private.query) + :SetScript("OnSelectionChanged", private.ItemsOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "lineBottom") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ActionButton", "combineBtn") + :SetHeight(26) + :SetMargin(12, 12, 12, 0) + :SetText(L["Combine Partial Stacks"]) + :SetScript("OnClick", private.CombineButtonOnClick) + ) + :AddChild(UIElements.NewNamed("SecureMacroActionButton", "destroyBtn", "TSMDestroyBtn") + :SetHeight(26) + :SetMargin(12, 12, 8, 12) + :SetText(L["Destroy Next"]) + :SetScript("PreClick", private.DestroyButtonPreClick) + ) + ) + frame:GetElement("titleFrame.closeBtn"):SetScript("OnClick", private.CloseButtonOnClick) + :Draw() + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnHide() + TSM.UI.AnalyticsRecordClose("destroying") + private.fsm:ProcessEvent("EV_FRAME_TOGGLE") +end + +function private.GetHideIcon(self, data, iconIndex, isMouseOver) + assert(iconIndex == 1) + -- TODO: needs a new texture for the icon + return true, isMouseOver and TSM.UI.TexturePacks.GetColoredKey("iconPack.12x12/Hide", "TEXT_ALT") or "iconPack.12x12/Hide", true, L["Click to hide this item for the current session. Hold shift to hide this item permanently."] +end + +function private.OnHideIconClick(self, data, iconIndex, mouseButton) + assert(iconIndex == 1) + if mouseButton ~= "LeftButton" then + return + end + local row = self._query:GetResultRowByUUID(data) + local itemString = row:GetField("itemString") + if IsShiftKeyDown() then + Log.PrintfUser(L["Destroying will ignore %s permanently. You can remove it from the ignored list in the settings."], ItemInfo.GetName(itemString)) + TSM.Destroying.IgnoreItemPermanent(itemString) + else + Log.PrintfUser(L["Destroying will ignore %s until you log out."], ItemInfo.GetName(itemString)) + TSM.Destroying.IgnoreItemSession(itemString) + end + if self._query:Count() == 0 then + private.fsm:ProcessEvent("EV_FRAME_TOGGLE") + end +end + +function private.GetDestroyInfo(itemString) + local quality = ItemInfo.GetQuality(itemString) + local ilvl = ItemInfo.GetItemLevel(ItemString.GetBase(itemString)) + local classId = ItemInfo.GetClassId(itemString) + local info = TempTable.Acquire() + local targetItems = TempTable.Acquire() + for targetItemString in DisenchantInfo.TargetItemIterator() do + local amountOfMats, matRate, minAmount, maxAmount = DisenchantInfo.GetTargetItemSourceInfo(targetItemString, classId, quality, ilvl) + if amountOfMats then + local name = ItemInfo.GetName(targetItemString) + local color = ItemInfo.GetQualityColor(targetItemString) + if name and color then + matRate = matRate and matRate * 100 + matRate = matRate and matRate.."% " or "" + local range = (minAmount and maxAmount) and Theme.GetFeedbackColor("YELLOW"):ColorText(minAmount ~= maxAmount and (" ["..minAmount.."-"..maxAmount.."]") or (" ["..minAmount.."]")) or "" + tinsert(info, color..matRate..name.." x"..amountOfMats.."|r"..range) + tinsert(targetItems, targetItemString) + end + end + end + for targetItemString, amountOfMats, matRate, minAmount, maxAmount in Conversions.TargetItemsByMethodIterator(itemString, Conversions.METHOD.PROSPECT) do + local name = ItemInfo.GetName(targetItemString) + local color = ItemInfo.GetQualityColor(targetItemString) + if name and color then + matRate = matRate and matRate * 100 + matRate = matRate and matRate.."% " or "" + local range = (minAmount and maxAmount) and Theme.GetFeedbackColor("YELLOW"):ColorText(minAmount ~= maxAmount and (" ["..minAmount.."-"..maxAmount.."]") or (" ["..minAmount.."]")) or "" + tinsert(info, color..matRate..name.." x"..amountOfMats.."|r"..range) + tinsert(targetItems, targetItemString) + end + end + for targetItemString, rate in Conversions.TargetItemsByMethodIterator(itemString, Conversions.METHOD.MILL) do + local name = ItemInfo.GetName(targetItemString) + local color = ItemInfo.GetQualityColor(targetItemString) + if name and color then + tinsert(info, color..ItemInfo.GetName(targetItemString).." x"..rate.."|r") + tinsert(targetItems, targetItemString) + end + end + return info, targetItems +end + +function private.ItemsOnSelectionChanged(self) + if not self:GetSelection() then + return + end + + local itemString = self:GetSelection():GetField("itemString") + local itemFrame = self:GetElement("__parent.item") + itemFrame:GetElement("header.icon") + :SetBackground(ItemInfo.GetTexture(itemString)) + :SetTooltip(itemString) + itemFrame:GetElement("header.name") + :SetText(TSM.UI.GetColoredItemName(itemString) or "") + + local info, targetItems = private.GetDestroyInfo(itemString) + local scrollFrame = itemFrame:GetElement("container.scroll") + scrollFrame:ReleaseAllChildren() + for i, text in ipairs(info) do + scrollFrame:AddChild(UIElements.New("Button", "row"..i) + :SetHeight(14) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetText(text) + :SetTooltip(targetItems[i]) + ) + end + TempTable.Release(info) + TempTable.Release(targetItems) + itemFrame:Draw() +end + +function private.CloseButtonOnClick(button) + private.fsm:ProcessEvent("EV_FRAME_TOGGLE") +end + +function private.CombineButtonOnClick(button) + button:SetPressed(true) + button:Draw() + private.fsm:ProcessEvent("EV_COMBINE_BUTTON_CLICKED") +end + +function private.DestroyButtonPreClick(button) + button:SetPressed(true) + button:Draw() + private.fsm:ProcessEvent("EV_DESTROY_BUTTON_PRE_CLICK") +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + TSM.Destroying.SetBagUpdateCallback(private.FSMBagUpdate) + local fsmContext = { + frame = nil, + combineFuture = nil, + destroyFuture = nil, + didShowOnce = false, + didAutoCombine = false, + } + local function UpdateDestroyingFrame(context, noDraw) + if not context.frame then + return + end + + local combineBtn = context.frame:GetElement("content.combineBtn") + combineBtn:SetText(context.combineFuture and L["Combining..."] or L["Combine Partial Stacks"]) + combineBtn:SetDisabled(context.combineFuture or context.destroyFuture or not TSM.Destroying.CanCombine()) + local destroyBtn = context.frame:GetElement("content.destroyBtn") + destroyBtn:SetText(context.destroyFuture and L["Destroying..."] or L["Destroy Next"]) + destroyBtn:SetDisabled(context.combineFuture or context.destroyFuture or private.query:Count() == 0) + if not noDraw then + context.frame:Draw() + end + end + private.fsm = FSM.New("DESTROYING") + :AddState(FSM.NewState("ST_FRAME_CLOSED") + :SetOnEnter(function(context) + if context.frame then + context.frame:Hide() + context.frame:Release() + context.frame = nil + end + if context.combineFuture then + context.combineFuture:Cancel() + context.combineFuture = nil + end + if context.destroyFuture then + context.destroyFuture:Cancel() + context.destroyFuture = nil + end + context.didAutoCombine = false + end) + :AddTransition("ST_FRAME_OPENING") + :AddEventTransition("EV_FRAME_TOGGLE", "ST_FRAME_OPENING") + :AddEvent("EV_BAG_UPDATE", function(context) + if not context.didShowOnce and private.settings.autoShow then + return "ST_FRAME_OPENING" + end + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPENING") + :SetOnEnter(function(context) + private.query = private.query or TSM.Destroying.CreateBagQuery() + private.query:ResetOrderBy() + private.query:OrderBy("name", true) + if (not private.settings.autoStack or not TSM.Destroying.CanCombine()) and private.query:Count() == 0 then + -- nothing to destroy or combine, so bail + return "ST_FRAME_CLOSED" + end + context.didShowOnce = true + context.frame = private.CreateMainFrame() + context.frame:Show() + private.ItemsOnSelectionChanged(context.frame:GetElement("content.items")) + return "ST_FRAME_OPEN" + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_FRAME_CLOSED") + ) + :AddState(FSM.NewState("ST_FRAME_OPEN") + :SetOnEnter(function(context) + UpdateDestroyingFrame(context) + if not context.frame:GetElement("content.items"):GetSelection() then + -- select the first row + local result = private.query:GetFirstResult() + context.frame:GetElement("content.items"):SetSelection(result and result:GetUUID() or nil) + end + if private.settings.autoStack and not context.didAutoCombine and TSM.Destroying.CanCombine() then + context.didAutoCombine = true + context.frame:GetElement("content.combineBtn") + :SetPressed(true) + :Draw() + return "ST_COMBINING_STACKS" + elseif not TSM.Destroying.CanCombine() and private.query:Count() == 0 then + -- nothing left to destroy or combine + return "ST_FRAME_CLOSED" + end + context.didAutoCombine = true + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_COMBINING_STACKS") + :AddTransition("ST_DESTROYING") + :AddTransition("ST_FRAME_CLOSED") + :AddEventTransition("EV_COMBINE_BUTTON_CLICKED", "ST_COMBINING_STACKS") + :AddEventTransition("EV_DESTROY_BUTTON_PRE_CLICK", "ST_DESTROYING") + :AddEventTransition("EV_BAG_UPDATE", "ST_FRAME_OPEN") + :AddEventTransition("EV_FRAME_HIDE", "ST_FRAME_CLOSED") + ) + :AddState(FSM.NewState("ST_COMBINING_STACKS") + :SetOnEnter(function(context) + assert(not context.combineFuture) + context.combineFuture = TSM.Destroying.StartCombine() + context.combineFuture:SetScript("OnDone", private.FSMCombineFutureOnDone) + UpdateDestroyingFrame(context, true) + end) + :AddTransition("ST_COMBINING_DONE") + :AddTransition("ST_FRAME_CLOSED") + :AddEventTransition("EV_COMBINE_DONE", "ST_COMBINING_DONE") + :AddEventTransition("EV_FRAME_HIDE", "ST_FRAME_CLOSED") + ) + :AddState(FSM.NewState("ST_COMBINING_DONE") + :SetOnEnter(function(context) + -- don't care what the result was + context.combineFuture:GetValue() + context.combineFuture = nil + context.frame:GetElement("content.combineBtn") + :SetPressed(false) + :Draw() + return "ST_FRAME_OPEN" + end) + :AddTransition("ST_FRAME_OPEN") + ) + :AddState(FSM.NewState("ST_DESTROYING") + :SetOnEnter(function(context) + assert(not context.destroyFuture) + context.destroyFuture = TSM.Destroying.StartDestroy(context.frame:GetElement("content.destroyBtn"), context.frame:GetElement("content.items"):GetSelection()) + context.destroyFuture:SetScript("OnDone", private.FSMDestroyFutureOnDone) + UpdateDestroyingFrame(context, true) + end) + :AddTransition("ST_DESTROYING_DONE") + :AddTransition("ST_FRAME_CLOSED") + :AddEventTransition("EV_DESTROY_DONE", "ST_DESTROYING_DONE") + :AddEventTransition("EV_FRAME_HIDE", "ST_FRAME_CLOSED") + ) + :AddState(FSM.NewState("ST_DESTROYING_DONE") + :SetOnEnter(function(context) + -- don't care what the result was + context.destroyFuture:GetValue() + context.destroyFuture = nil + context.isDestroying = false + context.frame:GetElement("content.destroyBtn") + :SetPressed(false) + :Draw() + return "ST_FRAME_OPEN" + end) + :AddTransition("ST_FRAME_OPEN") + ) + :AddDefaultEventTransition("EV_FRAME_TOGGLE", "ST_FRAME_CLOSED") + :Init("ST_FRAME_CLOSED", fsmContext) +end + +function private.FSMBagUpdate() + if not private.enterWorldTime or private.enterWorldTime == GetTime() then + -- delay for another frame as wow closes special frames in its PLAYER_ENTERING_WORLD handler + Delay.AfterTime("DESTROYING_BAG_UPDATE_DELAY", 0, private.FSMBagUpdate) + return + end + private.fsm:ProcessEvent("EV_BAG_UPDATE") +end + +function private.FSMCombineFutureOnDone() + private.fsm:ProcessEvent("EV_COMBINE_DONE") +end + +function private.FSMDestroyFutureOnDone() + private.fsm:ProcessEvent("EV_DESTROY_DONE") +end diff --git a/Core/UI/Elements/ActionButton.lua b/Core/UI/Elements/ActionButton.lua new file mode 100644 index 0000000..5b19edc --- /dev/null +++ b/Core/UI/Elements/ActionButton.lua @@ -0,0 +1,436 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ActionButton UI Element Class. +-- An action button is a button which uses specific background textures and has a pressed state. It is a subclass of the +-- @{Text} class. +-- @classmod ActionButton + +local _, TSM = ... +local ActionButton = TSM.Include("LibTSMClass").DefineClass("ActionButton", TSM.UI.Text) +local NineSlice = TSM.Include("Util.NineSlice") +local Vararg = TSM.Include("Util.Vararg") +local Event = TSM.Include("Util.Event") +local Theme = TSM.Include("Util.Theme") +local Color = TSM.Include("Util.Color") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ActionButton) +TSM.UI.ActionButton = ActionButton +local private = {} +local ICON_PADDING = 2 +local CLICK_COOLDOWN = 0.2 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ActionButton.__init(self, name, isSecure) + local frame = UIElements.CreateFrame(self, "Button", name, nil, isSecure and "SecureActionButtonTemplate" or nil) + ScriptWrapper.Set(frame, isSecure and "PostClick" or "OnClick", private.OnClick, self) + ScriptWrapper.Set(frame, "OnMouseDown", private.OnMouseDown, self) + ScriptWrapper.Set(frame, "OnEnter", private.OnEnter, self) + ScriptWrapper.Set(frame, "OnLeave", private.OnLeave, self) + + self.__super:__init(frame) + + self._nineSlice = NineSlice.New(frame) + + -- create the icon + frame.icon = frame:CreateTexture(nil, "ARTWORK") + frame.icon:SetPoint("RIGHT", frame.text, "LEFT", -ICON_PADDING, 0) + + Event.Register("MODIFIER_STATE_CHANGED", function() + if self:IsVisible() and next(self._modifierText) then + self:Draw() + if GameTooltip:IsOwned(self:_GetBaseFrame()) then + self:ShowTooltip(self._tooltip) + end + end + end) + + self._iconTexturePack = nil + self._pressed = nil + self._disabled = false + self._locked = false + self._lockedColor = nil + self._justifyH = "CENTER" + self._font = "BODY_BODY2_MEDIUM" + self._defaultText = "" + self._modifierText = {} + self._onClickHandler = nil + self._onEnterHandler = nil + self._onLeaveHandler = nil + self._clickCooldown = nil + self._clickCooldownDisabled = false + self._defaultNoBackground = false + self._isMouseDown = false + self._manualRequired = false +end + +function ActionButton.Release(self) + self._iconTexturePack = nil + self._pressed = nil + self._disabled = false + self._locked = false + self._lockedColor = nil + self._defaultText = "" + wipe(self._modifierText) + self._onClickHandler = nil + self._onEnterHandler = nil + self._onLeaveHandler = nil + self._clickCooldown = nil + self._clickCooldownDisabled = false + self._defaultNoBackground = false + self._manualRequired = false + self._isMouseDown = false + local frame = self:_GetBaseFrame() + ScriptWrapper.Clear(frame, "OnUpdate") + frame:Enable() + frame:RegisterForClicks("LeftButtonUp") + frame:UnlockHighlight() + self.__super:Release() + self._justifyH = "CENTER" + self._font = "BODY_BODY2_MEDIUM" +end + +--- Sets the icon that shows within the button. +-- @tparam ActionButton self The action button object +-- @tparam[opt=nil] string texturePack A texture pack string to set the icon and its size to +-- @treturn ActionButton The action button object +function ActionButton.SetIcon(self, texturePack) + if texturePack then + assert(TSM.UI.TexturePacks.IsValid(texturePack)) + self._iconTexturePack = texturePack + else + self._iconTexturePack = nil + end + return self +end + +--- Set the text. +-- @tparam ActionButton self The action button object +-- @tparam ?string|number text The text +-- @treturn ActionButton The action button object +function ActionButton.SetText(self, text) + self.__super:SetText(text) + self._defaultText = self:GetText() + return self +end + +--- Sets a script handler. +-- @see Element.SetScript +-- @tparam ActionButton self The action button object +-- @tparam string script The script to register for (currently only supports `OnClick`) +-- @tparam function handler The script handler which will be called with the action button object followed by any +-- arguments to the script +-- @treturn ActionButton The action button object +function ActionButton.SetScript(self, script, handler) + if script == "OnClick" then + self._onClickHandler = handler + elseif script == "OnEnter" then + self._onEnterHandler = handler + elseif script == "OnLeave" then + self._onLeaveHandler = handler + elseif script == "OnMouseDown" or script == "OnMouseUp" then + self.__super:SetScript(script, handler) + else + error("Unknown ActionButton script: "..tostring(script)) + end + return self +end + +--- Sets a script to propagate to the parent element. +-- @tparam ActionButton self The action button object +-- @tparam string script The script to propagate +-- @treturn ActionButton The action button object +function ActionButton.PropagateScript(self, script) + if script == "OnMouseDown" or script == "OnMouseUp" then + self.__super:PropagateScript(script) + else + error("Cannot propagate ActionButton script: "..tostring(script)) + end + return self +end + +--- Set whether or not the action button is disabled. +-- @tparam ActionButton self The action button object +-- @tparam boolean disabled Whether or not the action button should be disabled +-- @treturn ActionButton The action button object +function ActionButton.SetDisabled(self, disabled) + self._disabled = disabled + self:_UpdateDisabled() + return self +end + +--- Set whether or not the action button is pressed. +-- @tparam ActionButton self The action button object +-- @tparam boolean locked Whether or not to lock the action button's highlight +-- @tparam[opt=nil] string color The locked highlight color as a theme color key +-- @treturn ActionButton The action button object +function ActionButton.SetHighlightLocked(self, locked, color) + self._locked = locked + self._lockedColor = color + if locked then + self:_GetBaseFrame():LockHighlight() + else + self:_GetBaseFrame():UnlockHighlight() + end + return self +end + +--- Set whether or not the action button is pressed. +-- @tparam ActionButton self The action button object +-- @tparam boolean pressed Whether or not the action button should be pressed +-- @treturn ActionButton The action button object +function ActionButton.SetPressed(self, pressed) + self._pressed = pressed and private.GetModifierKey(IsShiftKeyDown(), IsControlKeyDown(), IsAltKeyDown()) or nil + self:_UpdateDisabled() + return self +end + +--- Disables the default click cooldown to allow the button to be spammed (i.e. for macro-able buttons). +-- @tparam ActionButton self The action button object +-- @treturn ActionButton The action button object +function ActionButton.DisableClickCooldown(self) + self._clickCooldownDisabled = true + return self +end + +function ActionButton.SetDefaultNoBackground(self) + self._defaultNoBackground = true + return self +end + +function ActionButton.SetModifierText(self, text, ...) + local key = private.GetModifierKey(private.ParseModifiers(...)) + assert(key and key ~= "NONE") + self._modifierText[key] = text + return self +end + +--- Set whether a manual click (vs. a macro) is required. +-- @tparam ActionButton self The action button object +-- @tparam boolean required Whether or not a manual click is required +-- @treturn ActionButton The action button object +function ActionButton.SetRequireManualClick(self, required) + self._manualRequired = required + return self +end + +--- Click on the action button. +-- @tparam ActionButton self The action button object +function ActionButton.Click(self) + local frame = self:_GetBaseFrame() + if frame:IsEnabled() and frame:IsVisible() then + private.OnClick(self) + end +end + +function ActionButton.Draw(self) + local maxRank, maxRankKey, numMaxRank = nil, nil, nil + local currentModifier = self._pressed or private.GetModifierKey(IsShiftKeyDown(), IsControlKeyDown(), IsAltKeyDown()) + local currentShift, currentControl, currentAlt = private.ParseModifiers(strsplit("-", currentModifier)) + for key in pairs(self._modifierText) do + local hasShift, hasControl, hasAlt = private.ParseModifiers(strsplit("-", key)) + if (not hasShift or currentShift) and (not hasControl or currentControl) and (not hasAlt or currentAlt) then + -- this key matches the current state + local rank = select("#", strsplit("-", key)) + if not maxRank or rank > maxRank then + maxRank = rank + numMaxRank = 1 + maxRankKey = key + elseif rank == maxRank then + numMaxRank = numMaxRank + 1 + end + end + end + if maxRank then + assert(numMaxRank == 1) + self.__super:SetText(self._modifierText[maxRankKey]) + else + self.__super:SetText(self._defaultText) + end + self.__super:Draw() + + local frame = self:_GetBaseFrame() + + -- set nine-slice and text color depending on the state + local textColor, nineSliceTheme, nineSliceColor = nil, nil, nil + if self._pressed or self._clickCooldown then + textColor = Color.GetFullBlack() + nineSliceTheme = "rounded" + nineSliceColor = Theme.GetColor("INDICATOR") + elseif self._disabled then + textColor = Theme.GetColor("ACTIVE_BG_ALT") + nineSliceTheme = "global" + nineSliceColor = Theme.GetColor("ACTIVE_BG") + elseif self._locked then + textColor = Color.GetFullBlack() + nineSliceTheme = "rounded" + nineSliceColor = Theme.GetColor(self._lockedColor or "ACTIVE_BG+HOVER") + elseif frame:IsMouseOver() then + textColor = Theme.GetColor("TEXT") + nineSliceTheme = "rounded" + nineSliceColor = Theme.GetColor("ACTIVE_BG+HOVER") + else + textColor = self:_GetTextColor() + if not self._defaultNoBackground then + nineSliceTheme = "rounded" + nineSliceColor = Theme.GetColor("ACTIVE_BG") + end + end + frame.text:SetTextColor(textColor:GetFractionalRGBA()) + if nineSliceTheme then + self._nineSlice:SetStyle(nineSliceTheme) + self._nineSlice:SetVertexColor(nineSliceColor:GetFractionalRGBA()) + else + self._nineSlice:Hide() + end + + if self._iconTexturePack then + TSM.UI.TexturePacks.SetTextureAndSize(frame.icon, self._iconTexturePack) + frame.icon:Show() + frame.icon:SetVertexColor(textColor:GetFractionalRGBA()) + local xOffset = self:GetText() ~= "" and ((TSM.UI.TexturePacks.GetWidth(self._iconTexturePack) + ICON_PADDING) / 2) or (self:_GetDimension("WIDTH") / 2) + frame.text:ClearAllPoints() + frame.text:SetPoint("TOP", xOffset, 0) + frame.text:SetPoint("BOTTOM", xOffset, 0) + frame.text:SetWidth(frame.text:GetStringWidth()) + else + frame.icon:Hide() + frame.text:ClearAllPoints() + frame.text:SetPoint("TOPLEFT") + frame.text:SetPoint("BOTTOMRIGHT") + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ActionButton._UpdateDisabled(self) + local frame = self:_GetBaseFrame() + if self._disabled or self._pressed or self._clickCooldown then + frame:Disable() + else + frame:Enable() + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnClick(self) + if not self._acquired then + return + end + if self._manualRequired and not self._isMouseDown then + return + end + self._isMouseDown = false + if not self._clickCooldownDisabled then + self._clickCooldown = CLICK_COOLDOWN + self:_UpdateDisabled() + ScriptWrapper.Set(self:_GetBaseFrame(), "OnUpdate", private.OnUpdate, self) + end + self:Draw() + if self._onClickHandler then + self:_onClickHandler() + end +end + +function private.OnMouseDown(self) + self._isMouseDown = true +end + +function private.OnEnter(self) + if self._onEnterHandler then + self:_onEnterHandler() + end + if self._disabled or self._pressed or self._clickCooldown then + return + end + self:Draw() +end + +function private.OnLeave(self) + if not self:IsVisible() then + return + end + if self._onLeaveHandler then + self:_onLeaveHandler() + end + if self._disabled or self._pressed or self._clickCooldown then + return + end + self:Draw() +end + +function private.OnUpdate(self, elapsed) + self._clickCooldown = self._clickCooldown - elapsed + if self._clickCooldown <= 0 then + ScriptWrapper.Clear(self:_GetBaseFrame(), "OnUpdate") + self._clickCooldown = nil + self:_UpdateDisabled() + self:Draw() + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetModifierKey(hasShift, hasControl, hasAlt) + if hasShift and hasControl and hasAlt then + return "SHIFT-CTRL-ALT" + elseif hasShift and hasControl then + return "SHIFT-CTRL" + elseif hasShift and hasAlt then + return "SHIFT-ALT" + elseif hasShift then + return "SHIFT" + elseif hasControl and hasAlt then + return "CTRL-ALT" + elseif hasControl then + return "CTRL" + elseif hasAlt then + return "ALT" + else + return "NONE" + end +end + +function private.ParseModifiers(...) + local hasShift, hasControl, hasAlt, hasNone = false, false, false, false + for _, modifier in Vararg.Iterator(...) do + if modifier == "SHIFT" then + assert(not hasShift and not hasNone) + hasShift = true + elseif modifier == "CTRL" then + assert(not hasControl and not hasNone) + hasControl = true + elseif modifier == "ALT" then + assert(not hasAlt and not hasNone) + hasAlt = true + elseif modifier == "NONE" then + assert(not hasShift and not hasControl and not hasAlt and not hasNone) + hasNone = true + else + error("Invalid modifier: "..tostring(modifier)) + end + end + return hasShift, hasControl, hasAlt +end diff --git a/Core/UI/Elements/AlphaAnimatedFrame.lua b/Core/UI/Elements/AlphaAnimatedFrame.lua new file mode 100644 index 0000000..6ddb6e8 --- /dev/null +++ b/Core/UI/Elements/AlphaAnimatedFrame.lua @@ -0,0 +1,82 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- AlphaAnimatedFrame UI Element Class. +-- An alpha animated frame is a frame which allows for animating its alpha. It is a subclass of the @{Frame} class. +-- @classmod AlphaAnimatedFrame + +local _, TSM = ... +local AlphaAnimatedFrame = TSM.Include("LibTSMClass").DefineClass("AlphaAnimatedFrame", TSM.UI.Frame) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(AlphaAnimatedFrame) +TSM.UI.AlphaAnimatedFrame = AlphaAnimatedFrame + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function AlphaAnimatedFrame.__init(self) + self.__super:__init() + + local frame = self:_GetBaseFrame() + self._ag = frame:CreateAnimationGroup() + self._ag:SetLooping("BOUNCE") + self._alpha = self._ag:CreateAnimation("Alpha") +end + +function AlphaAnimatedFrame.Acquire(self) + self._alpha:SetFromAlpha(1) + self._alpha:SetToAlpha(1) + self._alpha:SetDuration(1) + self.__super:Acquire() +end + +function AlphaAnimatedFrame.Release(self) + self._ag:Stop() + self.__super:Release() +end + +--- Sets the range of the alpha animation. +-- @tparam AlphaAnimatedFrame self The alpha animated frame object +-- @tparam number fromAlpha The initial alpha value (usually 1) +-- @tparam number toAlpha The end alpha value (between 0 and 1 inclusive) +-- @treturn AlphaAnimatedFrame The alpha animated frame object +function AlphaAnimatedFrame.SetRange(self, fromAlpha, toAlpha) + self._alpha:SetFromAlpha(fromAlpha) + self._alpha:SetToAlpha(toAlpha) + return self +end + +--- Sets the duration of the animation. +-- @tparam AlphaAnimatedFrame self The alpha animated frame object +-- @tparam number duration The duration in seconds +-- @treturn AlphaAnimatedFrame The alpha animated frame object +function AlphaAnimatedFrame.SetDuration(self, duration) + self._alpha:SetDuration(duration) + return self +end + +--- Sets whether or not the animation is playing. +-- @tparam AlphaAnimatedFrame self The alpha animated frame object +-- @tparam boolean play Whether the animation should be playing or not +-- @treturn AlphaAnimatedFrame The alpha animated frame object +function AlphaAnimatedFrame.SetPlaying(self, play) + if play then + self._ag:Play() + else + self._ag:Stop() + end + return self +end + +--- Gets whether or not the animation is playing. +-- @tparam AlphaAnimatedFrame self The alpha animated frame object +-- @treturn boolean Whether the animation is playing +function AlphaAnimatedFrame.IsPlaying(self) + return self._ag:IsPlaying() +end diff --git a/Core/UI/Elements/ApplicationFrame.lua b/Core/UI/Elements/ApplicationFrame.lua new file mode 100644 index 0000000..718f1c7 --- /dev/null +++ b/Core/UI/Elements/ApplicationFrame.lua @@ -0,0 +1,682 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ApplicationFrame UI Element Class. +-- An application frame is the base frame of all of the TSM UIs. It is a subclass of the @{Frame} class. +-- @classmod ApplicationFrame + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local Math = TSM.Include("Util.Math") +local TempTable = TSM.Include("Util.TempTable") +local Color = TSM.Include("Util.Color") +local NineSlice = TSM.Include("Util.NineSlice") +local Table = TSM.Include("Util.Table") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Theme = TSM.Include("Util.Theme") +local Tooltip = TSM.Include("UI.Tooltip") +local UIElements = TSM.Include("UI.UIElements") +local ApplicationFrame = TSM.Include("LibTSMClass").DefineClass("ApplicationFrame", TSM.UI.Frame) +UIElements.Register(ApplicationFrame) +TSM.UI.ApplicationFrame = ApplicationFrame +local private = { + menuDialogContext = {}, +} +local SECONDS_PER_HOUR = 60 * 60 +local SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +local CONTENT_FRAME_OFFSET = 8 +local DIALOG_RELATIVE_LEVEL = 18 +local HEADER_HEIGHT = 40 +local MIN_SCALE = 0.3 +local DIALOG_OPACITY_PCT = 65 +local MIN_ON_SCREEN_PX = 50 +local function NoOp() end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ApplicationFrame.__init(self) + self.__super:__init() + self._contentFrame = nil + self._contextTable = nil + self._defaultContextTable = nil + self._isScaling = nil + self._protected = nil + self._minWidth = 0 + self._minHeight = 0 + self._dialogStack = {} + + local frame = self:_GetBaseFrame() + local globalFrameName = tostring(frame) + _G[globalFrameName] = frame + -- insert our frames before other addons (i.e. Skillet) to avoid conflicts + tinsert(UISpecialFrames, 1, globalFrameName) + + self._nineSlice = NineSlice.New(frame) + self._nineSlice:SetStyle("outerFrame") + + frame.resizeIcon = frame:CreateTexture(nil, "ARTWORK") + frame.resizeIcon:SetPoint("BOTTOMRIGHT") + TSM.UI.TexturePacks.SetTextureAndSize(frame.resizeIcon, "iconPack.14x14/Resize") + + frame.resizeBtn = CreateFrame("Button", nil, frame) + frame.resizeBtn:SetAllPoints(frame.resizeIcon) + frame.resizeBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + ScriptWrapper.Set(frame.resizeBtn, "OnEnter", private.ResizeButtonOnEnter, self) + ScriptWrapper.Set(frame.resizeBtn, "OnLeave", private.ResizeButtonOnLeave, self) + ScriptWrapper.Set(frame.resizeBtn, "OnMouseDown", private.ResizeButtonOnMouseDown, self) + ScriptWrapper.Set(frame.resizeBtn, "OnMouseUp", private.ResizeButtonOnMouseUp, self) + ScriptWrapper.Set(frame.resizeBtn, "OnClick", private.ResizeButtonOnClick, self) + Theme.RegisterChangeCallback(function() + if self:IsVisible() then + self:Draw() + end + end) +end + +function ApplicationFrame.Acquire(self) + self:AddChildNoLayout(UIElements.New("Frame", "titleFrame") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddAnchor("TOPLEFT", 8, -8) + :AddAnchor("TOPRIGHT", -8, -8) + :SetBackgroundColor("FRAME_BG") + :AddChild(UIElements.New("Texture", "icon") + :SetMargin(0, 16, 0, 0) + :SetTextureAndSize("uiFrames.SmallLogo") + ) + :AddChild(UIElements.New("Text", "title") + :AddAnchor("CENTER") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_BOLD") + :SetTextColor("TEXT_ALT") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "closeBtn") + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.CloseButtonOnClick) + ) + ) + self.__super:Acquire() + local frame = self:_GetBaseFrame() + frame:EnableMouse(true) + frame:SetMovable(true) + frame:SetResizable(true) + frame:RegisterForDrag("LeftButton") + self:SetScript("OnDragStart", private.FrameOnDragStart) + self:SetScript("OnDragStop", private.FrameOnDragStop) +end + +function ApplicationFrame.Release(self) + if self._protected then + tinsert(UISpecialFrames, 1, tostring(self:_GetBaseFrame())) + end + self._contentFrame = nil + self._contextTable = nil + self._defaultContextTable = nil + self:_GetBaseFrame():SetMinResize(0, 0) + self:_GetBaseFrame():SetMaxResize(0, 0) + self._isScaling = nil + self._protected = nil + self._minWidth = 0 + self._minHeight = 0 + self.__super:Release() +end + +--- Adds player gold text to the title frame. +-- @tparam ApplicationFrame self The application frame object +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.AddPlayerGold(self) + local titleFrame = self:GetElement("titleFrame") + titleFrame:AddChildBeforeById(titleFrame:HasChildById("switchBtn") and "switchBtn" or "closeBtn", UIElements.New("PlayerGoldText", "playerGold") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + ) + return self +end + +--- Adds the app status icon to the title frame. +-- @tparam ApplicationFrame self The application frame object +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.AddAppStatusIcon(self) + local color, texture = nil, nil + local appUpdateAge = time() - TSM.GetAppUpdateTime() + local auctionDBRealmTime, auctionDBRegionTime = TSM.AuctionDB.GetAppDataUpdateTimes() + local auctionDBRealmAge = time() - auctionDBRealmTime + local auctionDBRegionAge = time() - auctionDBRegionTime + if appUpdateAge >= 2 * SECONDS_PER_DAY or auctionDBRealmAge > 2 * SECONDS_PER_DAY or auctionDBRegionAge > 2 * SECONDS_PER_DAY then + color = "RED" + texture = "iconPack.14x14/Attention" + elseif appUpdateAge >= 2 * SECONDS_PER_HOUR or auctionDBRealmAge >= 4 * SECONDS_PER_HOUR then + color = "YELLOW" + texture = "iconPack.14x14/Attention" + else + color = "GREEN" + texture = "iconPack.14x14/Checkmark/Circle" + end + local titleFrame = self:GetElement("titleFrame") + titleFrame:AddChildBeforeById("playerGold", UIElements.New("Button", "playerGold") + :SetBackgroundAndSize(TSM.UI.TexturePacks.GetColoredKey(texture, Theme.GetFeedbackColor(color))) + :SetMargin(0, 8, 0, 0) + :SetTooltip(private.GetAppStatusTooltip) + ) + return self +end + +--- Adds a switch button to the title frame. +-- @tparam ApplicationFrame self The application frame object +-- @tparam function onClickHandler The handler for the OnClick script for the button +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.AddSwitchButton(self, onClickHandler) + local titleFrame = self:GetElement("titleFrame") + titleFrame:AddChildBeforeById("closeBtn", UIElements.New("ActionButton", "switchBtn") + :SetSize(95, 20) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["WOW UI"]) + :SetScript("OnClick", onClickHandler) + ) + return self +end + +function ApplicationFrame.SetProtected(self, protected) + self._protected = protected + local globalFrameName = tostring(self:_GetBaseFrame()) + if protected then + Table.RemoveByValue(UISpecialFrames, globalFrameName) + else + if not Table.KeyByValue(UISpecialFrames, globalFrameName) then + -- insert our frames before other addons (i.e. Skillet) to avoid conflicts + tinsert(UISpecialFrames, 1, globalFrameName) + end + end + return self +end + +--- Sets the title text. +-- @tparam ApplicationFrame self The application frame object +-- @tparam string title The title text +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.SetTitle(self, title) + local titleFrame = self:GetElement("titleFrame") + titleFrame:GetElement("title"):SetText(title) + titleFrame:Draw() + return self +end + +--- Sets the content frame. +-- @tparam ApplicationFrame self The application frame object +-- @tparam Frame frame The frame's content frame +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.SetContentFrame(self, frame) + assert(frame:__isa(TSM.UI.Frame)) + frame:WipeAnchors() + frame:AddAnchor("TOPLEFT", CONTENT_FRAME_OFFSET, -HEADER_HEIGHT) + frame:AddAnchor("BOTTOMRIGHT", -CONTENT_FRAME_OFFSET, CONTENT_FRAME_OFFSET) + frame:SetPadding(2) + frame:SetBorderColor("ACTIVE_BG", 2) + self._contentFrame = frame + self:AddChildNoLayout(frame) + return self +end + +--- Sets the context table. +-- This table can be used to preserve position and size information across lifecycles of the application frame and even +-- WoW sessions if it's within the settings DB. +-- @tparam ApplicationFrame self The application frame object +-- @tparam table tbl The context table +-- @tparam table defaultTbl Default values (required attributes: `width`, `height`, `centerX`, `centerY`) +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.SetContextTable(self, tbl, defaultTbl) + assert(defaultTbl.width > 0 and defaultTbl.height > 0) + assert(defaultTbl.centerX and defaultTbl.centerY) + tbl.width = tbl.width or defaultTbl.width + tbl.height = tbl.height or defaultTbl.height + tbl.centerX = tbl.centerX or defaultTbl.centerX + tbl.centerY = tbl.centerY or defaultTbl.centerY + tbl.scale = tbl.scale or defaultTbl.scale + self._contextTable = tbl + self._defaultContextTable = defaultTbl + return self +end + +--- Sets the context table from a settings object. +-- @tparam ApplicationFrame self The application frame object +-- @tparam Settings settings The settings object +-- @tparam string key The setting key +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.SetSettingsContext(self, settings, key) + return self:SetContextTable(settings[key], settings:GetDefaultReadOnly(key)) +end + +--- Sets the minimum size the application frame can be resized to. +-- @tparam ApplicationFrame self The application frame object +-- @tparam number minWidth The minimum width +-- @tparam number minHeight The minimum height +-- @treturn ApplicationFrame The application frame object +function ApplicationFrame.SetMinResize(self, minWidth, minHeight) + self._minWidth = minWidth + self._minHeight = minHeight + return self +end + +--- Shows a dialog frame. +-- @tparam ApplicationFrame self The application frame object +-- @tparam Element frame The element to show in a dialog +-- @param context The context to set on the dialog frame +function ApplicationFrame.ShowDialogFrame(self, frame, context) + local dialogFrame = UIElements.New("Frame", "_dialog_"..random()) + :SetRelativeLevel(DIALOG_RELATIVE_LEVEL * (#self._dialogStack + 1)) + :SetBackgroundColor(Color.GetFullBlack():GetOpacity(DIALOG_OPACITY_PCT)) + :AddAnchor("TOPLEFT") + :AddAnchor("BOTTOMRIGHT") + :SetMouseEnabled(true) + :SetMouseWheelEnabled(true) + :SetContext(context) + :SetScript("OnMouseWheel", NoOp) + :SetScript("OnMouseUp", private.DialogOnMouseUp) + :SetScript("OnHide", private.DialogOnHide) + :AddChildNoLayout(frame) + tinsert(self._dialogStack, dialogFrame) + self._contentFrame:AddChildNoLayout(dialogFrame) + dialogFrame:Show() + dialogFrame:Draw() +end + +--- Show a confirmation dialog. +-- @tparam ApplicationFrame self The application frame object +-- @tparam string title The title of the dialog +-- @tparam string subTitle The sub-title of the dialog +-- @tparam function callback The callback for when the dialog is closed +-- @tparam[opt] varag ... Arguments to pass to the callback +function ApplicationFrame.ShowConfirmationDialog(self, title, subTitle, callback, ...) + local context = TempTable.Acquire(...) + context.callback = callback + local frame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(328, 158) + :SetPadding(12, 12, 8, 12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Text", "title") + :SetHeight(20) + :SetMargin(32, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(title) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.DialogCancelBtnOnClick) + ) + ) + :AddChild(UIElements.New("Text", "desc") + :SetMargin(0, 0, 16, 16) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetJustifyV("TOP") + :SetText(subTitle) + ) + :AddChild(UIElements.New("ActionButton", "confirmBtn") + :SetHeight(24) + :SetText(L["Confirm"]) + :SetScript("OnClick", private.DialogConfirmBtnOnClick) + ) + self:ShowDialogFrame(frame, context) +end + +--- Show a dialog triggered by a "more" button. +-- @tparam ApplicationFrame self The application frame object +-- @tparam Button moreBtn The "more" button +-- @tparam function iter A dialog menu row iterator with the following fields: `index, text, callback` +function ApplicationFrame.ShowMoreButtonDialog(self, moreBtn, iter) + local frame = UIElements.New("PopupFrame", "moreDialog") + :SetLayout("VERTICAL") + :SetWidth(200) + :SetPadding(0, 0, 8, 4) + :AddAnchor("TOPRIGHT", moreBtn:_GetBaseFrame(), "BOTTOM", 22, -16) + local numRows = 0 + for i, text, callback in iter do + frame:AddChild(UIElements.New("Button", "row"..i) + :SetHeight(20) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(text) + :SetScript("OnClick", callback) + ) + numRows = numRows + 1 + end + frame:SetHeight(12 + numRows * 20) + self:ShowDialogFrame(frame) +end + +--- Show a menu dialog. +-- @tparam ApplicationFrame self The application frame object +-- @tparam string frame The frame to anchor the dialog to +-- @tparam function iter A menu row iterator with the following fields: `index, text, subIter` +-- @param context Context to pass to the iter / subIter +-- @tparam function clickCallback The function to be called when a menu row is clicked +-- @tparam boolean flip Flip the anchor to the other side +function ApplicationFrame.ShowMenuDialog(self, frame, iter, context, clickCallback, flip) + wipe(private.menuDialogContext) + private.menuDialogContext.context = context + private.menuDialogContext.clickCallback = clickCallback + self:ShowDialogFrame(private.CreateMenuDialogFrame("_menuDialog", iter) + :AddAnchor(flip and "TOPRIGHT" or "TOPLEFT", frame, flip and "BOTTOMRIGHT" or "BOTTOMLEFT", 2, -4) + ) +end + +--- Hides the current dialog. +-- @tparam ApplicationFrame self The application frame object +function ApplicationFrame.HideDialog(self) + local dialogFrame = tremove(self._dialogStack) + if not dialogFrame then + return + end + dialogFrame:GetParentElement():RemoveChild(dialogFrame) + dialogFrame:Hide() + dialogFrame:Release() +end + +function ApplicationFrame.Draw(self) + local frame = self:_GetBaseFrame() + frame:SetToplevel(true) + frame:Raise() + self._nineSlice:SetVertexColor(Theme.GetColor("FRAME_BG"):GetFractionalRGBA()) + + -- update the size if it's less than the set min size + assert(self._minWidth > 0 and self._minHeight > 0) + self._contextTable.width = max(self._contextTable.width, self._minWidth) + self._contextTable.height = max(self._contextTable.height, self._minHeight) + self._contextTable.scale = max(self._contextTable.scale, MIN_SCALE) + + -- set the frame size from the contextTable + self:SetScale(self._contextTable.scale) + self:SetSize(self._contextTable.width, self._contextTable.height) + + -- make sure at least 50px of the frame is on the screen and offset by at least 1 scaled pixel to fix some rendering issues + local maxAbsCenterX = (UIParent:GetWidth() / self._contextTable.scale + self._contextTable.width) / 2 - MIN_ON_SCREEN_PX + local maxAbsCenterY = (UIParent:GetHeight() / self._contextTable.scale + self._contextTable.height) / 2 - MIN_ON_SCREEN_PX + local effectiveScale = UIParent:GetEffectiveScale() + if self._contextTable.centerX < 0 then + self._contextTable.centerX = min(max(self._contextTable.centerX, -maxAbsCenterX), -effectiveScale) + else + self._contextTable.centerX = max(min(self._contextTable.centerX, maxAbsCenterX), effectiveScale) + end + if self._contextTable.centerY < 0 then + self._contextTable.centerY = min(max(self._contextTable.centerY, -maxAbsCenterY), -effectiveScale) + else + self._contextTable.centerY = max(min(self._contextTable.centerY, maxAbsCenterY), effectiveScale) + end + + -- adjust the position of the frame based on the UI scale to make rendering more consistent + self._contextTable.centerX = Math.Round(self._contextTable.centerX, effectiveScale) + self._contextTable.centerY = Math.Round(self._contextTable.centerY, effectiveScale) + + -- set the frame position from the contextTable + self:WipeAnchors() + self:AddAnchor("CENTER", self._contextTable.centerX, self._contextTable.centerY) + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ApplicationFrame._SavePositionAndSize(self, wasScaling) + local frame = self:_GetBaseFrame() + local parentFrame = frame:GetParent() + local width = frame:GetWidth() + local height = frame:GetHeight() + if wasScaling then + -- the anchor is in our old frame's scale, so convert the parent measurements to our old scale and then the resuslt to our new scale + local scaleAdjustment = width / self._contextTable.width + local frameLeftOffset = frame:GetLeft() - parentFrame:GetLeft() / self._contextTable.scale + self._contextTable.centerX = (frameLeftOffset - (parentFrame:GetWidth() / self._contextTable.scale - width) / 2) / scaleAdjustment + local frameBottomOffset = frame:GetBottom() - parentFrame:GetBottom() / self._contextTable.scale + self._contextTable.centerY = (frameBottomOffset - (parentFrame:GetHeight() / self._contextTable.scale - height) / 2) / scaleAdjustment + self._contextTable.scale = self._contextTable.scale * scaleAdjustment + else + self._contextTable.width = width + self._contextTable.height = height + -- the anchor is in our frame's scale, so convert the parent measurements to our scale + local frameLeftOffset = frame:GetLeft() - parentFrame:GetLeft() / self._contextTable.scale + self._contextTable.centerX = (frameLeftOffset - (parentFrame:GetWidth() / self._contextTable.scale - width) / 2) + local frameBottomOffset = frame:GetBottom() - parentFrame:GetBottom() / self._contextTable.scale + self._contextTable.centerY = (frameBottomOffset - (parentFrame:GetHeight() / self._contextTable.scale - height) / 2) + end +end + +function ApplicationFrame._SetResizing(self, resizing) + if resizing then + self:GetElement("titleFrame"):Hide() + self._contentFrame:_GetBaseFrame():SetAlpha(0) + self._contentFrame:_GetBaseFrame():SetFrameStrata("LOW") + self._contentFrame:Draw() + else + self:GetElement("titleFrame"):Show() + self._contentFrame:_GetBaseFrame():SetAlpha(1) + self._contentFrame:_GetBaseFrame():SetFrameStrata(self._strata) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.CloseButtonOnClick(button) + button:GetElement("__parent.__parent"):Hide() +end + +function private.ResizeButtonOnEnter(self) + local tooltip = L["Click and drag to resize this window."].."\n".. + L["Hold SHIFT while dragging to scale the window instead."].."\n".. + L["Right-Click to reset the window size, scale, and position to their defaults."] + Tooltip.Show(self:_GetBaseFrame().resizeBtn, tooltip, true) +end + +function private.ResizeButtonOnLeave(self) + Tooltip.Hide() +end + +function private.ResizeButtonOnMouseDown(self, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + self._isScaling = IsShiftKeyDown() + local frame = self:_GetBaseFrame() + local width = frame:GetWidth() + local height = frame:GetHeight() + if self._isScaling then + local minWidth = width * MIN_SCALE / self._contextTable.scale + local minHeight = height * MIN_SCALE / self._contextTable.scale + frame:SetMinResize(minWidth, minHeight) + frame:SetMaxResize(width * 10, height * 10) + else + frame:SetMinResize(self._minWidth, self._minHeight) + frame:SetMaxResize(width * 10, height * 10) + end + self:_SetResizing(true) + frame:StartSizing("BOTTOMRIGHT") + -- force updating the size here, to prevent using cached values from previously opened application frames + frame:SetWidth(width) + frame:SetHeight(height) +end + +function private.ResizeButtonOnMouseUp(self, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + self:_GetBaseFrame():StopMovingOrSizing() + self:_SetResizing(false) + self:_SavePositionAndSize(self._isScaling) + self._isScaling = nil + self:Draw() +end + +function private.ResizeButtonOnClick(self, mouseButton) + if mouseButton ~= "RightButton" then + return + end + self._contextTable.scale = self._defaultContextTable.scale + self._contextTable.width = self._defaultContextTable.width + self._contextTable.height = self._defaultContextTable.height + self._contextTable.centerX = self._defaultContextTable.centerX + self._contextTable.centerY = self._defaultContextTable.centerY + self:Draw() +end + +function private.FrameOnDragStart(self) + self:_GetBaseFrame():StartMoving() +end + +function private.FrameOnDragStop(self) + self:_GetBaseFrame():StopMovingOrSizing() + self:_SavePositionAndSize() + self:Draw() +end + +function private.DialogOnMouseUp(dialog) + local self = dialog:GetParentElement():GetParentElement() + self:HideDialog() +end + +function private.DialogOnHide(dialog) + local context = dialog:GetContext() + if context then + TempTable.Release(context) + end +end + +function private.DialogCancelBtnOnClick(button) + local self = button:GetBaseElement() + self:HideDialog() +end + +function private.DialogConfirmBtnOnClick(button) + local self = button:GetBaseElement() + local dialogFrame = button:GetParentElement():GetParentElement() + local context = dialogFrame:GetContext() + dialogFrame:SetContext(nil) + self:HideDialog() + context.callback(TempTable.UnpackAndRelease(context)) +end + +function private.CreateMenuDialogFrame(id, iter) + local frame = UIElements.New("Frame", id) + :SetLayout("VERTICAL") + :SetWidth(180) + :SetPadding(2) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetBorderColor("ACTIVE_BG_ALT") + local numRows = 0 + for i, text, subIter in iter, private.menuDialogContext.context do + frame:AddChild(UIElements.New("Frame", "row"..i) + :SetLayout("HORIZONTAL") + :SetHeight(21) + :SetContext(subIter) + :AddChild(UIElements.New("Button", "btn") + :SetHeight(21) + :SetPadding(8, 0, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetBackground("PRIMARY_BG_ALT") + :SetHighlightEnabled(true) + :SetJustifyH("LEFT") + :SetIcon(subIter and "iconPack.12x12/Chevron/Right" or nil, subIter and "RIGHT" or nil) + :SetText(text) + :SetContext(i) + :SetScript("OnEnter", subIter and private.MenuDialogRowSubIterOnEnter or private.MenuDialogRowDefaultOnEnter) + :SetScript("OnClick", not subIter and private.MenuDialogRowOnClick or nil) + ) + ) + numRows = numRows + 1 + end + frame:SetHeight(4 + numRows * 21) + return frame +end + +function private.MenuDialogRowOnClick(button) + local path = TempTable.Acquire() + tinsert(path, button:GetContext()) + local parentFrame = button:GetParentElement():GetParentElement():GetParentElement() + local self = parentFrame:GetBaseElement() + while parentFrame:GetParentElement() ~= self._contentFrame do + local selectedButton = parentFrame:GetContext():GetElement("btn") + tinsert(path, 1, selectedButton:GetContext()) + parentFrame = parentFrame:GetParentElement() + end + private.menuDialogContext.clickCallback(button, private.menuDialogContext.context, TempTable.UnpackAndRelease(path)) +end + +function private.MenuDialogRowDefaultOnEnter(button) + local frame = button:GetParentElement():GetParentElement() + if frame:HasChildById("subFrame") then + local subFrame = frame:GetElement("subFrame") + local prevRow = frame:GetContext() + frame:SetContext(nil) + if prevRow then + prevRow:GetElement("btn"):SetHighlightLocked(false) + end + frame:RemoveChild(subFrame) + subFrame:Release() + frame:Draw() + end +end + +function private.MenuDialogRowSubIterOnEnter(button) + private.MenuDialogRowDefaultOnEnter(button) + button:SetHighlightLocked(true) + local row = button:GetParentElement() + local frame = row:GetParentElement() + frame:SetContext(row) + local subFrame = private.CreateMenuDialogFrame("subFrame", row:GetContext()) + :AddAnchor("TOPLEFT", button:_GetBaseFrame(), "TOPRIGHT", 4, 2) + frame:AddChildNoLayout(subFrame) + subFrame:Draw() +end + +function private.GetAppStatusTooltip() + local tooltipLines = TempTable.Acquire() + tinsert(tooltipLines, format(L["TSM Desktop App Status (%s)"], TSM.GetRegion().."-"..GetRealmName())) + + local appUpdateAge = time() - TSM.GetAppUpdateTime() + if appUpdateAge < 2 * SECONDS_PER_HOUR then + tinsert(tooltipLines, Theme.GetFeedbackColor("GREEN"):ColorText(format(L["App Synced %s Ago"], SecondsToTime(appUpdateAge)))) + elseif appUpdateAge < 2 * SECONDS_PER_DAY then + tinsert(tooltipLines, Theme.GetFeedbackColor("YELLOW"):ColorText(format(L["App Synced %s Ago"], SecondsToTime(appUpdateAge)))) + else + tinsert(tooltipLines, Theme.GetFeedbackColor("RED"):ColorText(L["App Not Synced"])) + end + + local auctionDBRealmTime, auctionDBRegionTime = TSM.AuctionDB.GetAppDataUpdateTimes() + local auctionDBRealmAge = time() - auctionDBRealmTime + local auctionDBRegionAge = time() - auctionDBRegionTime + if auctionDBRealmAge < 4 * SECONDS_PER_HOUR then + tinsert(tooltipLines, Theme.GetFeedbackColor("GREEN"):ColorText(format(L["AuctionDB Realm Data is %s Old"], SecondsToTime(auctionDBRealmAge)))) + elseif auctionDBRealmAge < 2 * SECONDS_PER_DAY then + tinsert(tooltipLines, Theme.GetFeedbackColor("YELLOW"):ColorText(format(L["AuctionDB Realm Data is %s Old"], SecondsToTime(auctionDBRealmAge)))) + else + tinsert(tooltipLines, Theme.GetFeedbackColor("RED"):ColorText(L["No AuctionDB Realm Data"])) + end + if auctionDBRegionAge < 2 * SECONDS_PER_DAY then + tinsert(tooltipLines, Theme.GetFeedbackColor("GREEN"):ColorText(format(L["AuctionDB Region Data is %s Old"], SecondsToTime(auctionDBRegionAge)))) + else + tinsert(tooltipLines, Theme.GetFeedbackColor("RED"):ColorText(L["No AuctionDB Region Data"])) + end + + return strjoin("\n", TempTable.UnpackAndRelease(tooltipLines)), true, 16 +end diff --git a/Core/UI/Elements/ApplicationGroupTree.lua b/Core/UI/Elements/ApplicationGroupTree.lua new file mode 100644 index 0000000..39e7662 --- /dev/null +++ b/Core/UI/Elements/ApplicationGroupTree.lua @@ -0,0 +1,189 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ApplicationGroupTree UI Element Class. +-- An application group tree displays the group tree in a way which allows the user to select any number of them. This +-- element is used wherever the user needs to select groups to perform some action on. It is a subclass of the +-- @{GroupTree} class. +-- @classmod ApplicationGroupTree + +local _, TSM = ... +local TempTable = TSM.Include("Util.TempTable") +local UIElements = TSM.Include("UI.UIElements") +local ApplicationGroupTree = TSM.Include("LibTSMClass").DefineClass("ApplicationGroupTree", TSM.UI.GroupTree) +UIElements.Register(ApplicationGroupTree) +TSM.UI.ApplicationGroupTree = ApplicationGroupTree + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ApplicationGroupTree.__init(self) + self.__super:__init() + self._selectedGroupsChangedHandler = nil +end + +function ApplicationGroupTree.Release(self) + self._selectedGroupsChangedHandler = nil + self.__super:Release() +end + +--- Registers a script handler. +-- @tparam ApplicationGroupTree self The application group tree object +-- @tparam string script The script to register for (supported scripts: `OnGroupSelectionChanged`) +-- @tparam function handler The script handler which will be called with the application group tree object followed by +-- any arguments to the script +-- @treturn ApplicationGroupTree The application group tree object +function ApplicationGroupTree.SetScript(self, script, handler) + if script == "OnGroupSelectionChanged" then + self._selectedGroupsChangedHandler = handler + else + error("Unknown ApplicationGroupTree script: "..tostring(script)) + end + return self +end + +--- Iterates through the selected groups. +-- @tparam ApplicationGroupTree self The application group tree object +-- @return Iterator with the following fields: `index, groupPath` +function ApplicationGroupTree.SelectedGroupsIterator(self) + local groups = TempTable.Acquire() + for _, groupPath in ipairs(self._allData) do + if self:_IsSelected(groupPath) then + tinsert(groups, groupPath) + end + end + return TempTable.Iterator(groups) +end + +--- Sets the context table. +-- This table can be used to preserve selection state across lifecycles of the application group tree and even WoW +-- sessions if it's within the settings DB. +-- @see GroupTree.SetContextTable +-- @tparam ApplicationGroupTree self The application group tree object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `unselected` OR `selected`, `collapsed`) +-- @treturn ApplicationGroupTree The application group tree object +function ApplicationGroupTree.SetContextTable(self, tbl, defaultTbl) + if defaultTbl.unselected then + assert(type(defaultTbl.unselected) == "table" and not defaultTbl.selected) + tbl.unselected = tbl.unselected or CopyTable(defaultTbl.unselected) + tbl.selected = nil + else + assert(type(defaultTbl.selected) == "table" and not defaultTbl.unselected) + tbl.selected = tbl.selected or CopyTable(defaultTbl.selected) + tbl.unselected = nil + end + self.__super:SetContextTable(tbl, defaultTbl) + return self +end + +--- Gets whether or not a group is currently selected. +-- @tparam ApplicationGroupTree self The application group tree object +-- @tparam string groupPath The group to check +-- @treturn boolean Whether or not the group is selected +function ApplicationGroupTree.IsGroupSelected(self, groupPath) + return self:_IsSelected(groupPath) +end + +--- Gets whether or not a group is currently selected. +-- @tparam ApplicationGroupTree self The application group tree object +-- @tparam string groupPath The group to set the selected state of +-- @tparam boolean selected Whether or not the group should be selected +-- @treturn ApplicationGroupTree The application group tree object +function ApplicationGroupTree.SetGroupSelected(self, groupPath, selected) + self:_SetSelected(groupPath, selected) + return self +end + +--- Gets whether or not the selection is cleared. +-- @tparam ApplicationGroupTree self The application group tree object +-- @tparam[opt=false] boolean updateData Whether or not to update the data first +-- @treturn boolean Whether or not the selection is cleared +function ApplicationGroupTree.IsSelectionCleared(self, updateData) + if updateData then + self:_UpdateData() + end + for _, groupPath in ipairs(self._searchStr == "" and self._allData or self._data) do + if self:_IsSelected(groupPath) then + return false + end + end + return true +end + +--- Toggle the selection state of the application group tree. +-- @tparam ApplicationGroupTree self The application group tree object +-- @treturn ApplicationGroupTree The application group tree object +function ApplicationGroupTree.ToggleSelectAll(self) + local isCleared = self:IsSelectionCleared() + for _, groupPath in ipairs(self._searchStr == "" and self._allData or self._data) do + self:_SetSelected(groupPath, isCleared) + end + self:Draw() + if self._selectedGroupsChangedHandler then + self:_selectedGroupsChangedHandler() + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ApplicationGroupTree._UpdateData(self) + self.__super:_UpdateData() + -- remove data which is no longer present from _contextTable + local selectedGroups = TempTable.Acquire() + for _, groupPath in ipairs(self._allData) do + if self:_IsSelected(groupPath) then + selectedGroups[groupPath] = true + end + end + wipe(self._contextTable.selected or self._contextTable.unselected) + for _, groupPath in ipairs(self._allData) do + self:_SetSelected(groupPath, selectedGroups[groupPath]) + end + TempTable.Release(selectedGroups) +end + +function ApplicationGroupTree._IsSelected(self, data) + if self._contextTable.unselected then + return not self._contextTable.unselected[data] + else + return self._contextTable.selected[data] + end +end + +function ApplicationGroupTree._SetSelected(self, data, selected) + if self._contextTable.unselected then + self._contextTable.unselected[data] = not selected or nil + else + self._contextTable.selected[data] = selected or nil + end +end + +function ApplicationGroupTree._HandleRowClick(self, data, mouseButton) + if mouseButton == "RightButton" then + self.__super:_HandleRowClick(data, mouseButton) + return + end + self:_SetSelected(data, not self:_IsSelected(data)) + -- also set the selection for all child groups to the same as this group + for _, groupPath in ipairs(self._allData) do + if TSM.Groups.Path.IsChild(groupPath, data) and data ~= TSM.CONST.ROOT_GROUP_PATH then + self:_SetSelected(groupPath, self:_IsSelected(data)) + end + end + self:Draw() + if self._selectedGroupsChangedHandler then + self:_selectedGroupsChangedHandler() + end +end diff --git a/Core/UI/Elements/AuctionScrollingTable.lua b/Core/UI/Elements/AuctionScrollingTable.lua new file mode 100644 index 0000000..07836ac --- /dev/null +++ b/Core/UI/Elements/AuctionScrollingTable.lua @@ -0,0 +1,1005 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- AuctionScrollingTable UI Element Class. +-- An auction scrolling table displays a scrollable list of auctions with a fixed set of columns. It operations on +-- auction records returned by the scanning code. It is a subclass of the @{ScrollingTable} class. +-- @classmod AuctionScrollingTable + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local Math = TSM.Include("Util.Math") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ItemInfo = TSM.Include("Service.ItemInfo") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local AuctionScrollingTable = TSM.Include("LibTSMClass").DefineClass("AuctionScrollingTable", TSM.UI.ScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(AuctionScrollingTable) +TSM.UI.AuctionScrollingTable = AuctionScrollingTable +local private = { + sortContext = { + rowSortValue = {}, + baseSortValue = {}, + sortValue = {}, + self = nil, + }, + subRowsTemp = {}, +} +local ICON_SIZE = 12 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function AuctionScrollingTable.__init(self) + self.__super:__init() + self._auctionScan = nil + self._marketValueFunc = nil + self._sortCol = nil + self._sortAscending = nil + self._expanded = {} + self._firstSubRowByItem = {} + self._rowByItem = {} + self._browseResultsVisible = false + self._firstUnscannedItem = nil + self._selectionBaseItemString = nil + self._selectionBaseSortValue = nil + self._selectionSubRowIndex = nil + self._currentSearchItem = nil + self._onResultsUpdated = function() + self:UpdateData(true) + end + self._getNextSearchItem = function() + if self._selectionBaseItemString and not self._firstSubRowByItem[self._selectionBaseItemString] then + -- the selected row has priority + return self._selectionBaseItemString + end + return self._firstUnscannedItem + end + self._currentSearchChanged = function(_, baseItemString) + if not baseItemString then + -- the search was paused or unpaused, so just update the current item + baseItemString = self._currentSearchItem + end + self._currentSearchItem = baseItemString + -- layout the new row to update it's action icon state + for _, row in ipairs(self._rows) do + local data = row:GetData() + if data and not data:IsSubRow() and data:GetBaseItemString() == baseItemString then + row:_LayoutDataRow() + end + end + end +end + +function AuctionScrollingTable.Acquire(self) + self.__super:Acquire() + -- temporarily set a context table so we can create the table columns (should be overridden later) + self:GetScrollingTableInfo() + :NewColumn("item") + :SetHeaderIndent("8") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(ICON_SIZE) + :SetTextFunction(private.GetItemCellText) + :SetIconFunction(private.GetItemCellIcon) + :SetTooltipFunction(private.GetItemCellTooltip) + :SetExpanderStateFunction(private.GetExpanderState) + :SetBadgeStateFunction(private.GetBadgeState) + :SetActionIconInfo(1, 12, private.GetPendingIcon) + :SetActionIconClickHandler(private.OnPendingIconClick) + :DisableHiding() + :Commit() + :NewColumn("ilvl") + :SetTitle(L["ilvl"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetItemLevelCellText) + :Commit() + if not TSM.IsWowClassic() then + self:GetScrollingTableInfo() + :NewColumn("qty") + :SetTitle(L["Qty"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetAuctionsQuantityText) + :Commit() + else + self:GetScrollingTableInfo() + :NewColumn("posts") + :SetTitle(L["Posts"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetAuctionsPostsText) + :Commit() + :NewColumn("stack") + :SetTitle(L["Stack"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetAuctionsStackText) + :Commit() + end + self:GetScrollingTableInfo() + :NewColumn("timeLeft") + :SetTitleIcon("iconPack.14x14/Clock") + :SetFont("TABLE_TABLE1") + :SetJustifyH("CENTER") + :SetTextFunction(private.GetTimeLeftCellText) + :Commit() + :NewColumn("seller") + :SetTitle(L["Seller"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetSellerCellText) + :Commit() + :NewColumn("itemBid") + :SetTitle(L["Bid (item)"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetItemBidCellText) + :Commit() + :NewColumn("bid") + :SetTitle(TSM.IsWowClassic() and L["Bid (stack)"] or L["Bid (total)"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetBidCellText) + :Commit() + :NewColumn("itemBuyout") + :SetTitle(L["Buyout (item)"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetItemBuyoutCellText) + :Commit() + :NewColumn("buyout") + :SetTitle(TSM.IsWowClassic() and L["Buyout (stack)"] or L["Buyout (total)"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetBuyoutCellText) + :Commit() + :NewColumn("bidPct") + :SetTitle(BID.." %") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetBidPercentCellText) + :Commit() + :NewColumn("pct") + :SetTitle("%") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetPercentCellText) + :Commit() + :Commit() + self._sortCol = "pct" + self._sortAscending = true +end + +function AuctionScrollingTable.Release(self) + if self._auctionScan then + self._auctionScan:RemoveResultsUpdateCallback(self._onResultsUpdated) + self._auctionScan:SetNextSearchItemFunction(nil) + self._auctionScan:SetScript("OnCurrentSearchChanged", nil) + end + self._auctionScan = nil + self._sortCol = nil + self._sortAscending = nil + self._marketValueFunc = nil + self._browseResultsVisible = false + self._firstUnscannedItem = nil + wipe(self._expanded) + wipe(self._firstSubRowByItem) + wipe(self._rowByItem) + for _, row in ipairs(self._rows) do + ScriptWrapper.Clear(row._frame, "OnDoubleClick") + for _, tooltipFrame in pairs(row._buttons) do + ScriptWrapper.Clear(tooltipFrame, "OnDoubleClick") + end + end + self._selectionBaseItemString = nil + self._selectionBaseSortValue = nil + self._selectionSubRowIndex = nil + self._currentSearchItem = nil + self.__super:Release() +end + +--- Sets the @{DatabaseQuery} source for this table. +-- This query is used to populate the entries in the auction scrolling table. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @tparam AuctionScanManager auctionScan The auction scan object +-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table +-- @treturn AuctionScrollingTable The auction scrolling table object +function AuctionScrollingTable.SetAuctionScan(self, auctionScan, redraw) + if auctionScan == self._auctionScan and not redraw then + return self + end + if self._auctionScan then + self._auctionScan:RemoveResultsUpdateCallback(self._onResultsUpdated) + self._auctionScan:SetNextSearchItemFunction(nil) + self._auctionScan:SetScript("OnCurrentSearchChanged", nil) + end + self._auctionScan = auctionScan + self._auctionScan:AddResultsUpdateCallback(self._onResultsUpdated) + self._auctionScan:SetNextSearchItemFunction(self._getNextSearchItem) + self._auctionScan:SetScript("OnCurrentSearchChanged", self._currentSearchChanged) + wipe(self._expanded) + self:UpdateData(redraw) + return self +end + +--- Sets whether or not browse results are visible. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @tparam boolean visible Whether or not browse results should be visible +-- @treturn AuctionScrollingTable The auction scrolling table object +function AuctionScrollingTable.SetBrowseResultsVisible(self, visible) + self._browseResultsVisible = visible + return self +end + +--- Sets the market value function. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @tparam function func The function to call with the item DB record to get the market value +-- @treturn AuctionScrollingTable The auction scrolling table object +function AuctionScrollingTable.SetMarketValueFunction(self, func) + if self._marketValueFunc ~= func then + self._marketValueFunc = func + self:UpdateData(false) + end + return self +end + +--- Gets the selected result row. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @return The selected result row or nil if there's no selection +function AuctionScrollingTable.GetSelection(self) + local selection = self.__super:GetSelection() + if not selection then + return nil + end + local baseItemString = selection:GetBaseItemString() + if not selection:IsSubRow() then + selection = self._firstSubRowByItem[baseItemString] or selection + end + return selection +end + +--- Sets the selected result row. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @param selection The selected result row or nil to clear the selection +-- @treturn AuctionScrollingTable The auction scrolling table object +function AuctionScrollingTable.SetSelection(self, selection) + if not selection then + self._selectionBaseItemString = nil + self._selectionBaseSortValue = nil + self._selectionSubRowIndex = nil + return self.__super:SetSelection(selection) + end + local baseItemString = selection:GetBaseItemString() + if selection == self._firstSubRowByItem[baseItemString] then + selection = self._rowByItem[baseItemString] + assert(selection) + end + self._selectionBaseItemString = baseItemString + self._selectionBaseSortValue = self:_GetSortValue(selection, self._sortCol, self._sortAscending) + local firstIndex = nil + for i, data in ipairs(self._data) do + if not firstIndex and data:GetBaseItemString() == baseItemString then + firstIndex = i + end + if data == selection then + self._selectionSubRowIndex = i - firstIndex + 1 + break + end + end + assert(self._selectionSubRowIndex) + return self.__super:SetSelection(selection) +end + +--- Expands a single auction result. +-- If there is a single top-level auction result, this will cause it to be expanded. Otherwise, this does nothing. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @treturn AuctionScrollingTable The auction scrolling table object +function AuctionScrollingTable.ExpandSingleResult(self) + -- if only one result, expand it + local singleResult = nil + for baseItemString in pairs(self._firstSubRowByItem) do + if not singleResult then + singleResult = baseItemString + elseif singleResult then + singleResult = nil + break + end + end + if singleResult then + self._expanded[singleResult] = true + self:UpdateData(true) + end +end + +--- Sets the % column header tooltip. +-- @tparam AuctionScrollingTable self The auction scrolling table object +-- @param tooltip The tooltip +-- @treturn AuctionScrollingTable The auction scrolling table object +function AuctionScrollingTable.SetPctTooltip(self, tooltip) + self._tableInfo:GetColById("pct"):SetHeaderTooltip(tooltip) + return self +end + +function AuctionScrollingTable.Draw(self) + self.__super:Draw() + self._header:SetSort(self._sortCol, self._sortAscending) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function AuctionScrollingTable._UpdateData(self) + if not self._auctionScan then + return + end + local sortKey = self._sortCol + wipe(self._data) + wipe(self._firstSubRowByItem) + wipe(self._rowByItem) + self._firstUnscannedItem = nil + private.sortContext.self = self + + local rows = TempTable.Acquire() + local subRows = TempTable.Acquire() + local subRowsStart = TempTable.Acquire() + local subRowsEnd = TempTable.Acquire() + local sortAscending = self._sortAscending + for _, query in self._auctionScan:QueryIterator() do + for baseItemString, row in query:BrowseResultsIterator() do + if not self._rowByItem[baseItemString] and row:HasItemInfo() then + assert(not next(private.subRowsTemp)) + local hasSubRows = false + for _, subRow in row:SubRowIterator() do + hasSubRows = true + local sortValue = self:_GetSortValue(subRow, sortKey, sortAscending) + private.sortContext.sortValue[subRow] = sortValue + tinsert(private.subRowsTemp, subRow) + end + if hasSubRows then + -- sort all the subRows + sort(private.subRowsTemp, sortAscending and private.SortSubRowAscendingHelper or private.SortSubRowDescendingHelper) + -- grab the first subRow which is shown when this item is collapsed + assert(not self._firstSubRowByItem[baseItemString]) + local firstSubRow = private.subRowsTemp[1] + self._firstSubRowByItem[baseItemString] = firstSubRow + private.sortContext.baseSortValue[baseItemString] = private.sortContext.sortValue[firstSubRow] + private.sortContext.rowSortValue[row] = private.sortContext.baseSortValue[baseItemString] + -- add all the subRows if this item is expanded + if self._expanded[baseItemString] and #private.subRowsTemp > 1 then + subRowsStart[baseItemString] = #subRows + 1 + for i = 2, #private.subRowsTemp do + tinsert(subRows, private.subRowsTemp[i]) + end + subRowsEnd[baseItemString] = #subRows + end + elseif self._browseResultsVisible then + private.sortContext.baseSortValue[baseItemString] = self:_GetSortValue(row, sortKey, sortAscending) + private.sortContext.rowSortValue[row] = private.sortContext.baseSortValue[baseItemString] + end + + -- insert this row + if hasSubRows or self._browseResultsVisible then + self._rowByItem[baseItemString] = row + tinsert(rows, row) + else + self._rowByItem[baseItemString] = private.subRowsTemp[1] + end + wipe(private.subRowsTemp) + end + end + end + + -- sort the rows + sort(rows, sortAscending and private.SortRowAscendingHelper or private.SortRowDescendingHelper) + + -- insert all the data + local hasPrevSelection, nextIndexSelection = false, nil + for _, row in ipairs(rows) do + local baseItemString = row:GetBaseItemString() + if not self._firstSubRowByItem[baseItemString] and not self._firstUnscannedItem then + self._firstUnscannedItem = baseItemString + end + tinsert(self._data, row) + hasPrevSelection = hasPrevSelection or self._selection == row + local startIndex = subRowsStart[baseItemString] + if startIndex then + for i = startIndex, subRowsEnd[baseItemString] do + local subRow = subRows[i] + tinsert(self._data, subRow) + hasPrevSelection = hasPrevSelection or self._selection == subRow + end + if not hasPrevSelection and not nextIndexSelection then + if self._selectionBaseItemString == baseItemString and self._selectionSubRowIndex then + -- check if we can find the new selection based on the sub row index (subtract 2 because subRows start from index 2) + local index = startIndex + self._selectionSubRowIndex - 2 + if index <= subRowsEnd[baseItemString] then + nextIndexSelection = subRows[index] + end + end + end + end + end + TempTable.Release(rows) + TempTable.Release(subRows) + TempTable.Release(subRowsStart) + TempTable.Release(subRowsEnd) + + if self._selection and not hasPrevSelection then + -- the previous selection doesn't exist anymore + local firstSubRow = self._firstSubRowByItem[self._selectionBaseItemString] + if nextIndexSelection then + -- we can select the next subRow for the same item + self:SetSelection(nextIndexSelection) + elseif firstSubRow then + -- select the first row of the same item + self:SetSelection(firstSubRow) + elseif self._selectionBaseSortValue then + -- select the next row by sort value (if it exists) + local bestSortValue, bestNewSelection = nil, nil + for _, row in ipairs(self._data) do + local baseItemString = row:GetBaseItemString() + local sortValue = private.sortContext.baseSortValue[baseItemString] + if sortValue and (row == self._firstSubRowByItem[baseItemString] or row == self._rowByItem[baseItemString]) then + local isBetterSortValue = nil + if self._sortAscending then + isBetterSortValue = (not bestSortValue or sortValue < bestSortValue) and (sortValue > self._selectionBaseSortValue or (sortValue == self._selectionBaseSortValue and baseItemString > self._selectionBaseItemString)) + else + isBetterSortValue = (not bestSortValue or sortValue > bestSortValue) and (sortValue < self._selectionBaseSortValue or (sortValue == self._selectionBaseSortValue and baseItemString < self._selectionBaseItemString)) + end + if isBetterSortValue then + bestSortValue = sortValue + bestNewSelection = row + end + end + end + self:SetSelection(bestNewSelection) + else + self:SetSelection(nil) + end + elseif self._selection then + self._selectionBaseSortValue = private.sortContext.baseSortValue[self._selection:GetBaseItemString()] + end + + wipe(private.sortContext.sortValue) + wipe(private.sortContext.rowSortValue) + wipe(private.sortContext.baseSortValue) + private.sortContext.self = nil +end + +function AuctionScrollingTable._GetSortValue(self, row, id, isAscending) + if id == "item" then + local baseItemString = row:GetBaseItemString() + return ItemInfo.GetName(baseItemString) + elseif id == "ilvl" then + return ItemInfo.GetItemLevel(row:GetItemString() or row:GetBaseItemString()) + elseif id == "posts" then + local _, numAuctions = row:GetQuantities() + return numAuctions or (isAscending and math.huge or -math.huge) + elseif id == "stack" then + local quantity = row:GetQuantities() + return quantity or (isAscending and math.huge or -math.huge) + elseif id == "qty" then + local quantity, numAuctions = row:GetQuantities() + if not quantity or not numAuctions then + return isAscending and math.huge or -math.huge + end + return quantity * numAuctions + elseif id == "timeLeft" then + if not row:IsSubRow() then + return isAscending and math.huge or -math.huge + end + local timeLeft = row:GetListingInfo() + return timeLeft + elseif id == "seller" then + if not row:IsSubRow() then + return "" + end + local ownerStr = row:GetOwnerInfo() + return ownerStr + elseif id == "itemBid" then + if not row:IsSubRow() then + return isAscending and math.huge or -math.huge + end + local _, itemDisplayedBid = row:GetDisplayedBids() + return itemDisplayedBid + elseif id == "bid" then + if not row:IsSubRow() then + return isAscending and math.huge or -math.huge + end + local displayedBid = row:GetDisplayedBids() + return displayedBid + elseif id == "itemBuyout" then + local _, itemBuyout, minItemBuyout = row:GetBuyouts() + itemBuyout = itemBuyout or minItemBuyout or 0 + return itemBuyout == 0 and (isAscending and math.huge or -math.huge) or itemBuyout + elseif id == "buyout" then + local buyout = row:GetBuyouts() or 0 + return buyout == 0 and (isAscending and math.huge or -math.huge) or buyout + elseif id == "bidPct" then + local _, pct = self:_GetMarketValuePct(row) + return pct or (isAscending and math.huge or -math.huge) + elseif id == "pct" then + local pct = self:_GetMarketValuePct(row) + return pct or (isAscending and math.huge or -math.huge) + else + error("Invalid sort col id: "..tostring(id)) + end +end + +function AuctionScrollingTable._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + for _, tooltipFrame in pairs(row._buttons) do + ScriptWrapper.SetPropagate(tooltipFrame, "OnDoubleClick") + end + ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnDoubleClick, row) + end + return row +end + +function AuctionScrollingTable._GetMarketValuePct(self, row) + if not self._marketValueFunc then + -- no market value function was set + return nil, nil + end + local marketValue = self._marketValueFunc(row) or 0 + if marketValue == 0 then + -- this item doesn't have a market value + return nil, nil + end + local _, itemBuyout, minItemBuyout = row:GetBuyouts() + itemBuyout = itemBuyout or minItemBuyout or 0 + local bidPct = nil + if row:IsSubRow() then + local _, itemDisplayedBid = row:GetDisplayedBids() + bidPct = itemDisplayedBid / marketValue + end + return itemBuyout > 0 and itemBuyout / marketValue or nil, bidPct +end + +function AuctionScrollingTable._ToggleSort(self, id) + if not self._sortCol or not self._auctionScan then + -- sorting disabled so ignore + return + end + + local sortCol = nil + for _, col in ipairs(self._tableInfo:_GetCols()) do + if col:_GetId() == id then + sortCol = col:_GetId() + end + end + assert(sortCol) + if sortCol == self._sortCol then + self._sortAscending = not self._sortAscending + else + self._sortCol = sortCol + self._sortAscending = true + end + if self._selection then + self._selectionBaseSortValue = self:_GetSortValue(self._selection, self._sortCol, self._sortAscending) + end + self:UpdateData(true) +end + +function AuctionScrollingTable._SetRowData(self, row, data) + local ownerStr = private.GetSellerCellText(self, data) + local isPlayer = false + if ownerStr ~= "" then + for owner in String.SplitIterator(ownerStr, ",") do + if PlayerInfo.IsPlayer(owner, true, true, true) then + isPlayer = true + break + end + end + end + if isPlayer then + row._texts.seller:SetTextColor(0.3, 0.6, 1, 1) + else + row._texts.seller:SetTextColor(1, 1, 1, 1) + end + self.__super:_SetRowData(row, data) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.RowOnDoubleClick(row, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local self = row._scrollingTable + local subRow = row:GetData() + local baseItemString = subRow:GetBaseItemString() + self._expanded[baseItemString] = not self._expanded[baseItemString] + self:UpdateData(true) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SortRowAscendingHelper(a, b) + local sortContext = private.sortContext + local aSortValue = sortContext.rowSortValue[a] + local bSortValue = sortContext.rowSortValue[b] + if aSortValue == bSortValue then + return a:GetBaseItemString() < b:GetBaseItemString() + end + return aSortValue < bSortValue +end + +function private.SortRowDescendingHelper(a, b) + local sortContext = private.sortContext + local aSortValue = sortContext.rowSortValue[a] + local bSortValue = sortContext.rowSortValue[b] + if aSortValue == bSortValue then + return a:GetBaseItemString() > b:GetBaseItemString() + end + return aSortValue > bSortValue +end + +function private.SortSubRowAscendingHelper(a, b) + local sortContext = private.sortContext + local aBaseItemString = a:GetBaseItemString() + local bBaseItemString = b:GetBaseItemString() + if aBaseItemString == bBaseItemString then + local aSortValue = sortContext.sortValue[a] + local bSortValue = sortContext.sortValue[b] + if aSortValue == bSortValue then + -- always show base records first + local self = sortContext.self + if self._firstSubRowByItem[aBaseItemString] == a then + return true + elseif self._firstSubRowByItem[bBaseItemString] == b then + return false + else + -- order by buyout + local _, aItemBuyout = a:GetBuyouts() + local _, bItemBuyout = b:GetBuyouts() + if aItemBuyout ~= bItemBuyout then + return aItemBuyout < bItemBuyout + end + -- show the higher auctionId first + local _, aAuctionId, aBrowseId = a:GetListingInfo() + local _, bAuctionId, bBrowseId = b:GetListingInfo() + if aAuctionId ~= bAuctionId then + return aAuctionId > bAuctionId + else + return aBrowseId > bBrowseId + end + end + end + return aSortValue < bSortValue + else + -- we're sorting different items + local aSortValue = sortContext.baseSortValue[aBaseItemString] + local bSortValue = sortContext.baseSortValue[bBaseItemString] + if aSortValue == bSortValue then + return aBaseItemString < bBaseItemString + end + return aSortValue < bSortValue + end +end + +function private.SortSubRowDescendingHelper(a, b) + local sortContext = private.sortContext + local aBaseItemString = a:GetBaseItemString() + local bBaseItemString = b:GetBaseItemString() + if aBaseItemString == bBaseItemString then + local aSortValue = sortContext.sortValue[a] + local bSortValue = sortContext.sortValue[b] + if aSortValue == bSortValue then + -- always show base records first + local self = sortContext.self + if self._firstSubRowByItem[aBaseItemString] == a then + return true + elseif self._firstSubRowByItem[bBaseItemString] == b then + return false + else + -- order by buyout + local _, aItemBuyout = a:GetBuyouts() + local _, bItemBuyout = b:GetBuyouts() + if aItemBuyout ~= bItemBuyout then + return aItemBuyout < bItemBuyout + end + -- show the higher auctionId first + local _, aAuctionId, aBrowseId = a:GetListingInfo() + local _, bAuctionId, bBrowseId = b:GetListingInfo() + if aAuctionId ~= bAuctionId then + return aAuctionId > bAuctionId + else + return aBrowseId > bBrowseId + end + end + end + return aSortValue > bSortValue + else + -- we're sorting different items + local aSortValue = sortContext.baseSortValue[aBaseItemString] + local bSortValue = sortContext.baseSortValue[bBaseItemString] + if aSortValue == bSortValue then + return aBaseItemString > bBaseItemString + end + return aSortValue > bSortValue + end +end + +function private.GetItemCellText(self, subRow) + local isIndented = subRow:IsSubRow() + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + local itemString = subRow:GetItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return TSM.UI.GetColoredItemName(itemString or baseItemString, 0) + end + end + local itemLink = subRow:GetLinks() + -- TODO: use theme constant for indented tint pct + return TSM.UI.GetColoredItemName(itemLink, isIndented and -20 or 0) +end + +function private.GetItemLevelCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + local itemString = subRow:GetItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return ItemInfo.GetItemLevel(itemString or baseItemString) + end + end + local itemLink = subRow:GetLinks() + return ItemInfo.GetItemLevel(itemLink) +end + +function private.GetAuctionsQuantityText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local quantity, numAuctions = subRow:GetQuantities() + if not quantity or not numAuctions then + return "" + end + return quantity * numAuctions +end + +function private.GetAuctionsPostsText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local _, numAuctions = subRow:GetQuantities() + return numAuctions +end + +function private.GetAuctionsStackText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local quantity = subRow:GetQuantities() + return quantity +end + +function private.GetTimeLeftCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return "" + end + end + local timeLeft = subRow:GetListingInfo() + return TSM.UI.GetTimeLeftString(timeLeft) +end + +function private.GetSellerCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return "" + end + end + local ownerStr = subRow:GetOwnerInfo() + return ownerStr +end + +function private.GetItemBidCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return "" + end + end + local _, itemDisplayedBid = subRow:GetDisplayedBids() + local _, _, _, isHighBidder = subRow:GetBidInfo() + return Money.ToString(itemDisplayedBid, isHighBidder and Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix() or nil, "OPT_83_NO_COPPER") +end + +function private.GetBidCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return "" + end + end + local displayedBid = subRow:GetDisplayedBids() + local _, _, _, isHighBidder = subRow:GetBidInfo() + return Money.ToString(displayedBid, isHighBidder and Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix() or nil, "OPT_83_NO_COPPER") +end + +function private.GetItemBuyoutCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local _, itemBuyout, minItemBuyout = subRow:GetBuyouts() + local value = itemBuyout or minItemBuyout + if not value then + return "" + end + return Money.ToString(value, nil, "OPT_83_NO_COPPER") +end + +function private.GetBuyoutCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local buyout = subRow:GetBuyouts() + if not buyout then + return "" + end + return Money.ToString(buyout, nil, "OPT_83_NO_COPPER") +end + +function private.GetBidPercentCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local _, pct = self:_GetMarketValuePct(subRow) + pct = pct and Math.Round(pct * 100) or nil + if not pct then + return "---" + end + local pctColor = Theme.GetAuctionPercentColor(pct) + if pct > 999 then + pct = ">999" + end + return pctColor:ColorText(pct.."%") +end + +function private.GetPercentCellText(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + local pct, bidPct = self:_GetMarketValuePct(subRow) + pct = pct and Math.Round(pct * 100) or nil + bidPct = bidPct and Math.Round(bidPct * 100) or nil + if pct then + local pctColor = Theme.GetAuctionPercentColor(pct) + if pct > 999 then + pct = ">999" + end + return pctColor:ColorText(pct.."%") + elseif bidPct then + local pctColor = Theme.GetAuctionPercentColor("BID") + if bidPct > 999 then + bidPct = ">999" + end + return pctColor:ColorText(bidPct.."%") + else + return "---" + end +end + +function private.GetItemCellIcon(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return ItemInfo.GetTexture(baseItemString) + end + end + local itemLink = subRow:GetLinks() + return ItemInfo.GetTexture(itemLink) +end + +function private.GetItemCellTooltip(self, subRow) + local baseItemString = subRow:GetBaseItemString() + local itemString = subRow:GetItemString() + if not subRow:IsSubRow() then + subRow = self._firstSubRowByItem[baseItemString] or subRow + baseItemString = subRow:GetBaseItemString() + itemString = subRow:GetItemString() + end + if subRow:IsSubRow() then + local _, rawLink = subRow:GetLinks() + return rawLink or itemString or baseItemString + else + return itemString or baseItemString + end +end + +function private.GetExpanderState(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return false, false, 0 + end + end + local baseItemString = subRow:GetBaseItemString() + local isExpanded = self._expanded[baseItemString] + local isIndented = isExpanded and subRow ~= self._firstSubRowByItem[baseItemString] + local numSubRows = subRow:GetResultRow():GetNumSubRows() + local expanderVisible = not isIndented and numSubRows > 1 + return expanderVisible, expanderVisible and isExpanded, isIndented and 1 or 0 +end + +function private.GetBadgeState(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + return false + end + end + local baseItemString = subRow:GetBaseItemString() + local numSubRows = subRow:GetResultRow():GetNumSubRows() + local isVisible = not self._expanded[baseItemString] and numSubRows > 1 + return isVisible, numSubRows > 999 and "(999+)" or ("("..numSubRows..")") +end + +function private.GetPendingIcon(self, subRow, iconIndex) + assert(iconIndex == 1) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] + if not subRow then + local texture, shouldRotate = nil, nil + if baseItemString == self._currentSearchItem then + local _, isPaused = self._auctionScan:GetProgress() + texture = "iconPack.12x12/Running" + shouldRotate = not isPaused + else + texture = TSM.UI.TexturePacks.GetColoredKey("iconPack.12x12/Running", "ACTIVE_BG_ALT") + shouldRotate = false + end + return true, texture, true, nil, shouldRotate + end + end + return false +end + +function private.OnPendingIconClick(self, data) + self:SetSelection(data) +end diff --git a/Core/UI/Elements/BaseDropdown.lua b/Core/UI/Elements/BaseDropdown.lua new file mode 100644 index 0000000..a39a278 --- /dev/null +++ b/Core/UI/Elements/BaseDropdown.lua @@ -0,0 +1,245 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Base Dropdown UI Element Class. +-- The base dropdown class is an abstract class which provides shared functionality between the @{SelectionDropdown} and +-- @{MultiselectionDropdown} classes. It is a subclass of the @{Text} class. +-- @classmod BaseDropdown + +local _, TSM = ... +local NineSlice = TSM.Include("Util.NineSlice") +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local BaseDropdown = TSM.Include("LibTSMClass").DefineClass("BaseDropdown", TSM.UI.Text, "ABSTRACT") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(BaseDropdown) +TSM.UI.BaseDropdown = BaseDropdown +local private = {} +local EXPANDER_SIZE = 18 +local TEXT_PADDING = 8 +local EXPANDER_PADDING = 8 + + + +-- ============================================================================ +-- Meta Class Methods +-- ============================================================================ + +function BaseDropdown.__init(self) + local frame = UIElements.CreateFrame(self, "Button", nil, nil, nil) + + self.__super:__init(frame) + + self._nineSlice = NineSlice.New(frame) + + ScriptWrapper.Set(frame, "OnClick", private.FrameOnClick, self) + frame.arrow = frame:CreateTexture(nil, "ARTWORK") + + self._widthText = UIElements.CreateFontString(self, frame) + self._widthText:Hide() + + self._font = "BODY_BODY2" + self._hintText = "" + self._items = {} + self._itemKeyLookup = {} + self._disabled = false + self._isOpen = false + self._onSelectionChangedHandler = nil +end + +function BaseDropdown.Release(self) + self._hintText = "" + wipe(self._items) + wipe(self._itemKeyLookup) + self._disabled = false + self._isOpen = false + self._onSelectionChangedHandler = nil + self:_GetBaseFrame():Enable() + self.__super:Release() + self._font = "BODY_BODY2" +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +--- Sets the hint text which is shown when there's no selection. +-- @tparam BaseDropdown self The dropdown object +-- @tparam string text The hint text string +-- @treturn BaseDropdown The dropdown object +function BaseDropdown.SetHintText(self, text) + self._hintText = text + return self +end + +--- Add an item to be shown in the dropdown dialog list. +-- @tparam BaseDropdown self The dropdown object +-- @tparam string item The item to add to the list (localized string) +-- @tparam[opt] string|number itemKey The internal representation of the item (if not specified will be the index) +-- @treturn BaseDropdown The dropdown object +function BaseDropdown.AddItem(self, item, itemKey) + tinsert(self._items, item) + self._itemKeyLookup[item] = itemKey or #self._items + return self +end + +--- Set the items to show in the dropdown dialog list. +-- @tparam BaseDropdown self The dropdown object +-- @tparam table items A list of items to be shown in the dropdown list +-- @tparam[opt] table itemKeys A list of keys which go with the item at the corresponding index in the items list +-- @treturn BaseDropdown The dropdown object +function BaseDropdown.SetItems(self, items, itemKeys) + wipe(self._items) + wipe(self._itemKeyLookup) + assert(not itemKeys or #itemKeys == #items) + for i, item in ipairs(items) do + self:AddItem(item, itemKeys and itemKeys[i]) + end + return self +end + +--- Set whether or not the dropdown is disabled. +-- @tparam BaseDropdown self The dropdown object +-- @tparam boolean disabled Whether or not to disable the dropdown +-- @treturn BaseDropdown The dropdown object +function BaseDropdown.SetDisabled(self, disabled) + self._disabled = disabled + if disabled then + self:_GetBaseFrame():Disable() + else + self:_GetBaseFrame():Enable() + end + return self +end + +--- Registers a script handler. +-- @tparam BaseDropdown self The dropdown object +-- @tparam string script The script to register for (supported scripts: `OnSelectionChanged`) +-- @tparam function handler The script handler which will be called with the dropdown object followed by any arguments +-- to the script +-- @treturn BaseDropdown The dropdown object +function BaseDropdown.SetScript(self, script, handler) + if script == "OnSelectionChanged" then + self._onSelectionChangedHandler = handler + else + error("Invalid BaseDropdown script: "..tostring(script)) + end + return self +end + +--- Sets whether or not the dropdown is open. +-- @tparam BaseDropdown self The dropdown object +-- @tparam boolean open Whether or not the dropdown is open +-- @treturn BaseDropdown The dropdown object +function BaseDropdown.SetOpen(self, open) + assert(type(open) == "boolean") + if open == self._isOpen then + return self + end + self._isOpen = open + if open then + local width, height = self:_GetDialogSize() + local dialogFrame = UIElements.New("Frame", "dropdown") + :SetLayout("VERTICAL") + :SetContext(self) + :AddAnchor("TOPLEFT", self:_GetBaseFrame(), "BOTTOMLEFT", 0, -4) + :SetPadding(0, 0, 4, 4) + :SetBackgroundColor("ACTIVE_BG", true) + :SetSize(max(width, self:_GetDimension("WIDTH")), height) + :SetScript("OnHide", private.DialogOnHide) + self:_AddDialogChildren(dialogFrame) + dialogFrame:GetElement("list"):SetScript("OnSelectionChanged", private.ListOnSelectionChanged) + self:GetBaseElement():ShowDialogFrame(dialogFrame) + else + self:GetBaseElement():HideDialog() + end + return self +end + +function BaseDropdown.SetText(self) + error("BaseDropdown does not support this method") +end + +function BaseDropdown.SetTextColor(self, color) + error("BaseDropdown does not support this method") +end + +function BaseDropdown.Draw(self) + self.__super:SetText(self:_GetCurrentSelectionString()) + self.__super:Draw() + local frame = self:_GetBaseFrame() + TSM.UI.TexturePacks.SetTexture(frame.arrow, "iconPack.18x18/Chevron/Down") + local frameHeight = frame:GetHeight() + local paddingX = EXPANDER_PADDING + local paddingY = (frameHeight - EXPANDER_SIZE) / 2 + frame.text:ClearAllPoints() + frame.text:SetPoint("TOPLEFT", TEXT_PADDING, 0) + frame.text:SetPoint("BOTTOMRIGHT", -EXPANDER_SIZE, 0) + frame.arrow:ClearAllPoints() + frame.arrow:SetPoint("BOTTOMLEFT", frame.text, "BOTTOMRIGHT", -paddingX, paddingY) + frame.arrow:SetPoint("TOPRIGHT", -paddingX, -paddingY) + + -- set textures and text color depending on the state + self._nineSlice:SetStyle("rounded") + local textColor = self:_GetTextColor() + frame.text:SetTextColor(textColor:GetFractionalRGBA()) + self._nineSlice:SetVertexColor(Theme.GetColor(self._disabled and "PRIMARY_BG_ALT" or "ACTIVE_BG"):GetFractionalRGBA()) + frame.arrow:SetVertexColor(textColor:GetFractionalRGBA()) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function BaseDropdown._GetTextColor(self) + local color = Theme.GetColor(self._disabled and "PRIMARY_BG_ALT" or "ACTIVE_BG") + -- the text color should have maximum contrast with the dropdown color, so set it to white/black based on the dropdown color + if color:IsLight() then + -- the dropdown is light, so set the text to black + return Color.GetFullBlack():GetTint(self._disabled and "-DISABLED" or 0) + else + -- the dropdown is dark, so set the text to white + return Color.GetFullWhite():GetTint(self._disabled and "+DISABLED" or 0) + end +end + +function BaseDropdown._GetDialogSize(self) + local maxStringWidth = 100 -- no smaller than 100 + self._widthText:Show() + self._widthText:SetFont(Theme.GetFont(self._font):GetWowFont()) + for _, item in ipairs(self._items) do + self._widthText:SetText(item) + maxStringWidth = max(maxStringWidth, self._widthText:GetUnboundedStringWidth()) + end + self._widthText:Hide() + return maxStringWidth + Theme.GetColSpacing() * 2, 8 + max(16, min(8, #self._items) * 20) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnClick(self) + self:SetOpen(true) +end + +function private.ListOnSelectionChanged(dropdownList, selection) + local self = dropdownList:GetParentElement():GetContext() + self:_OnListSelectionChanged(dropdownList, selection) + self:Draw() +end + +function private.DialogOnHide(frame) + local self = frame:GetContext() + self._isOpen = false +end diff --git a/Core/UI/Elements/BaseInput.lua b/Core/UI/Elements/BaseInput.lua new file mode 100644 index 0000000..37ee4e3 --- /dev/null +++ b/Core/UI/Elements/BaseInput.lua @@ -0,0 +1,584 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Base Input UI Element Class. +-- The base input class is an abstract class which provides shared functionality between the @{Input} and +-- @{MultiLineInput} classes. It is a subclass of the @{Element} class. +-- @classmod BaseInput + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local NineSlice = TSM.Include("Util.NineSlice") +local Log = TSM.Include("Util.Log") +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local Delay = TSM.Include("Util.Delay") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local CustomPrice = TSM.Include("Service.CustomPrice") +local BaseInput = TSM.Include("LibTSMClass").DefineClass("BaseInput", TSM.UI.Element, "ABSTRACT") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(BaseInput) +TSM.UI.BaseInput = BaseInput +local private = {} +local BORDER_THICKNESS = 1 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function BaseInput.__init(self, frame) + self.__super:__init(frame) + + self._borderNineSlice = NineSlice.New(frame) + self._borderNineSlice:Hide() + + self._backgroundNineSlice = NineSlice.New(frame, 1) + self._backgroundNineSlice:Hide() + + self._editBox:SetShadowColor(0, 0, 0, 0) + self._editBox:SetAutoFocus(false) + ScriptWrapper.Set(self._editBox, "OnEscapePressed", private.OnEscapePressed, self) + ScriptWrapper.Set(self._editBox, "OnTabPressed", private.OnTabPressed, self) + ScriptWrapper.Set(self._editBox, "OnEditFocusGained", private.OnEditFocusGained, self) + ScriptWrapper.Set(self._editBox, "OnEditFocusLost", private.OnEditFocusLost, self) + ScriptWrapper.Set(self._editBox, "OnChar", self._OnChar, self) + + self._lostFocusDelayName = "INPUT_LOST_FOCUS_"..tostring(frame) + self._backgroundColor = "ACTIVE_BG_ALT" + self._borderColor = nil + self._value = "" + self._escValue = nil + self._justifyH = "LEFT" + self._justifyV = "MIDDLE" + self._font = "BODY_BODY2" + self._pasteMode = nil + self._validateFunc = nil + self._validateContext = nil + self._settingTable = nil + self._settingKey = nil + self._disabled = false + self._isValid = true + self._tabPrevPath = nil + self._tabNextPath = nil + self._onValueChangedHandler = nil + self._onEnterPressedHandler = nil + self._onValidationChangedHandler = nil + self._onFocusLostHandler = nil + self._pasteChars = {} +end + +function BaseInput.Acquire(self) + self.__super:Acquire() + ScriptWrapper.Set(self._editBox, "OnEnterPressed", private.OnEnterPressed, self) + ScriptWrapper.Set(self._editBox, "OnTextChanged", private.OnTextChanged, self) +end + +function BaseInput.Release(self) + Delay.Cancel(self._lostFocusDelayName) + ScriptWrapper.Clear(self._editBox, "OnEnterPressed") + ScriptWrapper.Clear(self._editBox, "OnTextChanged") + self._editBox:SetText("") + self._editBox:ClearFocus() + self._editBox:Enable() + self._editBox:EnableMouse(true) + self._editBox:EnableKeyboard(true) + self._editBox:HighlightText(0, 0) + self._editBox:SetHitRectInsets(0, 0, 0, 0) + self._editBox:SetMaxLetters(2147483647) + self._editBox:SetMaxBytes(2147483647) + self._backgroundColor = "ACTIVE_BG_ALT" + self._borderColor = nil + self._value = "" + self._escValue = nil + self._justifyH = "LEFT" + self._justifyV = "MIDDLE" + self._font = "BODY_BODY2" + self._pasteMode = nil + self._validateFunc = nil + self._validateContext = nil + self._settingTable = nil + self._settingKey = nil + self._disabled = false + self._isValid = true + self._tabPrevPath = nil + self._tabNextPath = nil + self._onValueChangedHandler = nil + self._onEnterPressedHandler = nil + self._onValidationChangedHandler = nil + self._onFocusLostHandler = nil + wipe(self._pasteChars) + self.__super:Release() +end + +--- Sets the background of the input. +-- @tparam BaseInput self The input object +-- @tparam ?string|nil color The background color as a theme color key or nil +-- @treturn BaseInput The input object +function BaseInput.SetBackgroundColor(self, color) + assert(color == nil or Theme.GetColor(color)) + self._backgroundColor = color + return self +end + +--- Sets the border of the input. +-- @tparam BaseInput self The input object +-- @tparam ?string|nil color The border color as a theme color key or nil +-- @treturn BaseInput The input object +function BaseInput.SetBorderColor(self, color) + assert(color == nil or Theme.GetColor(color)) + self._borderColor = color + return self +end + +--- Sets the horizontal justification of the input. +-- @tparam BaseInput self The input object +-- @tparam string justifyH The horizontal justification (either "LEFT", "CENTER" or "RIGHT") +-- @treturn BaseInput The input object +function BaseInput.SetJustifyH(self, justifyH) + assert(justifyH == "LEFT" or justifyH == "CENTER" or justifyH == "RIGHT") + self._justifyH = justifyH + return self +end + +--- Sets the vertical justification of the input. +-- @tparam BaseInput self The input object +-- @tparam string justifyV The vertical justification (either "TOP", "MIDDLE" or "BOTTOM") +-- @treturn BaseInput The input object +function BaseInput.SetJustifyV(self, justifyV) + assert(justifyV == "TOP" or justifyV == "MIDDLE" or justifyV == "BOTTOM") + self._justifyV = justifyV + return self +end + +--- Sets the font. +-- @tparam BaseInput self The input object +-- @tparam string font The font key +-- @treturn BaseInput The input object +function BaseInput.SetFont(self, font) + assert(Theme.GetFont(font)) + self._font = font + return self +end + +--- Sets the path of the inputs to jump to when tab (or shift-tab to go backwards) is pressed. +-- @tparam BaseInput self The input object +-- @tparam string prevPath The path to the previous input (for shift-tab) +-- @tparam string nextPath The path to the next input (for tab) +-- @treturn BaseInput The input object +function BaseInput.SetTabPaths(self, prevPath, nextPath) + self._tabPrevPath = prevPath + self._tabNextPath = nextPath + return self +end + +--- Set the highlight to all or some of the input's text. +-- @tparam BaseInput self The input object +-- @tparam number starting The position at which to start the highlight +-- @tparam number ending The position at which to stop the highlight +-- @treturn BaseInput The input object +function BaseInput.HighlightText(self, starting, ending) + if starting and ending then + self._editBox:HighlightText(starting, ending) + else + self._editBox:HighlightText() + end + return self +end + +--- Sets the current value. +-- @tparam BaseInput self The input object +-- @tparam string value The value +-- @treturn BaseInput The input object +function BaseInput.SetValue(self, value) + if type(value) == "number" then + value = tostring(value) + end + assert(type(value) == "string") + if self:_SetValueHelper(value, true) then + self._escValue = self._value + else + self._escValue = nil + end + return self +end + +--- Sets whether or not the input is disabled. +-- @tparam BaseInput self The input object +-- @tparam boolean disabled Whether or not the input is disabled +-- @treturn BaseInput The input object +function BaseInput.SetDisabled(self, disabled) + self._disabled = disabled + if disabled then + self._editBox:Disable() + else + self._editBox:Enable() + end + return self +end + +--- Sets the function to use to validate the input text. +-- @tparam BaseInput self The input object +-- @tparam ?string|function validateFunc A function which returns true if the passed text is valid +-- or false and an error message if not, or one of the following strings for built in validate +-- functions: "CUSTOM_PRICE" +-- @param[opt=nil] context Extra context to pass to the validate function. For the built-in +-- "CUSTOM_PRICE" function, this is optionally a list of bad sources. For the built-in "NUMBER" +-- function, this must be a string such as "0:1000" to specify the min and max values. +-- @treturn BaseInput The input object +function BaseInput.SetValidateFunc(self, validateFunc, context) + if type(validateFunc) == "function" then + self._validateFunc = validateFunc + self._validateContext = context + elseif validateFunc == "CUSTOM_PRICE" then + assert(context == nil or type(context) == "table") + self._validateFunc = private.CustomPriceValidateFunc + self._validateContext = context + elseif validateFunc == "NUMBER" then + local minVal, maxVal, extra = strsplit(":", context) + assert(tonumber(minVal) <= tonumber(maxVal) and not extra) + self._validateFunc = private.NumberValidateFunc + self._validateContext = context + else + error("Invalid validateFunc: "..tostring(validateFunc)) + end + return self +end + +--- Returns the input's focus state. +-- @tparam BaseInput self The input object +function BaseInput.HasFocus(self) + return self._editBox:HasFocus() +end + +--- Sets whether or not this input is focused. +-- @tparam BaseInput self The input object +-- @tparam boolean focused Whether or not this input is focused +-- @treturn BaseInput The input object +function BaseInput.SetFocused(self, focused) + if focused then + self._editBox:SetFocus() + else + self._editBox:ClearFocus() + end + return self +end + +--- Clears the highlight. +-- @tparam BaseInput self The input object +-- @treturn BaseInput The input object +function BaseInput.ClearHighlight(self) + self._editBox:HighlightText(0, 0) + return self +end + +--- Set the maximum number of letters for the input's entered text. +-- @tparam BaseInput self The input object +-- @tparam number number The number of letters for entered text +-- @treturn BaseInput The input object +function BaseInput.SetMaxLetters(self, number) + self._editBox:SetMaxLetters(number) + return self +end + +--- Gets the input value. +-- @tparam BaseInput self The input object +-- @treturn string The input value +function BaseInput.GetValue(self) + return self._ignoreEnter and self._value or strtrim(self._value) +end + +--- Registers a script handler. +-- @tparam BaseInput self The input object +-- @tparam string script The script to register for +-- @tparam[opt=nil] function handler The script handler which should be called +-- @treturn BaseInput The element object +function BaseInput.SetScript(self, script, handler) + if script == "OnValueChanged" then + self._onValueChangedHandler = handler + elseif script == "OnEnterPressed" then + self._onEnterPressedHandler = handler + elseif script == "OnValidationChanged" then + self._onValidationChangedHandler = handler + elseif script == "OnFocusLost" then + self._onFocusLostHandler = handler + else + error("Invalid base input script: "..tostring(script)) + end + return self +end + +--- Sets the setting info. +-- This method is used to have the value of the input automatically correspond with the value of a field in a table. +-- This is useful for inputs which are tied directly to settings. +-- @tparam BaseInput self The input object +-- @tparam table tbl The table which the field to set belongs to +-- @tparam string key The key into the table to be set based on the input state +-- @treturn BaseInput The input object +function BaseInput.SetSettingInfo(self, tbl, key) + assert(self._value == "") + self._settingTable = tbl + self._settingKey = key + self:SetValue(tbl[key]) + return self +end + +--- Get the current validation state. +-- @tparam BaseInput self The input object +-- @treturn boolean The current valiation state +function BaseInput.IsValid(self) + return self._isValid +end + +--- Sets the input into paste mode for supporting the pasting of large strings. +-- @tparam BaseInput self The input object +-- @treturn BaseInput The input object +function BaseInput.SetPasteMode(self) + self._pasteMode = true + ScriptWrapper.Clear(self._editBox, "OnTextChanged") + self._editBox:SetMaxBytes(1) + return self +end + +function BaseInput.Draw(self) + self.__super:Draw() + + self:_DrawBackgroundAndBorder() + + -- set the font + self._editBox:SetFont(Theme.GetFont(self._font):GetWowFont()) + + -- set the justification + self._editBox:SetJustifyH(self._justifyH) + self._editBox:SetJustifyV(self._justifyV) + + -- set the text color + self._editBox:SetTextColor(self:_GetTextColor():GetFractionalRGBA()) + + -- set the highlight color + self._editBox:SetHighlightColor(Theme.GetColor("TEXT%HIGHLIGHT"):GetFractionalRGBA()) + + if not self._editBox:HasFocus() then + -- set the text + self._editBox:SetText(self._value) + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function BaseInput._GetTextColor(self, tint) + local color = Theme.GetColor(self._disabled and "PRIMARY_BG_ALT" or self._backgroundColor) + -- the text color should have maximum contrast with the input color, so set it to white/black based on the input color + if color:IsLight() then + -- the input is light, so set the text to black + return Color.GetFullBlack():GetTint(self._disabled and "-DISABLED" or tint or 0) + else + -- the input is dark, so set the text to white + return Color.GetFullWhite():GetTint(self._disabled and "+DISABLED" or tint or 0) + end +end + +function BaseInput._SetValueHelper(self, value, noCallback) + if not self._validateFunc or self:_validateFunc(strtrim(value), self._validateContext) then + self._value = value + if self._settingTable then + if type(self._settingTable[self._settingKey]) == "number" then + value = tonumber(value) + assert(value) + end + self._settingTable[self._settingKey] = value + end + if not noCallback and self._onValueChangedHandler then + self:_onValueChangedHandler() + end + if not self._isValid then + self._isValid = true + self:_DrawBackgroundAndBorder() + if self._onValidationChangedHandler then + self:_onValidationChangedHandler() + end + end + return true + else + if self._isValid then + self._isValid = false + self:_DrawBackgroundAndBorder() + if self._onValidationChangedHandler then + self:_onValidationChangedHandler() + end + end + return false + end +end + +function BaseInput._DrawBackgroundAndBorder(self) + assert(self._backgroundColor) + self._backgroundNineSlice:SetStyle("rounded", (self._borderColor or not self._isValid) and BORDER_THICKNESS or nil) + self._backgroundNineSlice:SetVertexColor(Theme.GetColor(self._disabled and "PRIMARY_BG_ALT" or self._backgroundColor):GetFractionalRGBA()) + if self._borderColor or not self._isValid then + self._borderNineSlice:SetStyle("rounded") + self._borderNineSlice:SetVertexColor((not self._isValid and Theme.GetFeedbackColor("RED") or Theme.GetColor(self._borderColor)):GetFractionalRGBA()) + else + self._borderNineSlice:Hide() + end +end + +function BaseInput._OnChar(self, c) + -- can be overridden + if not self._pasteMode then + return + end + tinsert(self._pasteChars, c) + ScriptWrapper.Set(self._editBox, "OnUpdate", private.OnUpdate, self) +end + +function BaseInput._OnTextChanged(self, value) + -- can be overridden +end + +function BaseInput._ShouldKeepFocus(self) + -- can be overridden + return false +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnEscapePressed(self) + if self._escValue then + self._value = self._escValue + assert(self:_SetValueHelper(self._escValue)) + end + self:SetFocused(false) + self:HighlightText(0, 0) + self:Draw() +end + +function private.OnTabPressed(self) + local isValid, err = true, nil + if self._validateFunc then + local value = strtrim(self._editBox:GetText()) + isValid, err = self:_validateFunc(value, self._validateContext) + end + if not isValid and err then + -- TODO: better way to show the error message? + Log.PrintUser(err) + end + self:SetFocused(false) + self:HighlightText(0, 0) + if self._tabPrevPath and IsShiftKeyDown() then + self:GetElement(self._tabPrevPath):SetFocused(true) + elseif self._tabNextPath and not IsShiftKeyDown() then + self:GetElement(self._tabNextPath):SetFocused(true) + end +end + +function private.OnEnterPressed(self) + local isValid, err = true, nil + if self._validateFunc then + local value = strtrim(self._editBox:GetText()) + isValid, err = self:_validateFunc(value, self._validateContext) + end + if not isValid and err then + -- TODO: better way to show the error message? + Log.PrintUser(err) + end + if isValid then + self:SetFocused(false) + self:HighlightText(0, 0) + if self._onEnterPressedHandler then + self:_onEnterPressedHandler() + end + end +end + +function private.OnEditFocusGained(self) + Delay.Cancel(self._lostFocusDelayName) + self:Draw() + self:HighlightText() +end + +function private.OnEditFocusLost(self) + if self:_ShouldKeepFocus() then + self:SetFocused(true) + return + end + if self._isValid then + self._escValue = self._value + end + self:HighlightText(0, 0) + self:Draw() + if not self._isValid then + self._isValid = true + self:_DrawBackgroundAndBorder() + if self._onValidationChangedHandler then + self:_onValidationChangedHandler() + end + end + -- wait until the next frame before calling the handler + Delay.AfterFrame(self._lostFocusDelayName, 0, private.OnFocusLost, nil, self) +end + +function private.OnFocusLost(self) + if self:HasFocus() then + return + end + if self._onFocusLostHandler then + self:_onFocusLostHandler() + end +end + +function private.OnTextChanged(self, isUserInput) + if not isUserInput then + return + end + local value = self._editBox:GetText() + self:_SetValueHelper(value) + self:_OnTextChanged(value) +end + +function private.OnUpdate(self) + ScriptWrapper.Clear(self._editBox, "OnUpdate") + local value = table.concat(self._pasteChars) + wipe(self._pasteChars) + self:_SetValueHelper(value) + self:_OnTextChanged(value) +end + + + +-- ============================================================================ +-- Built In Validate Functions +-- ============================================================================ + +function private.CustomPriceValidateFunc(_, value, badSources) + local isValid, err = CustomPrice.Validate(value, badSources) + if not isValid then + return false, L["Invalid custom price."].." "..err + end + return true +end + +function private.NumberValidateFunc(input, value, range) + local minValue, maxValue = strsplit(":", range) + minValue = tonumber(minValue) + maxValue = tonumber(maxValue) + value = tonumber(value) + if not value then + return false, L["Invalid numeric value."] + elseif value < minValue or value > maxValue then + return false, format(L["Value must be between %d and %d."], minValue, maxValue) + end + return true +end diff --git a/Core/UI/Elements/Button.lua b/Core/UI/Elements/Button.lua new file mode 100644 index 0000000..fac0009 --- /dev/null +++ b/Core/UI/Elements/Button.lua @@ -0,0 +1,285 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Button UI Element Class. +-- A button is a clickable element which has text drawn over top of it. It is a subclass of the @{Text} class. +-- @classmod Button + +local _, TSM = ... +local Button = TSM.Include("LibTSMClass").DefineClass("Button", TSM.UI.Text) +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Button) +TSM.UI.Button = Button +local ICON_SPACING = 4 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Button.__init(self) + local frame = UIElements.CreateFrame(self, "Button") + + self.__super:__init(frame) + + frame.backgroundTexture = frame:CreateTexture(nil, "BACKGROUND") + + -- create the highlight + frame.highlight = frame:CreateTexture(nil, "HIGHLIGHT") + frame.highlight:SetAllPoints() + frame.highlight:SetBlendMode("BLEND") + frame:SetHighlightTexture(frame.highlight) + + -- create the icon + frame.icon = frame:CreateTexture(nil, "ARTWORK") + + self._font = "BODY_BODY1" + self._justifyH = "CENTER" + self._background = nil + self._iconTexturePack = nil + self._iconPosition = nil + self._highlightEnabled = false +end + +function Button.Acquire(self) + self:_GetBaseFrame():Enable() + self:_GetBaseFrame():RegisterForClicks("LeftButtonUp") + self:_GetBaseFrame():SetHitRectInsets(0, 0, 0, 0) + self.__super:Acquire() +end + +function Button.Release(self) + local frame = self:_GetBaseFrame() + frame:UnlockHighlight() + self._background = nil + self._iconTexturePack = nil + self._iconPosition = nil + self._highlightEnabled = false + self.__super:Release() + self._font = "BODY_BODY1" + self._justifyH = "CENTER" +end + +--- Sets the background of the button. +-- @tparam Button self The button object +-- @tparam ?string|number|nil background Either a texture pack string, itemString, WoW file id, theme color key, or nil +-- @treturn Button The button object +function Button.SetBackground(self, background) + assert(background == nil or type(background) == "string" or type(background) == "number") + self._background = background + return self +end + +--- Sets the background and size of the button based on a texture pack string. +-- @tparam Button self The button object +-- @tparam string texturePack A texture pack string to set the background to and base the size on +-- @treturn Button The button object +function Button.SetBackgroundAndSize(self, texturePack) + self:SetBackground(texturePack) + self:SetSize(TSM.UI.TexturePacks.GetSize(texturePack)) + return self +end + +--- Sets whether or not the highlight is enabled. +-- @tparam Button self The button object +-- @tparam boolean enabled Whether or not the highlight is enabled +-- @treturn Button The button object +function Button.SetHighlightEnabled(self, enabled) + self._highlightEnabled = enabled + return self +end + +--- Sets the icon that shows within the button. +-- @tparam Button self The button object +-- @tparam[opt=nil] string texturePack A texture pack string to set the icon and its size to +-- @tparam[opt=nil] string position The positin of the icon +-- @treturn Button The button object +function Button.SetIcon(self, texturePack, position) + if texturePack or position then + assert(TSM.UI.TexturePacks.IsValid(texturePack)) + assert(position == "LEFT" or position == "LEFT_NO_TEXT" or position == "CENTER" or position == "RIGHT") + self._iconTexturePack = texturePack + self._iconPosition = position + else + self._iconTexturePack = nil + self._iconPosition = nil + end + return self +end + +--- Set whether or not the button is disabled. +-- @tparam Button self The button object +-- @tparam boolean disabled Whether or not the button should be disabled +-- @treturn Button The button object +function Button.SetDisabled(self, disabled) + if disabled then + self:_GetBaseFrame():Disable() + else + self:_GetBaseFrame():Enable() + end + return self +end + +--- Registers the button for drag events. +-- @tparam Button self The button object +-- @tparam string button The mouse button to register for drag events from +-- @treturn Button The button object +function Button.RegisterForDrag(self, button) + self:_GetBaseFrame():RegisterForDrag(button) + return self +end + +--- Click on the button. +-- @tparam Button self The button object +function Button.Click(self) + self:_GetBaseFrame():Click() +end + +--- Enable right-click events for the button. +-- @tparam Button self The button object +-- @treturn Button The button object +function Button.EnableRightClick(self) + self:_GetBaseFrame():RegisterForClicks("LeftButtonUp", "RightButtonUp") + return self +end + +--- Set the hit rectangle insets for the button. +-- @tparam Button self The button object +-- @tparam number left How much the left side of the hit rectangle is inset +-- @tparam number right How much the right side of the hit rectangle is inset +-- @tparam number top How much the top side of the hit rectangle is inset +-- @tparam number bottom How much the bottom side of the hit rectangle is inset +-- @treturn Button The button object +function Button.SetHitRectInsets(self, left, right, top, bottom) + self:_GetBaseFrame():SetHitRectInsets(left, right, top, bottom) + return self +end + +--- Set whether or not to lock the button's highlight. +-- @tparam Button self The action button object +-- @tparam boolean locked Whether or not to lock the action button's highlight +-- @treturn Button The action button object +function Button.SetHighlightLocked(self, locked) + if locked then + self:_GetBaseFrame():LockHighlight() + else + self:_GetBaseFrame():UnlockHighlight() + end + return self +end + +function Button.Draw(self) + local frame = self:_GetBaseFrame() + frame.text:Show() + self.__super:Draw() + + frame.backgroundTexture:SetTexture(nil) + frame.backgroundTexture:SetTexCoord(0, 1, 0, 1) + frame.backgroundTexture:SetVertexColor(1, 1, 1, 1) + + if self._background == nil then + frame.backgroundTexture:Hide() + elseif type(self._background) == "string" and TSM.UI.TexturePacks.IsValid(self._background) then + -- this is a texture pack + frame.backgroundTexture:Show() + frame.backgroundTexture:ClearAllPoints() + frame.backgroundTexture:SetPoint("CENTER") + TSM.UI.TexturePacks.SetTextureAndSize(frame.backgroundTexture, self._background) + elseif type(self._background) == "string" and strmatch(self._background, "^[ip]:%d+") then + -- this is an itemString + frame.backgroundTexture:Show() + frame.backgroundTexture:ClearAllPoints() + frame.backgroundTexture:SetAllPoints() + frame.backgroundTexture:SetTexture(ItemInfo.GetTexture(self._background)) + elseif type(self._background) == "string" then + -- this is a theme color key + frame.backgroundTexture:Show() + frame.backgroundTexture:ClearAllPoints() + frame.backgroundTexture:SetAllPoints() + frame.backgroundTexture:SetColorTexture(Theme.GetColor(self._background):GetFractionalRGBA()) + elseif type(self._background) == "number" then + -- this is a wow file id + frame.backgroundTexture:Show() + frame.backgroundTexture:ClearAllPoints() + frame.backgroundTexture:SetAllPoints() + frame.backgroundTexture:SetTexture(self._background) + else + error("Invalid background: "..tostring(self._background)) + end + + -- set the text color + local textColor = frame:IsEnabled() and self:_GetTextColor() or Theme.GetColor("ACTIVE_BG_ALT") + frame.text:SetTextColor(textColor:GetFractionalRGBA()) + + -- set the highlight texture + if self._highlightEnabled then + frame.highlight:SetColorTexture(Theme.GetColor(self._background):GetTint("+HOVER"):GetFractionalRGBA()) + else + frame.highlight:SetColorTexture(0, 0, 0, 0) + end + + if self._iconTexturePack then + TSM.UI.TexturePacks.SetTextureAndSize(frame.icon, self._iconTexturePack) + frame.icon:Show() + frame.icon:ClearAllPoints() + frame.icon:SetVertexColor(textColor:GetFractionalRGBA()) + local iconWidth = TSM.UI.TexturePacks.GetWidth(self._iconTexturePack) + ICON_SPACING + if self._iconPosition == "LEFT" then + frame.icon:SetPoint("RIGHT", frame.text, "LEFT", -ICON_SPACING, 0) + frame.text:ClearAllPoints() + if self._justifyH == "CENTER" then + local xOffset = iconWidth / 2 + frame.text:SetPoint("TOP", xOffset, -self:_GetPadding("TOP")) + frame.text:SetPoint("BOTTOM", xOffset, self:_GetPadding("BOTTOM")) + frame.text:SetWidth(frame.text:GetStringWidth()) + elseif self._justifyH == "LEFT" then + frame.text:SetPoint("TOPLEFT", iconWidth + self:_GetPadding("LEFT"), -self:_GetPadding("TOP")) + frame.text:SetPoint("BOTTOMRIGHT", -self:_GetPadding("RIGHT"), self:_GetPadding("BOTTOM")) + else + error("Unsupported justifyH: "..tostring(self._justifyH)) + end + elseif self._iconPosition == "LEFT_NO_TEXT" then + frame.icon:SetPoint("LEFT", self:_GetPadding("LEFT"), 0) + frame.text:ClearAllPoints() + frame.text:Hide() + elseif self._iconPosition == "CENTER" then + frame.icon:SetPoint("CENTER") + frame.text:ClearAllPoints() + frame.text:Hide() + elseif self._iconPosition == "RIGHT" then + frame.icon:SetPoint("RIGHT", -self:_GetPadding("RIGHT"), 0) + local xOffset = iconWidth + frame.text:ClearAllPoints() + -- TODO: support non-left-aligned text + frame.text:SetPoint("TOPLEFT", self:_GetPadding("LEFT"), -self:_GetPadding("TOP")) + frame.text:SetPoint("BOTTOMRIGHT", -xOffset, self:_GetPadding("BOTTOM")) + else + error("Invalid iconPosition: "..tostring(self._iconPosition)) + end + else + frame.icon:Hide() + frame.text:ClearAllPoints() + frame.text:SetPoint("TOPLEFT", self:_GetPadding("LEFT"), -self:_GetPadding("TOP")) + frame.text:SetPoint("BOTTOMRIGHT", -self:_GetPadding("RIGHT"), self:_GetPadding("BOTTOM")) + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Button._GetMinimumDimension(self, dimension) + if dimension == "WIDTH" and self._autoWidth then + return self:GetStringWidth() + (self._iconTexturePack and TSM.UI.TexturePacks.GetWidth(self._iconTexturePack) or 0) + else + return self.__super:_GetMinimumDimension(dimension) + end +end diff --git a/Core/UI/Elements/Checkbox.lua b/Core/UI/Elements/Checkbox.lua new file mode 100644 index 0000000..99c7a23 --- /dev/null +++ b/Core/UI/Elements/Checkbox.lua @@ -0,0 +1,245 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Checkbox UI Element Class. +-- This is a simple checkbox element with an attached description text. It is a subclass of the @{Text} class. +-- @classmod Checkbox + +local _, TSM = ... +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local Checkbox = TSM.Include("LibTSMClass").DefineClass("Checkbox", TSM.UI.Text) +UIElements.Register(Checkbox) +TSM.UI.Checkbox = Checkbox +local private = {} +local THEME_TEXTURES = { + RADIO = { + checked = "iconPack.Misc/Radio/Checked", + unchecked = "iconPack.Misc/Radio/Unchecked", + }, + CHECK = { + checked = "iconPack.Misc/Checkbox/Checked", + unchecked = "iconPack.Misc/Checkbox/Unchecked", + }, +} +local CHECKBOX_SPACING = 4 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Checkbox.__init(self) + local frame = UIElements.CreateFrame(self, "Button") + self.__super:__init(frame) + ScriptWrapper.Set(frame, "OnClick", private.FrameOnClick, self) + + -- create the text and check texture + frame.text = UIElements.CreateFontString(self, frame) + frame.text:SetJustifyV("MIDDLE") + frame.check = frame:CreateTexture() + + self._position = "LEFT" + self._theme = "CHECK" + self._font = "BODY_BODY3" + self._disabled = false + self._value = false + self._onValueChangedHandler = nil + self._settingTable = nil + self._settingKey = nil +end + +function Checkbox.Release(self) + self._position = "LEFT" + self._theme = "CHECK" + self._disabled = false + self._value = false + self._onValueChangedHandler = nil + self._settingTable = nil + self._settingKey = nil + self.__super:Release() + self._font = "BODY_BODY3" +end + +--- Sets the position of the checkbox relative to the text. +-- This method can be used to set the checkbox to be either on the left or right side of the text. +-- @tparam Checkbox self The checkbox object +-- @tparam string position The position of the checkbox relative to the text +-- @treturn Checkbox The checkbox object +function Checkbox.SetCheckboxPosition(self, position) + if position == "LEFT" or position == "RIGHT" then + self._position = position + else + error("Invalid checkbox position: "..tostring(position)) + end + return self +end + +--- Sets the checkbox theme +-- @tparam Checkbox self The checkbox object +-- @tparam string theme Either "RADIO" or "CHECK" +-- @treturn Checkbox The checkbox object +function Checkbox.SetTheme(self, theme) + assert(THEME_TEXTURES[theme]) + self._theme = theme + return self +end + +--- Sets whether or not the checkbox is disabled. +-- @tparam Checkbox self The checkbox object +-- @tparam boolean disabled Whether or not the checkbox is disabled +-- @treturn Checkbox The checkbox object +function Checkbox.SetDisabled(self, disabled) + self._disabled = disabled + return self +end + +--- Sets the text string. +-- @tparam Checkbox self The checkbox object +-- @tparam string text The text string to be displayed +-- @treturn Checkbox The checkbox object +function Checkbox.SetText(self, text) + self._textStr = text + return self +end + +--- Gets the text string. +-- @tparam Checkbox self The checkbox object +-- @treturn string The text string +function Checkbox.GetText(self) + return self._textStr +end + +--- Sets a formatted text string. +-- @tparam Checkbox self The checkbox object +-- @tparam vararg ... The format string and arguments +-- @treturn Checkbox The checkbox object +function Checkbox.SetFormattedText(self, ...) + self._textStr = format(...) + return self +end + +--- Sets whether or not the checkbox is checked. +-- @tparam Checkbox self The checkbox object +-- @tparam boolean value Whether or not the checkbox is checked +-- @tparam[opt=false] boolean silent If true, will not trigger the `OnValueChanged` script +-- @treturn Checkbox The checkbox object +function Checkbox.SetChecked(self, value, silent) + self._value = value and true or false + if self._onValueChangedHandler and not silent then + self:_onValueChangedHandler(value) + end + return self +end + +--- Sets the setting info. +-- This method is used to have the state of the checkbox automatically correspond with the boolean state of a field in +-- a table. This is useful for checkboxes which are tied directly to settings. +-- @tparam Checkbox self The checkbox object +-- @tparam table tbl The table which the field to set belongs to +-- @tparam string key The key into the table to be set based on the checkbox state +-- @treturn Checkbox The checkbox object +function Checkbox.SetSettingInfo(self, tbl, key) + self._settingTable = tbl + self._settingKey = key + self:SetChecked(tbl[key]) + return self +end + +--- Gets the checked state. +-- @tparam Checkbox self The checkbox object +-- @treturn boolean Whether or not the checkbox is checked +function Checkbox.IsChecked(self) + return self._value +end + +--- Registers a script handler. +-- @tparam Checkbox self The checkbox object +-- @tparam string script The script to register for (supported scripts: `OnValueChanged`) +-- @tparam function handler The script handler which will be called with the checkbox object followed by any arguments +-- to the script +-- @treturn Checkbox The checkbox object +function Checkbox.SetScript(self, script, handler) + if script == "OnValueChanged" then + self._onValueChangedHandler = handler + elseif script == "OnEnter" or script == "OnLeave" then + return self.__super:SetScript(script, handler) + else + error("Unknown Checkbox script: "..tostring(script)) + end + return self +end + +function Checkbox.Draw(self) + self.__super:Draw() + local frame = self:_GetBaseFrame() + + if self._disabled then + frame.text:SetTextColor(Theme.GetColor("TEXT_DISABLED"):GetFractionalRGBA()) + else + frame.text:SetTextColor(self:_GetTextColor():GetFractionalRGBA()) + end + TSM.UI.TexturePacks.SetTextureAndSize(frame.check, THEME_TEXTURES[self._theme][self._value and "checked" or "unchecked"]) + + frame.text:ClearAllPoints() + frame.check:ClearAllPoints() + if self._position == "LEFT" then + frame.check:SetPoint("LEFT") + frame.text:SetJustifyH("LEFT") + frame.text:SetPoint("LEFT", frame.check, "RIGHT", CHECKBOX_SPACING, 0) + frame.text:SetPoint("TOPRIGHT") + frame.text:SetPoint("BOTTOMRIGHT") + elseif self._position == "RIGHT" then + frame.check:SetPoint("RIGHT") + frame.text:SetJustifyH("RIGHT") + frame.text:SetPoint("BOTTOMLEFT") + frame.text:SetPoint("TOPLEFT") + frame.text:SetPoint("RIGHT", frame.check, "LEFT", -CHECKBOX_SPACING, 0) + else + error("Invalid position: "..tostring(self._position)) + end + if self._disabled then + frame.check:SetAlpha(0.3) + self:_GetBaseFrame():Disable() + else + frame.check:SetAlpha(1) + self:_GetBaseFrame():Enable() + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Checkbox._GetMinimumDimension(self, dimension) + if dimension == "WIDTH" and self._autoWidth then + local checkboxWidth = TSM.UI.TexturePacks.GetWidth(THEME_TEXTURES[self._theme].checked) + return self:GetStringWidth() + CHECKBOX_SPACING + checkboxWidth, nil + else + return self.__super:_GetMinimumDimension(dimension) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnClick(self) + local value = not self._value + + if self._settingTable and self._settingKey then + self._settingTable[self._settingKey] = value + end + + self:SetChecked(value) + self:Draw() +end diff --git a/Core/UI/Elements/CollapsibleContainer.lua b/Core/UI/Elements/CollapsibleContainer.lua new file mode 100644 index 0000000..cc337f4 --- /dev/null +++ b/Core/UI/Elements/CollapsibleContainer.lua @@ -0,0 +1,135 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Collapsible Container UI Element Class. +-- An collapsible container is a container which can be collapsed to a single heading line. It is a subclass of the @{Frame} class. +-- @classmod CollapsibleContainer + +local _, TSM = ... +local CollapsibleContainer = TSM.Include("LibTSMClass").DefineClass("CollapsibleContainer", TSM.UI.Frame) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(CollapsibleContainer) +TSM.UI.CollapsibleContainer = CollapsibleContainer +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function CollapsibleContainer.__init(self) + self.__super:__init() + self._headingText = "" + self._contextTbl = nil + self._contextKey = nil +end + +function CollapsibleContainer.Acquire(self) + self.__super:Acquire() + self:SetBackgroundColor("PRIMARY_BG_ALT", true) + self.__super:SetLayout("VERTICAL") + self.__super:SetPadding(12, 12, 8, 8) + self.__super:AddChild(UIElements.New("Frame", "heading") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Button", "expander") + :SetMargin(0, 4, 0, 0) + :SetScript("OnClick", private.OnExpanderClick) + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("BODY_BODY1_BOLD") + ) + ) + self.__super:AddChild(UIElements.New("Frame", "content")) +end + +function CollapsibleContainer.Release(self) + self._headingText = "" + self._contextTbl = nil + self._contextKey = nil + self.__super:Release() +end + +--- Sets the context table and key where to store the collapsed state. +-- @tparam CollapsibleContainer self The collapsible container object +-- @tparam table tbl The table +-- @tparam string key The key +-- @treturn CollapsibleContainer The collapsible container object +function CollapsibleContainer.SetContextTable(self, tbl, key) + assert(type(tbl) == "table" and type(key) == "string") + self._contextTbl = tbl + self._contextKey = key + if self._contextTbl[self._contextKey] then + self:GetElement("content"):Hide() + else + self:GetElement("content"):Show() + end + return self +end + +--- Set the heading text. +-- @tparam CollapsibleContainer self The collapsible container object +-- @tparam ?string|number headingText The heading text +-- @treturn CollapsibleContainer The collapsible container object +function CollapsibleContainer.SetHeadingText(self, headingText) + assert(type(headingText) == "string" or type(headingText) == "number") + self._headingText = headingText + return self +end + +function CollapsibleContainer.SetPadding(self, left, right, top, bottom) + error("CollapsibleContainer doesn't support this method") +end + +function CollapsibleContainer.SetLayout(self, layout) + self:GetElement("content"):SetLayout(layout) + return self +end + +function CollapsibleContainer.AddChild(self, child) + self:GetElement("content"):AddChild(child) + return self +end + +function CollapsibleContainer.AddChildIf(self, condition, child) + self:GetElement("content"):AddChildIf(condition, child) + return self +end + +function CollapsibleContainer.AddChildrenWithFunction(self, func, ...) + self:GetElement("content"):AddChildrenWithFunction(func, ...) + return self +end + +function CollapsibleContainer.AddChildBeforeById(self, beforeId, child) + self:GetElement("content"):AddChildBeforeById(beforeId, child) + return self +end + +function CollapsibleContainer.Draw(self) + self:GetElement("heading.text"):SetText(self._headingText) + self:GetElement("heading.expander"):SetBackgroundAndSize(self._contextTbl[self._contextKey] and "iconPack.18x18/Caret/Right" or "iconPack.18x18/Caret/Down") + self.__super:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnExpanderClick(button) + local self = button:GetParentElement():GetParentElement() + self._contextTbl[self._contextKey] = not self._contextTbl[self._contextKey] + if self._contextTbl[self._contextKey] then + self:GetElement("content"):Hide() + else + self:GetElement("content"):Show() + end + -- TODO: is there a better way to notify the elements up the stack that our size has changed? + self:GetBaseElement():Draw() +end diff --git a/Core/UI/Elements/CommodityList.lua b/Core/UI/Elements/CommodityList.lua new file mode 100644 index 0000000..e5f34a2 --- /dev/null +++ b/Core/UI/Elements/CommodityList.lua @@ -0,0 +1,254 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Commodity List UI Element Class. +-- The element used to show the details of a selected commodity in shopping. It is a subclass of the @{ScrollingTable} class. +-- @classmod CommodityList + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local CommodityList = TSM.Include("LibTSMClass").DefineClass("CommodityList", TSM.UI.ScrollingTable) +UIElements.Register(CommodityList) +TSM.UI.CommodityList = CommodityList +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function CommodityList.__init(self) + self.__super:__init() + self._row = nil + self._marketValueFunc = nil + self._alertThreshold = math.huge +end + +function CommodityList.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:GetScrollingTableInfo() + :NewColumn("warning") + :SetWidth(12) + :SetIconSize(12) + :SetIconHoverEnabled(true) + :SetIconFunction(private.GetWarningIcon) + :SetJustifyH("CENTER") + :SetFont("BODY_BODY3") + :Commit() + :NewColumn("itemBuyout") + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetItemBuyoutText) + :DisableHiding() + :Commit() + :NewColumn("quantity") + :SetWidth(60) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetQuantityText) + :DisableHiding() + :Commit() + :NewColumn("pct") + :SetWidth(50) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetPercentText) + :DisableHiding() + :Commit() + :Commit() +end + +function CommodityList.Release(self) + self._row = nil + self._marketValueFunc = nil + self._alertThreshold = math.huge + self.__super:Release() +end + +function CommodityList.GetTotalQuantity(self, maxIndex) + local totalQuantity = 0 + for _, index in ipairs(self._data) do + if index > maxIndex then + break + end + local subRow = self:_GetSubRow(index) + local _, numOwnerItems = subRow:GetOwnerInfo() + local quantityAvailable = subRow:GetQuantities() - numOwnerItems + totalQuantity = totalQuantity + quantityAvailable + end + return totalQuantity +end + +--- Sets the result row. +-- @tparam CommodityList self The commodity list object +-- @tparam table row The row to set +-- @treturn CommodityList The commodity list object +function CommodityList.SetData(self, row) + self._row = row + self:UpdateData() + return self +end + +--- Sets the selected quantity. +-- @tparam CommodityList self The commodity list object +-- @tparam number quantity The selected quantity +-- @treturn CommodityList The commodity list object +function CommodityList.SelectQuantity(self, quantity) + local maxIndex = nil + for _, index in ipairs(self._data) do + local subRow = self:_GetSubRow(index) + local _, numOwnerItems = subRow:GetOwnerInfo() + local quantityAvailable = subRow:GetQuantities() - numOwnerItems + maxIndex = index + quantity = quantity - quantityAvailable + if quantity <= 0 then + break + end + end + self:SetSelection(maxIndex) + return self +end + +--- Sets the market value function. +-- @tparam CommodityList self The commodity list object +-- @tparam function func The function to call with the ResultSubRow to get the market value +-- @treturn CommodityList The commodity list object +function CommodityList.SetMarketValueFunction(self, func) + self._marketValueFunc = func + return self +end + +--- Sets the alert threshold. +-- @tparam CommodityList self The commodity list object +-- @tparam number threshold The item buyout above which the alert icon should be shown +-- @treturn CommodityList The commodity list object +function CommodityList.SetAlertThreshold(self, threshold) + self._alertThreshold = threshold or math.huge + return self +end + +function CommodityList.SetSelection(self, selection) + self.__super:SetSelection(selection and self:_SanitizeSelectionIndex(selection) or nil) + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function CommodityList._HandleRowClick(self, data, mouseButton) + local index = self:_SanitizeSelectionIndex(data) + if not index then + return + end + self.__super:_HandleRowClick(index, mouseButton) +end + +function CommodityList._SanitizeSelectionIndex(self, selectedIndex) + -- select the highest subrow which isn't the player's auction and isn't above the selection + local highestIndex = nil + for index, subRow in self._row:SubRowIterator() do + if subRow:GetQuantities() - select(2, subRow:GetOwnerInfo()) ~= 0 then + highestIndex = index + end + if index == selectedIndex then + break + end + end + return highestIndex +end + +function CommodityList._GetSubRow(self, index) + return self._row._subRows[index] +end + +function CommodityList._UpdateData(self) + wipe(self._data) + if not self._row then + return + end + for index in self._row:SubRowIterator() do + tinsert(self._data, index) + end +end + +function CommodityList._IsSelected(self, data) + if data > (self._selection or 0) then + return false + end + local subRow = self:_GetSubRow(data) + local _, numOwnerItems = subRow:GetOwnerInfo() + local quantityAvailable = subRow:GetQuantities() - numOwnerItems + return quantityAvailable > 0 +end + +function CommodityList._GetMarketValuePct(self, row) + assert(row:IsSubRow()) + if not self._marketValueFunc then + -- no market value function was set + return nil, nil + end + local marketValue = self._marketValueFunc(row) or 0 + if marketValue == 0 then + -- this item doesn't have a market value + return nil, nil + end + local _, itemBuyout = row:GetBuyouts() + return itemBuyout > 0 and Math.Round(100 * itemBuyout / marketValue) or nil +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetWarningIcon(self, index) + local subRow = self:_GetSubRow(index) + assert(subRow) + local _, itemBuyout = subRow:GetBuyouts() + if itemBuyout < self._alertThreshold then + return + end + return "iconPack.12x12/Attention", L["This price is above your confirmation alert threshold."] +end + +function private.GetItemBuyoutText(self, index) + local _, itemBuyout = self:_GetSubRow(index):GetBuyouts() + return Money.ToString(itemBuyout, nil, "OPT_83_NO_COPPER") +end + +function private.GetPercentText(self, index) + local pct = self:_GetMarketValuePct(self:_GetSubRow(index)) + if not pct then + return "---" + end + local pctColor = Theme.GetAuctionPercentColor(pct) + if pct > 999 then + pct = ">999" + end + return pctColor:ColorText(pct.."%") +end + +function private.GetQuantityText(self, index) + local subRow = self:_GetSubRow(index) + local _, numOwnerItems = subRow:GetOwnerInfo() + local totalQuantity = subRow:GetQuantities() + local quantityAvailable = totalQuantity - numOwnerItems + if quantityAvailable == 0 then + return Theme.GetColor("INDICATOR_ALT"):ColorText(totalQuantity) + else + return quantityAvailable + end +end diff --git a/Core/UI/Elements/Container.lua b/Core/UI/Elements/Container.lua new file mode 100644 index 0000000..2766f90 --- /dev/null +++ b/Core/UI/Elements/Container.lua @@ -0,0 +1,207 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Container UI Element Class. +-- A container is an abstract element class which simply contains other elements. It is a subclass of the @{Element} class. +-- @classmod Container + +local _, TSM = ... +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Container = TSM.Include("LibTSMClass").DefineClass("Container", TSM.UI.Element, "ABSTRACT") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Container) +TSM.UI.Container = Container +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Container.__init(self, frame) + self.__super:__init(frame) + + self._children = {} + self._layoutChildren = {} + self._noLayoutChildren = {} +end + +function Container.Release(self) + self:ReleaseAllChildren() + self.__super:Release() +end + +--- Release all child elements. +-- @tparam Container self The container object +function Container.ReleaseAllChildren(self) + for _, child in ipairs(self._children) do + child:Release() + end + wipe(self._children) + wipe(self._layoutChildren) + wipe(self._noLayoutChildren) +end + +--- Add a child element. +-- @tparam Container self The container object +-- @tparam Element child The child element +-- @treturn Container The container object +function Container.AddChild(self, child) + self:_AddChildHelper(child, true) + return self +end + +--- Add a child element when the required condition is true. +-- @tparam Container self The container object +-- @tparam boolean condition The required condition +-- @tparam Element child The child element +-- @treturn Container The container object +function Container.AddChildIf(self, condition, child) + if not condition then + child:Release() + return self + end + self:_AddChildHelper(child, true) + return self +end + +--- Add a child element before another one. +-- @tparam Container self The container object +-- @tparam string beforeId The id of the child element to add this one before +-- @tparam Element child The child element +-- @treturn Container The container object +function Container.AddChildBeforeById(self, beforeId, child) + self:_AddChildHelper(child, true, beforeId) + return self +end + +--- Add child elements using a function. +-- @tparam Container self The container object +-- @tparam function func The function to call and pass this container object +-- @tparam vararg ... Additional arguments to pass to the function +-- @treturn Container The container object +function Container.AddChildrenWithFunction(self, func, ...) + func(self, ...) + return self +end + +--- Add a child element which is not involved in layout. +-- The layout of this child must be explicitly done by the application code. +-- @tparam Container self The container object +-- @tparam Element child The child element +-- @treturn Container The container object +function Container.AddChildNoLayout(self, child) + self:_AddChildHelper(child, false) + return self +end + +--- Remove a child element. +-- @tparam Container self The container object +-- @tparam Element child The child element to remove +function Container.RemoveChild(self, child) + assert(child:__isa(TSM.UI.Element) and child:_GetBaseFrame():GetParent()) + child:_GetBaseFrame():SetParent(nil) + Table.RemoveByValue(self._children, child) + Table.RemoveByValue(self._layoutChildren, child) + Table.RemoveByValue(self._noLayoutChildren, child) + child:_SetParentElement(nil) +end + +function Container.HasChildById(self, childId) + for _, child in ipairs(self._children) do + if child._id == childId then + return true + end + end + return false +end + +--- Gets the number of child elements involved in layout. +-- @tparam Container self The container object +-- @treturn number The number of elements +function Container.GetNumLayoutChildren(self) + local count = 0 + for _ in self:LayoutChildrenIterator() do + count = count + 1 + end + return count +end + +--- Iterates through the child elements involved in layout. +-- @tparam Container self The container object +-- @return An iterator with the following fields: `index, child` +function Container.LayoutChildrenIterator(self) + local children = TempTable.Acquire() + for _, child in ipairs(self._layoutChildren) do + if child:IsVisible() then + tinsert(children, child) + end + end + return TempTable.Iterator(children) +end + +--- Shows all child elements. +-- @tparam Container self The container object +function Container.ShowAllChildren(self) + for _, child in ipairs(self._layoutChildren) do + if not child:IsVisible() then + child:Show() + end + end +end + +function Container.Draw(self) + self.__super:Draw() + for _, child in ipairs(self._children) do + child:Draw() + end +end + + + +-- ============================================================================ +-- Container - Private Class Methods +-- ============================================================================ + +function Container._AddChildHelper(self, child, layout, beforeId) + assert(child:__isa(TSM.UI.Element) and not child:_GetBaseFrame():GetParent()) + child:_GetBaseFrame():SetParent(self:_GetBaseFrame()) + tinsert(self._children, private.GetElementInsertIndex(self._children, beforeId), child) + if layout then + tinsert(self._layoutChildren, private.GetElementInsertIndex(self._layoutChildren, beforeId), child) + else + tinsert(self._noLayoutChildren, private.GetElementInsertIndex(self._noLayoutChildren, beforeId), child) + end + child:_SetParentElement(self) + child:Show() +end + +function Container._ClearBaseElementCache(self) + self.__super:_ClearBaseElementCache() + for _, child in ipairs(self._children) do + child:_ClearBaseElementCache() + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetElementInsertIndex(tbl, beforeId) + if not beforeId then + return #tbl + 1 + end + for i, element in ipairs(tbl) do + if element._id == beforeId then + return i + end + end + error("Invalid beforeId: "..tostring(beforeId)) +end diff --git a/Core/UI/Elements/CraftingMatList.lua b/Core/UI/Elements/CraftingMatList.lua new file mode 100644 index 0000000..f64fed3 --- /dev/null +++ b/Core/UI/Elements/CraftingMatList.lua @@ -0,0 +1,141 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Crafting Mat List UI Element Class. +-- The element used to show the mats for a specific craft in the Crafting UI. It is a subclass of the @{ScrollingTable} class. +-- @classmod CraftingMatList + +local _, TSM = ... +local CraftingMatList = TSM.Include("LibTSMClass").DefineClass("CraftingMatList", TSM.UI.ScrollingTable) +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local Inventory = TSM.Include("Service.Inventory") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(CraftingMatList) +TSM.UI.CraftingMatList = CraftingMatList +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function CraftingMatList.__init(self) + self.__super:__init() + self._spellId = nil + self._rowHoverEnabled = false +end + +function CraftingMatList.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:SetSelectionDisabled(true) + self:GetScrollingTableInfo() + :NewColumn("check") + :SetWidth(14) + :SetIconSize(14) + :SetIconFunction(private.GetCheck) + :Commit() + :NewColumn("item") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetIconFunction(private.GetItemIcon) + :SetTextFunction(private.GetItemText) + :SetTooltipFunction(private.GetItemTooltip) + :Commit() + :NewColumn("qty") + :SetAutoWidth() + :SetFont("TABLE_TABLE1") + :SetJustifyH("CENTER") + :SetTextFunction(private.GetQty) + :Commit() + :Commit() +end + +function CraftingMatList.Release(self) + self._spellId = nil + self.__super:Release() +end + +function CraftingMatList.SetScript(self, script, handler) + error("Unknown CraftingMatList script: "..tostring(script)) + return self +end + +--- Sets the crafting recipe to display materials for. +-- @tparam CraftingMatList self The crafting mat list object +-- @tparam number spellId The spellId for the recipe +-- @treturn CraftingMatList The crafting mat list object +function CraftingMatList.SetRecipe(self, spellId) + self._spellId = spellId + self:_UpdateData() + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function CraftingMatList._UpdateData(self) + wipe(self._data) + if not self._spellId then + return + end + for i = 1, TSM.Crafting.ProfessionUtil.GetNumMats(self._spellId) do + tinsert(self._data, i) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetCheck(self, index) + local itemLink, _, _, quantity = TSM.Crafting.ProfessionUtil.GetMatInfo(self._spellId, index) + local itemString = ItemString.Get(itemLink) + local bagQuantity = Inventory.GetBagQuantity(itemString) + if not TSM.IsWowClassic() then + bagQuantity = bagQuantity + Inventory.GetReagentBankQuantity(itemString) + Inventory.GetBankQuantity(itemString) + end + if bagQuantity >= quantity then + return TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Checkmark/Default", Theme.GetFeedbackColor("GREEN")) + else + return TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Close/Default", Theme.GetFeedbackColor("RED")) + end +end + +function private.GetItemIcon(self, index) + local _, _, texture = TSM.Crafting.ProfessionUtil.GetMatInfo(self._spellId, index) + return texture +end + +function private.GetItemText(self, index) + local itemLink = TSM.Crafting.ProfessionUtil.GetMatInfo(self._spellId, index) + local itemString = ItemString.Get(itemLink) + return TSM.UI.GetColoredItemName(itemString) or Theme.GetFeedbackColor("RED"):ColorText("?") +end + +function private.GetItemTooltip(self, index) + local itemLink = TSM.Crafting.ProfessionUtil.GetMatInfo(self._spellId, index) + return ItemString.Get(itemLink) +end + +function private.GetQty(self, index) + local itemLink, _, _, quantity = TSM.Crafting.ProfessionUtil.GetMatInfo(self._spellId, index) + local itemString = ItemString.Get(itemLink) + local bagQuantity = Inventory.GetBagQuantity(itemString) + if not TSM.IsWowClassic() then + bagQuantity = bagQuantity + Inventory.GetReagentBankQuantity(itemString) + Inventory.GetBankQuantity(itemString) + end + local color = bagQuantity >= quantity and Theme.GetFeedbackColor("GREEN") or Theme.GetFeedbackColor("RED") + return color:ColorText(format("%d / %d", bagQuantity, quantity)) +end diff --git a/Core/UI/Elements/CraftingQueueList.lua b/Core/UI/Elements/CraftingQueueList.lua new file mode 100644 index 0000000..da737f2 --- /dev/null +++ b/Core/UI/Elements/CraftingQueueList.lua @@ -0,0 +1,498 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Crafting Queue List UI Element Class. +-- The element used to show the queue in the Crafting UI. It is a subclass of the @{ScrollingTable} class. +-- @classmod CraftingQueueList + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Inventory = TSM.Include("Service.Inventory") +local CraftingQueueList = TSM.Include("LibTSMClass").DefineClass("CraftingQueueList", TSM.UI.ScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(CraftingQueueList) +TSM.UI.CraftingQueueList = CraftingQueueList +local private = { + categoryOrder = {}, + sortSelf = nil, + sortProfitCache = {}, +} +local CATEGORY_SEP = "\001" + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function CraftingQueueList.__init(self) + self.__super:__init() + self._collapsed = {} + self._query = nil + self._numCraftableCache = {} + self._onRowMouseDownHandler = nil +end + +function CraftingQueueList.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:SetSelectionDisabled(true) + self:GetScrollingTableInfo() + :NewColumn("name") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetExpanderStateFunction(private.GetExpanderState) + :SetIconFunction(private.GetItemIcon) + :SetIconHoverEnabled(true) + :SetIconClickHandler(private.OnItemIconClick) + :SetTextFunction(private.GetItemText) + :SetTooltipFunction(private.GetItemTooltip) + :SetActionIconInfo(1, 12, private.GetDeleteIcon, true) + :SetActionIconClickHandler(private.OnDeleteIconClick) + :Commit() + :NewColumn("qty") + :SetAutoWidth() + :SetFont("TABLE_TABLE1") + :SetJustifyH("CENTER") + :SetTextFunction(private.GetQty) + :SetActionIconInfo(1, 12, private.GetEditIcon, true) + :SetActionIconClickHandler(private.OnEditIconClick) + :Commit() + :Commit() +end + +function CraftingQueueList.Release(self) + self._onRowMouseDownHandler = nil + wipe(self._numCraftableCache) + wipe(self._collapsed) + if self._query then + self._query:Release() + self._query = nil + end + for _, row in ipairs(self._rows) do + ScriptWrapper.Clear(row._frame, "OnDoubleClick") + ScriptWrapper.Clear(row._frame, "OnMouseDown") + for _, button in pairs(row._buttons) do + ScriptWrapper.Clear(button, "OnMouseDown") + end + end + self.__super:Release() +end + +--- Gets the data of the first row. +-- @tparam CraftingMatList self The crafting queue list object +-- @treturn CraftingQueueList The crafting queue list object +function CraftingQueueList.GetFirstData(self) + for _, data in ipairs(self._data) do + if type(data) ~= "string" then + return data + end + end +end + +--- Registers a script handler. +-- @tparam CraftingQueueList self The crafting queue list object +-- @tparam string script The script to register for (supported scripts: `OnRowClick`, `OnRowMouseDown`) +-- @tparam function handler The script handler which will be called with the crafting queue list object followed by any +-- arguments to the script +-- @treturn CraftingQueueList The crafting queue list object +function CraftingQueueList.SetScript(self, script, handler) + if script == "OnRowMouseDown" then + self._onRowMouseDownHandler = handler + else + self.__super:SetScript(script, handler) + end + return self +end + +--- Sets the @{DatabaseQuery} source for this list. +-- This query is used to populate the entries in the crafting queue list. +-- @tparam CraftingQueueList self The crafting queue list object +-- @tparam DatabaseQuery query The query object +-- @treturn CraftingQueueList The crafting queue list object +function CraftingQueueList.SetQuery(self, query) + if self._query then + self._query:Release() + end + self._query = query + self._query:SetUpdateCallback(private.QueryUpdateCallback, self) + self:_UpdateData() + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function CraftingQueueList._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + ScriptWrapper.Set(row._frame, "OnMouseDown", private.RowOnMouseDown, row) + ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnDoubleClick, row) + for _, button in pairs(row._buttons) do + ScriptWrapper.Set(button, "OnMouseDown", private.RowOnMouseDown, row) + end + end + return row +end + +function CraftingQueueList._UpdateData(self) + wipe(self._data) + if not self._query then + return + end + local categories = TempTable.Acquire() + wipe(self._numCraftableCache) + wipe(private.sortProfitCache) + for _, row in self._query:Iterator() do + local rawCategory = strjoin(CATEGORY_SEP, row:GetFields("profession", "players")) + local category = strlower(rawCategory) + if not categories[category] then + tinsert(categories, category) + end + categories[category] = rawCategory + if not self._collapsed[rawCategory] then + local spellId = row:GetField("spellId") + self._numCraftableCache[row] = TSM.Crafting.ProfessionUtil.GetNumCraftableFromDB(spellId) + private.sortProfitCache[spellId] = TSM.Crafting.Cost.GetProfitBySpellId(spellId) + tinsert(self._data, row) + end + end + sort(categories, private.CategorySortComparator) + wipe(private.categoryOrder) + for i, category in ipairs(categories) do + private.categoryOrder[category] = i + tinsert(self._data, categories[category]) + end + TempTable.Release(categories) + private.sortSelf = self + sort(self._data, private.DataSortComparator) + private.sortSelf = nil +end + +function CraftingQueueList._SetCollapsed(self, data, collapsed) + self._collapsed[data] = collapsed or nil +end + +function CraftingQueueList._HandleRowClick(self, data, mouseButton) + if type(data) == "string" then + self:_SetCollapsed(data, not self._collapsed[data]) + self:UpdateData(true) + else + local currentRow + for _, row in ipairs(self._rows) do + if row:GetData() == data then + currentRow = row + break + end + end + if currentRow._texts.qty:IsMouseOver(0, 0, 0, 12) then + private.OnEditIconClick(self, data, 1) + else + self.__super:_HandleRowClick(data, mouseButton) + end + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RowOnMouseDown(row, mouseButton) + local data = row:GetData() + if type(data) == "string" then + return + end + local self = row._scrollingTable + if self._onRowMouseDownHandler then + self:_onRowMouseDownHandler(data, mouseButton) + end +end + +function private.RowOnDoubleClick(row, mouseButton) + local self = row._scrollingTable + self:_HandleRowClick(row:GetData(), mouseButton) +end + +function private.GetExpanderState(self, data) + if type(data) == "string" then + return true, not self._collapsed[data], 0 + else + return false, false, 0 + end +end + +function private.GetItemIcon(self, data) + if type(data) == "string" then + return + end + local spellId = data:GetField("spellId") + local itemString = TSM.Crafting.GetItemString(spellId) + local texture, tooltip = nil, nil + if itemString then + texture = ItemInfo.GetTexture(itemString) + tooltip = itemString + else + texture = select(3, TSM.Crafting.ProfessionUtil.GetResultInfo(spellId)) + if TSM.Crafting.ProfessionState.IsClassicCrafting() then + tooltip = "craft:"..(TSM.Crafting.ProfessionScanner.GetIndexBySpellId(spellId) or spellId) + else + tooltip = "enchant:"..spellId + end + return + end + return texture, tooltip +end + +function private.OnItemIconClick(self, data, mouseButton) + self:_HandleRowClick(data, mouseButton) +end + +function private.GetItemText(self, data) + if type(data) == "string" then + local profession, players = strsplit(CATEGORY_SEP, data) + local isValid = private.PlayersContains(players, UnitName("player")) and strlower(profession) == strlower(TSM.Crafting.ProfessionUtil.GetCurrentProfessionName() or "") + local text = Theme.GetColor("INDICATOR"):ColorText(profession.." ("..players..")") + if isValid then + return text + else + return text.." "..TSM.UI.TexturePacks.GetTextureLink("iconPack.12x12/Attention") + end + else + local spellId = data:GetField("spellId") + local itemString = TSM.Crafting.GetItemString(spellId) + return itemString and TSM.UI.GetColoredItemName(itemString) or GetSpellInfo(spellId) or "?" + end +end + +function private.GetItemTooltip(self, data) + if type(data) == "string" then + local profession, players = strsplit(CATEGORY_SEP, data) + if not private.PlayersContains(players, UnitName("player")) then + return L["You are not on one of the listed characters."] + elseif strlower(profession) ~= strlower(TSM.Crafting.ProfessionUtil.GetCurrentProfessionName() or "") then + return L["This profession is not open."] + end + return + end + + local spellId = data:GetField("spellId") + local numQueued = data:GetField("num") + local itemString = TSM.Crafting.GetItemString(spellId) + local name = itemString and TSM.UI.GetColoredItemName(itemString) or GetSpellInfo(spellId) or "?" + local tooltipLines = TempTable.Acquire() + tinsert(tooltipLines, name.." (x"..numQueued..")") + local numResult = TSM.Crafting.GetNumResult(spellId) + local profit = TSM.Crafting.Cost.GetProfitBySpellId(spellId) + local profitStr = profit and Money.ToString(profit * numResult, Theme.GetFeedbackColor(profit >= 0 and "GREEN" or "RED"):GetTextColorPrefix()) or "---" + local totalProfitStr = profit and Money.ToString(profit * numResult * numQueued, Theme.GetFeedbackColor(profit >= 0 and "GREEN" or "RED"):GetTextColorPrefix()) or "---" + tinsert(tooltipLines, L["Profit (Total)"]..": "..profitStr.." ("..totalProfitStr..")") + for _, matItemString, quantity in TSM.Crafting.MatIterator(spellId) do + local numHave = Inventory.GetBagQuantity(matItemString) + if not TSM.IsWowClassic() then + numHave = numHave + Inventory.GetReagentBankQuantity(matItemString) + Inventory.GetBankQuantity(matItemString) + end + local numNeed = quantity * numQueued + local color = Theme.GetFeedbackColor(numHave >= numNeed and "GREEN" or "RED") + tinsert(tooltipLines, color:ColorText(numHave.."/"..numNeed).." - "..(ItemInfo.GetName(matItemString) or "?")) + end + if TSM.Crafting.ProfessionUtil.GetRemainingCooldown(spellId) then + tinsert(tooltipLines, Theme.GetFeedbackColor("RED"):ColorText(L["On Cooldown"])) + end + return strjoin("\n", TempTable.UnpackAndRelease(tooltipLines)), true, true +end + +function private.GetDeleteIcon(self, data, iconIndex) + assert(iconIndex == 1) + if type(data) == "string" then + return false + end + return true, "iconPack.12x12/Close/Default", true +end + +function private.OnDeleteIconClick(self, data, iconIndex) + assert(iconIndex == 1 and type(data) ~= "string") + TSM.Crafting.Queue.SetNum(data:GetField("spellId"), 0) +end + +function private.GetEditIcon(self, data, iconIndex) + assert(iconIndex == 1) + if type(data) == "string" then + return false + end + return true, "iconPack.12x12/Edit", true +end + +function private.OnEditIconClick(self, data, iconIndex) + assert(iconIndex == 1 and type(data) ~= "string") + local currentRow = nil + for _, row in ipairs(self._rows) do + if row:GetData() == data then + currentRow = row + break + end + end + local name = private.GetItemText(self, data) + local texture, tooltip = private.GetItemIcon(self, data) + local dialogFrame = UIElements.New("Frame", "qty") + :SetLayout("HORIZONTAL") + :AddAnchor("LEFT", currentRow._frame, Theme.GetColSpacing() / 2, 0) + :AddAnchor("RIGHT", currentRow._frame, -Theme.GetColSpacing(), 0) + :SetHeight(20) + :SetContext(self) + :SetBackgroundColor("PRIMARY_BG") + :SetScript("OnHide", private.DialogOnHide) + :AddChild(UIElements.New("Button", "icon") + :SetSize(12, 12) + :SetMargin(16, 4, 0, 0) + :SetBackground(texture) + :SetTooltip(tooltip) + ) + :AddChild(UIElements.New("Text", "name") + :SetWidth("AUTO") + :SetFont("ITEM_BODY3") + :SetText(name) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Input", "input") + :SetWidth(75) + :SetBackgroundColor("ACTIVE_BG") + :SetJustifyH("CENTER") + :SetContext(currentRow:GetData():GetField("spellId")) + :SetSubAddEnabled(true) + :SetValidateFunc("NUMBER", "1:9999") + :SetValue(currentRow:GetData():GetField("num")) + :SetScript("OnFocusLost", private.QtyInputOnFocusLost) + ) + local baseFrame = self:GetBaseElement() + baseFrame:ShowDialogFrame(dialogFrame) + dialogFrame:GetElement("input"):SetFocused(true) +end + +function private.DialogOnHide(frame) + local input = frame:GetElement("input") + TSM.Crafting.Queue.SetNum(input:GetContext(), tonumber(input:GetValue())) + frame:GetContext():Draw() +end + +function private.QtyInputOnFocusLost(input) + input:GetBaseElement():HideDialog() +end + +function private.GetQty(self, data) + if type(data) == "string" then + return "" + end + local numQueued = data:GetFields("num") + local numCraftable = min(self._numCraftableCache[data], numQueued) + local onCooldown = TSM.Crafting.ProfessionUtil.GetRemainingCooldown(data:GetField("spellId")) + local color = Theme.GetFeedbackColor(((numCraftable == 0 or onCooldown) and "RED") or (numCraftable < numQueued and "YELLOW") or "GREEN") + return color:ColorText(format("%s / %s", numCraftable, numQueued)) +end + +function private.PlayersContains(players, player) + players = strlower(players) + player = strlower(player) + return players == player or strmatch(players, "^"..player..",") or strmatch(players, ","..player..",") or strmatch(players, ","..player.."$") +end + +function private.CategorySortComparator(a, b) + local aProfession, aPlayers = strsplit(CATEGORY_SEP, a) + local bProfession, bPlayers = strsplit(CATEGORY_SEP, b) + if aProfession ~= bProfession then + local currentProfession = TSM.Crafting.ProfessionUtil.GetCurrentProfessionName() + currentProfession = strlower(currentProfession or "") + if aProfession == currentProfession then + return true + elseif bProfession == currentProfession then + return false + else + return aProfession < bProfession + end + end + local playerName = UnitName("player") + local aContainsPlayer = private.PlayersContains(aPlayers, playerName) + local bContainsPlayer = private.PlayersContains(bPlayers, playerName) + if aContainsPlayer and not bContainsPlayer then + return true + elseif bContainsPlayer and not aContainsPlayer then + return false + else + return aPlayers < bPlayers + end +end + +function private.DataSortComparator(a, b) + -- sort by category + local aCategory, bCategory = nil, nil + if type(a) == "string" and type(b) == "string" then + return private.categoryOrder[strlower(a)] < private.categoryOrder[strlower(b)] + elseif type(a) == "string" then + aCategory = strlower(a) + bCategory = strlower(strjoin(CATEGORY_SEP, b:GetFields("profession", "players"))) + if aCategory == bCategory then + return true + end + elseif type(b) == "string" then + aCategory = strlower(strjoin(CATEGORY_SEP, a:GetFields("profession", "players"))) + bCategory = strlower(b) + if aCategory == bCategory then + return false + end + else + aCategory = strlower(strjoin(CATEGORY_SEP, a:GetFields("profession", "players"))) + bCategory = strlower(strjoin(CATEGORY_SEP, b:GetFields("profession", "players"))) + end + if aCategory ~= bCategory then + return private.categoryOrder[aCategory] < private.categoryOrder[bCategory] + end + -- sort spells within a category + local aSpellId = a:GetField("spellId") + local bSpellId = b:GetField("spellId") + local aNumCraftable = private.sortSelf._numCraftableCache[a] + local bNumCraftable = private.sortSelf._numCraftableCache[b] + local aNumQueued = a:GetField("num") + local bNumQueued = b:GetField("num") + local aCanCraftAll = aNumCraftable >= aNumQueued + local bCanCraftAll = bNumCraftable >= bNumQueued + if aCanCraftAll and not bCanCraftAll then + return true + elseif not aCanCraftAll and bCanCraftAll then + return false + end + local aCanCraftSome = aNumCraftable > 0 + local bCanCraftSome = bNumCraftable > 0 + if aCanCraftSome and not bCanCraftSome then + return true + elseif not aCanCraftSome and bCanCraftSome then + return false + end + local aProfit = private.sortProfitCache[aSpellId] + local bProfit = private.sortProfitCache[bSpellId] + if aProfit and not bProfit then + return true + elseif not aProfit and bProfit then + return false + end + if aProfit ~= bProfit then + return aProfit > bProfit + end + return aSpellId < bSpellId +end + +function private.QueryUpdateCallback(_, _, self) + self:UpdateData(true) +end diff --git a/Core/UI/Elements/DividedContainer.lua b/Core/UI/Elements/DividedContainer.lua new file mode 100644 index 0000000..b5739d7 --- /dev/null +++ b/Core/UI/Elements/DividedContainer.lua @@ -0,0 +1,281 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Divided Container UI Element Class. +-- A divided container is a container with two children with a divider between them. It is a subclass of the @{Frame} class. +-- @classmod DividedContainer + +local _, TSM = ... +local UIElements = TSM.Include("UI.UIElements") +local DividedContainer = TSM.Include("LibTSMClass").DefineClass("DividedContainer", TSM.UI.Frame) +UIElements.Register(DividedContainer) +TSM.UI.DividedContainer = DividedContainer +local private = {} +local DIVIDER_SIZE = 2 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function DividedContainer.__init(self) + self.__super:__init() + self._leftChild = nil + self._rightChild = nil + self._resizeStartX = nil + self._resizeOffset = 0 + self._contextTable = nil + self._defaultContextTable = nil + self._minLeftWidth = nil + self._minRightWidth = nil +end + +function DividedContainer.Acquire(self) + self.__super:AddChildNoLayout(UIElements.New("Frame", "leftEmpty") + :AddAnchor("TOPLEFT") + :AddAnchor("BOTTOMRIGHT", "divider", "BOTTOMLEFT") + ) + self.__super:AddChild(UIElements.New("Button", "divider") + :SetSize(DIVIDER_SIZE, nil) + :SetHitRectInsets(-2, -2, 0, 0) + :SetRelativeLevel(2) + :EnableRightClick() + :SetScript("OnMouseDown", private.HandleOnMouseDown) + :SetScript("OnMouseUp", private.HandleOnMouseUp) + :SetScript("OnClick", private.HandleOnClick) + :SetScript("OnUpdate", private.HandleOnUpdate) + ) + self.__super:AddChildNoLayout(UIElements.New("Frame", "rightEmpty") + :AddAnchor("TOPLEFT", "divider", "TOPRIGHT") + :AddAnchor("BOTTOMRIGHT") + ) + self.__super:Acquire() + self.__super:SetLayout("HORIZONTAL") +end + +function DividedContainer.Release(self) + self._isVertical = false + self._leftChild = nil + self._rightChild = nil + self._resizeStartX = nil + self._resizeOffset = 0 + self._contextTable = nil + self._defaultContextTable = nil + self._minLeftWidth = nil + self._minRightWidth = nil + self.__super:Release() +end + +function DividedContainer.SetVertical(self) + assert(not self._leftChild and not self._rightChild and not self._isVertical) + self._isVertical = true + self:GetElement("leftEmpty") + :WipeAnchors() + :AddAnchor("TOPLEFT") + :AddAnchor("BOTTOMRIGHT", "divider", "TOPRIGHT") + self:GetElement("divider") + :SetSize(nil, DIVIDER_SIZE) + :SetHitRectInsets(0, 0, -2, -2) + self:GetElement("rightEmpty") + :WipeAnchors() + :AddAnchor("TOPLEFT", "divider", "BOTTOMLEFT") + :AddAnchor("BOTTOMRIGHT") + self.__super:SetLayout("VERTICAL") + return self +end + +function DividedContainer.SetLayout(self, layout) + error("DividedContainer doesn't support this method") +end + +function DividedContainer.AddChild(self, child) + error("DividedContainer doesn't support this method") +end + +function DividedContainer.AddChildBeforeById(self, beforeId, child) + error("DividedContainer doesn't support this method") +end + +--- Sets the context table. +-- This table can be used to preserve the divider position across lifecycles of the divided container and even WoW +-- sessions if it's within the settings DB. The position is stored as the width of the left child element. +-- @tparam DividedContainer self The divided container object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `leftWidth`) +-- @treturn DividedContainer The divided container object +function DividedContainer.SetContextTable(self, tbl, defaultTbl) + assert(defaultTbl.leftWidth > 0) + tbl.leftWidth = tbl.leftWidth or defaultTbl.leftWidth + self._contextTable = tbl + self._defaultContextTable = defaultTbl + return self +end + +--- Sets the context table from a settings object. +-- @tparam DividedContainer self The divided container object +-- @tparam Settings settings The settings object +-- @tparam string key The setting key +-- @treturn DividedContainer The divided container object +function DividedContainer.SetSettingsContext(self, settings, key) + return self:SetContextTable(settings[key], settings:GetDefaultReadOnly(key)) +end + +--- Sets the minimum width of the child element. +-- @tparam DividedContainer self The divided container object +-- @tparam number minLeftWidth The minimum width of the left child element +-- @tparam number minRightWidth The minimum width of the right child element +-- @treturn DividedContainer The divided container object +function DividedContainer.SetMinWidth(self, minLeftWidth, minRightWidth) + self._minLeftWidth = minLeftWidth + self._minRightWidth = minRightWidth + return self +end + +--- Sets the left child element. +-- @tparam DividedContainer self The divided container object +-- @tparam Element child The left child element +-- @treturn DividedContainer The divided container object +function DividedContainer.SetLeftChild(self, child) + assert(not self._isVertical and not self._leftChild and child) + self._leftChild = child + self.__super:AddChildBeforeById("divider", child) + return self +end + +--- Sets the right child element. +-- @tparam DividedContainer self The divided container object +-- @tparam Element child The right child element +-- @treturn DividedContainer The divided container object +function DividedContainer.SetRightChild(self, child) + assert(not self._isVertical and not self._rightChild and child) + self._rightChild = child + self.__super:AddChild(child) + return self +end + +--- Sets the top child element in vertical mode. +-- @tparam DividedContainer self The divided container object +-- @tparam Element child The top child element +-- @treturn DividedContainer The divided container object +function DividedContainer.SetTopChild(self, child) + assert(self._isVertical and not self._leftChild and child) + self._leftChild = child + self.__super:AddChildBeforeById("divider", child) + return self +end + +--- Sets the bottom child element in vertical mode. +-- @tparam DividedContainer self The divided container object +-- @tparam Element child The bottom child element +-- @treturn DividedContainer The divided container object +function DividedContainer.SetBottomChild(self, child) + assert(self._isVertical and not self._rightChild and child) + self._rightChild = child + self.__super:AddChild(child) + return self +end + +function DividedContainer.Draw(self) + assert(self._contextTable and self._minLeftWidth and self._minRightWidth) + self.__super.__super.__super:Draw() + self:GetElement("divider") + :SetBackground("ACTIVE_BG") + :SetHighlightEnabled(true) + + local width = self:_GetDimension(self._isVertical and "HEIGHT" or "WIDTH") - DIVIDER_SIZE + local leftWidth = self._contextTable.leftWidth + self._resizeOffset + local rightWidth = width - leftWidth + if rightWidth < self._minRightWidth then + leftWidth = width - self._minRightWidth + assert(leftWidth >= self._minLeftWidth) + elseif leftWidth < self._minLeftWidth then + leftWidth = self._minLeftWidth + end + self._contextTable.leftWidth = leftWidth - self._resizeOffset + + local leftChild = self._leftChild + local rightChild = self._rightChild + local leftEmpty = self:GetElement("leftEmpty") + local rightEmpty = self:GetElement("rightEmpty") + if self._isVertical then + leftEmpty:SetHeight(leftWidth) + leftChild:SetHeight(leftWidth) + else + leftEmpty:SetWidth(leftWidth) + leftChild:SetWidth(leftWidth) + end + if self._resizeStartX then + leftChild:_GetBaseFrame():SetAlpha(0) + leftChild:_GetBaseFrame():SetFrameStrata("LOW") + rightChild:_GetBaseFrame():SetAlpha(0) + rightChild:_GetBaseFrame():SetFrameStrata("LOW") + leftEmpty:Show() + rightEmpty:Show() + else + leftChild:_GetBaseFrame():SetAlpha(1) + leftChild:_GetBaseFrame():SetFrameStrata(self:_GetBaseFrame():GetFrameStrata()) + rightChild:_GetBaseFrame():SetAlpha(1) + rightChild:_GetBaseFrame():SetFrameStrata(self:_GetBaseFrame():GetFrameStrata()) + leftEmpty:Hide() + rightEmpty:Hide() + end + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.HandleOnUpdate(handle) + local self = handle:GetParentElement() + if self._resizeStartX then + if self._isVertical then + local currY = select(2, GetCursorPosition()) / self:_GetBaseFrame():GetEffectiveScale() + self._resizeOffset = self._resizeStartX - currY + else + local currX = GetCursorPosition() / self:_GetBaseFrame():GetEffectiveScale() + self._resizeOffset = currX - self._resizeStartX + end + self:Draw() + end +end + +function private.HandleOnMouseDown(handle, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local self = handle:GetParentElement() + if self._isVertical then + self._resizeStartX = select(2, GetCursorPosition()) / self:_GetBaseFrame():GetEffectiveScale() + else + self._resizeStartX = GetCursorPosition() / self:_GetBaseFrame():GetEffectiveScale() + end + self._resizeOffset = 0 +end + +function private.HandleOnMouseUp(handle, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local self = handle:GetParentElement() + self._contextTable.leftWidth = max(self._contextTable.leftWidth + self._resizeOffset, self._minLeftWidth) + self._resizeOffset = 0 + self._resizeStartX = nil + self:Draw() +end + +function private.HandleOnClick(handle, mouseButton) + if mouseButton ~= "RightButton" then + return + end + local self = handle:GetParentElement() + self._contextTable.leftWidth = self._defaultContextTable.leftWidth + self:Draw() +end diff --git a/Core/UI/Elements/DropdownList.lua b/Core/UI/Elements/DropdownList.lua new file mode 100644 index 0000000..44fab33 --- /dev/null +++ b/Core/UI/Elements/DropdownList.lua @@ -0,0 +1,195 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local DropdownList = TSM.Include("LibTSMClass").DefineClass("DropdownList", TSM.UI.ScrollingTable) +UIElements.Register(DropdownList) +TSM.UI.DropdownList = DropdownList +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function DropdownList.__init(self) + self.__super:__init() + self._selectedItems = {} + self._multiselect = false + self._onSelectionChangedHandler = nil +end + +function DropdownList.Acquire(self) + self._backgroundColor = "ACTIVE_BG" + self._headerHidden = true + self.__super:Acquire() + self:SetSelectionDisabled(true) + self:GetScrollingTableInfo() + :NewColumn("text") + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetText) + :SetIconSize(12) + :SetIconFunction(private.GetIcon) + :DisableHiding() + :Commit() + :Commit() +end + +function DropdownList.Release(self) + wipe(self._selectedItems) + self._multiselect = false + self._onSelectionChangedHandler = nil + self.__super:Release() +end + +function DropdownList.SetMultiselect(self, multiselect) + self._multiselect = multiselect + return self +end + +function DropdownList.SetItems(self, items, selection, redraw) + wipe(self._data) + for _, item in ipairs(items) do + tinsert(self._data, item) + end + self:_SetSelectionHelper(selection) + + if redraw then + self:Draw() + end + + return self +end + +function DropdownList.ItemIterator(self) + return private.ItemIterator, self, 0 +end + +function DropdownList.SetSelection(self, selection) + self:_SetSelectionHelper(selection) + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler(self._multiselect and self._selectedItems or selection) + end + return self +end + +function DropdownList.GetSelection(self) + if self._multiselect then + return self._selectedItems + else + local selectedItem = next(self._selectedItems) + return selectedItem + end +end + +function DropdownList.SelectAll(self) + assert(self._multiselect) + for _, data in ipairs(self._data) do + self._selectedItems[data] = true + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler(self._selectedItems) + end + self:Draw() +end + +function DropdownList.DeselectAll(self) + assert(self._multiselect) + wipe(self._selectedItems) + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler(self._selectedItems) + end + self:Draw() +end + +function DropdownList.SetScript(self, script, handler) + if script == "OnSelectionChanged" then + self._onSelectionChangedHandler = handler + else + error("Invalid DropdownList script: "..tostring(script)) + end + return self +end + +function DropdownList.Draw(self) + self.__super:Draw() + + local textColor = nil + + local color = Theme.GetColor(self._backgroundColor) + -- the text color should have maximum contrast with the background color, so set it to white/black based on the background color + if color:IsLight() then + -- the background is light, so set the text to black + textColor = Color.GetFullBlack() + else + -- the background is dark, so set the text to white + textColor = Color.GetFullWhite() + end + for _, row in ipairs(self._rows) do + row:SetTextColor(textColor) + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function DropdownList._SetSelectionHelper(self, selection) + wipe(self._selectedItems) + if selection then + if self._multiselect then + assert(type(selection) == "table") + for item, selected in pairs(selection) do + self._selectedItems[item] = selected + end + else + assert(type(selection) == "string" or type(selection) == "number") + self._selectedItems[selection] = true + end + end +end + +function DropdownList._HandleRowClick(self, data) + if self._multiselect then + self._selectedItems[data] = not self._selectedItems[data] or nil + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler(self._selectedItems) + end + self:Draw() + else + self:SetSelection(data) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.GetText(self, data) + return data +end + +function private.GetIcon(self, data) + return self._multiselect and self._selectedItems[data] and "iconPack.12x12/Checkmark/Default" or "" +end + +function private.ItemIterator(self, index) + index = index + 1 + local item = self._data[index] + if not item then + return + end + return index, item, self._selectedItems[item] +end diff --git a/Core/UI/Elements/EditableText.lua b/Core/UI/Elements/EditableText.lua new file mode 100644 index 0000000..fb89128 --- /dev/null +++ b/Core/UI/Elements/EditableText.lua @@ -0,0 +1,179 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- EditableText UI Element Class. +-- A text element which has an editing state. It is a subclass of the @{Text} class. +-- @classmod EditableText + +local _, TSM = ... +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ItemLinked = TSM.Include("Service.ItemLinked") +local UIElements = TSM.Include("UI.UIElements") +local EditableText = TSM.Include("LibTSMClass").DefineClass("EditableText", TSM.UI.Text) +UIElements.Register(EditableText) +TSM.UI.EditableText = EditableText +local private = {} +local STRING_RIGHT_PADDING = 16 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function EditableText.__init(self) + local frame = UIElements.CreateFrame(self, "EditBox") + + self.__super:__init(frame) + + frame:SetShadowColor(0, 0, 0, 0) + frame:SetAutoFocus(false) + ScriptWrapper.Set(frame, "OnEscapePressed", private.OnEscapePressed, self) + ScriptWrapper.Set(frame, "OnEnterPressed", private.OnEnterPressed, self) + ScriptWrapper.Set(frame, "OnEditFocusLost", private.OnEditFocusLost, self) + + frame.text = UIElements.CreateFontString(self, frame) + frame.text:SetAllPoints() + + + local function ItemLinkedCallback(name, link) + if self._allowItemInsert == nil or not self:IsVisible() or not self._editing then + return + end + if self._allowItemInsert == true then + frame:Insert(link) + else + frame:Insert(name) + end + return true + end + ItemLinked.RegisterCallback(ItemLinkedCallback, -1) + + self._editing = false + self._allowItemInsert = nil + self._onValueChangedHandler = nil + self._onEditingChangedHandler = nil +end + +function EditableText.Release(self) + self:_GetBaseFrame():ClearFocus() + self:_GetBaseFrame():Disable() + self._editing = false + self._allowItemInsert = nil + self._onValueChangedHandler = nil + self._onEditingChangedHandler = nil + self.__super:Release() +end + +--- Registers a script handler. +-- @tparam EditableText self The editable text object +-- @tparam string script The script to register for (supported scripts: `OnValueChanged`, `OnEditingChanged`) +-- @tparam function handler The script handler which will be called with the editable text object followed by any +-- arguments to the script +-- @treturn EditableText The editable text object +function EditableText.SetScript(self, script, handler) + if script == "OnValueChanged" then + self._onValueChangedHandler = handler + elseif script == "OnEditingChanged" then + self._onEditingChangedHandler = handler + elseif script == "OnEnter" or script == "OnLeave" or script == "OnMouseDown" then + self.__super:SetScript(script, handler) + else + error("Unknown EditableText script: "..tostring(script)) + end + return self +end + +--- Sets whether or not the text is currently being edited. +-- @tparam EditableText self The editable text object +-- @tparam boolean editing The editing state to set +-- @treturn EditableText The editable text object +function EditableText.SetEditing(self, editing) + self._editing = editing + if self._onEditingChangedHandler then + self:_onEditingChangedHandler(editing) + end + if self._autoWidth then + self:GetParentElement():Draw() + else + self:Draw() + end + return self +end + +--- Allows inserting an item into the editable text by linking it while the editable text has focus. +-- @tparam EditableText self The editable text object +-- @tparam[opt=false] boolean insertLink Insert the link instead of the item name +-- @treturn EditableText The editable text object +function EditableText.AllowItemInsert(self, insertLink) + assert(insertLink == true or insertLink == false or insertLink == nil) + self._allowItemInsert = insertLink or false + return self +end + +function EditableText.Draw(self) + self.__super:Draw() + local frame = self:_GetBaseFrame() + + -- set the editbox font + frame:SetFont(Theme.GetFont(self._font):GetWowFont()) + + -- set the justification + frame:SetJustifyH(self._justifyH) + frame:SetJustifyV(self._justifyV) + + -- set the text color + frame:SetTextColor(self:_GetTextColor():GetFractionalRGBA()) + + if self._editing then + frame:Enable() + frame:SetText(self._textStr) + frame:SetFocus() + frame:HighlightText(0, -1) + frame.text:Hide() + else + frame:SetText("") + frame:ClearFocus() + frame:HighlightText(0, 0) + frame:Disable() + frame.text:Show() + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function EditableText._GetPreferredDimension(self, dimension) + if dimension == "WIDTH" and self._autoWidth and not self._editing then + return self:GetStringWidth() + STRING_RIGHT_PADDING + else + return self.__super.__super:_GetPreferredDimension(dimension) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnEscapePressed(self) + self:SetEditing(false) +end + +function private.OnEnterPressed(self) + local newText = self:_GetBaseFrame():GetText() + self:SetEditing(false) + self:_onValueChangedHandler(newText) +end + +function private.OnEditFocusLost(self) + self:SetEditing(false) +end diff --git a/Core/UI/Elements/Element.lua b/Core/UI/Elements/Element.lua new file mode 100644 index 0000000..ccfa3be --- /dev/null +++ b/Core/UI/Elements/Element.lua @@ -0,0 +1,575 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Base UI Element Class. +-- This the base class for all other UI element classes. +-- @classmod Element + +local _, TSM = ... +local Element = TSM.Include("LibTSMClass").DefineClass("Element", nil, "ABSTRACT") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Analytics = TSM.Include("Util.Analytics") +local Tooltip = TSM.Include("UI.Tooltip") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Element) +TSM.UI.Element = Element +local private = {} +local ANCHOR_REL_PARENT = newproxy() + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Element.__init(self, frame) + self._tags = {} + self._frame = frame + self._scripts = {} + self._baseElementCache = nil + self._parent = nil + self._context = nil + self._acquired = nil + self._tooltip = nil + self._width = nil + self._height = nil + self._margin = { left = 0, right = 0, top = 0, bottom = 0 } + self._padding = { left = 0, right = 0, top = 0, bottom = 0 } + self._relativeLevel = nil + self._anchors = {} +end + +function Element.__tostring(self) + local parentId = self._parent and self._parent._id + return self.__class.__name..":"..(parentId and (parentId..".") or "")..(self._id or "?") +end + +function Element.SetId(self, id) + -- should only be called by core UI code before acquiring the element + assert(not self._acquired) + self._id = id or tostring(self) +end + +function Element.SetTags(self, ...) + -- should only be called by core UI code before acquiring the element + assert(not self._acquired) + assert(#self._tags == 0) + for i = 1, select("#", ...) do + local tag = select(i, ...) + tinsert(self._tags, tag) + end +end + +function Element.Acquire(self) + assert(not self._acquired) + self._acquired = true + self:Show() +end + +function Element.Release(self) + assert(self._acquired) + local frame = self:_GetBaseFrame() + + -- clear the OnLeave script before hiding the frame (otherwise it'll get called) + if self._scripts.OnLeave then + frame:SetScript("OnLeave", nil) + self._scripts.OnLeave = nil + end + + if self._tooltip and Tooltip.IsVisible(frame) then + -- hide the tooltip + Tooltip.Hide() + end + + self:Hide() + frame:ClearAllPoints() + frame:SetParent(nil) + frame:SetScale(1) + -- clear scripts + for script in pairs(self._scripts) do + frame:SetScript(script, nil) + end + + wipe(self._tags) + wipe(self._scripts) + self._baseElementCache = nil + self._parent = nil + self._context = nil + self._acquired = nil + self._tooltip = nil + self._width = nil + self._height = nil + self._margin.left = 0 + self._margin.right = 0 + self._margin.top = 0 + self._margin.bottom = 0 + self._padding.left = 0 + self._padding.right = 0 + self._padding.top = 0 + self._padding.bottom = 0 + self._relativeLevel = nil + wipe(self._anchors) + + UIElements.Recycle(self) +end + +--- Shows the element. +-- @tparam Element self The element object +function Element.Show(self) + self:_GetBaseFrame():Show() + return self +end + +--- Hides the element. +-- @tparam Element self The element object +function Element.Hide(self) + self:_GetBaseFrame():Hide() + return self +end + +--- Returns whether or not the element is visible. +-- @tparam Element self The element object +-- @treturn boolean Whether or not the element is currently visible +function Element.IsVisible(self) + return self:_GetBaseFrame():IsVisible() +end + +--- Sets the width of the element. +-- @tparam Element self The element object +-- @tparam ?number width The width of the element, or nil to have an undefined width +-- @treturn Element The element object +function Element.SetWidth(self, width) + assert(width == nil or type(width) == "number") + self._width = width + return self +end + +--- Sets the height of the element. +-- @tparam Element self The element object +-- @tparam ?number height The height of the element, or nil to have an undefined height +-- @treturn Element The element object +function Element.SetHeight(self, height) + assert(height == nil or type(height) == "number") + self._height = height + return self +end + +--- Sets the width and height of the element. +-- @tparam Element self The element object +-- @tparam ?number width The width of the element, or nil to have an undefined width +-- @tparam ?number height The height of the element, or nil to have an undefined height +-- @treturn Element The element object +function Element.SetSize(self, width, height) + self:SetWidth(width) + self:SetHeight(height) + return self +end + +--- Sets the padding of the element. +-- @tparam Element self The element object +-- @tparam number left The left padding value if all arguments are passed or the value of all sides if a single argument is passed +-- @tparam[opt] number right The right padding value if all arguments are passed +-- @tparam[opt] number top The top padding value if all arguments are passed +-- @tparam[opt] number bottom The bottom padding value if all arguments are passed +-- @treturn Element The element object +function Element.SetPadding(self, left, right, top, bottom) + if not right and not top and not bottom then + right = left + top = left + bottom = left + end + assert(type(left) == "number" and type(right) == "number" and type(top) == "number" and type(bottom) == "number") + self._padding.left = left + self._padding.right = right + self._padding.top = top + self._padding.bottom = bottom + return self +end + +--- Sets the margin of the element. +-- @tparam Element self The element object +-- @tparam number left The left margin value if all arguments are passed or the value of all sides if a single argument is passed +-- @tparam[opt] number right The right margin value if all arguments are passed +-- @tparam[opt] number top The top margin value if all arguments are passed +-- @tparam[opt] number bottom The bottom margin value if all arguments are passed +-- @treturn Element The element object +function Element.SetMargin(self, left, right, top, bottom) + if not right and not top and not bottom then + right = left + top = left + bottom = left + end + assert(type(left) == "number" and type(right) == "number" and type(top) == "number" and type(bottom) == "number") + self._margin.left = left + self._margin.right = right + self._margin.top = top + self._margin.bottom = bottom + return self +end + +--- Sets the relative level of this element with regards to its parent. +-- @tparam Element self The element object +-- @tparam number level The relative level of this element +-- @treturn Element The element object +function Element.SetRelativeLevel(self, level) + self._relativeLevel = level + return self +end + +--- Wipes the element's anchors. +-- @treturn Element The element object +function Element.WipeAnchors(self) + wipe(self._anchors) + return self +end + +--- Adds an anchor to the element. +-- @tparam Element self The element object +-- @param ... The anchor arguments (following WoW's SetPoint() arguments) +-- @treturn Element The element object +function Element.AddAnchor(self, ...) + local numArgs = select("#", ...) + local point, relFrame, relPoint, x, y = nil, nil, nil, nil, nil + if numArgs == 1 then + point = ... + elseif numArgs == 2 then + point, relFrame = ... + elseif numArgs == 3 then + local arg2 = select(2, ...) + if type(arg2) == "number" then + point, x, y = ... + else + point, relFrame, relPoint = ... + end + elseif numArgs == 4 then + point, relFrame, x, y = ... + elseif numArgs == 5 then + point, relFrame, relPoint, x, y = ... + else + error("Invalid anchor") + end + tinsert(self._anchors, point) + tinsert(self._anchors, relFrame or ANCHOR_REL_PARENT) + tinsert(self._anchors, relPoint or point) + tinsert(self._anchors, x or 0) + tinsert(self._anchors, y or 0) + return self +end + +--- Gets the top-most element in the tree. +-- @tparam Element self The element object +-- @treturn Element The top-most element object +function Element.GetBaseElement(self) + if not self._baseElementCache then + local element = self + local parent = element:GetParentElement() + while parent do + local temp = element + element = parent + parent = temp:GetParentElement() + end + self._baseElementCache = element + end + return self._baseElementCache +end + +--- Gets the parent element's base frame. +-- @tparam Element self The element object +-- @treturn Element The parent element's base frame +function Element.GetParent(self) + return self:GetParentElement():_GetBaseFrame() +end + +--- Gets the parent element. +-- @tparam Element self The element object +-- @treturn Element The parent element object +function Element.GetParentElement(self) + return self._parent +end + +--- Gets another element in the tree by relative path. +-- The path consists of element ids separated by `.`. `__parent` may also be used to indicate the parent element. +-- @tparam Element self The element object +-- @tparam string path The relative path to the element +-- @treturn Element The desired element +function Element.GetElement(self, path) + -- First try to find the element as a child of self + local result = private.GetElementHelper(self, path) + if not result then + Analytics.Action("GET_ELEMENT_FAIL", tostring(self), path) + end + -- TODO: is this needed? + result = result or private.GetElementHelper(self:GetBaseElement(), path) + return result +end + +--- Sets the tooltip of the element. +-- @tparam Element self The element object +-- @param tooltip The value passed to @{Tooltip.Show} when the user hovers over the element, or nil to clear it +-- @treturn Element The element object +function Element.SetTooltip(self, tooltip) + self._tooltip = tooltip + if tooltip then + -- setting OnEnter/OnLeave will implicitly enable the mouse, so make sure it's previously been enabled + assert(self:_GetBaseFrame():IsMouseEnabled()) + self:SetScript("OnEnter", private.OnEnter) + self:SetScript("OnLeave", private.OnLeave) + else + self:SetScript("OnEnter", nil) + self:SetScript("OnLeave", nil) + end + return self +end + +--- Shows a tooltip on the element. +-- @tparam Element self The element object +-- @param tooltip The value passed to @{Tooltip.Show} when the user hovers over the element +-- @tparam ?boolean noWrapping Disables wrapping of text lines +-- @tparam[opt=0] number xOffset An extra x offset to apply to the anchor of the tooltip +-- @treturn Element The element object +function Element.ShowTooltip(self, tooltip, noWrapping, xOffset) + Tooltip.Show(self:_GetBaseFrame(), tooltip, noWrapping, xOffset) + return self +end + +--- Sets the context value of the element. +-- @tparam Element self The element object +-- @param context The context value +-- @treturn Element The element object +function Element.SetContext(self, context) + self._context = context + return self +end + +--- Gets the context value from the element. +-- @tparam Element self The element object +-- @return The context value +function Element.GetContext(self) + return self._context +end + +--- Registers a script handler. +-- @tparam Element self The element object +-- @tparam string script The script to register for +-- @tparam function handler The script handler which will be called with the element object followed by any arguments to +-- the script +-- @treturn Element The element object +function Element.SetScript(self, script, handler) + self._scripts[script] = handler + if handler then + ScriptWrapper.Set(self:_GetBaseFrame(), script, handler, self) + else + ScriptWrapper.Clear(self:_GetBaseFrame(), script) + end + return self +end + +--- Sets a script to propagate to the parent element. +-- @tparam Element self The element object +-- @tparam string script The script to propagate +-- @treturn Element The element object +function Element.PropagateScript(self, script) + self._scripts[script] = "__PROPAGATE" + ScriptWrapper.SetPropagate(self:_GetBaseFrame(), script, self) + return self +end + +function Element.Draw(self) + assert(self._acquired) + local frame = self:_GetBaseFrame() + local numAnchors = self:_GetNumAnchors() + if numAnchors > 0 then + frame:ClearAllPoints() + for i = 1, numAnchors do + local point, relFrame, relPoint, x, y = self:_GetAnchor(i) + if relFrame == ANCHOR_REL_PARENT then + relFrame = frame:GetParent() + elseif type(relFrame) == "string" then + -- this is a relative element + relFrame = self:GetParentElement():GetElement(relFrame):_GetBaseFrame() + end + frame:SetPoint(point, relFrame, relPoint, x, y) + end + end + local width = self._width + if width then + self:_SetDimension("WIDTH", width) + end + local height = self._height + if height then + self:_SetDimension("HEIGHT", height) + end + local relativeLevel = self._relativeLevel + if relativeLevel then + frame:SetFrameLevel(frame:GetParent():GetFrameLevel() + relativeLevel) + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Element._GetNumAnchors(self) + assert(#self._anchors % 5 == 0) + return #self._anchors / 5 +end + +function Element._GetAnchor(self, index) + index = (index - 1) * 5 + 1 + assert(index < #self._anchors) + return unpack(self._anchors, index, index + 4) +end + +function Element._SetParentElement(self, parent) + self._parent = parent + self:_ClearBaseElementCache() +end + +function Element._ClearBaseElementCache(self) + self._baseElementCache = nil +end + +function Element._GetMinimumDimension(self, dimension) + if dimension == "WIDTH" then + local width = self._width + return width or 0, width == nil + elseif dimension == "HEIGHT" then + local height = self._height + return height or 0, height == nil + else + error("Invalid dimension: " .. tostring(dimension)) + end +end + +function Element._GetPreferredDimension(self, dimension) + if dimension == "WIDTH" then + return nil + elseif dimension == "HEIGHT" then + return nil + else + error("Invalid dimension: " .. tostring(dimension)) + end +end + +function Element._GetDimension(self, dimension) + if dimension == "WIDTH" then + return self:_GetBaseFrame():GetWidth() + elseif dimension == "HEIGHT" then + return self:_GetBaseFrame():GetHeight() + else + error("Invalid dimension: " .. tostring(dimension)) + end +end + +function Element._SetDimension(self, dimension, ...) + if dimension == "WIDTH" then + self:_GetBaseFrame():SetWidth(...) + elseif dimension == "HEIGHT" then + self:_GetBaseFrame():SetHeight(...) + else + error("Invalid dimension: " .. tostring(dimension)) + end +end + +function Element._GetBaseFrame(self) + return self._frame +end + +function Element._GetPadding(self, side) + return self._padding[strlower(side)] +end + +function Element._GetPaddingAnchorOffsets(self, anchor) + local xPart, yPart = private.SplitAnchor(anchor) + local x = xPart and ((xPart == "LEFT" and 1 or -1) * self:_GetPadding(xPart)) or 0 + local y = yPart and ((yPart == "BOTTOM" and 1 or -1) * self:_GetPadding(yPart)) or 0 + return x, y +end + +function Element._GetMargin(self, side) + return self._margin[strlower(side)] +end + +function Element._GetMarginAnchorOffsets(self, anchor) + local xPart, yPart = private.SplitAnchor(anchor) + local x = xPart and ((xPart == "LEFT" and 1 or -1) * self:_GetMargin(xPart)) or 0 + local y = yPart and ((yPart == "BOTTOM" and 1 or -1) * self:_GetMargin(yPart)) or 0 + return x, y +end + + + +-- ============================================================================ +-- Helper Functions +-- ============================================================================ + +function private.GetElementHelper(element, path) + local numParts = select("#", strsplit(".", path)) + local partIndex = 1 + while partIndex <= numParts do + local part = select(partIndex, strsplit(".", path)) + if part == "__parent" then + local parentElement = element:GetParentElement() + if not parentElement then + error(format("Element (%s) has no parent", tostring(element._id))) + end + element = parentElement + elseif part == "__base" then + local baseElement = element:GetBaseElement() + if not baseElement then + error(format("Element (%s) has no base element", tostring(element._id))) + end + element = baseElement + else + local found = false + for _, child in ipairs(element._children) do + if child._id == part then + element = child + found = true + break + end + end + if not found then + element = nil + break + end + end + partIndex = partIndex + 1 + end + return element +end + +function private.SplitAnchor(anchor) + if anchor == "BOTTOMLEFT" then + return "LEFT", "BOTTOM" + elseif anchor == "BOTTOM" then + return nil, "BOTTOM" + elseif anchor == "BOTTOMRIGHT" then + return "RIGHT", "BOTTOM" + elseif anchor == "RIGHT" then + return "RIGHT", nil + elseif anchor == "TOPRIGHT" then + return "RIGHT", "TOP" + elseif anchor == "TOP" then + return nil, "TOP" + elseif anchor == "TOPLEFT" then + return "LEFT", "TOP" + elseif anchor == "LEFT" then + return "LEFT", nil + else + error("Invalid anchor: "..tostring(anchor)) + end +end + +function private.OnEnter(element) + element:ShowTooltip(element._tooltip) +end + +function private.OnLeave(element) + Tooltip.Hide() +end diff --git a/Core/UI/Elements/Frame.lua b/Core/UI/Elements/Frame.lua new file mode 100644 index 0000000..4e73197 --- /dev/null +++ b/Core/UI/Elements/Frame.lua @@ -0,0 +1,456 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Frame UI Element Class. +-- A frame is a container which supports automated layout of its children. It also supports being the base element of a UI and anchoring/parenting directly to a WoW frame. It is a subclass of the @{Container} class. +-- @classmod Frame + +local _, TSM = ... +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local NineSlice = TSM.Include("Util.NineSlice") +local VALID_LAYOUTS = { + NONE = true, + HORIZONTAL = true, + VERTICAL = true, + FLOW = true, +} +local LAYOUT_CONTEXT = { + VERTICAL = { + primaryDimension = "HEIGHT", + secondaryDimension = "WIDTH", + sides = { primary = { "TOP", "BOTTOM" }, secondary = { "LEFT", "RIGHT" } }, + }, + HORIZONTAL = { + primaryDimension = "WIDTH", + secondaryDimension = "HEIGHT", + sides = { primary = { "LEFT", "RIGHT" }, secondary = { "TOP", "BOTTOM" } }, + }, +} +local Frame = TSM.Include("LibTSMClass").DefineClass("Frame", TSM.UI.Container) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Frame) +TSM.UI.Frame = Frame + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Frame.__init(self) + local frame = UIElements.CreateFrame(self, "Frame") + + self.__super:__init(frame) + + self._borderNineSlice = NineSlice.New(frame) + self._borderNineSlice:Hide() + + self._backgroundNineSlice = NineSlice.New(frame, 1) + self._backgroundNineSlice:Hide() + + self._layout = "NONE" + self._backgroundColor = nil + self._roundedCorners = false + self._borderColor = nil + self._borderSize = nil + self._expandWidth = false + self._strata = nil + self._scale = 1 +end + +function Frame.Release(self) + self._layout = "NONE" + self._backgroundColor = nil + self._roundedCorners = false + self._borderColor = nil + self._borderSize = nil + self._expandWidth = false + self._strata = nil + self._scale = 1 + self._borderNineSlice:Hide() + self._backgroundNineSlice:Hide() + local frame = self:_GetBaseFrame() + frame:RegisterForDrag(nil) + frame:EnableMouse(false) + frame:SetMovable(false) + frame:EnableMouseWheel(false) + frame:SetHitRectInsets(0, 0, 0, 0) + self.__super:Release() +end + +--- Sets the background of the frame. +-- @tparam Frame self The frame object +-- @tparam ?string|Color|nil color The background color as a theme color key, Color object, or nil +-- @tparam[opt=false] boolean roundedCorners Whether or not the corners should be rounded +-- @treturn Frame The frame object +function Frame.SetBackgroundColor(self, color, roundedCorners) + assert(color == nil or Color.IsInstance(color) or Theme.GetColor(color)) + self._backgroundColor = color + self._roundedCorners = roundedCorners + return self +end + +--- Sets the border color of the frame. +-- @tparam Frame self The frame object +-- @tparam ?string|nil color The border color as a theme color key or nil +-- @tparam[opt=1] ?number borderSize The border size +-- @treturn Frame The frame object +function Frame.SetBorderColor(self, color, borderSize) + assert(color == nil or Color.IsInstance(color) or Theme.GetColor(color)) + self._borderColor = color + self._borderSize = borderSize or 1 + return self +end + +--- Sets the width of the frame. +-- @tparam Frame self The frame object +-- @tparam ?number|string width The width of the frame, "EXPAND" to set the width to expand to be +-- as large as possible, or nil to have an undefined width +-- @treturn Frame The frame object +function Frame.SetWidth(self, width) + if width == "EXPAND" then + self._expandWidth = true + else + self.__super:SetWidth(width) + end + return self +end + +--- Sets the parent frame. +-- @tparam Frame self The frame object +-- @tparam frame parent The WoW frame to parent to +-- @treturn Frame The frame object +function Frame.SetParent(self, parent) + self:_GetBaseFrame():SetParent(parent) + return self +end + +--- Sets the level of the frame. +-- @tparam Frame self The frame object +-- @tparam number level The frame level +-- @treturn Frame The frame object +function Frame.SetFrameLevel(self, level) + self:_GetBaseFrame():SetFrameLevel(level) + return self +end + +--- Sets the strata of the frame. +-- @tparam Frame self The frame object +-- @tparam string strata The frame strata +-- @treturn Frame The frame object +function Frame.SetStrata(self, strata) + self._strata = strata + return self +end + +--- Sets the scale of the frame. +-- @tparam Frame self The frame object +-- @tparam string scale The frame scale +-- @treturn Frame The frame object +function Frame.SetScale(self, scale) + self._scale = scale + return self +end + +--- Sets the layout of the frame. +-- @tparam Frame self The frame object +-- @tparam string layout The frame layout (`NONE`, `HORIZONTAL`, `VERTICAL`, or `FLOW`) +-- @treturn Frame The frame object +function Frame.SetLayout(self, layout) + assert(VALID_LAYOUTS[layout], format("Invalid layout (%s)", tostring(layout))) + self._layout = layout + return self +end + +--- Sets whether mouse interaction is enabled. +-- @tparam Frame self The frame object +-- @tparam boolean enabled Whether mouse interaction is enabled +-- @treturn Frame The frame object +function Frame.SetMouseEnabled(self, enabled) + self:_GetBaseFrame():EnableMouse(enabled) + return self +end + +--- Sets whether mouse wheel interaction is enabled. +-- @tparam Frame self The frame object +-- @tparam boolean enabled Whether mouse wheel interaction is enabled +-- @treturn Frame The frame object +function Frame.SetMouseWheelEnabled(self, enabled) + self:_GetBaseFrame():EnableMouseWheel(enabled) + return self +end + +--- Allows dragging of the frame. +-- @tparam Frame self The frame object +-- @tparam string button The button to support dragging with +-- @treturn Frame The frame object +function Frame.RegisterForDrag(self, button) + self:SetMouseEnabled(button and true or false) + self:_GetBaseFrame():RegisterForDrag(button) + return self +end + +--- Gets whether the mouse is currently over the frame. +-- @tparam Frame self The frame object +-- @treturn boolean Whether or not the mouse is over the frame +function Frame.IsMouseOver(self) + return self:_GetBaseFrame():IsMouseOver() +end + +--- Sets the hit rectangle insets. +-- @tparam Frame self The frame object +-- @tparam number left The left hit rectangle inset +-- @tparam number right The right hit rectangle inset +-- @tparam number top The top hit rectangle inset +-- @tparam number bottom The bottom hit rectangle inset +-- @treturn Frame The frame object +function Frame.SetHitRectInsets(self, left, right, top, bottom) + self:_GetBaseFrame():SetHitRectInsets(left, right, top, bottom) + return self +end + +--- Makes the element movable and starts moving it. +-- @tparam Frame self The element object +function Frame.StartMoving(self) + self:_GetBaseFrame():SetMovable(true) + self:_GetBaseFrame():StartMoving() + return self +end + +--- Stops moving the element, and makes it unmovable. +-- @tparam Frame self The element object +function Frame.StopMovingOrSizing(self) + self:_GetBaseFrame():StopMovingOrSizing() + self:_GetBaseFrame():SetMovable(false) + return self +end + +function Frame.Draw(self) + local layout = self._layout + self.__super.__super:Draw() + local frame = self:_GetBaseFrame() + + if self._backgroundColor then + self._backgroundNineSlice:SetStyle(self._roundedCorners and "rounded" or "solid", self._borderColor and self._borderSize or nil) + local color = Color.IsInstance(self._backgroundColor) and self._backgroundColor or Theme.GetColor(self._backgroundColor) + self._backgroundNineSlice:SetVertexColor(color:GetFractionalRGBA()) + else + assert(not self._borderColor) + self._backgroundNineSlice:Hide() + end + if self._borderColor then + assert(self._backgroundColor) + self._borderNineSlice:SetStyle(self._roundedCorners and "rounded" or "solid") + local color = Color.IsInstance(self._borderColor) and self._borderColor or Theme.GetColor(self._borderColor) + self._borderNineSlice:SetVertexColor(color:GetFractionalRGBA()) + else + self._borderNineSlice:Hide() + end + + frame:SetScale(self._scale) + + local strata = self._strata + if strata then + frame:SetFrameStrata(strata) + end + + if layout == "NONE" then + -- pass + elseif layout == "FLOW" then + local width = self:_GetDimension("WIDTH") + local height = self:_GetDimension("HEIGHT") - self:_GetPadding("TOP") - self:_GetPadding("BOTTOM") + local rowHeight = 0 + for _, child in self:LayoutChildrenIterator() do + child:_GetBaseFrame():ClearAllPoints() + local childPrimary = child:_GetMinimumDimension("WIDTH") + child:_SetDimension("WIDTH", childPrimary) + local childSecondary = child:_GetMinimumDimension("HEIGHT") + rowHeight = childSecondary + child:_GetMargin("BOTTOM") + child:_GetMargin("TOP") + child:_SetDimension("HEIGHT", childSecondary) + end + local xOffset = self:_GetPadding("LEFT") + -- calculate the Y offset to properly position stuff with the padding of this frame taken into account + local yOffset = -self:_GetPadding("TOP") + for _, child in self:LayoutChildrenIterator() do + local childFrame = child:_GetBaseFrame() + local childWidth = childFrame:GetWidth() + child:_GetMargin("LEFT") + child:_GetMargin("RIGHT") + if xOffset + childWidth + self:_GetPadding("RIGHT") > width then + -- move to the next row + xOffset = self:_GetPadding("LEFT") + yOffset = yOffset - rowHeight + end + local childYOffset = yOffset + (height - childFrame:GetHeight()) / 2 - child:_GetMargin("TOP") + childFrame:SetPoint("LEFT", xOffset + child:_GetMargin("LEFT"), childYOffset) + xOffset = xOffset + childWidth + end + else + local context = LAYOUT_CONTEXT[layout] + assert(context) + local primary = self:_GetDimension(context.primaryDimension) - self:_GetPadding(context.sides.primary[1]) - self:_GetPadding(context.sides.primary[2]) + local secondary = self:_GetDimension(context.secondaryDimension) - self:_GetPadding(context.sides.secondary[1]) - self:_GetPadding(context.sides.secondary[2]) + + local expandChildren = TempTable.Acquire() + local preferredChildren = TempTable.Acquire() + for _, child in self:LayoutChildrenIterator() do + child:_GetBaseFrame():ClearAllPoints() + local childPrimary, childPrimaryCanExpand = child:_GetMinimumDimension(context.primaryDimension) + if childPrimaryCanExpand then + local childPreferredPrimary = child:_GetPreferredDimension(context.primaryDimension) + if childPreferredPrimary then + assert(childPreferredPrimary > childPrimary, "Invalid preferred dimension") + preferredChildren[child] = childPreferredPrimary + else + expandChildren[child] = childPrimary + end + else + child:_SetDimension(context.primaryDimension, childPrimary) + end + primary = primary - childPrimary - child:_GetMargin(context.sides.primary[1]) - child:_GetMargin(context.sides.primary[2]) + local childSecondary, childSecondaryCanExpand = child:_GetMinimumDimension(context.secondaryDimension) + childSecondary = min(childSecondary, secondary) + if childSecondaryCanExpand and childSecondary < secondary then + childSecondary = secondary + end + child:_SetDimension(context.secondaryDimension, childSecondary - child:_GetMargin(context.sides.secondary[1]) - child:_GetMargin(context.sides.secondary[2])) + end + for child, preferredPrimary in pairs(preferredChildren) do + local childPrimary = min(primary, preferredPrimary) + child:_SetDimension(context.primaryDimension, childPrimary) + primary = primary - (childPrimary - child:_GetMinimumDimension(context.primaryDimension)) + end + local numExpandChildren = Table.Count(expandChildren) + for child, childPrimary in pairs(expandChildren) do + childPrimary = max(childPrimary, childPrimary + primary / numExpandChildren) + child:_SetDimension(context.primaryDimension, childPrimary) + end + TempTable.Release(expandChildren) + TempTable.Release(preferredChildren) + if layout == "HORIZONTAL" then + local xOffset = self:_GetPadding("LEFT") + -- calculate the Y offset to properly position stuff with the padding of this frame taken into account + local yOffset = (self:_GetPadding("BOTTOM") - self:_GetPadding("TOP")) / 2 + for _, child in self:LayoutChildrenIterator() do + local childFrame = child:_GetBaseFrame() + xOffset = xOffset + child:_GetMargin("LEFT") + local childYOffset = (child:_GetMargin("BOTTOM") - child:_GetMargin("TOP")) / 2 + childFrame:SetPoint("LEFT", xOffset, childYOffset + yOffset) + xOffset = xOffset + childFrame:GetWidth() + child:_GetMargin("RIGHT") + end + elseif layout == "VERTICAL" then + local yOffset = -self:_GetPadding("TOP") + -- calculate the X offset to properly position stuff with the padding of this frame taken into account + local xOffset = (self:_GetPadding("LEFT") - self:_GetPadding("RIGHT")) / 2 + for _, child in self:LayoutChildrenIterator() do + local childFrame = child:_GetBaseFrame() + yOffset = yOffset - child:_GetMargin("TOP") + local childXOffset = (child:_GetMargin("LEFT") - child:_GetMargin("RIGHT")) / 2 + childFrame:SetPoint("TOP", childXOffset + xOffset, yOffset) + yOffset = yOffset - childFrame:GetHeight() - child:_GetMargin("BOTTOM") + end + else + error() + end + end + for _, child in self:LayoutChildrenIterator() do + child:Draw() + end + for _, child in ipairs(self._noLayoutChildren) do + child:Draw() + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Frame._GetMinimumDimension(self, dimension) + assert(dimension == "WIDTH" or dimension == "HEIGHT") + local styleResult = nil + if dimension == "WIDTH" then + styleResult = self._width + elseif dimension == "HEIGHT" then + styleResult = self._height + else + error("Invalid dimension: "..tostring(dimension)) + end + local layout = self._layout + local context = LAYOUT_CONTEXT[layout] + if styleResult then + return styleResult, false + elseif self:GetNumLayoutChildren() == 0 or layout == "NONE" then + return 0, true + elseif layout == "FLOW" then + -- calculate our minimum width which is the largest of the widths of the children + local minWidth = 0 + for _, child in self:LayoutChildrenIterator() do + local childMin = child:_GetMinimumDimension("WIDTH") + childMin = childMin + child:_GetMargin("LEFT") + child:_GetMargin("RIGHT") + minWidth = max(minWidth, childMin) + end + minWidth = minWidth + self:_GetPadding("LEFT") + self:_GetPadding("RIGHT") + if dimension == "WIDTH" then + return minWidth, true + end + + -- calculate the row height (all children should be the exact same height) + local rowHeight = nil + for _, child in self:LayoutChildrenIterator() do + local childMin, childCanExpand = child:_GetMinimumDimension("HEIGHT") + childMin = childMin + child:_GetMargin("TOP") + child:_GetMargin("BOTTOM") + rowHeight = rowHeight or childMin + assert(childMin == rowHeight and not childCanExpand) + end + rowHeight = rowHeight or 0 + + local parentElement = self:GetParentElement() + local parentWidth = parentElement:_GetDimension("WIDTH") - parentElement:_GetPadding("LEFT") - parentElement:_GetPadding("RIGHT") + if minWidth > parentWidth then + -- we won't fit, so just pretend we're a single row + return rowHeight, false + end + + -- calculate our height based on our parent's width + local height = rowHeight + local currentRowWidth = 0 + for _, child in self:LayoutChildrenIterator() do + local childWidth = child:_GetMinimumDimension("WIDTH") + child:_GetMargin("LEFT") + child:_GetMargin("RIGHT") + if currentRowWidth + childWidth > parentWidth then + -- this child will go on the next row + height = height + rowHeight + currentRowWidth = childWidth + else + -- this child fits on the current row + currentRowWidth = currentRowWidth + childWidth + end + end + + return height, false + elseif context then + -- calculate the dimension based on the children + local sides = (dimension == context.primaryDimension) and context.sides.primary or context.sides.secondary + local result = 0 + local canExpand = false + for _, child in self:LayoutChildrenIterator() do + local childMin, childCanExpand = child:_GetMinimumDimension(dimension) + childMin = childMin + child:_GetMargin(sides[1]) + child:_GetMargin(sides[2]) + canExpand = canExpand or childCanExpand + if dimension == context.primaryDimension then + result = result + childMin + else + result = max(result, childMin) + end + end + result = result + self:_GetPadding(sides[1]) + self:_GetPadding(sides[2]) + return result, self._expandWidth or canExpand + else + error(format("Invalid layout (%s)", tostring(layout))) + end +end diff --git a/Core/UI/Elements/Graph.lua b/Core/UI/Elements/Graph.lua new file mode 100644 index 0000000..0edad11 --- /dev/null +++ b/Core/UI/Elements/Graph.lua @@ -0,0 +1,615 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Graph UI Element Class. +-- The graph element allows for generating line graphs. It is a subclass of the @{Element} class. +-- @classmod Graph + +local _, TSM = ... +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Graph = TSM.Include("LibTSMClass").DefineClass("Graph", TSM.UI.Element) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Graph) +TSM.UI.Graph = Graph +local private = {} +local PLOT_X_LABEL_WIDTH = 48 +local PLOT_X_LABEL_HEIGHT = 16 +local PLOT_X_LABEL_MARGIN = 6 +local PLOT_Y_LABEL_WIDTH = 48 +local PLOT_Y_LABEL_HEIGHT = 16 +local PLOT_Y_LABEL_MARGIN = 4 +local PLOT_HIGHLIGHT_TEXT_WIDTH = 80 +local PLOT_HIGHLIGHT_TEXT_HEIGHT = 16 +local PLOT_X_EXTRA_HIT_RECT = 4 +local PLOT_Y_MARGIN = 4 +local LINE_THICKNESS = 1 +local LINE_THICKNESS_RATIO = 16 +local PLOT_MIN_X_LINE_SPACING = PLOT_X_LABEL_WIDTH * 1.5 + 8 +local PLOT_MIN_Y_LINE_SPACING = PLOT_Y_LABEL_HEIGHT * 1.5 + 8 +local HOVER_LINE_THICKNESS = 1 +local MAX_FILL_ALPHA = 0.5 +local SELECTION_ALPHA = 0.2 +local MAX_PLOT_POINTS = 300 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Graph.__init(self) + local frame = UIElements.CreateFrame(self, "Frame", nil, nil, TSM.IsShadowlands() and "BackdropTemplate" or nil) + + self.__super:__init(frame) + + frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + + frame.plot = CreateFrame("Frame", nil, frame, nil) + frame.plot:SetPoint("BOTTOMLEFT", PLOT_Y_LABEL_WIDTH, PLOT_X_LABEL_HEIGHT) + frame.plot:SetPoint("TOPRIGHT", -PLOT_X_EXTRA_HIT_RECT, -PLOT_HIGHLIGHT_TEXT_HEIGHT - PLOT_Y_MARGIN) + frame.plot:SetHitRectInsets(-PLOT_X_EXTRA_HIT_RECT, -PLOT_X_EXTRA_HIT_RECT, 0, 0) + frame.plot:EnableMouse(true) + ScriptWrapper.Set(frame.plot, "OnEnter", private.PlotFrameOnEnter, self) + ScriptWrapper.Set(frame.plot, "OnLeave", private.PlotFrameOnLeave, self) + ScriptWrapper.Set(frame.plot, "OnMouseDown", private.PlotFrameOnMouseDown, self) + ScriptWrapper.Set(frame.plot, "OnMouseUp", private.PlotFrameOnMouseUp, self) + + frame.plot.dot = frame.plot:CreateTexture(nil, "ARTWORK", nil, 3) + TSM.UI.TexturePacks.SetTextureAndSize(frame.plot.dot, "uiFrames.HighlightDot") + + frame.plot.hoverLine = frame.plot:CreateTexture(nil, "ARTWORK", nil, 2) + frame.plot.hoverLine:SetWidth(HOVER_LINE_THICKNESS) + frame.plot.hoverLine:Hide() + + frame.plot.hoverText = frame.plot:CreateFontString() + frame.plot.hoverText:SetSize(PLOT_HIGHLIGHT_TEXT_WIDTH, PLOT_HIGHLIGHT_TEXT_HEIGHT) + frame.plot.hoverText:Hide() + + frame.plot.selectionBox = frame.plot:CreateTexture(nil, "ARTWORK", nil, 2) + frame.plot.selectionBox:Hide() + + self._usedTextures = {} + self._freeTextures = {} + self._usedFontStrings = {} + self._freeFontStrings = {} + self._xValuesFiltered = {} + self._yLookup = {} + self._yValueFunc = nil + self._xFormatFunc = nil + self._yFormatFunc = nil + self._xStepFunc = nil + self._yStepFunc = nil + self._xMin = nil + self._xMax = nil + self._yMin = nil + self._yMax = nil + self._isMouseOver = false + self._selectionStartX = nil + self._zoomStart = nil + self._zoomEnd = nil + self._onZoomChanged = nil + self._onHoverUpdate = nil +end + +function Graph.Release(self) + self:_ReleaseAllTextures() + self:_ReleaseAllFontStrings() + wipe(self._xValuesFiltered) + wipe(self._yLookup) + self._yValueFunc = nil + self._xFormatFunc = nil + self._yFormatFunc = nil + self._xStepFunc = nil + self._yStepFunc = nil + self._xMin = nil + self._xMax = nil + self._yMin = nil + self._yMax = nil + self._isMouseOver = false + self._selectionStartX = nil + self._zoomStart = nil + self._zoomEnd = nil + self._onZoomChanged = nil + self._onHoverUpdate = nil + self.__super:Release() +end + +--- Sets the step size of the axes. +-- @tparam Graph self The graph object +-- @tparam function x A function which gets the next x-axis step value +-- @tparam function y A function which gets the next y-axis step value +-- @treturn Graph The graph object +function Graph.SetAxisStepFunctions(self, x, y) + self._xStepFunc = x + self._yStepFunc = y + return self +end + +function Graph.SetXRange(self, xMin, xMax, stepInterval) + assert(xMin <= xMax) + self._xMin = xMin + self._xMax = xMax + self._xStepInterval = stepInterval + self._zoomStart = xMin + self._zoomEnd = xMax + return self +end + +function Graph.SetZoom(self, zoomStart, zoomEnd) + self._zoomStart = zoomStart + self._zoomEnd = zoomEnd + return self +end + +function Graph.GetZoom(self) + return self._zoomStart, self._zoomEnd +end + +function Graph.GetXRange(self) + local yMin, yMax = nil, nil + for _, x in ipairs(self._xValuesFiltered) do + local y = self._yValueFunc(x) + yMin = min(yMin or math.huge, y) + yMax = max(yMax or -math.huge, y) + end + return self._xMin, self._xMax +end + +function Graph.GetYRange(self) + local yMin, yMax = nil, nil + for _, x in ipairs(self._xValuesFiltered) do + local y = self._yValueFunc(x) + yMin = min(yMin or math.huge, y) + yMax = max(yMax or -math.huge, y) + end + return yMin, yMax +end + +function Graph.SetYValueFunction(self, func) + self._yValueFunc = func + return self +end + +--- Sets functions for formatting values. +-- @tparam Graph self The graph object +-- @tparam function xFormatFunc A function which is passed an x value and returns a formatted string +-- @tparam function yFormatFunc A function which is passed a y value and returns a formatted string +-- @treturn Graph The graph object +function Graph.SetFormatFunctions(self, xFormatFunc, yFormatFunc) + self._xFormatFunc = xFormatFunc + self._yFormatFunc = yFormatFunc + return self +end + +--- Registers a script handler. +-- @tparam ScrollingTable self The graph object +-- @tparam string script The script to register for (supported scripts: `OnZoomChanged`) +-- @tparam function handler The script handler which will be called with the graph object followed by any +-- arguments to the script +-- @treturn Graph The graph object +function Graph.SetScript(self, script, handler) + if script == "OnZoomChanged" then + self._onZoomChanged = handler + elseif script == "OnHoverUpdate" then + self._onHoverUpdate = handler + else + error("Unknown Graph script: "..tostring(script)) + end + return self +end + +function Graph.Draw(self) + self.__super:Draw() + self:_ReleaseAllTextures() + self:_ReleaseAllFontStrings() + local frame = self:_GetBaseFrame() + frame:SetBackdropColor(Theme.GetColor("PRIMARY_BG"):GetFractionalRGBA()) + local plot = frame.plot + plot.hoverText:SetFont(Theme.GetFont("TABLE_TABLE1"):GetWowFont()) + plot.hoverText:SetTextColor(Theme.GetColor("INDICATOR_ALT"):GetFractionalRGBA()) + + local plotWidth = plot:GetWidth() + local plotHeight = plot:GetHeight() + + -- update the filtered set of x values to show and the bounds of the plot data + self:_PopulateFilteredData(plotWidth) + + -- calculate the min and max y values which should be shown + self._yMin, self._yMax = self._yStepFunc("RANGE", self._yMin, self._yMax, floor(plotHeight / PLOT_MIN_Y_LINE_SPACING)) + if Math.IsNan(self._yMax) then + -- this happens when we're resizing the application frame + return + end + + -- draw the y axis lines and labels + local prevYAxisOffset = -math.huge + local yAxisValue = self._yMin + while yAxisValue <= self._yMax do + local yAxisOffset = Math.Scale(yAxisValue, self._yMin, self._yMax, 0, plotHeight) + if not prevYAxisOffset or (yAxisOffset - prevYAxisOffset) >= PLOT_MIN_Y_LINE_SPACING then + self:_DrawYAxisLine(yAxisOffset, yAxisValue, plotWidth, plotHeight) + prevYAxisOffset = yAxisOffset + end + yAxisValue = self._yStepFunc("NEXT", yAxisValue, self._yMax) + end + + -- draw the x axis lines and labels + local xSuggestedStep = Math.Scale(PLOT_MIN_X_LINE_SPACING, 0, plotWidth, 0, self._zoomEnd - self._zoomStart) + local prevXAxisOffset = -math.huge + local xAxisValue = self._xStepFunc(self._zoomStart, xSuggestedStep) + while xAxisValue <= self._zoomEnd do + local xAxisOffset = Math.Scale(xAxisValue, self._zoomStart, self._zoomEnd, 0, plotWidth) + if not prevXAxisOffset or (xAxisOffset - prevXAxisOffset) > PLOT_MIN_X_LINE_SPACING then + self:_DrawXAxisLine(xAxisOffset, xAxisValue, plotWidth, plotHeight, xSuggestedStep) + prevXAxisOffset = xAxisOffset + end + xAxisValue = self._xStepFunc(xAxisValue, xSuggestedStep) + end + + -- draw all the lines + local color = nil + if self._isMouseOver or self._selectionStartX then + color = Theme.GetColor("INDICATOR_ALT") + elseif self._yLookup[self._xValuesFiltered[1]] <= self._yLookup[self._xValuesFiltered[#self._xValuesFiltered]] then + color = Theme.GetFeedbackColor("GREEN") + else + color = Theme.GetFeedbackColor("RED") + end + local xPrev, yPrev = nil, nil + for _, x in ipairs(self._xValuesFiltered) do + local y = self._yLookup[x] + local xCoord = Math.Scale(x, self._zoomStart, self._zoomEnd, 0, plotWidth) + local yCoord = Math.Scale(y, self._yMin, self._yMax, 0, plotHeight) + if xPrev then + self:_DrawFillLine(xPrev, yPrev, xCoord, yCoord, LINE_THICKNESS, plotHeight, color) + end + xPrev = xCoord + yPrev = yCoord + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Graph._PopulateFilteredData(self, plotWidth) + wipe(self._xValuesFiltered) + wipe(self._yLookup) + self._yMin = math.huge + self._yMax = -math.huge + local minStep = Math.Ceil((self._zoomEnd - self._zoomStart) / min(plotWidth / 3, MAX_PLOT_POINTS), self._xStepInterval) + local x = self._zoomStart + while x <= self._zoomEnd do + local prevX = self._xValuesFiltered[#self._xValuesFiltered] + if not prevX or x == self._zoomEnd or (x - prevX > minStep and self._zoomEnd - x > minStep) then + -- this is either the first / last point or a middle point which is sufficiently far from the previous and last points + tinsert(self._xValuesFiltered, x) + local y = self._yValueFunc(x) + self._yMin = min(self._yMin, y) + self._yMax = max(self._yMax, y) + self._yLookup[x] = y + end + if x == self._zoomEnd then + break + end + x = min(x + minStep, self._zoomEnd) + end +end + +function Graph._DrawYAxisLine(self, yOffset, yValue, plotWidth, plotHeight, ySuggestedStep) + local line = self:_AcquireLine("ARTWORK") + local thickness = LINE_THICKNESS + local textureHeight = thickness * LINE_THICKNESS_RATIO + -- trim the texture a bit on the left/right since it's not completely filled to the edges which is noticeable on long lines + line:SetTexCoord(0.1, 1, 0.1, 0, 0.9, 1, 0.9, 0) + line:SetPoint("BOTTOMLEFT", 0 - thickness / 2, yOffset - textureHeight / 2) + line:SetPoint("TOPRIGHT", line:GetParent(), "BOTTOMLEFT", plotWidth + thickness / 2, yOffset + textureHeight / 2) + line:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + line:SetDrawLayer("BACKGROUND", 0) + local text = self:_AcquireFontString(Theme.GetFont("TABLE_TABLE1")) + text:SetJustifyH("RIGHT") + local textYOffset = 0 + if PLOT_Y_LABEL_HEIGHT / 2 > yOffset then + text:SetJustifyV("BOTTOM") + textYOffset = max(PLOT_Y_LABEL_HEIGHT / 2 - yOffset, 0) + elseif yOffset + PLOT_Y_LABEL_HEIGHT / 2 > plotHeight then + text:SetJustifyV("TOP") + textYOffset = plotHeight - yOffset - PLOT_Y_LABEL_HEIGHT / 2 + else + text:SetJustifyV("MIDDLE") + end + text:SetPoint("RIGHT", line, "LEFT", -PLOT_Y_LABEL_MARGIN, textYOffset) + text:SetSize(PLOT_Y_LABEL_WIDTH, PLOT_Y_LABEL_HEIGHT) + text:SetText(self._yFormatFunc(yValue, ySuggestedStep)) +end + +function Graph._DrawXAxisLine(self, xOffset, xValue, plotWidth, plotHeight, xSuggestedStep) + local line = self:_AcquireLine("ARTWORK") + local thickness = LINE_THICKNESS + local textureHeight = thickness * LINE_THICKNESS_RATIO + -- trim the texture a bit on the left/right since it's not completely filled to the edges which is noticeable on long lines + line:SetTexCoord(0.9, 1, 0.1, 1, 0.9, 0, 0.1, 0) + line:SetPoint("BOTTOMLEFT", xOffset - textureHeight / 2, thickness / 2) + line:SetPoint("TOPRIGHT", line:GetParent(), "BOTTOMLEFT", xOffset + textureHeight / 2, plotHeight + thickness / 2) + line:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + line:SetDrawLayer("BACKGROUND", 0) + local text = self:_AcquireFontString(Theme.GetFont("BODY_BODY3_MEDIUM")) + text:ClearAllPoints() + text:SetJustifyV("TOP") + local textXOffset = 0 + if PLOT_X_LABEL_WIDTH / 2 > xOffset then + text:SetJustifyH("LEFT") + textXOffset = max(PLOT_X_LABEL_WIDTH / 2 - xOffset, 0) + elseif xOffset + PLOT_X_LABEL_WIDTH / 2 > plotWidth then + text:SetJustifyH("RIGHT") + textXOffset = plotWidth - xOffset - PLOT_X_LABEL_WIDTH / 2 + else + text:SetJustifyH("CENTER") + end + text:SetPoint("TOP", line, "BOTTOM", textXOffset, -PLOT_X_LABEL_MARGIN) + text:SetSize(PLOT_X_LABEL_WIDTH, PLOT_X_LABEL_HEIGHT) + text:SetText(self._xFormatFunc(xValue, xSuggestedStep)) +end + +function Graph._DrawFillLine(self, xFrom, yFrom, xTo, yTo, thickness, plotHeight, color) + assert(xFrom <= xTo) + local line = self:_AcquireLine("ARTWORK") + local textureHeight = thickness * LINE_THICKNESS_RATIO + local xDiff = xTo - xFrom + local yDiff = yTo - yFrom + local length = sqrt(xDiff * xDiff + yDiff * yDiff) + local sinValue = -yDiff / length + local cosValue = xDiff / length + local sinCosValue = sinValue * cosValue + local aspectRatio = length / textureHeight + local invAspectRatio = textureHeight / length + + -- calculate and set tex coords + local LLx, LLy, ULx, ULy, URx, URy, LRx, LRy = nil, nil, nil, nil, nil, nil, nil, nil + if yDiff >= 0 then + LLx = invAspectRatio * sinCosValue + LLy = sinValue * sinValue + LRy = aspectRatio * sinCosValue + LRx = 1 - LLy + ULx = LLy + ULy = 1 - LRy + URx = 1 - LLx + URy = LRx + else + LLx = sinValue * sinValue + LLy = -aspectRatio * sinCosValue + LRx = 1 + invAspectRatio * sinCosValue + LRy = LLx + ULx = 1 - LRx + ULy = 1 - LLx + URy = 1 - LLy + URx = ULy + end + line:SetTexCoord(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy) + + -- calculate and set texture anchors + local xCenter = (xFrom + xTo) / 2 + local yCenter = (yFrom + yTo) / 2 + local halfWidth = (xDiff + invAspectRatio * abs(yDiff) + thickness) / 2 + local halfHeight = (abs(yDiff) + invAspectRatio * xDiff + thickness) / 2 + line:SetPoint("BOTTOMLEFT", xCenter - halfWidth, yCenter - halfHeight) + line:SetPoint("TOPRIGHT", line:GetParent(), "BOTTOMLEFT", xCenter + halfWidth, yCenter + halfHeight) + + local minY = min(yFrom, yTo) + local maxY = max(yFrom, yTo) + local r, g, b, a = color:GetFractionalRGBA() + local barMaxAlpha = Math.Scale(minY, 0, plotHeight, 0, MAX_FILL_ALPHA * a) + local topMaxAlpha = Math.Scale(maxY, 0, plotHeight, 0, MAX_FILL_ALPHA * a) + line:SetVertexColor(r, g, b, a) + + local fillTop = self:_AcquireTexture("ARTWORK", -1) + fillTop:SetTexture("Interface\\AddOns\\TradeSkillMaster\\Media\\triangle") + if yFrom < yTo then + fillTop:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1) + else + fillTop:SetTexCoord(1, 0, 1, 1, 0, 0, 0, 1) + end + fillTop:SetGradientAlpha("VERTICAL", r, g, b, barMaxAlpha, r, g, b, topMaxAlpha) + fillTop:SetPoint("BOTTOMLEFT", xFrom, minY) + fillTop:SetPoint("TOPRIGHT", fillTop:GetParent(), "BOTTOMLEFT", xTo, maxY) + + local fillBar = self:_AcquireTexture("ARTWORK", -1) + fillBar:SetTexture("Interface\\Buttons\\WHITE8X8") + fillBar:SetGradientAlpha("VERTICAL", r, g, b, 0, r, g, b, barMaxAlpha) + fillBar:SetPoint("BOTTOMLEFT", xFrom, 0) + fillBar:SetPoint("TOPRIGHT", fillBar:GetParent(), "BOTTOMLEFT", xTo, minY) + + return line +end + +function Graph._AcquireLine(self, layer, subLayer) + local line = self:_AcquireTexture(layer, subLayer) + line:SetTexture("Interface\\AddOns\\TradeSkillMaster\\Media\\line.tga") + return line +end + +function Graph._AcquireTexture(self, layer, subLayer) + local plot = self:_GetBaseFrame().plot + local result = tremove(self._freeTextures) or plot:CreateTexture() + tinsert(self._usedTextures, result) + result:SetParent(plot) + result:Show() + result:SetDrawLayer(layer, subLayer) + return result +end + +function Graph._ReleaseAllTextures(self) + while #self._usedTextures > 0 do + local texture = tremove(self._usedTextures) + texture:SetTexture(nil) + texture:SetVertexColor(0, 0, 0, 0) + texture:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1) + texture:SetWidth(0) + texture:SetHeight(0) + texture:ClearAllPoints() + texture:Hide() + tinsert(self._freeTextures, texture) + end +end + +function Graph._AcquireFontString(self, font) + local plot = self:_GetBaseFrame().plot + local result = tremove(self._freeFontStrings) or plot:CreateFontString() + tinsert(self._usedFontStrings, result) + result:SetParent(plot) + result:Show() + result:SetFont(font:GetWowFont()) + result:SetTextColor(Theme.GetColor("TEXT"):GetFractionalRGBA()) + return result +end + +function Graph._ReleaseAllFontStrings(self) + while #self._usedFontStrings > 0 do + local fontString = tremove(self._usedFontStrings) + fontString:SetWidth(0) + fontString:SetHeight(0) + fontString:ClearAllPoints() + fontString:Hide() + tinsert(self._freeFontStrings, fontString) + end +end + +function Graph._GetCursorClosestPoint(self) + local plotFrame = self:_GetBaseFrame().plot + local xPos = GetCursorPosition() / plotFrame:GetEffectiveScale() + local fromMin = plotFrame:GetLeft() + local fromMax = plotFrame:GetRight() + -- Convert the cursor position to be relative to the plotted x values + xPos = Math.Scale(Math.Bound(xPos, fromMin, fromMax), fromMin, fromMax, self._zoomStart, self._zoomEnd) + -- Find the closest point to the cursor (based on the x distance) + local closestX, closestY = nil, nil + for _, x in ipairs(self._xValuesFiltered) do + local y = self._yLookup[x] + local xDist = abs(x - xPos) + if not closestX or xDist < abs(closestX - xPos) then + closestX = x + closestY = y + end + end + assert(closestY) + return closestX, closestY +end + +function Graph._XValueToPlotCoord(self, xValue) + local plotFrame = self:_GetBaseFrame().plot + return Math.Scale(xValue, self._zoomStart, self._zoomEnd, 0, plotFrame:GetWidth()) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PlotFrameOnEnter(self) + self._isMouseOver = true + self:Draw() + local plotFrame = self:_GetBaseFrame().plot + ScriptWrapper.Set(plotFrame, "OnUpdate", private.PlotFrameOnUpdate, self) +end + +function private.PlotFrameOnLeave(self) + self._isMouseOver = false +end + +function private.PlotFrameOnUpdate(self) + local plotFrame = self:_GetBaseFrame().plot + local closestX, closestY = self:_GetCursorClosestPoint() + local xCoord = self:_XValueToPlotCoord(closestX) + local yCoord = Math.Scale(closestY, self._yMin, self._yMax, 0, plotFrame:GetHeight()) + + if self._isMouseOver then + plotFrame.dot:Show() + plotFrame.dot:ClearAllPoints() + plotFrame.dot:SetPoint("CENTER", plotFrame, "BOTTOMLEFT", xCoord, yCoord) + + plotFrame.hoverLine:Show() + plotFrame.hoverLine:SetColorTexture(Theme.GetColor("INDICATOR_ALT"):GetFractionalRGBA()) + plotFrame.hoverLine:ClearAllPoints() + plotFrame.hoverLine:SetPoint("TOP", plotFrame, "TOPLEFT", xCoord, 0) + plotFrame.hoverLine:SetPoint("BOTTOM", plotFrame, "BOTTOMLEFT", xCoord, 0) + + plotFrame.hoverText:Show() + plotFrame.hoverText:SetWidth(1000) + plotFrame.hoverText:SetText(self._yFormatFunc(closestY, nil, true)) + local textWidth = plotFrame.hoverText:GetStringWidth() + plotFrame.hoverText:SetWidth(textWidth) + plotFrame.hoverText:ClearAllPoints() + if xCoord - textWidth / 2 < 0 then + plotFrame.hoverText:SetPoint("BOTTOMLEFT", plotFrame, "TOPLEFT", 0, PLOT_Y_MARGIN) + elseif textWidth / 2 + xCoord > plotFrame:GetWidth() then + plotFrame.hoverText:SetPoint("BOTTOMRIGHT", plotFrame, "TOPRIGHT", 0, PLOT_Y_MARGIN) + else + plotFrame.hoverText:SetPoint("BOTTOM", plotFrame, "TOPLEFT", xCoord, PLOT_Y_MARGIN) + end + else + plotFrame.dot:Hide() + plotFrame.hoverLine:Hide() + plotFrame.hoverText:Hide() + end + + if self._selectionStartX then + local startXCoord = self:_XValueToPlotCoord(self._selectionStartX) + local selectionMinX = min(startXCoord, xCoord) + local selectionMaxX = max(startXCoord, xCoord) + plotFrame.selectionBox:Show() + local r, g, b, a = Theme.GetColor("INDICATOR_ALT"):GetFractionalRGBA() + assert(a == 1) + plotFrame.selectionBox:SetColorTexture(r, g, b, SELECTION_ALPHA) + plotFrame.selectionBox:ClearAllPoints() + plotFrame.selectionBox:SetPoint("TOPLEFT", plotFrame, selectionMinX, 0) + plotFrame.selectionBox:SetPoint("BOTTOMRIGHT", plotFrame, "BOTTOMLEFT", selectionMaxX, 0) + else + plotFrame.selectionBox:Hide() + end + + local isHovered = self._isMouseOver or self._selectionStartX + if not isHovered then + self:Draw() + ScriptWrapper.Clear(plotFrame, "OnUpdate") + end + if self._onHoverUpdate then + self:_onHoverUpdate(isHovered and closestX or nil) + end +end + +function private.PlotFrameOnMouseDown(self, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + assert(self._isMouseOver) + self._selectionStartX = self:_GetCursorClosestPoint() +end + +function private.PlotFrameOnMouseUp(self, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local currentX = self:_GetCursorClosestPoint() + local startX = min(self._selectionStartX, currentX) + local endX = max(self._selectionStartX, currentX) + self._selectionStartX = nil + local plotFrame = self:_GetBaseFrame().plot + plotFrame.selectionBox:Hide() + + if startX ~= endX and (startX ~= self._zoomStart or endX ~= self._zoomEnd) then + self._zoomStart = startX + self._zoomEnd = endX + self:Draw() + if self._onZoomChanged then + self:_onZoomChanged() + end + end +end diff --git a/Core/UI/Elements/GroupSelector.lua b/Core/UI/Elements/GroupSelector.lua new file mode 100644 index 0000000..3b1b136 --- /dev/null +++ b/Core/UI/Elements/GroupSelector.lua @@ -0,0 +1,360 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Group Selector UI Element Class. +-- A group selector is an element which can be used to prompt the user to select a list of groups, usually for +-- filtering. It is a subclass of the @{Element} class. +-- @classmod GroupSelector + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Analytics = TSM.Include("Util.Analytics") +local Theme = TSM.Include("Util.Theme") +local NineSlice = TSM.Include("Util.NineSlice") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local UIElements = TSM.Include("UI.UIElements") +local GroupSelector = TSM.Include("LibTSMClass").DefineClass("GroupSelector", TSM.UI.Element) +UIElements.Register(GroupSelector) +TSM.UI.GroupSelector = GroupSelector +local private = {} +local TEXT_MARGIN = 8 +local ICON_MARGIN = 8 +local DEFAULT_CONTEXT = { selected = {}, collapsed = {} } + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function GroupSelector.__init(self) + local frame = UIElements.CreateFrame(self, "Button") + ScriptWrapper.Set(frame, "OnClick", private.OnClick, self) + self.__super:__init(frame) + + frame.text = UIElements.CreateFontString(self, frame) + frame.text:SetPoint("TOPLEFT", TEXT_MARGIN, 0) + frame.text:SetPoint("BOTTOMRIGHT", -ICON_MARGIN - TSM.UI.TexturePacks.GetWidth("iconPack.18x18/Add/Default") - TEXT_MARGIN, 0) + frame.text:SetJustifyH("LEFT") + frame.text:SetJustifyV("MIDDLE") + + frame.icon = frame:CreateTexture(nil, "ARTWORK") + frame.icon:SetPoint("RIGHT", -ICON_MARGIN, 0) + + frame.iconBtn = CreateFrame("Button", nil, frame) + frame.iconBtn:SetAllPoints(frame.icon) + ScriptWrapper.Set(frame.iconBtn, "OnClick", private.OnIconClick, self) + + self._nineSlice = NineSlice.New(frame) + + self._groupTreeContext = CopyTable(DEFAULT_CONTEXT) + self._hintText = "" + self._selectedText = L["%d groups"] + self._singleSelection = nil + self._onSelectionChanged = nil + self._customQueryFunc = nil + self._showCreateNew = false +end + +function GroupSelector.Release(self) + wipe(self._groupTreeContext.collapsed) + wipe(self._groupTreeContext.selected) + self._hintText = "" + self._selectedText = L["%d groups"] + self._singleSelection = nil + self._onSelectionChanged = nil + self._customQueryFunc = nil + self._showCreateNew = false + self.__super:Release() +end + +--- Sets the hint text. +-- @tparam GroupSelector self The group selector object +-- @tparam string text The hint text +-- @treturn GroupSelector The group selector object +function GroupSelector.SetHintText(self, text) + assert(type(text) == "string") + self._hintText = text + return self +end + +--- Sets the selected text. +-- @tparam GroupSelector self The group selector object +-- @tparam string text The selected text (with a %d formatter for the number of groups) +-- @treturn GroupSelector The group selector object +function GroupSelector.SetSelectedText(self, text) + assert(type(text) == "string" and strmatch(text, "%%d")) + self._selectedText = text + return self +end + +--- Registers a script handler. +-- @tparam GroupSelector self The group selector object +-- @tparam string script The script to register for (supported scripts: `OnSelectionChanged`) +-- @tparam function handler The script handler which will be called with the group selector object followed by any +-- arguments to the script +-- @treturn GroupSelector The group selector object +function GroupSelector.SetScript(self, script, handler) + if script == "OnSelectionChanged" then + self._onSelectionChanged = handler + else + error("Unknown GroupSelector script: "..tostring(script)) + end + return self +end + +--- Sets a function to generate a custom query to use for the group tree +-- @tparam GroupSelector self The group selector object +-- @tparam function func A function to call to create the custom query (gets auto-released by the GroupTree) +-- @treturn GroupSelector The group selector object +function GroupSelector.SetCustomQueryFunc(self, func) + self._customQueryFunc = func + return self +end + +--- Adds the "Create New Group" option to the group tree +-- @tparam GroupSelector self The group selector object +-- @treturn GroupSelector The group selector object +function GroupSelector.AddCreateNew(self) + self._showCreateNew = true + return self +end + +--- Sets the selection to only handle single selection. +-- @tparam GroupSelector self The group selector object +-- @tparam boolean enabled The state of the single selection +-- @treturn GroupSelector The group selector object +function GroupSelector.SetSingleSelection(self, enabled) + self._singleSelection = enabled + return self +end + +--- Returns the single selected group path. +-- @tparam GroupSelector self The group selector object +function GroupSelector.GetSelection(self) + assert(self._singleSelection) + return next(self._groupTreeContext.selected) +end + +--- Sets the single selected group path. +-- @tparam GroupSelector self The group selector object +-- @tparam string|table selection The selected group(s) or nil if nothing should be selected +-- @treturn GroupSelector The group selector object +function GroupSelector.SetSelection(self, selection) + wipe(self._groupTreeContext.selected) + if not selection then + return self + end + if self._singleSelection then + self._groupTreeContext.selected[selection] = true + else + for groupPath in pairs(selection) do + self._groupTreeContext.selected[groupPath] = true + end + end + return self +end + +--- Returns an iterator for all selected groups. +-- @tparam GroupSelector self The group selector object +-- @return An iterator which iterates over the selected groups and has the following values: `groupPath` +function GroupSelector.SelectedGroupIterator(self) + return pairs(self._groupTreeContext.selected) +end + +--- Clears all selected groups. +-- @tparam GroupSelector self The group selector object +-- @tparam boolean silent Don't call the selection changed callback +-- @treturn GroupSelector The group selector object +function GroupSelector.ClearSelectedGroups(self, silent) + wipe(self._groupTreeContext.selected) + if not silent and self._onSelectionChanged then + self:_onSelectionChanged() + end + return self +end + +function GroupSelector.Draw(self) + self.__super:Draw() + local frame = self:_GetBaseFrame() + + frame.text:SetFont(Theme.GetFont("BODY_BODY2"):GetWowFont()) + local numGroups = Table.Count(self._groupTreeContext.selected) + frame.text:SetText(numGroups == 0 and self._hintText or (self._singleSelection and TSM.Groups.Path.Format(next(self._groupTreeContext.selected)) or format(self._selectedText, numGroups))) + + TSM.UI.TexturePacks.SetTextureAndSize(frame.icon, numGroups == 0 and "iconPack.18x18/Add/Default" or "iconPack.18x18/Close/Default") + + self._nineSlice:SetStyle("rounded") + self._nineSlice:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function GroupSelector._CreateQuery(self) + local query = nil + if self._customQueryFunc then + query = self._customQueryFunc() + else + query = TSM.Groups.CreateQuery() + end + if self._singleSelection then + query:NotEqual("groupPath", TSM.CONST.ROOT_GROUP_PATH) + end + return query +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.OnClick(self) + self:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame", "DIALOG") + :SetLayout("VERTICAL") + :SetSize(464, 500) + :SetPadding(8) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Text", "title") + :SetMargin(32, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Select Group"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetPadding(2) + :SetBackgroundColor("PRIMARY_BG") + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "input") + :AllowItemInsert(true) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.DialogFilterOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 0, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChildIf(not self._singleSelection, UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetMargin(8, 0, 0, 0) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + :AddChildIf(self._showCreateNew, UIElements.New("Button", "createGroup") + :SetHeight(24) + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("LEFT") + :SetIcon("iconPack.14x14/Add/Circle", "LEFT") + :SetText(L["Create New Group"]) + :SetScript("OnClick", private.CreateGroupOnClick) + ) + :AddChild(UIElements.New(self._singleSelection and "SelectionGroupTree" or "ApplicationGroupTree", "groupTree") + :SetContext(self) + :SetContextTable(self._groupTreeContext, DEFAULT_CONTEXT) + :SetQuery(self:_CreateQuery()) + ) + ) + :AddChild(UIElements.New("ActionButton", "groupBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetContext(self) + :SetText(L["Select Group"]) + :SetScript("OnClick", private.DialogSelectOnClick) + ) + ) +end + +function private.OnIconClick(self) + if Table.Count(self._groupTreeContext.selected) > 0 then + self:ClearSelectedGroups() + self:Draw() + if self._onSelectionChanged then + self:_onSelectionChanged() + end + else + private.OnClick(self) + end +end + +function private.DialogCloseBtnOnClick(button) + local self = button:GetElement("__parent.__parent.groupBtn"):GetContext() + button:GetBaseElement():HideDialog() + self:Draw() + if self._onSelectionChanged then + self:_onSelectionChanged() + end +end + +function private.DialogFilterOnValueChanged(input) + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(strlower(input:GetValue())) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.DialogSelectOnClick(button) + local self = button:GetContext() + button:GetBaseElement():HideDialog() + self:Draw() + if self._onSelectionChanged then + self:_onSelectionChanged() + end +end + +function private.CreateGroupOnClick(button) + local newGroupPath = L["New Group"] + if TSM.Groups.Exists(newGroupPath) then + local num = 1 + while TSM.Groups.Exists(newGroupPath.." "..num) do + num = num + 1 + end + newGroupPath = newGroupPath.." "..num + end + TSM.Groups.Create(newGroupPath) + Analytics.Action("CREATED_GROUP", newGroupPath) + button:GetElement("__parent.groupTree") + :UpdateData() + :SetSelection(newGroupPath) + :Draw() +end diff --git a/Core/UI/Elements/GroupTree.lua b/Core/UI/Elements/GroupTree.lua new file mode 100644 index 0000000..ec5d7b8 --- /dev/null +++ b/Core/UI/Elements/GroupTree.lua @@ -0,0 +1,345 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- GroupTree UI Element Class. +-- A group tree is an abstract element which displays TSM groups. It is a subclass of the @{ScrollingTable} class. +-- @classmod GroupTree + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local String = TSM.Include("Util.String") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local GroupTree = TSM.Include("LibTSMClass").DefineClass("GroupTree", TSM.UI.ScrollingTable, "ABSTRACT") +UIElements.Register(GroupTree) +TSM.UI.GroupTree = GroupTree +local private = {} +local EXPANDER_SPACING = 2 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function GroupTree.__init(self) + self.__super:__init() + self:SetRowHeight(24) + + self._allData = {} + self._contextTable = nil + self._defaultContextTable = nil + self._hasChildrenLookup = {} + self._query = nil + self._searchStr = "" + self._moduleOperationFilter = nil +end + +function GroupTree.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:GetScrollingTableInfo() + :NewColumn("group") + :SetFont("BODY_BODY2") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetGroupText) + :SetExpanderStateFunction(private.GetExpanderState) + :SetFlagStateFunction(private.GetFlagState) + :SetTooltipFunction(private.GetTooltip) + :Commit() + :Commit() +end + +function GroupTree.Release(self) + wipe(self._allData) + if self._query then + self._query:Release() + self._query = nil + end + self._searchStr = "" + self._moduleOperationFilter = nil + self._contextTable = nil + self._defaultContextTable = nil + wipe(self._hasChildrenLookup) + for _, row in ipairs(self._rows) do + ScriptWrapper.Clear(row._frame, "OnDoubleClick") + end + self.__super:Release() + self:SetRowHeight(24) +end + +--- Sets the context table. +-- This table can be used to preserve collapsed state across lifecycles of the group tree and even WoW sessions if it's +-- within the settings DB. +-- @tparam GroupTree self The group tree object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `collapsed`) +-- @treturn GroupTree The group tree object +function GroupTree.SetContextTable(self, tbl, defaultTbl) + assert(type(defaultTbl.collapsed) == "table") + tbl.collapsed = tbl.collapsed or CopyTable(defaultTbl.collapsed) + self._contextTable = tbl + self._defaultContextTable = defaultTbl + return self +end + +--- Sets the context table from a settings object. +-- @tparam GroupTree self The group tree object +-- @tparam Settings settings The settings object +-- @tparam string key The setting key +-- @treturn GroupTree The group tree object +function GroupTree.SetSettingsContext(self, settings, key) + return self:SetContextTable(settings[key], settings:GetDefaultReadOnly(key)) +end + +--- Sets the query used to populate the group tree. +-- @tparam GroupTree self The group tree object +-- @tparam DatabaseQuery query The database query object +-- @tparam[opt=nil] string moduleName The name of the module to filter visible groups to only ones with operations +-- @treturn GroupTree The group tree object +function GroupTree.SetQuery(self, query, moduleName) + assert(query) + if self._query then + self._query:Release() + end + self._query = query + self._query:SetUpdateCallback(private.QueryUpdateCallback, self) + self._moduleOperationFilter = moduleName + self:UpdateData() + return self +end + +function GroupTree.SetScript(self, script, handler) + -- GroupTree doesn't support any scripts + error("Unknown GroupTree script: "..tostring(script)) + return self +end + +--- Sets the search string. +-- This search string is used to filter the groups which are displayed in the group tree. +-- @tparam GroupTree self The group tree object +-- @tparam string searchStr The search string which filters the displayed groups +-- @treturn GroupTree The group tree object +function GroupTree.SetSearchString(self, searchStr) + self._searchStr = String.Escape(searchStr) + self:UpdateData() + return self +end + +--- Expand every group. +-- @tparam GroupTree self The application group tree object +-- @treturn GroupTree The application group tree object +function GroupTree.ExpandAll(self) + for _, groupPath in ipairs(self._allData) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH and self._hasChildrenLookup[groupPath] and self._contextTable.collapsed[groupPath] then + self:_SetCollapsed(groupPath, false) + end + end + self:UpdateData(true) + return self +end + +--- Collapse every group. +-- @tparam GroupTree self The application group tree object +-- @treturn GroupTree The application group tree object +function GroupTree.CollapseAll(self) + for _, groupPath in ipairs(self._allData) do + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH and self._hasChildrenLookup[groupPath] and not self._contextTable.collapsed[groupPath] then + self:_SetCollapsed(groupPath, true) + end + end + self:UpdateData(true) + return self +end + +--- Toggle the expand/collapse all state of the group tree. +-- @tparam GroupTree self The application group tree object +-- @treturn GroupTree The application group tree object +function GroupTree.ToggleExpandAll(self) + if next(self._contextTable.collapsed) then + -- at least one group is collapsed, so expand everything + self:ExpandAll() + else + -- nothing is collapsed, so collapse everything + self:CollapseAll() + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function GroupTree._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnDoubleClick, row) + end + return row +end + +function GroupTree._CanResizeCols(self) + return false +end + +function GroupTree._UpdateData(self) + -- update our groups list + wipe(self._hasChildrenLookup) + wipe(self._allData) + wipe(self._data) + local groups = TempTable.Acquire() + if self._moduleOperationFilter then + local shouldKeep = TempTable.Acquire() + for _, row in self._query:Iterator() do + local groupPath = row:GetField("groupPath") + shouldKeep[groupPath] = row:GetField("has"..self._moduleOperationFilter.."Operation") + if shouldKeep[groupPath] then + shouldKeep[TSM.CONST.ROOT_GROUP_PATH] = true + -- add all parent groups to the keep table as well + local checkPath = TSM.Groups.Path.GetParent(groupPath) + while checkPath and checkPath ~= TSM.CONST.ROOT_GROUP_PATH do + shouldKeep[checkPath] = true + checkPath = TSM.Groups.Path.GetParent(checkPath) + end + end + end + for _, row in self._query:Iterator() do + local groupPath = row:GetField("groupPath") + if shouldKeep[groupPath] then + tinsert(groups, groupPath) + end + end + TempTable.Release(shouldKeep) + else + for _, row in self._query:Iterator() do + tinsert(groups, row:GetField("groupPath")) + end + end + + -- remove collapsed state for any groups which no longer exist or no longer have children + local pathExists = TempTable.Acquire() + for i, groupPath in ipairs(groups) do + pathExists[groupPath] = true + local nextGroupPath = groups[i + 1] + self._hasChildrenLookup[groupPath] = nextGroupPath and TSM.Groups.Path.IsChild(nextGroupPath, groupPath) or nil + end + for groupPath in pairs(self._contextTable.collapsed) do + if groupPath == TSM.CONST.ROOT_GROUP_PATH or not pathExists[groupPath] or not self._hasChildrenLookup[groupPath] then + self._contextTable.collapsed[groupPath] = nil + end + end + TempTable.Release(pathExists) + + for _, groupPath in ipairs(groups) do + tinsert(self._allData, groupPath) + if self._searchStr ~= "" or not self:_IsGroupHidden(groupPath) then + local groupName = groupPath == TSM.CONST.ROOT_GROUP_PATH and L["Base Group"] or TSM.Groups.Path.GetName(groupPath) + if strmatch(strlower(groupName), self._searchStr) and (self._searchStr == "" or groupPath ~= TSM.CONST.ROOT_GROUP_PATH) then + tinsert(self._data, groupPath) + end + end + end + TempTable.Release(groups) +end + +function GroupTree._IsGroupHidden(self, data) + if data == TSM.CONST.ROOT_GROUP_PATH then + return false + elseif self._contextTable.collapsed[TSM.CONST.ROOT_GROUP_PATH] then + return true + end + local parent = TSM.Groups.Path.GetParent(data) + while parent and parent ~= TSM.CONST.ROOT_GROUP_PATH do + if self._contextTable.collapsed[parent] then + return true + end + parent = TSM.Groups.Path.GetParent(parent) + end + return false +end + +function GroupTree._SetCollapsed(self, data, collapsed) + self._contextTable.collapsed[data] = collapsed or nil +end + +function GroupTree._IsSelected(self, data) + return false +end + +function GroupTree._HandleRowClick(self, data, mouseButton) + if mouseButton == "RightButton" and self._searchStr == "" and data ~= TSM.CONST.ROOT_GROUP_PATH and self._hasChildrenLookup[data] then + self:_SetCollapsed(data, not self._contextTable.collapsed[data]) + self:UpdateData(true) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetGroupText(self, data) + local groupName = data == TSM.CONST.ROOT_GROUP_PATH and L["Base Group"] or TSM.Groups.Path.GetName(data) + if data ~= TSM.CONST.ROOT_GROUP_PATH then + groupName = Theme.GetGroupColor(select('#', strsplit(TSM.CONST.GROUP_SEP, data))):ColorText(groupName) + end + return groupName +end + +function private.GetExpanderState(self, data) + local indentWidth = nil + local searchIsActive = self._searchStr ~= "" + if data == TSM.CONST.ROOT_GROUP_PATH then + indentWidth = -TSM.UI.TexturePacks.GetWidth("iconPack.14x14/Caret/Right") + EXPANDER_SPACING + else + local level = select('#', strsplit(TSM.CONST.GROUP_SEP, data)) + indentWidth = (searchIsActive and 0 or (level - 1)) * (TSM.UI.TexturePacks.GetWidth("iconPack.14x14/Caret/Right") + EXPANDER_SPACING) + end + return not searchIsActive and data ~= TSM.CONST.ROOT_GROUP_PATH and self._hasChildrenLookup[data], not self._contextTable.collapsed[data], nil, indentWidth, EXPANDER_SPACING, true +end + +function private.GetFlagState(self, data, isMouseOver) + if data == TSM.CONST.ROOT_GROUP_PATH then + return true, Theme.GetColor("TEXT") + end + local level = select('#', strsplit(TSM.CONST.GROUP_SEP, data)) + local levelColor = Theme.GetGroupColor(level) + local color = (self:_IsSelected(data) or isMouseOver) and levelColor or Theme.GetColor("PRIMARY_BG_ALT") + return true, color +end + +function private.GetTooltip(self, data) + if self._searchStr == "" then + return nil + end + return TSM.Groups.Path.Format(data), true +end + +function private.QueryUpdateCallback(_, _, self) + self:UpdateData(true) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.RowOnDoubleClick(row, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local data = row:GetData() + local self = row._scrollingTable + assert(self._searchStr == "" and data ~= TSM.CONST.ROOT_GROUP_PATH and self._hasChildrenLookup[data]) + self:_SetCollapsed(data, not self._contextTable.collapsed[data]) + self:UpdateData(true) +end diff --git a/Core/UI/Elements/Input.lua b/Core/UI/Elements/Input.lua new file mode 100644 index 0000000..e2a5009 --- /dev/null +++ b/Core/UI/Elements/Input.lua @@ -0,0 +1,351 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Input UI Element Class. +-- The input element allows the user to enter text. It is a subclass of the @{BaseInput} class. +-- @classmod Input + +local _, TSM = ... +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ItemLinked = TSM.Include("Service.ItemLinked") +local UIElements = TSM.Include("UI.UIElements") +local Input = TSM.Include("LibTSMClass").DefineClass("Input", TSM.UI.BaseInput) +UIElements.Register(Input) +TSM.UI.Input = Input +local private = {} +local PADDING_LEFT = 8 +local PADDING_RIGHT = 8 +local PADDING_TOP_BOTTOM = 4 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Input.__init(self) + local frame = UIElements.CreateFrame(self, "EditBox") + self._editBox = frame + self.__super:__init(frame) + + self._hintText = UIElements.CreateFontString(self, frame) + self._hintText:SetFont(Theme.GetFont("BODY_BODY3"):GetWowFont()) + self._hintText:SetJustifyH("LEFT") + self._hintText:SetJustifyV("MIDDLE") + self._hintText:SetPoint("TOPLEFT", PADDING_LEFT, 0) + self._hintText:SetPoint("BOTTOMRIGHT", -PADDING_RIGHT, 0) + + self._icon = frame:CreateTexture(nil, "ARTWORK") + self._icon:SetPoint("RIGHT", -PADDING_RIGHT / 2, 0) + + self._clearBtn = CreateFrame("Button", nil, frame) + self._clearBtn:SetAllPoints(self._icon) + ScriptWrapper.Set(self._clearBtn, "OnClick", private.ClearBtnOnClick, self) + + self._subIcon = frame:CreateTexture(nil, "ARTWORK") + self._subIcon:SetPoint("LEFT", PADDING_LEFT / 2, 0) + TSM.UI.TexturePacks.SetTextureAndSize(self._subIcon, "iconPack.14x14/Subtract/Default") + + self._subBtn = CreateFrame("Button", nil, frame) + self._subBtn:SetAllPoints(self._subIcon) + ScriptWrapper.Set(self._subBtn, "OnClick", private.SubBtnOnClick, self) + ScriptWrapper.SetPropagate(self._subBtn, "OnEnter") + ScriptWrapper.SetPropagate(self._subBtn, "OnLeave") + + self._addIcon = frame:CreateTexture(nil, "ARTWORK") + self._addIcon:SetPoint("RIGHT", -PADDING_RIGHT / 2, 0) + TSM.UI.TexturePacks.SetTextureAndSize(self._addIcon, "iconPack.14x14/Add/Default") + + self._addBtn = CreateFrame("Button", nil, frame) + self._addBtn:SetAllPoints(self._addIcon) + ScriptWrapper.Set(self._addBtn, "OnClick", private.AddBtnOnClick, self) + ScriptWrapper.SetPropagate(self._addBtn, "OnEnter") + ScriptWrapper.SetPropagate(self._addBtn, "OnLeave") + + ScriptWrapper.Set(frame, "OnEnter", private.OnEnter, self) + ScriptWrapper.Set(frame, "OnLeave", private.OnLeave, self) + + local function ItemLinkedCallback(name, link) + if self._allowItemInsert == nil or not self:IsVisible() or not self:HasFocus() then + return + end + if self._allowItemInsert == true then + self._editBox:Insert(link) + else + self._editBox:Insert(name) + end + return true + end + ItemLinked.RegisterCallback(ItemLinkedCallback, -1) + + self._clearEnabled = false + self._subAddEnabled = false + self._iconTexture = nil + self._autoComplete = nil + self._allowItemInsert = nil + self._lostFocusOnButton = false +end + +function Input.Release(self) + self._clearEnabled = false + self._subAddEnabled = false + self._iconTexture = nil + self._autoComplete = nil + self._allowItemInsert = nil + self._lostFocusOnButton = false + self._hintText:SetText("") + self.__super:Release() +end + +--- Sets the horizontal justification of the hint text. +-- @tparam Input self The input object +-- @tparam string justifyH The horizontal justification (either "LEFT", "CENTER" or "RIGHT") +-- @treturn Input The input object +function Input.SetHintJustifyH(self, justifyH) + assert(justifyH == "LEFT" or justifyH == "CENTER" or justifyH == "RIGHT") + self._hintText:SetJustifyH(justifyH) + return self +end + +--- Sets the vertical justification of the hint text. +-- @tparam Input self The input object +-- @tparam string justifyV The vertical justification (either "TOP", "MIDDLE" or "BOTTOM") +-- @treturn Input The input object +function Input.SetHintJustifyV(self, justifyV) + assert(justifyV == "TOP" or justifyV == "MIDDLE" or justifyV == "BOTTOM") + self._hintText:SetJustifyV(justifyV) + return self +end + +--- Sets the auto complete table. +-- @tparam Input self The input object +-- @tparam table tbl A list of strings to auto-complete to +-- @treturn Input The input object +function Input.SetAutoComplete(self, tbl) + assert(type(tbl) == "table") + self._autoComplete = tbl + return self +end + +--- Sets the hint text. +-- The hint text is shown when there's no other text in the input. +-- @tparam Input self The input object +-- @tparam string text The hint text +-- @treturn Input The input object +function Input.SetHintText(self, text) + self._hintText:SetText(text) + return self +end + +--- Sets whether or not the clear button is enabled. +-- @tparam Input self The input object +-- @tparam boolean enabled Whether or not the clear button is enabled +-- @treturn Input The input object +function Input.SetClearButtonEnabled(self, enabled) + assert(type(enabled) == "boolean") + assert(not self._subAddEnabled) + self._clearEnabled = enabled + return self +end + +--- Sets whether or not the sub/add buttons are enabled. +-- @tparam Input self The input object +-- @tparam boolean enabled Whether or not the sub/add buttons are enabled +-- @treturn Input The input object +function Input.SetSubAddEnabled(self, enabled) + assert(type(enabled) == "boolean") + assert(not self._clearEnabled and not self._iconTexture) + self._subAddEnabled = enabled + return self +end + +--- Sets the icon texture. +-- @tparam Input self The input object +-- @tparam[opt=nil] string iconTexture The texture string to use for the icon texture +-- @treturn Input The input object +function Input.SetIconTexture(self, iconTexture) + assert(iconTexture == nil or TSM.UI.TexturePacks.IsValid(iconTexture)) + assert(not self._subAddEnabled) + self._iconTexture = iconTexture + return self +end + +--- Allows inserting an item into the input by linking it while the input has focus. +-- @tparam Input self The input object +-- @tparam[opt=false] boolean insertLink Insert the link instead of the item name +-- @treturn Input The input object +function Input.AllowItemInsert(self, insertLink) + assert(insertLink == true or insertLink == false or insertLink == nil) + self._allowItemInsert = insertLink or false + return self +end + +function Input.Draw(self) + self.__super:Draw() + self:_UpdateIconsForValue(self._value) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Input._GetHintTextColor(self) + local color = Theme.GetColor(self._disabled and "PRIMARY_BG_ALT" or self._backgroundColor) + if color:IsLight() then + return self:_GetTextColor("+HOVER") + else + return self:_GetTextColor("-HOVER") + end +end + +function Input._UpdateIconsForValue(self, value) + local frame = self:_GetBaseFrame() + local leftPadding, rightPadding = PADDING_LEFT, PADDING_RIGHT + + -- set the hint text + if value == "" and self._hintText:GetText() ~= "" then + self._hintText:SetFont(Theme.GetFont(self._font):GetWowFont()) + self._hintText:SetTextColor(self:_GetHintTextColor():GetFractionalRGBA()) + self._hintText:Show() + else + self._hintText:Hide() + end + + local showSubAdd = self._subAddEnabled and (frame:IsMouseOver() or frame:HasFocus()) + if showSubAdd then + self._subIcon:Show() + self._subBtn:Show() + self._addIcon:Show() + self._addBtn:Show() + else + self._subIcon:Hide() + self._subBtn:Hide() + self._addIcon:Hide() + self._addBtn:Hide() + end + + -- set the icon + local iconTexture = nil + if self._clearEnabled and value ~= "" then + self._clearBtn:Show() + iconTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.18x18/Close/Default", self:_GetTextColor()) + else + self._clearBtn:Hide() + iconTexture = not frame:HasFocus() and self._iconTexture and TSM.UI.TexturePacks.GetColoredKey(self._iconTexture, self:_GetTextColor()) or nil + end + if iconTexture then + assert(not showSubAdd) + self._icon:Show() + TSM.UI.TexturePacks.SetTextureAndSize(self._icon, iconTexture) + rightPadding = rightPadding + TSM.UI.TexturePacks.GetWidth(iconTexture) + else + self._icon:Hide() + end + frame:SetTextInsets(leftPadding, rightPadding, PADDING_TOP_BOTTOM, PADDING_TOP_BOTTOM) + -- for some reason the text insets don't take effect right away, so on the next frame, we call GetTextInsets() which seems to fix things + ScriptWrapper.Set(frame, "OnUpdate", private.OnUpdate, self) +end + +function Input._OnTextChanged(self, value) + self:_UpdateIconsForValue(value) +end + +function Input._ShouldKeepFocus(self) + if not IsMouseButtonDown("LeftButton") then + return false + end + if self._clearBtn:IsVisible() and self._clearBtn:IsMouseOver() then + return true + elseif self._subBtn:IsVisible() and self._subBtn:IsMouseOver() then + return true + elseif self._addBtn:IsVisible() and self._addBtn:IsMouseOver() then + return true + else + return false + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Input._OnChar(self, c) + self.__super:_OnChar(c) + if not self._autoComplete then + return + end + local frame = self:_GetBaseFrame() + local text = frame:GetText() + local match = nil + for _, k in ipairs(self._autoComplete) do + local start, ending = strfind(strlower(k), strlower(text), 1, true) + if start == 1 and ending and ending == #text then + match = k + break + end + end + if match and not IsControlKeyDown() then + local compStart = #text + frame:SetText(match) + self:HighlightText(compStart, #match) + frame:GetScript("OnTextChanged")(frame, true) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnUpdate(self) + local frame = self:_GetBaseFrame() + ScriptWrapper.Clear(frame, "OnUpdate") + frame:GetTextInsets() +end + +function private.ClearBtnOnClick(self) + assert(self:_SetValueHelper("")) + self._escValue = "" + self._editBox:SetText(self._value) + self:Draw() +end + +function private.SubBtnOnClick(self) + local minVal = self._validateContext and strsplit(":", self._validateContext) + local value = tostring(max(tonumber(self:GetValue()) - (IsShiftKeyDown() and 10 or 1), minVal or -math.huge)) + if self:_SetValueHelper(value) then + self._escValue = self._value + self:_GetBaseFrame():SetText(value) + self:_UpdateIconsForValue(value) + end +end + +function private.AddBtnOnClick(self) + local _, maxVal = nil, nil + if self._validateContext then + _, maxVal = strsplit(":", self._validateContext) + end + local value = tostring(min(tonumber(self:GetValue()) + (IsShiftKeyDown() and 10 or 1), maxVal or math.huge)) + if self:_SetValueHelper(value) then + self._escValue = self._value + self:_GetBaseFrame():SetText(value) + self:_UpdateIconsForValue(value) + end +end + +function private.OnEnter(self) + self:_UpdateIconsForValue(self._value) +end + +function private.OnLeave(self) + self:_UpdateIconsForValue(self._value) +end diff --git a/Core/UI/Elements/ItemList.lua b/Core/UI/Elements/ItemList.lua new file mode 100644 index 0000000..1a5b98c --- /dev/null +++ b/Core/UI/Elements/ItemList.lua @@ -0,0 +1,287 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ItemList UI Element Class. +-- This element is used for the item lists in the group UI. It is a subclass of the @{ScrollingTable} class. +-- @classmod ItemList + +local _, TSM = ... +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ItemInfo = TSM.Include("Service.ItemInfo") +local ItemList = TSM.Include("LibTSMClass").DefineClass("ItemList", TSM.UI.ScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ItemList) +TSM.UI.ItemList = ItemList +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ItemList.__init(self) + self.__super:__init() + + self._rightClickToggle = true + self._allData = {} + self._selectedItems = {} + self._category = {} + self._categoryCollapsed = {} + self._filterFunc = nil + self._onSelectionChangedHandler = nil +end + +function ItemList.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:SetSelectionDisabled(true) + self:GetScrollingTableInfo() + :NewColumn("item") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetExpanderStateFunction(private.GetExpanderState) + :SetCheckStateFunction(private.GetCheckState) + :SetIconFunction(private.GetItemIcon) + :SetTextFunction(private.GetItemText) + :SetTooltipFunction(private.GetItemTooltip) + :Commit() + :Commit() +end + +function ItemList.Release(self) + wipe(self._allData) + wipe(self._selectedItems) + wipe(self._category) + wipe(self._categoryCollapsed) + self._filterFunc = nil + self._onSelectionChangedHandler = nil + for _, row in ipairs(self._rows) do + ScriptWrapper.Clear(row._frame, "OnDoubleClick") + end + self.__super:Release() +end + +--- Sets the items. +-- @tparam ItemList self The item list object +-- @tparam table items Either a list of items or list of tables with a `header` field and sub-list of items +-- @tparam boolean redraw Whether or not to redraw the item list +-- @treturn ItemList The item list object +function ItemList.SetItems(self, items, redraw) + wipe(self._allData) + wipe(self._category) + wipe(self._categoryCollapsed) + + for _, item in ipairs(items) do + if type(item) == "table" and next(item) then + assert(item.header) + tinsert(self._allData, item.header) + for _, subItem in ipairs(item) do + tinsert(self._allData, subItem) + self._category[subItem] = item.header + end + elseif type(item) ~= "table" then + tinsert(self._allData, item) + self._category[item] = "" + end + end + self:_UpdateData() + + wipe(self._selectedItems) + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + + if redraw then + -- scroll up to the top + self._vScrollbar:SetValue(0) + self:Draw() + end + + return self +end + +--- Sets a filter function. +-- @tparam ItemList self The item list object +-- @tparam function func A function which is passed an item and returns true if it should be filtered (not shown) +-- @treturn ItemList The item list object +function ItemList.SetFilterFunction(self, func) + self._filterFunc = func + self:_UpdateData() + return self +end + +--- Gets whether or not an item is selected. +-- @tparam ItemList self The item list object +-- @tparam string item The item +-- @treturn boolean Whether or not the item is selected +function ItemList.IsItemSelected(self, item) + return tContains(self._data, item) and self._selectedItems[item] +end + +--- Selects all items. +-- @tparam ItemList self The item list object +function ItemList.SelectAll(self) + for _, item in ipairs(self._data) do + if self._category[item] then + self._selectedItems[item] = true + end + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + self:Draw() +end + +--- Deselects all items. +-- @tparam ItemList self The item list object +function ItemList.ClearSelection(self) + wipe(self._selectedItems) + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + self:Draw() +end + +--- Toggle the selection state of the item list. +-- @tparam ItemList self The item list object +-- @treturn ItemList The item list object +function ItemList.ToggleSelectAll(self) + if self:GetNumSelected() == 0 then + self:SelectAll() + else + self:ClearSelection() + end + return self +end + +--- Registers a script handler. +-- @tparam ItemList self The item list object +-- @tparam string script The script to register for (supported scripts: `OnSelectionChanged`) +-- @tparam function handler The script handler which will be called with the item list object followed by any arguments +-- to the script +-- @treturn ItemList The item list object +function ItemList.SetScript(self, script, handler) + if script == "OnSelectionChanged" then + self._onSelectionChangedHandler = handler + else + error("Unknown ItemList script: "..tostring(script)) + end + return self +end + +--- Gets the number of selected items. +-- @tparam ItemList self The item list object +-- @treturn number The number of selected items +function ItemList.GetNumSelected(self) + local num = 0 + for _, item in ipairs(self._data) do + if self._selectedItems[item] then + num = num + 1 + end + end + return num +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ItemList._UpdateData(self) + wipe(self._data) + for _, data in ipairs(self._allData) do + if not self:_IsDataHidden(data) then + tinsert(self._data, data) + end + end +end + +function ItemList._IsDataHidden(self, data) + if not self._category[data] then + return false + end + if self._categoryCollapsed[self._category[data]] then + return true + end + if self._filterFunc then + return self._filterFunc(data) + end + return false +end + +function ItemList._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnDoubleClick, row) + end + return row +end + +function ItemList._HandleRowClick(self, data) + if self._category[data] then + self._selectedItems[data] = not self._selectedItems[data] + else + if IsMouseButtonDown("RightButton") then + return + end + self._categoryCollapsed[data] = not self._categoryCollapsed[data] + self:_UpdateData() + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + self:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.GetExpanderState(self, data) + local isHeading = not self._category[data] + return isHeading, not self._categoryCollapsed[data], isHeading and 0 or 1 +end + +function private.GetCheckState(self, data) + return self._category[data] and self._selectedItems[data] +end + +function private.GetItemIcon(self, data) + if not self._category[data] then + return + end + return ItemInfo.GetTexture(data) +end + +function private.GetItemText(self, data) + if self._category[data] then + return TSM.UI.GetColoredItemName(data) or Theme.GetFeedbackColor("RED"):ColorText("?") + else + return data + end +end + +function private.GetItemTooltip(self, data) + if not self._category[data] then + return nil + end + return ItemString.Get(data) +end + +function private.RowOnDoubleClick(row, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local self = row._scrollingTable + self:_HandleRowClick(row:GetData()) +end diff --git a/Core/UI/Elements/LargeApplicationFrame.lua b/Core/UI/Elements/LargeApplicationFrame.lua new file mode 100644 index 0000000..fd09518 --- /dev/null +++ b/Core/UI/Elements/LargeApplicationFrame.lua @@ -0,0 +1,204 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- LargeApplicationFrame UI Element Class. +-- This is the base frame of the large TSM windows which have tabs along the top (i.e. MainUI, AuctionUI, CraftingUI). +-- It is a subclass of the @{ApplicationFrame} class. +-- @classmod LargeApplicationFrame + +local _, TSM = ... +local LargeApplicationFrame = TSM.Include("LibTSMClass").DefineClass("LargeApplicationFrame", TSM.UI.ApplicationFrame) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(LargeApplicationFrame) +TSM.UI.LargeApplicationFrame = LargeApplicationFrame +local private = {} +local NAV_BAR_SPACING = 16 +local NAV_BAR_HEIGHT = 24 +local NAV_BAR_RELATIVE_LEVEL = 21 +local NAV_BAR_TOP_OFFSET = -8 + + + +-- ============================================================================ +-- Meta Class Methods +-- ============================================================================ + +function LargeApplicationFrame.__init(self) + self.__super:__init() + + self._buttons = {} + self._selectedButton = nil + self._buttonIndex = {} +end + +function LargeApplicationFrame.Acquire(self) + self:SetContentFrame(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("FRAME_BG") + ) + self.__super:Acquire() +end + +function LargeApplicationFrame.Release(self) + wipe(self._buttons) + wipe(self._buttonIndex) + self._selectedButton = nil + self.__super:Release() +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +--- Sets the context table. +-- This table can be used to preserve position, size, and current page information across lifecycles of the frame and +-- even WoW sessions if it's within the settings DB. +-- @see ApplicationFrame.SetContextTable +-- @tparam LargeApplicationFrame self The large application frame object +-- @tparam table tbl The context table +-- @tparam table defaultTbl Default values (see @{ApplicationFrame.SetContextTable} for fields) +-- @treturn LargeApplicationFrame The large application frame object +function LargeApplicationFrame.SetContextTable(self, tbl, defaultTbl) + assert(defaultTbl.page) + tbl.page = tbl.page or defaultTbl.page + self.__super:SetContextTable(tbl, defaultTbl) + return self +end + +--- Adds a top-level navigation button. +-- @tparam LargeApplicationFrame self The large application frame object +-- @tparam string text The button text +-- @tparam function drawCallback The function called when the button is clicked to get the corresponding content +-- @treturn LargeApplicationFrame The large application frame object +function LargeApplicationFrame.AddNavButton(self, text, drawCallback) + local button = UIElements.New("AlphaAnimatedFrame", "NavBar_"..text) + :SetRange(1, 0.3) + :SetDuration(1) + :SetLayout("HORIZONTAL") + :SetRelativeLevel(NAV_BAR_RELATIVE_LEVEL) + :SetContext(drawCallback) + :AddChild(UIElements.New("Button", "button") + :SetText(text) + :SetScript("OnEnter", private.OnNavBarButtonEnter) + :SetScript("OnLeave", private.OnNavBarButtonLeave) + :SetScript("OnClick", private.OnNavBarButtonClicked) + ) + self:AddChildNoLayout(button) + tinsert(self._buttons, button) + self._buttonIndex[text] = #self._buttons + if self._buttonIndex[text] == self._contextTable.page then + self:SetSelectedNavButton(text) + end + return self +end + +--- Set the selected nav button. +-- @tparam LargeApplicationFrame self The large application frame object +-- @tparam string buttonText The button text +-- @tparam boolean redraw Whether or not to redraw the frame +function LargeApplicationFrame.SetSelectedNavButton(self, buttonText, redraw) + if buttonText == self._selectedButton then + return + end + local index = self._buttonIndex[buttonText] + self._contextTable.page = index + self._selectedButton = buttonText + self._contentFrame:ReleaseAllChildren() + self._contentFrame:AddChild(self._buttons[index]:GetContext()(self)) + if redraw then + self:Draw() + end + return self +end + +--- Get the selected nav button. +-- @tparam LargeApplicationFrame self The large application frame object +-- @treturn string The text of the selected button +function LargeApplicationFrame.GetSelectedNavButton(self) + return self._selectedButton +end + +--- Sets which nav button is pulsing. +-- @tparam LargeApplicationFrame self The large application frame object +-- @tparam ?string buttonText The button text or nil if no nav button should be pulsing +function LargeApplicationFrame.SetPulsingNavButton(self, buttonText) + local index = buttonText and self._buttonIndex[buttonText] + for i, button in ipairs(self._buttons) do + if not index or i ~= index then + button:SetPlaying(false) + elseif not button:IsPlaying() then + button:SetPlaying(true) + end + end +end + +function LargeApplicationFrame.Draw(self) + self.__super:Draw() + for i, buttonFrame in ipairs(self._buttons) do + local button = buttonFrame:GetElement("button") + button:SetFont("BODY_BODY1_BOLD") + button:SetTextColor(i == self._contextTable.page and "INDICATOR" or "TEXT_ALT") + button:Draw() + buttonFrame:SetSize(button:GetStringWidth(), NAV_BAR_HEIGHT) + end + + local offsetX = 104 + for _, buttonFrame in ipairs(self._buttons) do + local buttonWidth = buttonFrame:GetElement("button"):GetStringWidth() + buttonFrame:SetSize(buttonWidth, NAV_BAR_HEIGHT) + buttonFrame:WipeAnchors() + buttonFrame:AddAnchor("TOPLEFT", offsetX, NAV_BAR_TOP_OFFSET) + offsetX = offsetX + buttonWidth + NAV_BAR_SPACING + -- draw the buttons again now that we know their dimensions + buttonFrame:Draw() + end +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function LargeApplicationFrame._SetResizing(self, resizing) + for _, button in ipairs(self._buttons) do + if resizing then + button:Hide() + else + button:Show() + end + end + self.__super:_SetResizing(resizing) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnNavBarButtonEnter(button) + if button:GetBaseElement():GetSelectedNavButton() == button:GetText() then + return + end + button:SetTextColor("TEXT") + :Draw() +end + +function private.OnNavBarButtonLeave(button) + if button:GetBaseElement():GetSelectedNavButton() == button:GetText() then + return + end + button:SetTextColor("TEXT_ALT") + :Draw() +end + +function private.OnNavBarButtonClicked(button) + local self = button:GetParentElement():GetParentElement() + self:SetSelectedNavButton(button:GetText(), true) +end diff --git a/Core/UI/Elements/ManagementGroupTree.lua b/Core/UI/Elements/ManagementGroupTree.lua new file mode 100644 index 0000000..0ebb59f --- /dev/null +++ b/Core/UI/Elements/ManagementGroupTree.lua @@ -0,0 +1,301 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ManagementGroupTree UI Element Class. +-- The management group tree allows for moving, adding, and deleting groups. It also only allows for a single group to +-- be selected. It is a subclass of the @{GroupTree} class. +-- @classmod ManagementGroupTree + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local Analytics = TSM.Include("Util.Analytics") +local String = TSM.Include("Util.String") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ManagementGroupTree = TSM.Include("LibTSMClass").DefineClass("ManagementGroupTree", TSM.UI.GroupTree) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ManagementGroupTree) +TSM.UI.ManagementGroupTree = ManagementGroupTree +local private = {} +local DRAG_SCROLL_SPEED_FACTOR = 12 +local MOVE_FRAME_PADDING = 8 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ManagementGroupTree.__init(self) + self.__super:__init() + + self._moveFrame = nil + self._selectedGroup = nil + self._onGroupSelectedHandler = nil + self._onNewGroupHandler = nil + self._scrollAmount = 0 +end + +function ManagementGroupTree.Acquire(self) + self._moveFrame = UIElements.New("Frame", self._id.."_MoveFrame") + :SetLayout("VERTICAL") + :SetHeight(20) + :SetStrata("TOOLTIP") + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :SetBorderColor("INDICATOR") + :SetContext(self) + :AddChild(UIElements.New("Text", "text") + :SetFont("BODY_BODY3") + :SetJustifyH("CENTER") + ) + self._moveFrame:SetParent(self:_GetBaseFrame()) + self._moveFrame:Hide() + self._moveFrame:SetScript("OnShow", private.MoveFrameOnShow) + self._moveFrame:SetScript("OnUpdate", private.MoveFrameOnUpdate) + + self.__super:Acquire() + + self:GetScrollingTableInfo() + :GetColById("group") + :SetActionIconInfo(2, 14, private.GetActionIcon, true) + :SetActionIconClickHandler(private.OnActionIconClick) + :Commit() + :Commit() +end + +function ManagementGroupTree.Release(self) + self._selectedGroup = nil + self._onGroupSelectedHandler = nil + self._onNewGroupHandler = nil + self._moveFrame:Release() + self._moveFrame = nil + for _, row in ipairs(self._rows) do + row._frame:RegisterForDrag() + ScriptWrapper.Clear(row._frame, "OnDragStart") + ScriptWrapper.Clear(row._frame, "OnDragStop") + for _, button in pairs(row._buttons) do + button:RegisterForDrag() + ScriptWrapper.Clear(button, "OnDragStart") + ScriptWrapper.Clear(button, "OnDragStop") + end + end + self.__super:Release() +end + +--- Sets the selected group. +-- @tparam ManagementGroupTree self The management group tree object +-- @tparam string groupPath The selected group's path +-- @tparam boolean redraw Whether or not to redraw the management group tree +-- @treturn ManagementGroupTree The management group tree object +function ManagementGroupTree.SetSelectedGroup(self, groupPath, redraw) + self._selectedGroup = groupPath + if self._onGroupSelectedHandler then + self:_onGroupSelectedHandler(groupPath) + end + if redraw then + -- make sure this group is visible (its parent is expanded) + local parent = TSM.Groups.Path.GetParent(groupPath) + self._contextTable.collapsed[TSM.CONST.ROOT_GROUP_PATH] = nil + while parent and parent ~= TSM.CONST.ROOT_GROUP_PATH do + self._contextTable.collapsed[parent] = nil + parent = TSM.Groups.Path.GetParent(parent) + end + self:UpdateData(true) + self:_ScrollToData(self._selectedGroup) + end + return self +end + +--- Registers a script handler. +-- @tparam ManagementGroupTree self The management group tree object +-- @tparam string script The script to register for (supported scripts: `OnGroupSelected`) +-- @tparam function handler The script handler which will be called with the management group tree object followed by +-- any arguments to the script +-- @treturn ManagementGroupTree The management group tree object +function ManagementGroupTree.SetScript(self, script, handler) + if script == "OnGroupSelected" then + self._onGroupSelectedHandler = handler + elseif script == "OnNewGroup" then + self._onNewGroupHandler = handler + else + error("Unknown ManagementGroupTree script: "..tostring(script)) + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ManagementGroupTree._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + row._frame:RegisterForDrag("LeftButton") + ScriptWrapper.Set(row._frame, "OnDragStart", private.RowOnDragStart, row) + ScriptWrapper.Set(row._frame, "OnDragStop", private.RowOnDragStop, row) + for _, button in pairs(row._buttons) do + button:RegisterForDrag("LeftButton") + ScriptWrapper.Set(button, "OnDragStart", private.RowOnDragStart, row) + ScriptWrapper.Set(button, "OnDragStop", private.RowOnDragStop, row) + end + end + return row +end + +function ManagementGroupTree._SetCollapsed(self, data, collapsed) + self.__super:_SetCollapsed(data, collapsed) + if collapsed and self._selectedGroup ~= data and strmatch(self._selectedGroup, "^"..String.Escape(data)) then + -- we collapsed a parent of the selected group, so select the group we just collapsed instead + self:SetSelectedGroup(data, true) + end +end + +function ManagementGroupTree._IsSelected(self, data) + return data == self._selectedGroup +end + +function ManagementGroupTree._HandleRowClick(self, data, mouseButton) + if mouseButton == "RightButton" then + self.__super:_HandleRowClick(data, mouseButton) + return + end + self:SetSelectedGroup(data, true) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetActionIcon(self, data, iconIndex, isMouseOver) + if iconIndex == 1 then + local texturePack = "iconPack.14x14/Add/Circle" + return true, isMouseOver and TSM.UI.TexturePacks.GetColoredKey(texturePack, Theme.GetColor("INDICATOR")) or texturePack + elseif iconIndex == 2 then + if data ~= TSM.CONST.ROOT_GROUP_PATH then + local texturePack = "iconPack.14x14/Delete" + return true, isMouseOver and TSM.UI.TexturePacks.GetColoredKey(texturePack, Theme.GetColor("INDICATOR")) or texturePack + else + return false, nil + end + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.OnActionIconClick(self, data, iconIndex) + if iconIndex == 1 then + local newGroupPath = TSM.Groups.Path.Join(data, L["New Group"]) + if TSM.Groups.Exists(newGroupPath) then + local num = 1 + while TSM.Groups.Exists(newGroupPath.." "..num) do + num = num + 1 + end + newGroupPath = newGroupPath.." "..num + end + TSM.Groups.Create(newGroupPath) + Analytics.Action("CREATED_GROUP", newGroupPath) + self:SetSelectedGroup(newGroupPath, true) + if self._onNewGroupHandler then + self:_onNewGroupHandler() + end + elseif iconIndex == 2 then + local groupColor = Theme.GetGroupColor(select('#', strsplit(TSM.CONST.GROUP_SEP, data))) + self:GetBaseElement():ShowConfirmationDialog(L["Delete Group?"], format(L["Deleting this group (%s) will also remove any sub-groups attached to this group."], groupColor:ColorText(TSM.Groups.Path.GetName(data))), private.DeleteConfirmed, self, data) + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.DeleteConfirmed(self, data) + TSM.Groups.Delete(data) + Analytics.Action("DELETED_GROUP", data) + self:SetSelectedGroup(TSM.CONST.ROOT_GROUP_PATH, true) +end + +function private.MoveFrameOnShow(frame) + local self = frame:GetContext() + self._scrollAmount = 0 +end + +function private.MoveFrameOnUpdate(frame) + local self = frame:GetContext() + local uiScale = UIParent:GetEffectiveScale() + local x, y = GetCursorPosition() + x = x / uiScale + y = y / uiScale + frame:_GetBaseFrame():SetPoint("CENTER", UIParent, "BOTTOMLEFT", x, y) + + -- figure out if we're above or below the frame for scrolling while dragging + local top = self:_GetBaseFrame():GetTop() + local bottom = self:_GetBaseFrame():GetBottom() + if y > top then + self._scrollAmount = top - y + elseif y < bottom then + self._scrollAmount = bottom - y + else + self._scrollAmount = 0 + end + + self._vScrollbar:SetValue(self._vScrollbar:GetValue() + self._scrollAmount / DRAG_SCROLL_SPEED_FACTOR) +end + +function private.RowOnDragStart(row) + local self = row._scrollingTable + local groupPath = row:GetData() + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + -- don't do anything for the root group + return + end + local level = select('#', strsplit(TSM.CONST.GROUP_SEP, groupPath)) + local levelColor = Theme.GetGroupColor(level) + self._dragGroupPath = groupPath + self._moveFrame:Show() + self._moveFrame:SetHeight(self._rowHeight) + local moveFrameText = self._moveFrame:GetElement("text") + moveFrameText:SetTextColor(levelColor) + moveFrameText:SetText(TSM.Groups.Path.GetName(groupPath)) + moveFrameText:SetWidth(1000) + moveFrameText:Draw() + self._moveFrame:SetWidth(moveFrameText:GetStringWidth() + MOVE_FRAME_PADDING * 2) + self._moveFrame:Draw() +end + +function private.RowOnDragStop(row) + local self = row._scrollingTable + local groupPath = row:GetData() + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + -- don't do anything for the root group + return + end + self._moveFrame:Hide() + + local destPath = nil + for _, targetRow in ipairs(self._rows) do + if targetRow:IsMouseOver() then + destPath = targetRow:GetData() + break + end + end + local oldPath = self._dragGroupPath + self._dragGroupPath = nil + if not destPath or destPath == oldPath or TSM.Groups.Path.IsChild(destPath, oldPath) then + return + end + local newPath = TSM.Groups.Path.Join(destPath, TSM.Groups.Path.GetName(oldPath)) + if oldPath == newPath then + return + elseif TSM.Groups.Exists(newPath) then + return + end + + TSM.Groups.Move(oldPath, newPath) + Analytics.Action("MOVED_GROUP", oldPath, newPath) + self:SetSelectedGroup(newPath, true) +end diff --git a/Core/UI/Elements/MultiLineInput.lua b/Core/UI/Elements/MultiLineInput.lua new file mode 100644 index 0000000..aefd7eb --- /dev/null +++ b/Core/UI/Elements/MultiLineInput.lua @@ -0,0 +1,170 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- MultiLineInput UI Element Class. +-- The input element allows the user to enter text. It is a subclass of the @{BaseInput} class. +-- @classmod MultiLineInput + +local _, TSM = ... +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local MultiLineInput = TSM.Include("LibTSMClass").DefineClass("MultiLineInput", TSM.UI.BaseInput) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(MultiLineInput) +TSM.UI.MultiLineInput = MultiLineInput +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function MultiLineInput.__init(self) + local frame = UIElements.CreateFrame(self, "ScrollFrame") + self._editBox = CreateFrame("EditBox", nil, frame) + self.__super:__init(frame) + + frame:EnableMouseWheel(true) + frame:SetClipsChildren(true) + ScriptWrapper.Set(frame, "OnUpdate", private.FrameOnUpdate, self) + ScriptWrapper.Set(frame, "OnMouseWheel", private.FrameOnMouseWheel, self) + ScriptWrapper.Set(frame, "OnMouseUp", private.FrameOnMouseUp, self) + + self._scrollbar = TSM.UI.Scrollbar.Create(frame) + ScriptWrapper.Set(self._scrollbar, "OnValueChanged", private.OnScrollbarValueChanged, self) + + self._editBox:SetSpacing(4) + self._editBox:SetMultiLine(true) + self._editBox:SetTextInsets(8, 8, 4, 4) + frame:SetScrollChild(self._editBox) + + ScriptWrapper.Set(self._editBox, "OnCursorChanged", private.OnCursorChanged, self) + ScriptWrapper.Set(self._editBox, "OnSizeChanged", private.OnSizeChanged, self) + + self._scrollValue = 0 + self._ignoreEnter = false +end + +function MultiLineInput.Acquire(self) + self:SetBackgroundColor("ACTIVE_BG") + self:SetJustifyH("LEFT") + self:SetJustifyV("TOP") + self.__super:Acquire() + self._scrollValue = 0 + self._ignoreEnter = false + self._scrollbar:SetValue(0) +end + +function MultiLineInput.Draw(self) + self._editBox:SetWidth(self:_GetBaseFrame():GetWidth()) + + self.__super:Draw() + + local maxScroll = self:_GetMaxScroll() + self._scrollbar:SetMinMaxValues(0, maxScroll) + self._scrollbar:SetValue(min(self._scrollValue, maxScroll)) + self._scrollbar.thumb:SetHeight(TSM.UI.Scrollbar.GetLength(self._editBox:GetHeight(), self:_GetDimension("HEIGHT"))) +end + +--- Sets to ignore enter pressed scripts for the input multi-line input. +-- @tparam MultiLineInput self The multi-line input object +-- @treturn MultiLineInput The multi-line input object +function MultiLineInput.SetIgnoreEnter(self) + ScriptWrapper.Clear(self._editBox, "OnEnterPressed") + self._ignoreEnter = true + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function MultiLineInput._OnScrollValueChanged(self, value) + self:_GetBaseFrame():SetVerticalScroll(value) + self._scrollValue = value +end + +function MultiLineInput._GetMaxScroll(self) + return max(self._editBox:GetHeight() - self:_GetDimension("HEIGHT"), 0) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnCursorChanged(self, _, y, _, lineHeight) + y = abs(y) + local offset = self._scrollValue - y + if offset > 0 or offset < self:_GetDimension("HEIGHT") + lineHeight then + self._scrollbar:SetValue(y) + end +end + +function private.OnSizeChanged(self, _, height) + local maxScroll = self:_GetMaxScroll() + self._scrollbar:SetMinMaxValues(0, maxScroll) + self._scrollbar:SetValue(min(self._scrollValue, maxScroll)) + self._scrollbar.thumb:SetHeight(TSM.UI.Scrollbar.GetLength(self._editBox:GetHeight(), self:_GetDimension("HEIGHT"))) +end + +function private.OnScrollbarValueChanged(self, value) + value = max(min(value, self:_GetMaxScroll()), 0) + self:_OnScrollValueChanged(value) +end + +function private.FrameOnUpdate(self) + if (self:_GetBaseFrame():IsMouseOver() and self:_GetMaxScroll() > 0) or self._scrollbar.dragging then + self._scrollbar:Show() + else + self._scrollbar:Hide() + end +end + +function private.FrameOnMouseWheel(self, direction) + local parentScroll = nil + local parent = self:GetParentElement() + while parent do + if parent:__isa(TSM.UI.ScrollFrame) then + parentScroll = parent + break + else + parent = parent:GetParentElement() + end + end + + if parentScroll then + local minValue, maxValue = self._scrollbar:GetMinMaxValues() + if direction > 0 then + if self._scrollbar:GetValue() == minValue then + local scrollAmount = min(parentScroll:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + parentScroll._scrollbar:SetValue(parentScroll._scrollbar:GetValue() + -1 * direction * scrollAmount) + else + local scrollAmount = min(self:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + self._scrollbar:SetValue(self._scrollbar:GetValue() + -1 * direction * scrollAmount) + end + else + if self._scrollbar:GetValue() == maxValue then + local scrollAmount = min(parentScroll:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + parentScroll._scrollbar:SetValue(parentScroll._scrollbar:GetValue() + -1 * direction * scrollAmount) + else + local scrollAmount = min(self:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + self._scrollbar:SetValue(self._scrollbar:GetValue() + -1 * direction * scrollAmount) + end + end + else + local scrollAmount = min(self:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + self._scrollbar:SetValue(self._scrollbar:GetValue() + -1 * direction * scrollAmount) + end +end + +function private.FrameOnMouseUp(self) + self:SetFocused(true) +end diff --git a/Core/UI/Elements/MultiselectionDropdown.lua b/Core/UI/Elements/MultiselectionDropdown.lua new file mode 100644 index 0000000..7f6d1b2 --- /dev/null +++ b/Core/UI/Elements/MultiselectionDropdown.lua @@ -0,0 +1,292 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Multiselection Dropdown UI Element Class. +-- A dropdown element allows the user to select from a dialog list. It is a subclass of the @{BaseDropdown} class. +-- @classmod MultiselectionDropdown + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Theme = TSM.Include("Util.Theme") +local MultiselectionDropdown = TSM.Include("LibTSMClass").DefineClass("MultiselectionDropdown", TSM.UI.BaseDropdown) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(MultiselectionDropdown) +TSM.UI.MultiselectionDropdown = MultiselectionDropdown +local private = {} + + + +-- ============================================================================ +-- Meta Class Methods +-- ============================================================================ + +function MultiselectionDropdown.__init(self) + self.__super:__init() + self._itemIsSelected = {} + self._settingTableDirect = nil + self._text = self:_GetBaseFrame():CreateFontString() + self._text:SetFont(Theme.GetFont("BODY_BODY3"):GetWowFont()) + self._text:Hide() + self._noneSelectionText = L["None Selected"] + self._partialSelectionText = L["%d Selected"] + self._allSelectionText = L["All Selected"] +end + +function MultiselectionDropdown.Release(self) + wipe(self._itemIsSelected) + self._settingTableDirect = nil + self._noneSelectionText = L["None Selected"] + self._partialSelectionText = L["%d Selected"] + self._allSelectionText = L["All Selected"] + self.__super:Release() +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +--- Set whether the item is selected. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam string item The item +-- @tparam boolean selected Whether or not the item should be selected +-- @treturn MultiselectionDropdown The dropdown object +function MultiselectionDropdown.SetItemSelected(self, item, selected) + self:_SetItemSelectedHelper(item, selected) + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + return self +end + +--- Set whether the item is selected by key. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam string itemKey The key for the item +-- @tparam boolean selected Whether or not the item should be selected +-- @treturn MultiselectionDropdown The dropdown object +function MultiselectionDropdown.SetItemSelectedByKey(self, itemKey, selected) + self:SetItemSelected(Table.GetDistinctKey(self._itemKeyLookup, itemKey), selected) + return self +end + +--- Set the selected items. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam table selected A table where the keys are the items to be selected +-- @treturn MultiselectionDropdown The dropdown object +function MultiselectionDropdown.SetSelectedItems(self, selected) + wipe(self._itemIsSelected) + if self._settingTableDirect then + wipe(self._settingTableDirect) + end + for _, item in ipairs(self._items) do + self:_SetItemSelectedHelper(item, selected[item]) + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + return self +end + +--- Set the selected items. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam table selected A table where the keys are the items to be selected +-- @treturn MultiselectionDropdown The dropdown object +function MultiselectionDropdown.SetSelectedItemKeys(self, selected) + wipe(self._itemIsSelected) + if self._settingTableDirect then + wipe(self._settingTableDirect) + end + for _, item in ipairs(self._items) do + self:_SetItemSelectedHelper(item, selected[self._itemKeyLookup[item]]) + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + return self +end + +--- Set the unselected items. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam table unselected A table where the keys are the items which aren't selected +-- @treturn MultiselectionDropdown The dropdown object +function MultiselectionDropdown.SetUnselectedItemKeys(self, unselected) + wipe(self._itemIsSelected) + if self._settingTableDirect then + wipe(self._settingTableDirect) + end + for _, item in ipairs(self._items) do + self:_SetItemSelectedHelper(item, not unselected[self._itemKeyLookup[item]]) + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + return self +end + +--- Get the currently selected item. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam string item The item +-- @treturn ?string The selected item +function MultiselectionDropdown.ItemIsSelected(self, item) + return self._itemIsSelected[item] +end + +--- Get the currently selected item. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam string|number itemKey The key for the item +-- @treturn boolean Whether or not the item is selected +function MultiselectionDropdown.ItemIsSelectedByKey(self, itemKey) + return self:ItemIsSelected(Table.GetDistinctKey(self._itemKeyLookup, itemKey)) +end + +--- Sets the setting info. +-- This method is used to have the selected keys of the dropdown automatically correspond with the value of a field in a +-- table. This is useful for dropdowns which are tied directly to settings. +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam table tbl The table which the field to set belongs to +-- @tparam string key The key into the table to be set based on the dropdown state +-- @treturn MultiselectionDropdown The dropdown object +function MultiselectionDropdown.SetSettingInfo(self, tbl, key) + local directTbl = tbl[key] + assert(type(directTbl) == "table") + -- this function wipes our settingTable, so set the selected items first + self:SetSelectedItemKeys(directTbl) + self._settingTableDirect = directTbl + return self +end + +--- Populate the specified table with a list of selected items +-- @tparam MultiselectionDropdown self The dropdown object +-- @tparam table resultTbl The table to populate +function MultiselectionDropdown.GetSelectedItems(self, resultTbl) + for _, item in ipairs(self._items) do + if self:ItemIsSelected(item) then + tinsert(resultTbl, item) + end + end +end + +--- Sets the selection text which is shown to summarize the current value. +-- @tparam BaseDropdown self The dropdown object +-- @tparam string noneText The selection text string when none are selected +-- @tparam string partialText The selection text string for a partial selection +-- @tparam string allText The selection text string when all are selected +-- @treturn BaseDropdown The dropdown object +function MultiselectionDropdown.SetSelectionText(self, noneText, partialText, allText) + assert(type(partialText) == "string" and type(partialText) == "string" and type(allText) == "string") + self._noneSelectionText = noneText + self._partialSelectionText = partialText + self._allSelectionText = allText + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function MultiselectionDropdown._GetDialogSize(self) + local width, height = self.__super:_GetDialogSize() + width = max(width + 12, 200) -- check icon, and big enough for select all / deselect all buttons + height = height + 26 -- header + line + return width, height +end + +function MultiselectionDropdown._GetNumSelected(self) + local num = 0 + for _, item in ipairs(self._items) do + if self:ItemIsSelected(item) then + num = num + 1 + end + end + return num +end + +function MultiselectionDropdown._AddDialogChildren(self, frame) + local numSelected = self:_GetNumSelected() + frame:AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetPadding(8, 8, 2, 2) + :SetHeight(24) + :AddChild(UIElements.New("Button", "selectAll") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetTextColor(numSelected == #self._items and "ACTIVE_BG_ALT" or "TEXT") + :SetDisabled(numSelected == #self._items) + :SetText(L["Select All"]) + :SetScript("OnClick", private.SelectAllOnClick) + ) + :AddChild(UIElements.New("Button", "deselectAll") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetTextColor(numSelected == 0 and "ACTIVE_BG_ALT" or "TEXT") + :SetDisabled(numSelected == 0) + :SetText(L["Deselect All"]) + :SetScript("OnClick", private.DeselectAllOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + frame:AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG_ALT") + ) + frame:AddChild(UIElements.New("DropdownList", "list") + :SetMultiselect(true) + :SetItems(self._items, self._itemIsSelected) + ) +end + +function MultiselectionDropdown._GetCurrentSelectionString(self) + local numSelected = self:_GetNumSelected() + local result = nil + if numSelected == 0 then + result = self._hintText ~= "" and self._hintText or self._noneSelectionText + elseif numSelected == #self._items then + result = self._allSelectionText.." ("..numSelected..")" + else + result = format(self._partialSelectionText, numSelected) + end + return result +end + +function MultiselectionDropdown._OnListSelectionChanged(self, dropdownList, selection) + self:SetSelectedItems(selection) + local numSelected = self:_GetNumSelected() + dropdownList:GetElement("__parent.header.selectAll") + :SetTextColor(numSelected == #self._items and "ACTIVE_BG_ALT" or "TEXT") + :SetDisabled(numSelected == #self._items) + :Draw() + dropdownList:GetElement("__parent.header.deselectAll") + :SetTextColor(numSelected == 0 and "ACTIVE_BG_ALT" or "TEXT") + :SetDisabled(numSelected == 0) + :Draw() +end + +function MultiselectionDropdown._SetItemSelectedHelper(self, item, selected) + self._itemIsSelected[item] = selected and true or nil + if self._settingTableDirect then + self._settingTableDirect[self._itemKeyLookup[item]] = self._itemIsSelected[item] + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SelectAllOnClick(button) + button:GetElement("__parent.__parent.list"):SelectAll() +end + +function private.DeselectAllOnClick(button) + button:GetElement("__parent.__parent.list"):DeselectAll() +end diff --git a/Core/UI/Elements/MyAuctionsScrollingTable.lua b/Core/UI/Elements/MyAuctionsScrollingTable.lua new file mode 100644 index 0000000..48e575a --- /dev/null +++ b/Core/UI/Elements/MyAuctionsScrollingTable.lua @@ -0,0 +1,183 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- My Auctions Scrolling Table Class. +-- A scrolling table containing the player's auctions. It is a subclass of the @{QueryScrollingTable} class. +-- @classmod MyAuctionsScrollingTable + +local _, TSM = ... +local MyAuctionsScrollingTable = TSM.Include("LibTSMClass").DefineClass("MyAuctionsScrollingTable", TSM.UI.QueryScrollingTable) +local TempTable = TSM.Include("Util.TempTable") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(MyAuctionsScrollingTable) +TSM.UI.MyAuctionsScrollingTable = MyAuctionsScrollingTable + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function MyAuctionsScrollingTable.__init(self) + self.__super:__init() + self._selectionSortValue = nil + self._selectionAuctionId = nil +end + +function MyAuctionsScrollingTable.Release(self) + self._selectionSortValue = nil + self._selectionAuctionId = nil + self.__super:Release() +end + +--- Sets the selected record. +-- @tparam MyAuctionsScrollingTable self The my auctions scrolling table object +-- @param selection The selected record or nil to clear the selection +-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table +-- @treturn MyAuctionsScrollingTable The my auctions scrolling table object +function MyAuctionsScrollingTable.SetSelection(self, selection, redraw) + self.__super:SetSelection(selection, redraw) + if self._selection then + local selectedRow = self:GetSelection() + local sortField = TSM.IsWowClassic() and "index" or self._tableInfo:_GetSortFieldById(self._sortCol) + self._selectionSortValue = selectedRow:GetField(sortField) + if type(self._selectionSortValue) == "string" then + self._selectionSortValue = strlower(self._selectionSortValue) + end + self._selectionAuctionId = selectedRow:GetField("auctionId") + else + self._selectionSortValue = nil + self._selectionAuctionId = nil + end + return self +end + +--- Selects the next row. +-- @tparam MyAuctionsScrollingTable self The my auctions scrolling table object +-- @treturn MyAuctionsScrollingTable The my auctions scrolling table object +function MyAuctionsScrollingTable.SelectNextRow(self) + local newSelection = nil + for i = 1, #self._data - 1 do + if self._data[i] == self._selection then + for j = i + 1, #self._data do + if not self._selectionValidator or self:_selectionValidator(self._query:GetResultRowByUUID(self._data[j])) then + newSelection = self._data[j] + break + end + end + break + end + end + self:SetSelection(newSelection, true) + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function MyAuctionsScrollingTable._UpdateSortFromQuery(self) + if self._tableInfo:_IsSortEnabled() then + assert(not TSM.IsWowClassic()) + local sortField, sortAscending = self._query:GetOrderBy(2) + if sortField then + self._sortCol = self._tableInfo:_GetIdBySortField(sortField) + self._sortAscending = sortAscending + else + self._sortCol = nil + self._sortAscending = nil + end + end +end + +function MyAuctionsScrollingTable._UpdateData(self) + -- we need to fix up the data within the rows updated to avoid errors with trying to access old DatabaseQueryResultRows + local prevRowIndex = TempTable.Acquire() + local newRowData = TempTable.Acquire() + for i, row in ipairs(self._rows) do + if row:IsVisible() then + prevRowIndex[row:GetData()] = i + end + end + local prevSelection = self._selection + wipe(self._data) + self._selection = nil + for _, uuid in self._query:UUIDIterator() do + local row = self._query:GetResultRowByUUID(uuid) + if (uuid == prevSelection or (row:GetField("auctionId") == self._selectionAuctionId)) and not row:GetField("isPending") then + self._selection = uuid + end + if prevRowIndex[uuid] then + newRowData[prevRowIndex[uuid]] = uuid + end + tinsert(self._data, uuid) + end + for i, row in ipairs(self._rows) do + if row:IsVisible() then + if newRowData[i] then + row:SetData(newRowData[i]) + else + row:ClearData() + end + end + end + TempTable.Release(prevRowIndex) + TempTable.Release(newRowData) + if prevSelection and not self._selection then + local newSelection = nil + -- try to select the next row based on the sorting + local sortField = TSM.IsWowClassic() and "index" or self._tableInfo:_GetSortFieldById(self._sortCol) + local sortAscending = not TSM.IsWowClassic() and self._sortAscending + for _, uuid in ipairs(self._data) do + local row = self._query:GetResultRowByUUID(uuid) + local sortValue = row:GetField(sortField) + if type(sortValue) == "string" then + sortValue = strlower(sortValue) + end + if (sortAscending and sortValue > self._selectionSortValue) or (not sortAscending and sortValue < self._selectionSortValue) then + if not self._selectionValidator or self:_selectionValidator(row) then + newSelection = uuid + break + end + elseif not TSM.IsWowClassic() and sortValue == self._selectionSortValue and row:GetField("auctionId") > self._selectionAuctionId then + if not self._selectionValidator or self:_selectionValidator(row) then + newSelection = uuid + break + end + end + end + -- select either the next row + self:SetSelection(newSelection) + end + if self._onDataUpdated then + self:_onDataUpdated() + end +end + +function MyAuctionsScrollingTable._ToggleSort(self, id) + local sortField = self._tableInfo:_GetSortFieldById(id) + if not self._sortCol or not self._query or not sortField then + -- sorting disabled so ignore + return + end + + if id == self._sortCol then + self._sortAscending = not self._sortAscending + else + self._sortCol = id + self._sortAscending = true + end + + assert(not TSM.IsWowClassic()) + self._query:ResetOrderBy() + :OrderBy("saleStatus", false) + :OrderBy(sortField, self._sortAscending) + :OrderBy("auctionId", true) + self:_UpdateData() + self:Draw() +end diff --git a/Core/UI/Elements/OperationTree.lua b/Core/UI/Elements/OperationTree.lua new file mode 100644 index 0000000..b63306b --- /dev/null +++ b/Core/UI/Elements/OperationTree.lua @@ -0,0 +1,288 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- OperationTree UI Element Class. +-- The operation tree is used to display operations grouped by module and allows for adding, duplicating, and deleting +-- them. Only one module is allowed to be expanded at a time. It is a subclass of the @{ScrollingTable} class. +-- @classmod OperationTree + +local _, TSM = ... +local OperationTree = TSM.Include("LibTSMClass").DefineClass("OperationTree", TSM.UI.ScrollingTable) +local L = TSM.Include("Locale").GetTable() +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(OperationTree) +TSM.UI.OperationTree = OperationTree +local private = {} +local DATA_SEP = "\001" + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function OperationTree.__init(self) + self.__super:__init() + self:SetRowHeight(28) + + self._operationNameFilter = "" + self._selected = nil + self._expandedModule = nil + self._selectedOperation = nil + self._prevSelectedOperation = nil + self._onOperationSelectedHandler = nil + self._onOperationAddedHandler = nil + self._onOperationDeletedHandler = nil +end + +function OperationTree.Acquire(self) + self._backgroundColor = "PRIMARY_BG_ALT" + self._headerHidden = true + self.__super:Acquire() + self:GetScrollingTableInfo() + :NewColumn("text") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetText) + :SetExpanderStateFunction(private.GetExpanderState) + :SetActionIconInfo(2, 14, private.GetActionIcon) + :SetActionIconClickHandler(private.OnActionIconClick) + :DisableHiding() + :Commit() + :Commit() + self:UpdateData() +end + +function OperationTree.Release(self) + for _, row in ipairs(self._rows) do + ScriptWrapper.Clear(row._frame, "OnDoubleClick") + end + self._selected = nil + self._operationNameFilter = "" + self._expandedModule = nil + self._selectedOperation = nil + self._prevSelectedOperation = nil + self._onOperationSelectedHandler = nil + self._onOperationAddedHandler = nil + self._onOperationDeletedHandler = nil + self.__super:Release() + self:SetRowHeight(28) +end + +--- Sets the operation name filter. +-- @tparam OperationTree self The operation tree object +-- @tparam string filter The filter string (any operations which don't match this are hidden) +function OperationTree.SetOperationNameFilter(self, filter) + self._operationNameFilter = filter + if filter == "" and self._prevSelectedOperation and not self._selectedOperation then + -- restore any previous selection if we don't have something selected + self:SetSelectedOperation(self:_SplitOperationKey(self._prevSelectedOperation)) + self._prevSelectedOperation = nil + elseif filter ~= "" and self._selectedOperation then + local _, operationName = self:_SplitOperationKey(self._selectedOperation) + if not operationName or not strmatch(strlower(operationName), filter) then + -- save the current selection to restore after the filter is cleared and then clear the current selection + self._prevSelectedOperation = self._selectedOperation + self:SetSelectedOperation() + end + end + self:UpdateData(true) +end + +--- Registers a script handler. +-- @tparam OperationTree self The operation tree object +-- @tparam string script The script to register for (supported scripts: `OnOperationSelected`, `OnOperationAdded`, +-- `OnOperationDeleted`) +-- @tparam function handler The script handler which will be called with the operation tree object followed by any +-- arguments to the script +-- @treturn OperationTree The operation tree object +function OperationTree.SetScript(self, script, handler) + if script == "OnOperationSelected" then + self._onOperationSelectedHandler = handler + elseif script == "OnOperationAdded" then + self._onOperationAddedHandler = handler + elseif script == "OnOperationDeleted" then + self._onOperationDeletedHandler = handler + else + error("Unknown OperationTree script: "..tostring(script)) + end + return self +end + +--- Sets the selected operation. +-- @tparam OperationTree self The operation tree object +-- @tparam string moduleName The name of the module which the operation belongs to +-- @tparam string operationName The name of the operation +-- @treturn OperationTree The operation tree object +function OperationTree.SetSelectedOperation(self, moduleName, operationName) + if moduleName and operationName then + self._selectedOperation = moduleName..DATA_SEP..operationName + self._expandedModule = moduleName + elseif moduleName then + self._selectedOperation = moduleName + self._expandedModule = moduleName + else + self._selectedOperation = nil + self._expandedModule = nil + end + self:UpdateData() + self.__super:SetSelection(self._selectedOperation, true) + if self._onOperationSelectedHandler then + self:_onOperationSelectedHandler(moduleName, operationName) + end + self:_ForceLastDataUpdate() + self:UpdateData(true) + return self +end + +function OperationTree.SetSelection(self, data) + self:SetSelectedOperation(self:_SplitOperationKey(data)) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function OperationTree._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnDoubleClick, row) + end + return row +end + +function OperationTree._IsDataHidden(self, data) + local moduleName, operationName = self:_SplitOperationKey(data) + if operationName and not strmatch(strlower(operationName), self._operationNameFilter) then + return true + elseif operationName and moduleName ~= self._expandedModule then + return true + end + return false +end + +function OperationTree._SplitOperationKey(self, data) + local moduleName, operationName = strmatch(data, "([^"..DATA_SEP.."]+)"..DATA_SEP.."?(.*)") + operationName = operationName ~= "" and operationName or nil + return moduleName, operationName +end + +function OperationTree._UpdateData(self) + wipe(self._data) + for _, moduleName in TSM.Operations.ModuleIterator() do + if not self:_IsDataHidden(moduleName) then + tinsert(self._data, moduleName) + end + for _, operationName in TSM.Operations.OperationIterator(moduleName) do + local data = moduleName..DATA_SEP..operationName + if not self:_IsDataHidden(data) then + tinsert(self._data, data) + end + end + end +end + +function OperationTree._HandleRowClick(self) + self:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.GetText(self, data) + local moduleName, operationName = self:_SplitOperationKey(data) + local color = Theme.GetColor(operationName and "TEXT" or "INDICATOR") + return color:ColorText(operationName or moduleName.." "..L["Operations"]) +end + +function private.GetExpanderState(self, data) + local moduleName, operationName = self:_SplitOperationKey(data) + return not operationName, self._expandedModule == moduleName, operationName and 1 or 0 +end + +function private.GetActionIcon(self, data, iconIndex, isMouseOver) + local _, operationName = self:_SplitOperationKey(data) + if iconIndex == 1 then + if operationName and data == self._selectedOperation then + local texturePack = "iconPack.14x14/Duplicate" + return true, isMouseOver and TSM.UI.TexturePacks.GetColoredKey(texturePack, Theme.GetColor("INDICATOR")) or texturePack + elseif operationName then + return false, nil + else + local texturePack = "iconPack.14x14/Add/Circle" + return true, isMouseOver and TSM.UI.TexturePacks.GetColoredKey(texturePack, Theme.GetColor("INDICATOR")) or texturePack + end + elseif iconIndex == 2 then + if operationName and data == self._selectedOperation then + local texturePack = "iconPack.14x14/Delete" + return true, isMouseOver and TSM.UI.TexturePacks.GetColoredKey(texturePack, Theme.GetColor("INDICATOR")) or texturePack + else + return false, nil + end + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.OnActionIconClick(self, data, iconIndex) + local moduleName, operationName = self:_SplitOperationKey(data) + if iconIndex == 1 then + if operationName then + -- duplicate + local num = 1 + while TSM.Operations.Exists(moduleName, operationName.." "..num) do + num = num + 1 + end + local newOperationName = operationName.." "..num + self:_onOperationAddedHandler(moduleName, newOperationName, operationName) + self:UpdateData() + self:SetSelectedOperation(moduleName, newOperationName) + else + -- add + operationName = "New Operation" + local num = 1 + while TSM.Operations.Exists(moduleName, operationName.." "..num) do + num = num + 1 + end + operationName = operationName .. " " .. num + self._expandedModule = moduleName + self:_onOperationAddedHandler(moduleName, operationName) + self:UpdateData() + self:SetSelectedOperation(moduleName, operationName) + end + self:Draw() + elseif iconIndex == 2 then + assert(operationName) + -- delete + self:_onOperationDeletedHandler(moduleName, operationName) + self:UpdateData(true) + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.RowOnDoubleClick(row, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + local self = row._scrollingTable + local data = row:GetData() + local moduleName, operationName = self:_SplitOperationKey(data) + if operationName then + return + end + if moduleName == self._selectedOperation then + self:SetSelectedOperation() + else + self:SetSelectedOperation(moduleName, operationName) + end +end diff --git a/Core/UI/Elements/OverlayApplicationFrame.lua b/Core/UI/Elements/OverlayApplicationFrame.lua new file mode 100644 index 0000000..36cf1ee --- /dev/null +++ b/Core/UI/Elements/OverlayApplicationFrame.lua @@ -0,0 +1,186 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- OverlayApplicationFrame UI Element Class. +-- The overlay application frame is currently just used for the TaskListUI. It is a subclass of the @{Frame} class. +-- @classmod OverlayApplicationFrame + +local _, TSM = ... +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local OverlayApplicationFrame = TSM.Include("LibTSMClass").DefineClass("OverlayApplicationFrame", TSM.UI.Frame) +UIElements.Register(OverlayApplicationFrame) +TSM.UI.OverlayApplicationFrame = OverlayApplicationFrame +local private = {} +local TITLE_HEIGHT = 40 +local CONTENT_PADDING_BOTTOM = 16 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function OverlayApplicationFrame.__init(self) + self.__super:__init() + self._contentFrame = nil + self._contextTable = nil + self._defaultContextTable = nil + Theme.RegisterChangeCallback(function() + if self:IsVisible() then + self:Draw() + end + end) +end + +function OverlayApplicationFrame.Acquire(self) + local frame = self:_GetBaseFrame() + frame:EnableMouse(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") + self:AddChildNoLayout(UIElements.New("Button", "closeBtn") + :AddAnchor("TOPRIGHT", -8, -11) + :SetBackgroundAndSize("iconPack.18x18/Close/Circle") + :SetScript("OnClick", private.CloseButtonOnClick) + ) + self:AddChildNoLayout(UIElements.New("Button", "minimizeBtn") + :AddAnchor("TOPRIGHT", -26, -11) + :SetBackgroundAndSize("iconPack.18x18/Subtract/Circle") + :SetScript("OnClick", private.MinimizeBtnOnClick) + ) + self:AddChildNoLayout(UIElements.New("Text", "title") + :SetHeight(24) + :SetFont("BODY_BODY1_BOLD") + :AddAnchor("TOPLEFT", 8, -8) + :AddAnchor("TOPRIGHT", -52, -8) + ) + self:SetScript("OnDragStart", private.FrameOnDragStart) + self:SetScript("OnDragStop", private.FrameOnDragStop) + + self.__super:Acquire() +end + +function OverlayApplicationFrame.Release(self) + self._contentFrame = nil + self._contextTable = nil + self._defaultContextTable = nil + self:_GetBaseFrame():SetMinResize(0, 0) + self.__super:Release() +end + +--- Sets the title text. +-- @tparam OverlayApplicationFrame self The overlay application frame object +-- @tparam string title The title text +-- @treturn OverlayApplicationFrame The overlay application frame object +function OverlayApplicationFrame.SetTitle(self, title) + self:GetElement("title"):SetText(title) + return self +end + +--- Sets the content frame. +-- @tparam OverlayApplicationFrame self The overlay application frame object +-- @tparam Element frame The content frame +-- @treturn OverlayApplicationFrame The overlay application frame object +function OverlayApplicationFrame.SetContentFrame(self, frame) + frame:WipeAnchors() + frame:AddAnchor("TOPLEFT", 0, -TITLE_HEIGHT) + frame:AddAnchor("BOTTOMRIGHT", 0, CONTENT_PADDING_BOTTOM) + self._contentFrame = frame + self:AddChildNoLayout(frame) + return self +end + +--- Sets the context table. +-- This table can be used to preserve position information across lifecycles of the frame and even WoW sessions if it's +-- within the settings DB. +-- @tparam OverlayApplicationFrame self The overlay application frame object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default values (required fields: `minimized`, `topRightX`, `topRightY`) +-- @treturn OverlayApplicationFrame The overlay application frame object +function OverlayApplicationFrame.SetContextTable(self, tbl, defaultTbl) + assert(defaultTbl.minimized ~= nil and defaultTbl.topRightX and defaultTbl.topRightY) + if tbl.minimized == nil then + tbl.minimized = defaultTbl.minimized + end + tbl.topRightX = tbl.topRightX or defaultTbl.topRightX + tbl.topRightY = tbl.topRightY or defaultTbl.topRightY + self._contextTable = tbl + self._defaultContextTable = defaultTbl + return self +end + +--- Sets the context table from a settings object. +-- @tparam OverlayApplicationFrame self The overlay application frame object +-- @tparam Settings settings The settings object +-- @tparam string key The setting key +-- @treturn OverlayApplicationFrame The overlay application frame object +function OverlayApplicationFrame.SetSettingsContext(self, settings, key) + return self:SetContextTable(settings[key], settings:GetDefaultReadOnly(key)) +end + +function OverlayApplicationFrame.Draw(self) + if self._contextTable.minimized then + self:GetElement("minimizeBtn"):SetBackgroundAndSize("iconPack.18x18/Add/Circle") + self:GetElement("content"):Hide() + self:SetHeight(TITLE_HEIGHT) + else + self:GetElement("minimizeBtn"):SetBackgroundAndSize("iconPack.18x18/Subtract/Circle") + self:GetElement("content"):Show() + -- set the height of the frame based on the height of the children + local contentHeight, contentHeightExpandable = self:GetElement("content"):_GetMinimumDimension("HEIGHT") + assert(not contentHeightExpandable) + self:SetHeight(contentHeight + TITLE_HEIGHT + CONTENT_PADDING_BOTTOM) + end + + -- make sure the frame is on the screen + self._contextTable.topRightX = max(min(self._contextTable.topRightX, 0), -UIParent:GetWidth() + 100) + self._contextTable.topRightY = max(min(self._contextTable.topRightY, 0), -UIParent:GetHeight() + 100) + + -- set the frame position from the contextTable + self:WipeAnchors() + self:AddAnchor("TOPRIGHT", self._contextTable.topRightX, self._contextTable.topRightY) + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function OverlayApplicationFrame._SavePosition(self) + local frame = self:_GetBaseFrame() + local parentFrame = frame:GetParent() + self._contextTable.topRightX = frame:GetRight() - parentFrame:GetRight() + self._contextTable.topRightY = frame:GetTop() - parentFrame:GetTop() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnDragStart(self) + self:_GetBaseFrame():StartMoving() +end + +function private.FrameOnDragStop(self) + local frame = self:_GetBaseFrame() + frame:StopMovingOrSizing() + self:_SavePosition() +end + +function private.CloseButtonOnClick(button) + button:GetParentElement():Hide() +end + +function private.MinimizeBtnOnClick(button) + local self = button:GetParentElement() + self._contextTable.minimized = not self._contextTable.minimized + self:Draw() +end diff --git a/Core/UI/Elements/PlayerGoldText.lua b/Core/UI/Elements/PlayerGoldText.lua new file mode 100644 index 0000000..31f0c28 --- /dev/null +++ b/Core/UI/Elements/PlayerGoldText.lua @@ -0,0 +1,103 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- PlayerGoldText UI Element Class. +-- A text element which contains player gold info which automatically updates when the player's gold amount changes. It +-- is a subclass of the @{Text} class. +-- @classmod PlayerGoldText + +local _, TSM = ... +local PlayerGoldText = TSM.Include("LibTSMClass").DefineClass("PlayerGoldText", TSM.UI.Text) +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Event = TSM.Include("Util.Event") +local Money = TSM.Include("Util.Money") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(PlayerGoldText) +TSM.UI.PlayerGoldText = PlayerGoldText +local private = { + registered = false, + elements = {}, +} + + + +-- ============================================================================ +-- Meta Class Methods +-- ============================================================================ + +function PlayerGoldText.__init(self) + self.__super:__init() + self:_GetBaseFrame():EnableMouse(true) + + if not private.registered then + Event.Register("PLAYER_MONEY", private.MoneyOnUpdate) + private.registered = true + end + + self._justifyH = "RIGHT" + self._font = "TABLE_TABLE1" +end + +function PlayerGoldText.Acquire(self) + private.elements[self] = true + self.__super:Acquire() + self:SetText(Money.ToString(TSM.db.global.appearanceOptions.showTotalMoney and private.GetTotalMoney() or GetMoney())) + self:SetTooltip(private.MoneyTooltipFunc) +end + +function PlayerGoldText.Release(self) + private.elements[self] = nil + self.__super:Release() + self._justifyH = "RIGHT" + self._font = "TABLE_TABLE1" +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.MoneyOnUpdate() + for element in pairs(private.elements) do + element:SetText(Money.ToString(TSM.db.global.appearanceOptions.showTotalMoney and private.GetTotalMoney() or GetMoney())) + element:Draw() + end +end + +function private.MoneyTooltipFunc() + local tooltipLines = TempTable.Acquire() + local playerMoney = TSM.db.sync.internalData.money + local total = playerMoney + tinsert(tooltipLines, strjoin(TSM.CONST.TOOLTIP_SEP, UnitName("player")..":", Money.ToString(playerMoney))) + local numPosted, numSold, postedGold, soldGold = TSM.MyAuctions.GetAuctionInfo() + if numPosted then + tinsert(tooltipLines, " "..strjoin(TSM.CONST.TOOLTIP_SEP, format(L["%s Sold Auctions"], numSold)..":", Money.ToString(soldGold))) + tinsert(tooltipLines, " "..strjoin(TSM.CONST.TOOLTIP_SEP, format(L["%s Posted Auctions"], numPosted)..":", Money.ToString(postedGold))) + end + for _, _, character, syncScopeKey in Settings.ConnectedFactionrealmAltCharacterIterator() do + local money = Settings.Get("sync", syncScopeKey, "internalData", "money") + if money > 0 then + tinsert(tooltipLines, strjoin(TSM.CONST.TOOLTIP_SEP, character..":", Money.ToString(money))) + total = total + money + end + end + tinsert(tooltipLines, 1, strjoin(TSM.CONST.TOOLTIP_SEP, L["Total Gold"]..":", Money.ToString(total))) + return strjoin("\n", TempTable.UnpackAndRelease(tooltipLines)) +end + +function private.GetTotalMoney() + local total = TSM.db.sync.internalData.money + for _, _, _, syncScopeKey in Settings.ConnectedFactionrealmAltCharacterIterator() do + local money = Settings.Get("sync", syncScopeKey, "internalData", "money") + if money > 0 then + total = total + money + end + end + return total +end diff --git a/Core/UI/Elements/PopupFrame.lua b/Core/UI/Elements/PopupFrame.lua new file mode 100644 index 0000000..c0f85e6 --- /dev/null +++ b/Core/UI/Elements/PopupFrame.lua @@ -0,0 +1,35 @@ + -- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- PopupFrame UI Element Class. +-- A popup frame which shows when clicking on a "more" button. +-- @classmod PopupFrame + +local _, TSM = ... +local NineSlice = TSM.Include("Util.NineSlice") +local Theme = TSM.Include("Util.Theme") +local PopupFrame = TSM.Include("LibTSMClass").DefineClass("PopupFrame", TSM.UI.Frame) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(PopupFrame) +TSM.UI.PopupFrame = PopupFrame + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function PopupFrame.__init(self) + self.__super:__init() + self._nineSlice = NineSlice.New(self:_GetBaseFrame()) +end + +function PopupFrame.Draw(self) + self.__super:Draw() + self._nineSlice:SetStyle("popup") + -- TOOD: fix the texture color properly + self._nineSlice:SetPartVertexColor("center", Theme.GetColor("PRIMARY_BG_ALT", "duskwood"):GetFractionalRGBA()) +end diff --git a/Core/UI/Elements/ProfessionScrollingTable.lua b/Core/UI/Elements/ProfessionScrollingTable.lua new file mode 100644 index 0000000..8517815 --- /dev/null +++ b/Core/UI/Elements/ProfessionScrollingTable.lua @@ -0,0 +1,615 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ProfessionScrollingTable UI Element Class. +-- This is used to display the crafts within the currently-selected profession in the CraftingUI. It is a subclass of +-- the @{ScrollingTable} class. +-- @classmod ProfessionScrollingTable + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local Theme = TSM.Include("Util.Theme") +local Log = TSM.Include("Util.Log") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Event = TSM.Include("Util.Event") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Tooltip = TSM.Include("UI.Tooltip") +local ProfessionScrollingTable = TSM.Include("LibTSMClass").DefineClass("ProfessionScrollingTable", TSM.UI.ScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ProfessionScrollingTable) +TSM.UI.ProfessionScrollingTable = ProfessionScrollingTable +local private = { + activeElements = {}, + categoryInfoCache = { + parent = {}, + numIndents = {}, + name = {}, + currentSkillLevel = {}, + maxSkillLevel = {}, + }, +} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ProfessionScrollingTable.__init(self) + self.__super:__init() + self._query = nil + self._isSpellId = {} + self._favoritesContextTable = nil +end + +function ProfessionScrollingTable.Acquire(self) + self.__super:Acquire() + self:GetScrollingTableInfo() + :SetMenuInfo(private.MenuIterator, private.MenuClickHandler) + :NewColumn("name") + :SetTitle(L["Recipe Name"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetNameCellText) + :SetExpanderStateFunction(private.GetExpanderState) + :SetActionIconInfo(1, 14, private.GetFavoriteIcon, true) + :SetActionIconClickHandler(private.OnFavoriteIconClick) + :DisableHiding() + :Commit() + :NewColumn("qty") + :SetTitle(L["Craft"]) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("CENTER") + :SetTextFunction(private.GetQtyCellText) + :Commit() + if not TSM.IsWowClassic() then + self:GetScrollingTableInfo() + :NewColumn("rank") + :SetTitle(RANK) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("CENTER") + :SetTextFunction(private.GetRankCellText) + :Commit() + end + self:GetScrollingTableInfo() + :NewColumn("craftingCost") + :SetTitle(L["Crafting Cost"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetCraftingCostCellText) + :Commit() + :NewColumn("itemValue") + :SetTitle(L["Item Value"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetItemValueCellIndex) + :Commit() + :NewColumn("profit") + :SetTitle(L["Profit"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetProfitCellText) + :Commit() + :NewColumn("profitPct") + :SetTitle("%") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetProfitPctCellText) + :Commit() + :NewColumn("saleRate") + :SetTitleIcon("iconPack.14x14/SaleRate") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextFunction(private.GetSaleRateCellText) + :Commit() + :Commit() + if not next(private.activeElements) then + Event.Register("CHAT_MSG_SKILL", private.OnChatMsgSkill) + end + private.activeElements[self] = true +end + +function ProfessionScrollingTable.Release(self) + private.activeElements[self] = nil + if not next(private.activeElements) then + Event.Unregister("CHAT_MSG_SKILL", private.OnChatMsgSkill) + end + if self._query then + self._query:SetUpdateCallback() + self._query = nil + end + wipe(self._isSpellId) + self._favoritesContextTable = nil + for _, row in ipairs(self._rows) do + ScriptWrapper.Clear(row._frame, "OnDoubleClick") + end + self.__super:Release() +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +--- Sets the @{DatabaseQuery} source for this table. +-- This query is used to populate the entries in the profession scrolling table. +-- @tparam ProfessionScrollingTable self The profession scrolling table object +-- @tparam DatabaseQuery query The query object +-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table +-- @treturn ProfessionScrollingTable The profession scrolling table object +function ProfessionScrollingTable.SetQuery(self, query, redraw) + if query == self._query and not redraw then + return self + end + if self._query then + self._query:SetUpdateCallback() + end + self._query = query + self._query:SetUpdateCallback(private.QueryUpdateCallback, self) + + self:_ForceLastDataUpdate() + self:UpdateData(redraw) + return self +end + +--- Sets the context table to use to store favorite craft information. +-- @tparam ProfessionScrollingTable self The profession scrolling table object +-- @tparam table tbl The context table +-- @treturn ProfessionScrollingTable The profession scrolling table object +function ProfessionScrollingTable.SetFavoritesContext(self, tbl) + assert(type(tbl) == "table") + self._favoritesContextTable = tbl + return self +end + +--- Sets the context table. +-- @tparam ProfessionScrollingTable self The profession scrolling table object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `colWidth`, `colHidden`, `collapsed`) +-- @treturn ProfessionScrollingTable The profession scrolling table object +function ProfessionScrollingTable.SetContextTable(self, tbl, defaultTbl) + assert(type(defaultTbl.collapsed) == "table") + tbl.collapsed = tbl.collapsed or CopyTable(defaultTbl.collapsed) + self.__super:SetContextTable(tbl, defaultTbl) + return self +end + +function ProfessionScrollingTable.IsSpellIdVisible(self, spellId) + if not self._isSpellId[spellId] then + -- this spellId isn't included in the query + return false + end + local categoryId = TSM.Crafting.ProfessionScanner.GetCategoryIdBySpellId(spellId) + return not self:_IsCategoryHidden(categoryId) and not self._contextTable.collapsed[categoryId] +end + +function ProfessionScrollingTable.Draw(self) + if self._lastDataUpdate == nil then + self:_IgnoreLastDataUpdate() + end + self.__super:Draw() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ProfessionScrollingTable._ToggleCollapsed(self, categoryId) + self._contextTable.collapsed[categoryId] = not self._contextTable.collapsed[categoryId] or nil + if self._selection and not self:IsSpellIdVisible(self._selection) then + self:SetSelection(nil, true) + end +end + +function ProfessionScrollingTable._GetTableRow(self, isHeader) + local row = self.__super:_GetTableRow(isHeader) + if not isHeader then + ScriptWrapper.Set(row._frame, "OnClick", private.RowOnClick, row) + ScriptWrapper.Set(row._frame, "OnDoubleClick", private.RowOnClick, row) + local rankBtn = row:_GetButton() + rankBtn:SetAllPoints(row._texts.rank) + ScriptWrapper.SetPropagate(rankBtn, "OnClick") + ScriptWrapper.Set(rankBtn, "OnEnter", private.RankOnEnter, row) + ScriptWrapper.Set(rankBtn, "OnLeave", private.RankOnLeave, row) + row._buttons.rank = rankBtn + end + return row +end + +function ProfessionScrollingTable._UpdateData(self) + local currentCategoryPath = TempTable.Acquire() + local foundSelection = false + -- populate the data + wipe(self._data) + wipe(self._isSpellId) + for _, spellId in self._query:Iterator() do + if self._favoritesContextTable[spellId] then + local categoryId = -1 + if categoryId ~= currentCategoryPath[#currentCategoryPath] then + -- this is a new category + local newCategoryPath = TempTable.Acquire() + tinsert(newCategoryPath, 1, categoryId) + -- create new category headers + if currentCategoryPath[1] ~= categoryId then + if not self:_IsCategoryHidden(categoryId) then + tinsert(self._data, categoryId) + end + end + TempTable.Release(currentCategoryPath) + currentCategoryPath = newCategoryPath + end + foundSelection = foundSelection or spellId == self:GetSelection() + if not self._contextTable.collapsed[categoryId] and not self:_IsCategoryHidden(categoryId) then + tinsert(self._data, spellId) + self._isSpellId[spellId] = true + end + end + end + for _, spellId, categoryId in self._query:Iterator() do + if not self._favoritesContextTable[spellId] then + if categoryId ~= currentCategoryPath[#currentCategoryPath] then + -- this is a new category + local newCategoryPath = TempTable.Acquire() + local currentCategoryId = categoryId + while currentCategoryId do + tinsert(newCategoryPath, 1, currentCategoryId) + currentCategoryId = private.CategoryGetParentCategoryId(currentCategoryId) + end + -- create new category headers + for i = 1, #newCategoryPath do + local newCategoryId = newCategoryPath[i] + if currentCategoryPath[i] ~= newCategoryId then + if not self:_IsCategoryHidden(newCategoryId) then + tinsert(self._data, newCategoryId) + end + end + end + TempTable.Release(currentCategoryPath) + currentCategoryPath = newCategoryPath + end + foundSelection = foundSelection or spellId == self:GetSelection() + if not self._contextTable.collapsed[categoryId] and not self:_IsCategoryHidden(categoryId) then + tinsert(self._data, spellId) + self._isSpellId[spellId] = true + end + end + end + TempTable.Release(currentCategoryPath) + if not foundSelection then + -- try to select the first visible spellId + local newSelection = nil + for _, data in ipairs(self._data) do + if not newSelection and self._isSpellId[data] then + newSelection = data + end + end + self:SetSelection(newSelection, true) + end +end + +function ProfessionScrollingTable._IsCategoryHidden(self, categoryId) + if private.IsFavoriteCategory(categoryId) then + return false + end + local parent = private.CategoryGetParentCategoryId(categoryId) + while parent do + if self._contextTable.collapsed[parent] then + return true + end + parent = private.CategoryGetParentCategoryId(parent) + end + return false +end + +function ProfessionScrollingTable._SetRowData(self, row, data) + local rank = self._isSpellId[data] and TSM.Crafting.ProfessionScanner.GetRankBySpellId(data) or -1 + if rank == -1 then + row._buttons.rank:Hide() + else + row._buttons.rank:Show() + end + self.__super:_SetRowData(row, data) +end + +function ProfessionScrollingTable._ToggleSort(self, id) + -- do nothing +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.PopulateCategoryInfoCache(categoryId) + -- numIndents always gets set, so use that to know whether or not this category is already cached + if not private.categoryInfoCache.numIndents[categoryId] then + local name, numIndents, parentCategoryId, currentSkillLevel, maxSkillLevel = TSM.Crafting.ProfessionUtil.GetCategoryInfo(categoryId) + private.categoryInfoCache.name[categoryId] = name + private.categoryInfoCache.numIndents[categoryId] = numIndents + private.categoryInfoCache.parent[categoryId] = parentCategoryId + private.categoryInfoCache.currentSkillLevel[categoryId] = currentSkillLevel + private.categoryInfoCache.maxSkillLevel[categoryId] = maxSkillLevel + end +end + +function private.CategoryGetParentCategoryId(categoryId) + private.PopulateCategoryInfoCache(categoryId) + return private.categoryInfoCache.parent[categoryId] +end + +function private.CategoryGetNumIndents(categoryId) + private.PopulateCategoryInfoCache(categoryId) + return private.categoryInfoCache.numIndents[categoryId] +end + +function private.CategoryGetName(categoryId) + private.PopulateCategoryInfoCache(categoryId) + return private.categoryInfoCache.name[categoryId] +end + +function private.CategoryGetSkillLevel(categoryId) + private.PopulateCategoryInfoCache(categoryId) + return private.categoryInfoCache.currentSkillLevel[categoryId], private.categoryInfoCache.maxSkillLevel[categoryId] +end + +function private.IsFavoriteCategory(categoryId) + return categoryId == -1 +end + +function private.QueryUpdateCallback(_, _, self) + self:_ForceLastDataUpdate() + self:UpdateData(true) +end + +function private.MenuIterator(self, prevIndex) + if prevIndex == "CREATE_GROUPS" then + -- we're done + return + else + return "CREATE_GROUPS", L["Create Groups from Table"] + end +end + +function private.MenuClickHandler(self, index1, index2) + if index1 == "CREATE_GROUPS" then + assert(not index2) + self:GetBaseElement():HideDialog() + local numCreated, numAdded = 0, 0 + for _, spellId in self._query:Iterator() do + local itemString = TSM.Crafting.GetItemString(spellId) + if itemString then + local groupPath = private.GetCategoryGroupPath(TSM.Crafting.ProfessionScanner.GetCategoryIdBySpellId(spellId)) + if not TSM.Groups.Exists(groupPath) then + TSM.Groups.Create(groupPath) + numCreated = numCreated + 1 + end + if not TSM.Groups.IsItemInGroup(itemString) and not ItemInfo.IsSoulbound(itemString) then + TSM.Groups.SetItemGroup(itemString, groupPath) + numAdded = numAdded + 1 + end + end + end + Log.PrintfUser(L["%d groups were created and %d items were added from the table."], numCreated, numAdded) + else + error("Unexpected index1: "..tostring(index1)) + end +end + +function private.GetCategoryGroupPath(categoryId) + local parts = TempTable.Acquire() + while categoryId do + tinsert(parts, 1, private.categoryInfoCache.name[categoryId]) + categoryId = private.categoryInfoCache.parent[categoryId] + end + tinsert(parts, 1, TSM.Crafting.ProfessionUtil.GetCurrentProfessionName()) + return TSM.Groups.Path.Join(TempTable.UnpackAndRelease(parts)) +end + +function private.GetNameCellText(self, data) + if self._isSpellId[data] then + local name = TSM.Crafting.ProfessionScanner.GetNameBySpellId(data) + local color = nil + if TSM.Crafting.ProfessionUtil.IsGuildProfession() then + color = Theme.GetProfessionDifficultyColor("easy") + elseif TSM.Crafting.ProfessionUtil.IsNPCProfession() then + color = Theme.GetProfessionDifficultyColor("nodifficulty") + else + local difficulty = TSM.Crafting.ProfessionScanner.GetDifficultyBySpellId(data) + color = Theme.GetProfessionDifficultyColor(difficulty) + end + return color:ColorText(name) + else + -- this is a category + local name = nil + if private.IsFavoriteCategory(data) then + name = L["Favorited Patterns"] + else + local currentSkillLevel, maxSkillLevel = private.CategoryGetSkillLevel(data) + name = private.CategoryGetName(data) + if name and currentSkillLevel and maxSkillLevel then + name = name.." ("..currentSkillLevel.."/"..maxSkillLevel..")" + end + end + if not name then + -- happens if we're switching to another profession + return "?" + end + if private.IsFavoriteCategory(data) or private.CategoryGetNumIndents(data) == 0 then + return Theme.GetColor("INDICATOR"):ColorText(name) + else + return Theme.GetColor("INDICATOR_ALT"):ColorText(name) + end + end +end + +function private.GetExpanderState(self, data) + local indentLevel = 0 + if self._isSpellId[data] then + indentLevel = 2 + elseif not private.IsFavoriteCategory(data) then + indentLevel = private.CategoryGetNumIndents(data) * 2 + end + return not self._isSpellId[data], not self._contextTable.collapsed[data], -indentLevel +end + +function private.GetFavoriteIcon(self, data, iconIndex, isMouseOver) + if iconIndex == 1 then + if not self._isSpellId[data] then + return false, nil, true + else + return true, self._favoritesContextTable[data] and "iconPack.12x12/Star/Filled" or "iconPack.12x12/Star/Unfilled", true + end + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.OnFavoriteIconClick(self, data, iconIndex) + if iconIndex == 1 then + if self._isSpellId[data] and private.IsPlayerProfession() then + self._favoritesContextTable[data] = not self._favoritesContextTable[data] or nil + self:_ForceLastDataUpdate() + self:UpdateData(true) + end + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.GetQtyCellText(self, data) + if not self._isSpellId[data] then + return "" + end + local num, numAll = TSM.Crafting.ProfessionUtil.GetNumCraftable(data) + if num == numAll then + if num > 0 then + return Theme.GetFeedbackColor("GREEN"):ColorText(num) + end + return tostring(num) + else + if num > 0 then + return Theme.GetFeedbackColor("GREEN"):ColorText(num.."-"..numAll) + elseif numAll > 0 then + return Theme.GetFeedbackColor("YELLOW"):ColorText(num.."-"..numAll) + else + return num.."-"..numAll + end + end +end + +function private.GetRankCellText(self, data) + local rank = self._isSpellId[data] and TSM.Crafting.ProfessionScanner.GetRankBySpellId(data) or -1 + if rank == -1 then + return "" + end + local filled = TSM.UI.TexturePacks.GetTextureLink("iconPack.14x14/Star/Filled") + local unfilled = TSM.UI.TexturePacks.GetTextureLink("iconPack.14x14/Star/Unfilled") + assert(rank >= 1 and rank <= 3) + return strrep(filled, rank)..strrep(unfilled, 3 - rank) +end + +function private.GetCraftingCostCellText(self, data) + if not self._isSpellId[data] then + return "" + end + local craftingCost = TSM.Crafting.Cost.GetCostsBySpellId(data) + return craftingCost and Money.ToString(craftingCost) or "" +end + +function private.GetItemValueCellIndex(self, data) + if not self._isSpellId[data] then + return "" + end + local _, craftedItemValue = TSM.Crafting.Cost.GetCostsBySpellId(data) + return craftedItemValue and Money.ToString(craftedItemValue) or "" +end + +function private.GetProfitCellText(self, data, currentTitleIndex) + if not self._isSpellId[data] then + return "" + end + local _, _, profit = TSM.Crafting.Cost.GetCostsBySpellId(data) + local color = profit and Theme.GetFeedbackColor(profit >= 0 and "GREEN" or "RED") + return profit and Money.ToString(profit, color:GetTextColorPrefix()) or "" +end + +function private.GetProfitPctCellText(self, data, currentTitleIndex) + if not self._isSpellId[data] then + return "" + end + local craftingCost, _, profit = TSM.Crafting.Cost.GetCostsBySpellId(data) + local color = profit and Theme.GetFeedbackColor(profit >= 0 and "GREEN" or "RED") + return profit and color:ColorText(floor(profit * 100 / craftingCost).."%") or "" +end + +function private.GetSaleRateCellText(self, data) + return self._isSpellId[data] and TSM.Crafting.Cost.GetSaleRateBySpellId(data) or "" +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.RowOnClick(row, mouseButton) + local scrollingTable = row._scrollingTable + local data = row:GetData() + if mouseButton == "LeftButton" then + if scrollingTable._isSpellId[data] then + scrollingTable:SetSelection(data) + else + scrollingTable:_ToggleCollapsed(data) + end + scrollingTable:UpdateData(true) + + if scrollingTable._isSpellId[data] then + row:SetHighlightState("selectedHover") + else + row:SetHighlightState("hover") + end + end +end + +function private.RankOnEnter(row) + local data = row:GetData() + local rank = row._scrollingTable._isSpellId[data] and TSM.Crafting.ProfessionScanner.GetRankBySpellId(data) or -1 + if rank > 0 and not TSM.IsWowClassic() then + assert(not Tooltip.IsVisible()) + GameTooltip:SetOwner(row._buttons.rank, "ANCHOR_PRESERVE") + GameTooltip:ClearAllPoints() + GameTooltip:SetPoint("LEFT", row._buttons.rank, "RIGHT") + GameTooltip:SetRecipeRankInfo(data, rank) + GameTooltip:Show() + end + row._frame:GetScript("OnEnter")(row._frame) +end + +function private.RankOnLeave(row) + Tooltip.Hide() + row._frame:GetScript("OnLeave")(row._frame) +end + +function private.IsPlayerProfession() + return not (TSM.Crafting.ProfessionUtil.IsNPCProfession() or TSM.Crafting.ProfessionUtil.IsLinkedProfession() or TSM.Crafting.ProfessionUtil.IsGuildProfession()) +end + +function private.OnChatMsgSkill(_, msg) + if not strmatch(msg, TSM.Crafting.ProfessionUtil.GetCurrentProfessionName()) then + return + end + for self in pairs(private.activeElements) do + wipe(private.categoryInfoCache.numIndents) + self:_ForceLastDataUpdate() + self:_UpdateData(true) + end +end diff --git a/Core/UI/Elements/ProgressBar.lua b/Core/UI/Elements/ProgressBar.lua new file mode 100644 index 0000000..7e5459a --- /dev/null +++ b/Core/UI/Elements/ProgressBar.lua @@ -0,0 +1,168 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ProgressBar UI Element Class. +-- The progress bar element is a left-to-right progress bar with an anaimated progress indicator and text. It is a +-- subclass of the @{Text} class. +-- @classmod ProgressBar + +local _, TSM = ... +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local ProgressBar = TSM.Include("LibTSMClass").DefineClass("ProgressBar", TSM.UI.Text) +UIElements.Register(ProgressBar) +TSM.UI.ProgressBar = ProgressBar +local PROGRESS_PADDING = 2 +local PROGRESS_ICON_PADDING = 4 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ProgressBar.__init(self) + local frame = UIElements.CreateFrame(self, "Frame") + + self.__super:__init(frame) + + self._bgLeft = frame:CreateTexture(nil, "BACKGROUND") + self._bgLeft:SetPoint("TOPLEFT") + self._bgLeft:SetPoint("BOTTOMLEFT") + TSM.UI.TexturePacks.SetTextureAndWidth(self._bgLeft, "uiFrames.LoadingBarLeft") + + self._bgRight = frame:CreateTexture(nil, "BACKGROUND") + self._bgRight:SetPoint("TOPRIGHT") + self._bgRight:SetPoint("BOTTOMRIGHT") + TSM.UI.TexturePacks.SetTextureAndWidth(self._bgRight, "uiFrames.LoadingBarRight") + + self._bgMiddle = frame:CreateTexture(nil, "BACKGROUND") + self._bgMiddle:SetPoint("TOPLEFT", self._bgLeft, "TOPRIGHT") + self._bgMiddle:SetPoint("BOTTOMRIGHT", self._bgRight, "BOTTOMLEFT") + TSM.UI.TexturePacks.SetTexture(self._bgMiddle, "uiFrames.LoadingBarMiddle") + + -- create the progress textures + self._progressLeft = frame:CreateTexture(nil, "ARTWORK") + self._progressLeft:SetPoint("TOPLEFT", PROGRESS_PADDING, -PROGRESS_PADDING) + self._progressLeft:SetPoint("BOTTOMLEFT", PROGRESS_PADDING, PROGRESS_PADDING) + self._progressLeft:SetBlendMode("BLEND") + TSM.UI.TexturePacks.SetTexture(self._progressLeft, "uiFrames.LoadingBarLeft") + + self._progressMiddle = frame:CreateTexture(nil, "ARTWORK") + self._progressMiddle:SetPoint("TOPLEFT", self._progressLeft, "TOPRIGHT") + self._progressMiddle:SetPoint("BOTTOMLEFT", self._progressLeft, "BOTTOMRIGHT") + self._progressMiddle:SetBlendMode("BLEND") + TSM.UI.TexturePacks.SetTexture(self._progressMiddle, "uiFrames.LoadingBarMiddle") + + self._progressRight = frame:CreateTexture(nil, "ARTWORK") + self._progressRight:SetPoint("TOPLEFT", self._progressMiddle, "TOPRIGHT") + self._progressRight:SetPoint("BOTTOMLEFT", self._progressMiddle, "BOTTOMRIGHT") + self._progressRight:SetBlendMode("BLEND") + TSM.UI.TexturePacks.SetTexture(self._progressRight, "uiFrames.LoadingBarRight") + + -- create the progress icon + frame.progressIcon = frame:CreateTexture(nil, "OVERLAY") + frame.progressIcon:SetPoint("RIGHT", frame.text, "LEFT", -PROGRESS_ICON_PADDING, 0) + frame.progressIcon:Hide() + + frame.progressIcon.ag = frame.progressIcon:CreateAnimationGroup() + local spin = frame.progressIcon.ag:CreateAnimation("Rotation") + spin:SetDuration(2) + spin:SetDegrees(360) + frame.progressIcon.ag:SetLooping("REPEAT") + + self._progress = 0 + self._progressIconHidden = false + self._justifyH = "CENTER" + self._font = "BODY_BODY2_MEDIUM" + self._textColor = "INDICATOR" +end + +function ProgressBar.Release(self) + self._progress = 0 + self._progressIconHidden = false + self:_GetBaseFrame().progressIcon.ag:Stop() + self:_GetBaseFrame().progressIcon:Hide() + self.__super:Release() + self._justifyH = "CENTER" + self._font = "BODY_BODY2_MEDIUM" + self._textColor = "INDICATOR" +end + +--- Sets the progress. +-- @tparam ProgressBar self The progress bar object +-- @tparam number progress The progress from a value of 0 to 1 (inclusive) +-- @tparam boolean isDone Whether or not the progress is finished +-- @treturn ProgressBar The progress bar object +function ProgressBar.SetProgress(self, progress, isDone) + self._progress = progress + return self +end + +--- Sets whether or not the progress indicator is hidden. +-- @tparam ProgressBar self The progress bar object +-- @tparam boolean hidden Whether or not the progress indicator is hidden +-- @treturn ProgressBar The progress bar object +function ProgressBar.SetProgressIconHidden(self, hidden) + self._progressIconHidden = hidden + return self +end + +function ProgressBar.Draw(self) + self.__super:Draw() + local frame = self:_GetBaseFrame() + + self._bgLeft:SetVertexColor(Theme.GetColor("PRIMARY_BG"):GetFractionalRGBA()) + self._bgMiddle:SetVertexColor(Theme.GetColor("PRIMARY_BG"):GetFractionalRGBA()) + self._bgRight:SetVertexColor(Theme.GetColor("PRIMARY_BG"):GetFractionalRGBA()) + + local text = frame.text + text:ClearAllPoints() + text:SetWidth(self:_GetDimension("WIDTH")) + text:SetWidth(frame.text:GetStringWidth()) + text:SetHeight(self:_GetDimension("HEIGHT")) + text:SetPoint("CENTER", self._progressIconHidden and 0 or ((TSM.UI.TexturePacks.GetWidth("iconPack.18x18/Running") + PROGRESS_ICON_PADDING) / 2), 0) + + TSM.UI.TexturePacks.SetTextureAndSize(frame.progressIcon, "iconPack.18x18/Running") + frame.progressIcon:SetVertexColor(self:_GetTextColor():GetFractionalRGBA()) + if self._progressIconHidden and frame.progressIcon:IsVisible() then + frame.progressIcon.ag:Stop() + frame.progressIcon:Hide() + elseif not self._progressIconHidden and not frame.progressIcon:IsVisible() then + frame.progressIcon:Show() + frame.progressIcon.ag:Play() + end + + if self._progress == 0 then + self._progressLeft:Hide() + self._progressMiddle:Hide() + self._progressRight:Hide() + else + self._progressLeft:Show() + self._progressMiddle:Show() + self._progressRight:Show() + self._progressLeft:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + self._progressMiddle:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + self._progressRight:SetVertexColor(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + local leftTextureWidth = TSM.UI.TexturePacks.GetWidth("uiFrames.LoadingBarLeft") + local rightTextureWidth = TSM.UI.TexturePacks.GetWidth("uiFrames.LoadingBarRight") + local maxProgressWidth = self:_GetDimension("WIDTH") - PROGRESS_PADDING * 2 + local progressWidth = maxProgressWidth * self._progress + if progressWidth <= leftTextureWidth then + self._progressLeft:SetWidth(progressWidth) + self._progressMiddle:Hide() + self._progressRight:Hide() + elseif progressWidth < maxProgressWidth - rightTextureWidth then + self._progressLeft:SetWidth(leftTextureWidth) + self._progressMiddle:SetWidth(progressWidth - leftTextureWidth) + self._progressRight:Hide() + else + self._progressLeft:SetWidth(leftTextureWidth) + self._progressMiddle:SetWidth(progressWidth - leftTextureWidth - rightTextureWidth) + self._progressRight:SetWidth(rightTextureWidth) + end + end +end diff --git a/Core/UI/Elements/QueryScrollingTable.lua b/Core/UI/Elements/QueryScrollingTable.lua new file mode 100644 index 0000000..67fa9b4 --- /dev/null +++ b/Core/UI/Elements/QueryScrollingTable.lua @@ -0,0 +1,258 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Query Scrolling Table UI ScrollingTable Class. +-- A query scrolling table contains a scrollable list of rows with a fixed set of columns. It is a subclass of the +-- @{ScrollingTable} class. +-- @classmod QueryScrollingTable + +local _, TSM = ... +local QueryScrollingTable = TSM.Include("LibTSMClass").DefineClass("QueryScrollingTable", TSM.UI.ScrollingTable) +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(QueryScrollingTable) +TSM.UI.QueryScrollingTable = QueryScrollingTable +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function QueryScrollingTable.__init(self) + self.__super:__init() + self._query = nil + self._sortCol = nil + self._sortAscending = nil + self._autoReleaseQuery = false +end + +function QueryScrollingTable.Release(self) + if self._query then + self._query:SetUpdateCallback() + if self._autoReleaseQuery then + self._query:Release() + end + self._query = nil + end + self._sortCol = nil + self._sortAscending = nil + self._autoReleaseQuery = false + self.__super:Release() +end + +--- Sets the @{DatabaseQuery} source for this table. +-- This query is used to populate the entries in the query scrolling table. +-- @tparam QueryScrollingTable self The query scrolling table object +-- @tparam DatabaseQuery query The query object +-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table +-- @treturn QueryScrollingTable The query scrolling table object +function QueryScrollingTable.SetQuery(self, query, redraw) + if query == self._query and not redraw then + return self + end + if self._query then + self._query:SetUpdateCallback() + end + self._query = query + self._query:SetUpdateCallback(private.QueryUpdateCallback, self) + + self:_UpdateSortFromQuery() + self:_ForceLastDataUpdate() + self:UpdateData(redraw) + return self +end + +--- Sets whether or not the @{DatabaseQuery} is automatically released. +-- @tparam QueryScrollingTable self The query scrolling table object +-- @tparam bool autoRelease Whether or not to auto-release the query +-- @treturn QueryScrollingTable The query scrolling table object +function QueryScrollingTable.SetAutoReleaseQuery(self, autoRelease) + self._autoReleaseQuery = autoRelease + return self +end + +--- Sets the selected record. +-- @tparam QueryScrollingTable self The query scrolling table object +-- @param selection The selected record or nil to clear the selection +-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table +-- @treturn QueryScrollingTable The query scrolling table object +function QueryScrollingTable.SetSelection(self, selection, redraw) + if selection == self._selection then + return self + elseif selection and self._selectionValidator and not self:_selectionValidator(self._query:GetResultRowByUUID(selection)) then + return self + end + local index = nil + if selection then + index = Table.KeyByValue(self._data, selection) + assert(index) + end + self:_IgnoreLastDataUpdate() + self._selection = selection + if selection then + -- set the scroll so that the selection is visible if necessary + local rowHeight = self._rowHeight + local firstVisibleIndex = ceil(self._vScrollValue / rowHeight) + 1 + local lastVisibleIndex = floor((self._vScrollValue + self:_GetDimension("HEIGHT")) / rowHeight) + if lastVisibleIndex > firstVisibleIndex and (index < firstVisibleIndex or index > lastVisibleIndex) then + self:_OnScrollValueChanged(min((index - 1) * rowHeight, self:_GetMaxScroll())) + end + end + for _, row in ipairs(self._rows) do + if not row:IsMouseOver() and row:IsVisible() and row:GetData() ~= selection then + row:SetHighlightState(nil) + elseif row:IsMouseOver() and row:IsVisible() then + row:SetHighlightState(row:GetData() == selection and "selectedHover" or "hover") + elseif row:IsVisible() and row:GetData() == selection then + row:SetHighlightState("selected") + end + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + if redraw then + self:Draw() + end + return self +end + +--- Gets the currently selected row. +-- @tparam QueryScrollingTable self The scrolling table object +-- @return The selected row or nil if there's nothing selected +function QueryScrollingTable.GetSelection(self) + return self._selection and self._query:GetResultRowByUUID(self._selection) or nil +end + +--- Registers a script handler. +-- @tparam QueryScrollingTable self The scrolling table object +-- @tparam string script The script to register for (supported scripts: `OnDataUpdated`, `OnSelectionChanged`, `OnRowClick`) +-- @tparam function handler The script handler which will be called with the scrolling table object followed by any +-- arguments to the script +-- @treturn QueryScrollingTable The scrolling table object +function QueryScrollingTable.SetScript(self, script, handler) + if script == "OnDataUpdated" then + self._onDataUpdated = handler + else + self.__super:SetScript(script, handler) + end + return self +end + +function QueryScrollingTable.Draw(self) + self._query:SetUpdatesPaused(true) + if self._lastDataUpdate == nil then + self:_IgnoreLastDataUpdate() + end + self.__super:Draw() + self._header:SetSort(self._sortCol, self._sortAscending) + self._query:SetUpdatesPaused(false) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function QueryScrollingTable._CreateScrollingTableInfo(self) + return TSM.UI.Util.QueryScrollingTableInfo() +end + +function QueryScrollingTable._UpdateSortFromQuery(self) + if self._tableInfo:_IsSortEnabled() then + local sortField, sortAscending = self._query:GetLastOrderBy() + if sortField then + self._sortCol = self._tableInfo:_GetIdBySortField(sortField) + self._sortAscending = sortAscending + else + self._sortCol = nil + self._sortAscending = nil + end + end +end + +function QueryScrollingTable._UpdateData(self) + -- we need to fix up the data within the rows updated to avoid errors with trying to access old DatabaseQueryResultRows + local prevRowIndex = TempTable.Acquire() + local newRowData = TempTable.Acquire() + for i, row in ipairs(self._rows) do + if row:IsVisible() then + prevRowIndex[row:GetData()] = i + end + end + local prevSelection = self._selection + wipe(self._data) + self._selection = nil + for _, uuid in self._query:UUIDIterator() do + if uuid == prevSelection then + self._selection = uuid + end + if prevRowIndex[uuid] then + newRowData[prevRowIndex[uuid]] = uuid + end + tinsert(self._data, uuid) + end + for i, row in ipairs(self._rows) do + if row:IsVisible() then + if newRowData[i] then + row:SetData(newRowData[i]) + else + row:ClearData() + end + end + end + TempTable.Release(prevRowIndex) + TempTable.Release(newRowData) + if prevSelection and not self._selection then + -- select the first row since we weren't able to find the previously-selected row + self:SetSelection(self._data[1]) + end + if self._onDataUpdated then + self:_onDataUpdated() + end +end + +function QueryScrollingTable._ToggleSort(self, id) + local sortField = self._tableInfo:_GetSortFieldById(id) + if not self._sortCol or not self._query or not sortField then + -- sorting disabled so ignore + return + end + + if id == self._sortCol then + self._sortAscending = not self._sortAscending + else + self._sortCol = id + self._sortAscending = true + end + + self._query:UpdateLastOrderBy(sortField, self._sortAscending) + self:_UpdateData() + self:Draw() +end + +function QueryScrollingTable._HandleRowClick(self, uuid, mouseButton) + if self._onRowClickHandler then + self:_onRowClickHandler(self._query:GetResultRowByUUID(uuid), mouseButton) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.QueryUpdateCallback(_, uuid, self) + self:_SetLastDataUpdate(uuid) + if not uuid then + self:_UpdateData() + end + self:Draw() +end diff --git a/Core/UI/Elements/ScrollFrame.lua b/Core/UI/Elements/ScrollFrame.lua new file mode 100644 index 0000000..b11f479 --- /dev/null +++ b/Core/UI/Elements/ScrollFrame.lua @@ -0,0 +1,237 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ScrollFrame UI Element Class. +-- A scroll frame is a container which allows the content to be of unlimited (but fixed/static) height within a +-- scrollable window. It is a subclass of the @{Container} class. +-- @classmod ScrollFrame + +local _, TSM = ... +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Theme = TSM.Include("Util.Theme") +local NineSlice = TSM.Include("Util.NineSlice") +local ScrollFrame = TSM.Include("LibTSMClass").DefineClass("ScrollFrame", TSM.UI.Container) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ScrollFrame) +TSM.UI.ScrollFrame = ScrollFrame +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ScrollFrame.__init(self) + local frame = UIElements.CreateFrame(self, "ScrollFrame") + + self.__super:__init(frame) + + self._backgroundNineSlice = NineSlice.New(frame, 1) + self._backgroundNineSlice:Hide() + + frame:EnableMouseWheel(true) + frame:SetClipsChildren(true) + ScriptWrapper.Set(frame, "OnUpdate", private.FrameOnUpdate, self) + ScriptWrapper.Set(frame, "OnMouseWheel", private.FrameOnMouseWheel, self) + + self._scrollbar = TSM.UI.Scrollbar.Create(frame) + ScriptWrapper.Set(self._scrollbar, "OnValueChanged", private.OnScrollbarValueChanged, self) + + self._content = CreateFrame("Frame", nil, frame) + self._content:SetPoint("TOPLEFT") + self._content:SetPoint("TOPRIGHT") + frame:SetScrollChild(self._content) + + self._scrollValue = 0 + self._onUpdateHandler = nil + self._backgroundColor = nil +end + +function ScrollFrame.Acquire(self) + self.__super:Acquire() + self._scrollValue = 0 + self._scrollbar:SetValue(0) +end + +function ScrollFrame.Release(self) + self._onUpdateHandler = nil + self._backgroundColor = nil + self._backgroundNineSlice:Hide() + self.__super:Release() +end + +--- Sets the background of the scroll frame. +-- @tparam ScrollFrame self The scroll frame object +-- @tparam ?string|nil color The background color as a theme color key or nil +-- @treturn ScrollFrame The scroll frame object +function ScrollFrame.SetBackgroundColor(self, color) + assert(color == nil or Theme.GetColor(color)) + self._backgroundColor = color + return self +end + +function ScrollFrame.SetScript(self, script, handler) + if script == "OnUpdate" then + self._onUpdateHandler = handler + else + self.__super:SetScript(script, handler) + end + return self +end + +function ScrollFrame.Draw(self) + self.__super.__super:Draw() + + if self._backgroundColor then + self._backgroundNineSlice:SetStyle("solid") + self._backgroundNineSlice:SetVertexColor(Theme.GetColor(self._backgroundColor):GetFractionalRGBA()) + else + self._backgroundNineSlice:Hide() + end + + local width = self:_GetDimension("WIDTH") + self._content:SetWidth(width) + width = width - self:_GetPadding("LEFT") - self:_GetPadding("RIGHT") + + local totalHeight = self:_GetPadding("TOP") + self:_GetPadding("BOTTOM") + for _, child in self:LayoutChildrenIterator() do + child:_GetBaseFrame():SetParent(self._content) + child:_GetBaseFrame():ClearAllPoints() + + -- set the height + local childHeight, childHeightCanExpand = child:_GetMinimumDimension("HEIGHT") + assert(not childHeightCanExpand, "Invalid height for child: "..tostring(child._id)) + child:_SetDimension("HEIGHT", childHeight) + totalHeight = totalHeight + childHeight + child:_GetMargin("TOP") + child:_GetMargin("BOTTOM") + + -- set the width + local childWidth, childWidthCanExpand = child:_GetMinimumDimension("WIDTH") + if childWidthCanExpand then + childWidth = max(childWidth, width - child:_GetMargin("LEFT") - child:_GetMargin("RIGHT")) + end + child:_SetDimension("WIDTH", childWidth) + end + self._content:SetHeight(totalHeight) + local maxScroll = self:_GetMaxScroll() + self._scrollbar:SetMinMaxValues(0, maxScroll) + self._scrollbar:SetValue(min(self._scrollValue, maxScroll)) + self._scrollbar.thumb:SetHeight(TSM.UI.Scrollbar.GetLength(totalHeight, self:_GetDimension("HEIGHT"))) + + local yOffset = -1 * self:_GetPadding("TOP") + for _, child in self:LayoutChildrenIterator() do + local childFrame = child:_GetBaseFrame() + yOffset = yOffset - child:_GetMargin("TOP") + childFrame:SetPoint("TOPLEFT", child:_GetMargin("LEFT") + self:_GetPadding("LEFT"), yOffset) + yOffset = yOffset - childFrame:GetHeight() - child:_GetMargin("BOTTOM") + end + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ScrollFrame._OnScrollValueChanged(self, value) + self:_GetBaseFrame():SetVerticalScroll(value) + self._scrollValue = value +end + +function ScrollFrame._GetMaxScroll(self) + return max(self._content:GetHeight() - self:_GetDimension("HEIGHT"), 0) +end + +function ScrollFrame._GetMinimumDimension(self, dimension) + local styleResult = nil + if dimension == "WIDTH" then + styleResult = self._width + elseif dimension == "HEIGHT" then + styleResult = self._height + else + error("Invalid dimension: "..tostring(dimension)) + end + if styleResult then + return styleResult, false + elseif dimension == "HEIGHT" or self:GetNumLayoutChildren() == 0 then + -- regarding the first condition for this if statment, a scrollframe can be any height (including greater than + -- the height of the content if no scrolling is needed), so has no minimum and can always expand + return 0, true + else + -- we're trying to determine the width based on the max width of any of the children + local result = 0 + local canExpand = false + for _, child in self:LayoutChildrenIterator() do + local childMin, childCanExpand = child:_GetMinimumDimension(dimension) + childMin = childMin + child:_GetMargin("LEFT") + child:_GetMargin("RIGHT") + canExpand = canExpand or childCanExpand + result = max(result, childMin) + end + result = result + self:_GetPadding("LEFT") + self:_GetPadding("RIGHT") + return result, canExpand + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnScrollbarValueChanged(self, value) + value = max(min(value, self:_GetMaxScroll()), 0) + self:_OnScrollValueChanged(value) +end + +function private.FrameOnUpdate(self) + if (self:_GetBaseFrame():IsMouseOver() and self:_GetMaxScroll() > 0) or self._scrollbar.dragging then + self._scrollbar:Show() + else + self._scrollbar:Hide() + end + if self._onUpdateHandler then + self:_onUpdateHandler() + end +end + +function private.FrameOnMouseWheel(self, direction) + local parentScroll = nil + local parent = self:GetParentElement() + while parent do + if parent:__isa(ScrollFrame) then + parentScroll = parent + break + else + parent = parent:GetParentElement() + end + end + + if parentScroll then + local minValue, maxValue = self._scrollbar:GetMinMaxValues() + if direction > 0 then + if self._scrollbar:GetValue() == minValue then + local scrollAmount = min(parentScroll:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + parentScroll._scrollbar:SetValue(parentScroll._scrollbar:GetValue() + -1 * direction * scrollAmount) + else + local scrollAmount = min(self:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + self._scrollbar:SetValue(self._scrollbar:GetValue() + -1 * direction * scrollAmount) + end + else + if self._scrollbar:GetValue() == maxValue then + local scrollAmount = min(parentScroll:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + parentScroll._scrollbar:SetValue(parentScroll._scrollbar:GetValue() + -1 * direction * scrollAmount) + else + local scrollAmount = min(self:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + self._scrollbar:SetValue(self._scrollbar:GetValue() + -1 * direction * scrollAmount) + end + end + else + local scrollAmount = min(self:_GetDimension("HEIGHT") / 3, Theme.GetMouseWheelScrollAmount()) + self._scrollbar:SetValue(self._scrollbar:GetValue() + -1 * direction * scrollAmount) + end +end diff --git a/Core/UI/Elements/ScrollingTable.lua b/Core/UI/Elements/ScrollingTable.lua new file mode 100644 index 0000000..fdd4aae --- /dev/null +++ b/Core/UI/Elements/ScrollingTable.lua @@ -0,0 +1,741 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Scrolling Table UI Element Class. +-- A scrolling table contains a scrollable list of rows with a fixed set of columns. It is a subclass of the @{Element} +-- class. +-- @classmod ScrollingTable + +local _, TSM = ... +local ObjectPool = TSM.Include("Util.ObjectPool") +local Table = TSM.Include("Util.Table") +local Math = TSM.Include("Util.Math") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local ScrollingTable = TSM.Include("LibTSMClass").DefineClass("ScrollingTable", TSM.UI.Element, "ABSTRACT") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ScrollingTable) +TSM.UI.ScrollingTable = ScrollingTable +local private = { + rowPool = ObjectPool.New("TABLE_ROWS", TSM.UI.Util.TableRow, 1), +} +local HEADER_HEIGHT = 22 +local HEADER_LINE_HEIGHT = 2 +local MORE_COL_WIDTH = 8 +local FORCE_DATA_UPDATE = newproxy() +local IGNORE_DATA_UPDATE = newproxy() +local SCROLL_TO_DATA_TOTAL_TIME_S = 0.1 + + + +-- ============================================================================ +-- Meta Class Methods +-- ============================================================================ + +function ScrollingTable.__init(self) + local frame = UIElements.CreateFrame(self, "Frame", nil, nil, TSM.IsShadowlands() and "BackdropTemplate" or nil) + self.__super:__init(frame) + + frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + + self._lineTop = frame:CreateTexture(nil, "ARTWORK") + self._lineTop:SetPoint("TOPLEFT") + self._lineTop:SetPoint("TOPRIGHT") + self._lineTop:SetHeight(HEADER_LINE_HEIGHT) + self._lineTop:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + + self._lineBottom = frame:CreateTexture(nil, "ARTWORK") + self._lineBottom:SetPoint("TOPLEFT", 0, -HEADER_HEIGHT - HEADER_LINE_HEIGHT) + self._lineBottom:SetPoint("TOPRIGHT", 0, -HEADER_HEIGHT - HEADER_LINE_HEIGHT) + self._lineBottom:SetHeight(HEADER_LINE_HEIGHT) + self._lineBottom:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + + self._hScrollFrame = CreateFrame("ScrollFrame", nil, frame) + self._hScrollFrame:SetPoint("TOPLEFT") + self._hScrollFrame:SetPoint("BOTTOMRIGHT") + self._hScrollFrame:EnableMouseWheel(true) + self._hScrollFrame:SetClipsChildren(true) + ScriptWrapper.Set(self._hScrollFrame, "OnUpdate", private.HScrollFrameOnUpdate, self) + ScriptWrapper.Set(self._hScrollFrame, "OnMouseWheel", private.FrameOnMouseWheel, self) + + self._hContent = CreateFrame("Frame", nil, self._hScrollFrame) + self._hContent:SetPoint("TOPLEFT") + self._hScrollFrame:SetScrollChild(self._hContent) + + self._vScrollFrame = CreateFrame("ScrollFrame", nil, self._hContent) + self._vScrollFrame:SetPoint("TOPLEFT") + self._vScrollFrame:SetPoint("BOTTOMRIGHT") + self._vScrollFrame:EnableMouseWheel(true) + self._vScrollFrame:SetClipsChildren(true) + ScriptWrapper.Set(self._vScrollFrame, "OnUpdate", private.VScrollFrameOnUpdate, self) + ScriptWrapper.Set(self._vScrollFrame, "OnMouseWheel", private.FrameOnMouseWheel, self) + + self._content = CreateFrame("Frame", nil, self._vScrollFrame) + self._content:SetPoint("TOPLEFT") + self._vScrollFrame:SetScrollChild(self._content) + + self._hScrollbar = TSM.UI.Scrollbar.Create(frame, true) + self._vScrollbar = TSM.UI.Scrollbar.Create(frame) + + self._rowHeight = 20 + self._backgroundColor = "PRIMARY_BG" + self._rows = {} + self._data = {} + self._hScrollValue = 0 + self._vScrollValue = 0 + self._onSelectionChangedHandler = nil + self._onRowClickHandler = nil + self._selection = nil + self._selectionDisabled = nil + self._selectionValidator = nil + self._tableInfo = self:_CreateScrollingTableInfo() + self._header = nil + self._dataTranslationFunc = nil + self._contextTable = nil + self._defaultContextTable = nil + self._prevDataOffset = nil + self._lastDataUpdate = nil + self._rowHoverEnabled = true + self._headerHidden = false + self._targetScrollValue = nil + self._totalScrollDistance = nil + self._rightClickToggle = nil + + Theme.RegisterChangeCallback(function() + if self:IsVisible() and self._header then + self._header:_LayoutHeaderRow() + end + end) +end + +function ScrollingTable.Acquire(self) + self.__super:Acquire() + self._tableInfo:_Acquire(self) + self._hScrollFrame:SetHorizontalScroll(0) + self._hScrollValue = 0 + self._vScrollValue = 0 + + ScriptWrapper.Set(self._vScrollbar, "OnValueChanged", private.OnVScrollbarValueChangedNoDraw, self) + -- don't want to cause this element to be drawn for this initial scrollbar change + self._vScrollbar:SetValue(0) + ScriptWrapper.Set(self._vScrollbar, "OnValueChanged", private.OnVScrollbarValueChanged, self) + + ScriptWrapper.Set(self._hScrollbar, "OnValueChanged", private.OnHScrollbarValueChangedNoDraw, self) + -- don't want to cause this element to be drawn for this initial scrollbar change + self._hScrollbar:SetValue(0) + ScriptWrapper.Set(self._hScrollbar, "OnValueChanged", private.OnHScrollbarValueChanged, self) +end + +function ScrollingTable.Release(self) + self._rowHeight = 20 + self._backgroundColor = "PRIMARY_BG" + self._onSelectionChangedHandler = nil + self._onRowClickHandler = nil + self._selection = nil + self._selectionDisabled = nil + self._selectionValidator = nil + self._dataTranslationFunc = nil + self._contextTable = nil + self._defaultContextTable = nil + self._prevDataOffset = nil + self._lastDataUpdate = nil + if self._header then + self._header:Release() + private.rowPool:Recycle(self._header) + self._header = nil + end + for _, row in ipairs(self._rows) do + row:Release() + private.rowPool:Recycle(row) + end + wipe(self._rows) + self._tableInfo:_Release() + wipe(self._data) + self._headerHidden = false + self._targetScrollValue = nil + self._totalScrollDistance = nil + self.__super:Release() +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +--- Sets the background of the scrolling table. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam number rowHeight The row height +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetRowHeight(self, rowHeight) + self._rowHeight = rowHeight + return self +end + +--- Sets the background of the scrolling table. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam boolean hidden Whether or not the header should be hidden +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetHeaderHidden(self, hidden) + self._headerHidden = hidden + return self +end + +--- Sets the background of the scrolling table. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam string color The background color as a theme color key +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetBackgroundColor(self, color) + assert(Theme.GetColor(color)) + self._backgroundColor = color + return self +end + +--- Sets the context table. +-- This table can be used to preserve the table configuration across lifecycles of the scrolling table and even WoW +-- sessions if it's within the settings DB. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `colWidth`, `colHidden`) +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetContextTable(self, tbl, defaultTbl) + assert(type(defaultTbl.colWidth) == "table" and type(defaultTbl.colHidden) == "table") + tbl.colWidth = tbl.colWidth or CopyTable(defaultTbl.colWidth) + tbl.colHidden = tbl.colHidden or CopyTable(defaultTbl.colHidden) + self._contextTable = tbl + self._defaultContextTable = defaultTbl + self:_UpdateColsHidden() + return self +end + +--- Sets the context table from a settings object. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam Settings settings The settings object +-- @tparam string key The setting key +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetSettingsContext(self, settings, key) + return self:SetContextTable(settings[key], settings:GetDefaultReadOnly(key)) +end + +--- Forces an update of the data shown within the table. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam[opt=false] bool redraw Whether or not to redraw the scrolling table +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.UpdateData(self, redraw) + self:_ForceLastDataUpdate() + self:_UpdateData() + if redraw then + self:Draw() + end + return self +end + +--- Gets the ScrollingTableInfo object. +-- @tparam ScrollingTable self The scrolling table object +-- @treturn ScrollingTableInfo The scrolling table info object +function ScrollingTable.GetScrollingTableInfo(self) + return self._tableInfo +end + +--- Commits the scrolling table info. +-- This should be called once the scrolling table info is completely set (retrieved via @{ScrollingTable.GetScrollingTableInfo}). +-- @tparam ScrollingTable self The scrolling table object +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.CommitTableInfo(self) + self:_UpdateColsHidden() + if self._header then + self._header:Release() + private.rowPool:Recycle(self._header) + self._header = nil + end + return self +end + +--- Registers a script handler. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam string script The script to register for (supported scripts: `OnSelectionChanged`, `OnRowClick`) +-- @tparam function handler The script handler which will be called with the scrolling table object followed by any +-- arguments to the script +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetScript(self, script, handler) + if script == "OnSelectionChanged" then + self._onSelectionChangedHandler = handler + elseif script == "OnRowClick" then + self._onRowClickHandler = handler + else + error("Unknown ScrollingTable script: "..tostring(script)) + end + return self +end + +--- Sets the selected row. +-- @tparam ScrollingTable self The scrolling table object +-- @param selection The selected row or nil to clear the selection +-- @tparam[opt=false] boolean noDraw Don't redraw the rows +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetSelection(self, selection, noDraw) + if selection == self._selection then + self:_JumpToData(selection) + return self + elseif selection and self._selectionValidator and not self:_selectionValidator(selection) then + return self + end + self:_IgnoreLastDataUpdate() + self._selection = selection + self:_JumpToData(selection) + if not noDraw then + for _, row in ipairs(self._rows) do + if not row:IsMouseOver() and row:IsVisible() and not self:_IsSelected(row:GetData()) then + row:SetHighlightState(nil) + elseif row:IsMouseOver() and row:IsVisible() and not self:_IsSelected(row:GetData()) then + row:SetHighlightState("hover") + elseif row:IsMouseOver() and row:IsVisible() and self:_IsSelected(row:GetData()) then + row:SetHighlightState(self._selectionDisabled and "hover" or "selectedHover") + elseif row:IsVisible() and self:_IsSelected(row:GetData()) then + row:SetHighlightState("selected") + end + end + end + if self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + return self +end + +--- Gets the currently selected row. +-- @tparam ScrollingTable self The scrolling table object +-- @return The selected row or nil if there's nothing selected +function ScrollingTable.GetSelection(self) + return self._selection +end + +--- Sets a selection validator function. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam function validator A function which gets called with the scrolling table object and a row to validate +-- whether or not it's selectable (returns true if it is, false otherwise) +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetSelectionValidator(self, validator) + self._selectionValidator = validator + return self +end + +--- Sets whether or not selection is disabled. +-- @tparam ScrollingTable self The scrolling table object +-- @tparam boolean disabled Whether or not to disable selection +-- @treturn ScrollingTable The scrolling table object +function ScrollingTable.SetSelectionDisabled(self, disabled) + self._selectionDisabled = disabled + return self +end + +function ScrollingTable.Draw(self) + self.__super:Draw() + local frame = self:_GetBaseFrame() + local background = Theme.GetColor(self._backgroundColor) + frame:SetBackdropColor(background:GetFractionalRGBA()) + if self:_CanResizeCols() then + self:_UpdateColsHidden() + end + + if not self._header then + self._header = self:_GetTableRow(true) + self._header:SetBackgroundColor(Theme.GetColor("FRAME_BG")) + self._header:SetHeight(HEADER_HEIGHT) + end + + -- update the scrollbar layout + if self._headerHidden then + self._vScrollbar:SetPoint("TOPRIGHT", -Theme.GetScrollbarMargin(), -Theme.GetScrollbarMargin()) + else + self._vScrollbar:SetPoint("TOPRIGHT", -Theme.GetScrollbarMargin(), -Theme.GetScrollbarMargin() - HEADER_HEIGHT - HEADER_LINE_HEIGHT * 2) + end + + local totalWidth = 0 + if self:_CanResizeCols() then + -- add the "more" column + totalWidth = totalWidth + MORE_COL_WIDTH + Theme.GetColSpacing() + for colId, colWidth in pairs(self._contextTable.colWidth) do + if not self._contextTable.colHidden[colId] then + totalWidth = totalWidth + colWidth + Theme.GetColSpacing() + end + end + end + totalWidth = max(totalWidth, self:_GetDimension("WIDTH")) + self._hContent:SetHeight(self._hScrollFrame:GetHeight()) + self._hContent:SetWidth(totalWidth) + self._content:SetWidth(self._hContent:GetWidth()) + + local rowHeight = self._rowHeight + local totalHeight = #self._data * rowHeight + local visibleHeight = self._vScrollFrame:GetHeight() + local visibleWidth = self._hScrollFrame:GetWidth() + local numVisibleRows = min(ceil(visibleHeight / rowHeight), #self._data) + local maxScroll = self:_GetMaxScroll() + local vScrollOffset = min(self._vScrollValue, maxScroll) + local hScrollOffset = min(self._hScrollValue, self:_GetMaxHScroll()) + local dataOffset = floor(vScrollOffset / rowHeight) + + self._vScrollbar.thumb:SetHeight(TSM.UI.Scrollbar.GetLength(totalHeight, visibleHeight)) + self._vScrollbar:SetMinMaxValues(0, maxScroll) + self._vScrollbar:SetValue(vScrollOffset) + self._hScrollbar.thumb:SetWidth(TSM.UI.Scrollbar.GetLength(self._hContent:GetWidth(), visibleWidth)) + self._hScrollbar:SetMinMaxValues(0, self:_GetMaxHScroll()) + self._hScrollbar:SetValue(hScrollOffset) + self._content:SetHeight(numVisibleRows * rowHeight) + + if self._headerHidden then + self._lineTop:Hide() + self._lineBottom:Hide() + self._header:SetHeight(0) + self._header:SetBackgroundColor(Color.GetTransparent()) + self._vScrollFrame:SetPoint("TOPLEFT", 0, 0) + else + self._lineTop:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + self._lineBottom:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + self._lineTop:Show() + self._lineBottom:Show() + self._vScrollFrame:SetPoint("TOPLEFT", 0, -HEADER_HEIGHT - HEADER_LINE_HEIGHT * 2) + self._header:SetBackgroundColor(Theme.GetColor("FRAME_BG")) + self._header:SetHeight(HEADER_HEIGHT) + end + + if Math.Round(vScrollOffset + visibleHeight) == totalHeight then + -- we are at the bottom + self._vScrollFrame:SetVerticalScroll(numVisibleRows * rowHeight - visibleHeight) + else + self._vScrollFrame:SetVerticalScroll(0) + end + self._hScrollFrame:SetHorizontalScroll(hScrollOffset) + + while #self._rows < numVisibleRows do + local row = self:_GetTableRow(false) + row._frame:SetPoint("TOPLEFT", 0, -rowHeight * #self._rows) + row._frame:SetPoint("TOPRIGHT", 0, -rowHeight * #self._rows) + tinsert(self._rows, row) + end + + local scrollDiff = dataOffset - (self._prevDataOffset or dataOffset) + self._prevDataOffset = dataOffset + if scrollDiff ~= 0 then + -- Shuffle the rows around to accomplish the scrolling so that the data only changes + -- for the minimal number of rows, which allows for better optimization + for _ = 1, abs(scrollDiff) do + if scrollDiff > 0 then + tinsert(self._rows, tremove(self._rows, 1)) + else + tinsert(self._rows, 1, tremove(self._rows)) + end + end + -- fix the points of all the rows + for i, row in ipairs(self._rows) do + row._frame:SetPoint("TOPLEFT", 0, -rowHeight * (i - 1)) + row._frame:SetPoint("TOPRIGHT", 0, -rowHeight * (i - 1)) + end + end + + for i, row in ipairs(self._rows) do + local dataIndex = i + dataOffset + local data = self._data[dataIndex] + if i > numVisibleRows or not data then + row:SetVisible(false) + row:ClearData() + else + row:SetVisible(true) + self:_SetRowData(row, data) + row:SetBackgroundColor(background) + row:SetHeight(rowHeight) + end + end + self._lastDataUpdate = nil + + self._header:SetHeaderData() +end + + + +-- ============================================================================ +-- ScrollingTable - Private Class Methods +-- ============================================================================ + +function ScrollingTable._CreateScrollingTableInfo(self) + return TSM.UI.Util.ScrollingTableInfo() +end + +function ScrollingTable._GetTableRow(self, isHeader) + local row = private.rowPool:Get() + row:Acquire(self, isHeader) + return row +end + +function ScrollingTable._SetRowData(self, row, data) + -- updating the row data is expensive, so only do it if necessary + local dataUpdated = row:GetData() ~= data or not self._lastDataUpdate or self._lastDataUpdate == FORCE_DATA_UPDATE or self._lastDataUpdate == data + local isMouseOver = row:IsMouseOver() + local isSelected = self:_IsSelected(data) + if not isMouseOver and isSelected then + row:SetHighlightState("selected", dataUpdated) + elseif isMouseOver and isSelected then + row:SetHighlightState(self._selectionDisabled and "hover" or "selectedHover", dataUpdated) + elseif isMouseOver and not isSelected then + row:SetHighlightState("hover", dataUpdated) + else + row:SetHighlightState(nil, dataUpdated) + end + if dataUpdated then + row:SetData(data) + end +end + +function ScrollingTable._OnScrollValueChanged(self, value, noDraw) + self._vScrollValue = value + if not noDraw then + self:Draw() + end +end + +function ScrollingTable._OnHScrollValueChanged(self, value, noDraw) + self._hScrollValue = value + if not noDraw then + self:Draw() + end +end + +function ScrollingTable._GetMaxScroll(self) + return max(#self._data * self._rowHeight - self._vScrollFrame:GetHeight(), 0) +end + +function ScrollingTable._GetMaxHScroll(self) + return max(self._hContent:GetWidth() - self._hScrollFrame:GetWidth(), 0) +end + +function ScrollingTable._UpdateData(self) + error("Must be implemented by the child class") +end + +function ScrollingTable._ToggleSort(self, id) + error("Must be implemented by the child class") +end + +function ScrollingTable._IsSelected(self, data) + return data == self._selection +end + +function ScrollingTable._HandleRowClick(self, data, mouseButton) + if self._onRowClickHandler then + self:_onRowClickHandler(data, mouseButton) + end +end + +function ScrollingTable._GetColWidth(self, id) + return self._contextTable.colWidth[id] +end + +function ScrollingTable._ResetColWidth(self, id) + local defaultWidth = self._defaultContextTable.colWidth[id] + local currentWidth = self._contextTable.colWidth[id] + assert(currentWidth and defaultWidth) + self._contextTable.colWidth[id] = defaultWidth + self._header:_LayoutHeaderRow() + for _, row in ipairs(self._rows) do + row:_LayoutDataRow() + end + self:Draw() +end + +function ScrollingTable._SetColWidth(self, id, width, redraw) + assert(not self._contextTable.colWidthLocked) + local prevWidth = self._contextTable.colWidth[id] + assert(prevWidth) + if width == prevWidth and not redraw then + return + end + self._contextTable.colWidth[id] = width + for _, row in ipairs(self._rows) do + row:_LayoutDataRow() + end + if redraw then + self:Draw() + end +end + +function ScrollingTable._IsColWidthLocked(self) + return self._contextTable.colWidthLocked +end + +function ScrollingTable._ToogleColWidthLocked(self) + self._contextTable.colWidthLocked = not self._contextTable.colWidthLocked or nil + self._header:_LayoutHeaderRow() + self:Draw() +end + +function ScrollingTable._CanResizeCols(self) + return self._contextTable and true or false +end + +function ScrollingTable._ToggleColHide(self, id) + if not self._contextTable then + return + end + self._contextTable.colHidden[id] = not self._contextTable.colHidden[id] or nil + self:_UpdateColsHidden() + self._header:_LayoutHeaderRow() + for _, row in ipairs(self._rows) do + row:_LayoutDataRow() + end + self:Draw() +end + +function ScrollingTable._ResetContext(self) + assert(self._contextTable) + if self._defaultContextTable.colWidth then + wipe(self._contextTable.colWidth) + for col, width in pairs(self._defaultContextTable.colWidth) do + self._contextTable.colWidth[col] = width + end + end + if self._defaultContextTable.colHidden then + wipe(self._contextTable.colHidden) + for col, hidden in pairs(self._defaultContextTable.colHidden) do + self._contextTable.colHidden[col] = hidden + end + self:_UpdateColsHidden() + end + self._header:_LayoutHeaderRow() + for _, row in ipairs(self._rows) do + row:_LayoutDataRow() + end + self:Draw() +end + +function ScrollingTable._UpdateColsHidden(self) + for _, col in self:GetScrollingTableInfo():_ColIterator() do + local colId = col:_GetId() + if col:_CanHide() then + col:_SetHidden(self._contextTable and self._contextTable.colHidden[colId] and true or false) + elseif self._contextTable then + self._contextTable.colHidden[colId] = nil + end + end +end + +function ScrollingTable._SetLastDataUpdate(self, value) + self._lastDataUpdate = value +end + +function ScrollingTable._IgnoreLastDataUpdate(self) + self._lastDataUpdate = IGNORE_DATA_UPDATE +end + +function ScrollingTable._ForceLastDataUpdate(self) + self._lastDataUpdate = FORCE_DATA_UPDATE +end + +function ScrollingTable._ScrollToData(self, data) + local rowHeight = self._rowHeight + local visibleHeight = self._vScrollFrame:GetHeight() + local currentOffset = self._vScrollbar:GetValue() + local dataIndex = Table.KeyByValue(self._data, data) + -- if we are going to scroll up/down, we want to scroll such that the top of the passed row is in the visible area + -- by at least 1 row height + local scrollUpOffset = max(rowHeight * (dataIndex - 1) - rowHeight, 0) + local scrollDownOffset = min(rowHeight * dataIndex + rowHeight - visibleHeight, self:_GetMaxScroll()) + if scrollUpOffset < currentOffset and scrollDownOffset > currentOffset then + -- it's impossible to scroll to the right place, so do nothing + elseif scrollUpOffset < currentOffset then + -- we need to scroll up + self._targetScrollValue = scrollUpOffset + self._totalScrollDistance = currentOffset - scrollUpOffset + elseif scrollDownOffset > currentOffset then + -- we need to scroll down + self._targetScrollValue = scrollDownOffset + self._totalScrollDistance = scrollDownOffset - currentOffset + else + -- the data is already in the visible area, so do nothing + end +end + +function ScrollingTable._JumpToData(self, data) + if not data then + return + end + local index = Table.KeyByValue(self._data, data) + assert(index) + -- set the scroll so that the selection is visible if necessary + local rowHeight = self._rowHeight + local firstVisibleIndex = ceil(self._vScrollValue / rowHeight) + 1 + local lastVisibleIndex = floor((self._vScrollValue + self:_GetDimension("HEIGHT")) / rowHeight) + if lastVisibleIndex > firstVisibleIndex and (index < firstVisibleIndex or index > lastVisibleIndex) then + self:_OnScrollValueChanged(min((index - 1) * rowHeight, self:_GetMaxScroll())) + end +end + + + +-- ============================================================================ +-- ScrollingTable - Local Script Handlers +-- ============================================================================ + +function private.OnHScrollbarValueChanged(self, value) + value = max(min(value, self:_GetMaxHScroll()), 0) + self:_OnHScrollValueChanged(value) +end + +function private.OnVScrollbarValueChanged(self, value) + value = max(min(value, self:_GetMaxScroll()), 0) + self:_OnScrollValueChanged(value) +end + +function private.OnHScrollbarValueChangedNoDraw(self, value) + value = max(min(value, self:_GetMaxHScroll()), 0) + self:_OnHScrollValueChanged(value, true) +end + +function private.OnVScrollbarValueChangedNoDraw(self, value) + value = max(min(value, self:_GetMaxScroll()), 0) + self:_OnScrollValueChanged(value, true) +end + +function private.HScrollFrameOnUpdate(self) + if (self._hScrollFrame:IsMouseOver() and self:_GetMaxHScroll() > 1) or self._hScrollbar.dragging then + self._hScrollbar:Show() + else + self._hScrollbar:Hide() + end +end + +function private.VScrollFrameOnUpdate(self, elapsed) + elapsed = min(elapsed, 0.01) + + if self._targetScrollValue then + local scrollValue = self._vScrollbar:GetValue() + local direction = scrollValue < self._targetScrollValue and 1 or -1 + local newScrollValue = scrollValue + direction * self._totalScrollDistance * elapsed / SCROLL_TO_DATA_TOTAL_TIME_S + self._vScrollbar:SetValue(newScrollValue) + if direction * newScrollValue >= direction * self._targetScrollValue or newScrollValue <= 0 or newScrollValue >= self:_GetMaxScroll() then + -- we are done scrolling + self._targetScrollValue = nil + self._totalScrollDistance = nil + end + end + + local rOffset = max(self._hContent:GetWidth() - self._hScrollFrame:GetWidth() - self._hScrollbar:GetValue(), 0) + if (self._vScrollFrame:IsMouseOver(0, 0, 0, -rOffset) and self:_GetMaxScroll() > 1) or self._vScrollbar.dragging then + self._vScrollbar:Show() + else + self._vScrollbar:Hide() + end +end + +function private.FrameOnMouseWheel(self, direction) + local scrollAmount = -direction * Theme.GetMouseWheelScrollAmount() + if IsShiftKeyDown() and self._hScrollbar:IsVisible() then + -- scroll horizontally + self._hScrollbar:SetValue(self._hScrollbar:GetValue() + scrollAmount) + else + self._vScrollbar:SetValue(self._vScrollbar:GetValue() + scrollAmount) + end +end diff --git a/Core/UI/Elements/SearchList.lua b/Core/UI/Elements/SearchList.lua new file mode 100644 index 0000000..48cc66d --- /dev/null +++ b/Core/UI/Elements/SearchList.lua @@ -0,0 +1,165 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Search List UI Element Class. +-- A search list contains a list of recent or favorite searches. It is a subclass of the @{ScrollingTable} class. +-- @classmod SearchList + +local _, TSM = ... +local SearchList = TSM.Include("LibTSMClass").DefineClass("SearchList", TSM.UI.ScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(SearchList) +TSM.UI.SearchList = SearchList +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SearchList.__init(self) + self.__super:__init() + self._onRowClickHandler = nil + self._onFavoriteChangedHandler = nil + self._onEditClickHandler = nil + self._onDeleteHandler = nil + self._query = nil + self._editBtnHidden = false +end + +function SearchList.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:GetScrollingTableInfo() + :NewColumn("name") + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetNameText) + :SetActionIconInfo(3, 18, private.GetActionIcon, true) + :SetActionIconClickHandler(private.OnActionIconClick) + :DisableHiding() + :Commit() + :Commit() +end + +function SearchList.Release(self) + self._onRowClickHandler = nil + self._onFavoriteChangedHandler = nil + self._onEditClickHandler = nil + self._onDeleteHandler = nil + if self._query then + self._query:Release() + self._query = nil + end + self._editBtnHidden = false + self.__super:Release() +end + +--- Sets whether or not the edit button is hidden. +-- @tparam SearchList self The search list object +-- @tparam boolean hidden Whether or not the edit button is hidden +-- @treturn SearchList The search list object +function SearchList.SetEditButtonHidden(self, hidden) + self._editBtnHidden = hidden + return self +end + +--- Sets the @{DatabaseQuery} source for this list. +-- This query is used to populate the entries in the search list. +-- @tparam SearchList self The search list object +-- @tparam DatabaseQuery query The query object +-- @tparam[opt=false] bool redraw Whether or not to redraw the search list +-- @treturn SearchList The search list object +function SearchList.SetQuery(self, query, redraw) + if self._query then + self._query:Release() + end + self._query = query + self._query:SetUpdateCallback(private.QueryUpdateCallback, self) + self:UpdateData(redraw) + return self +end + +--- Registers a script handler. +-- @tparam SearchList self The search list object +-- @tparam string script The script to register for (supported scripts: `OnRowClick`, `OnFavoriteChanged`, +-- `OnEditClick`, `OnDelete`) +-- @tparam function handler The script handler which will be called with the search list object followed by any +-- arguments to the script +-- @treturn SearchList The search list object +function SearchList.SetScript(self, script, handler) + if script == "OnRowClick" then + self._onRowClickHandler = handler + elseif script == "OnFavoriteChanged" then + self._onFavoriteChangedHandler = handler + elseif script == "OnEditClick" then + self._onEditClickHandler = handler + elseif script == "OnDelete" then + self._onDeleteHandler = handler + else + error("Unknown SearchList script: "..tostring(script)) + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SearchList._UpdateData(self) + wipe(self._data) + for _, row in self._query:Iterator() do + tinsert(self._data, row) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.GetNameText(self, data) + return data:GetField("name") +end + +function private.GetActionIcon(self, data, iconIndex, isMouseOver) + if iconIndex == 1 then + return true, data:GetField("isFavorite") and "iconPack.18x18/Star/Filled" or "iconPack.18x18/Star/Unfilled" + elseif iconIndex == 2 then + if self._editBtnHidden then + return false, nil + end + return true, "iconPack.18x18/Edit" + elseif iconIndex == 3 then + return true, "iconPack.18x18/Delete" + else + error("Invalid iconIndex: "..tostring(iconIndex)) + end +end + +function private.OnActionIconClick(self, data, iconIndex) + if iconIndex == 1 then + -- favorite + self:_onFavoriteChangedHandler(data, not data:GetField("isFavorite")) + elseif iconIndex == 2 then + -- edit + assert(not self._editBtnHidden) + self:_onEditClickHandler(data) + elseif iconIndex == 3 then + -- delete + self:_onDeleteHandler(data) + else + error("Invalid iconIndex: "..tostring(iconIndex)) + end +end + +function private.QueryUpdateCallback(_, _, self) + self:UpdateData(true) +end diff --git a/Core/UI/Elements/SecureMacroActionButton.lua b/Core/UI/Elements/SecureMacroActionButton.lua new file mode 100644 index 0000000..c1a069c --- /dev/null +++ b/Core/UI/Elements/SecureMacroActionButton.lua @@ -0,0 +1,61 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- SecureMacroActionButton UI Element Class. +-- A secure macro action button builds on top of WoW's `SecureActionButtonTemplate` to allow executing scripts which +-- addon buttons would otherwise be forbidden from running. It is a subclass of the @{ActionButton} class. +-- @classmod SecureMacroActionButton + +local _, TSM = ... +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local SecureMacroActionButton = TSM.Include("LibTSMClass").DefineClass("SecureMacroActionButton", TSM.UI.ActionButton) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(SecureMacroActionButton) +TSM.UI.SecureMacroActionButton = SecureMacroActionButton + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SecureMacroActionButton.__init(self, name) + self.__super:__init(name, true) + local frame = self:_GetBaseFrame() + frame:SetAttribute("type1", "macro") + frame:SetAttribute("macrotext1", "") +end + +function SecureMacroActionButton.Release(self) + local frame = self:_GetBaseFrame() + ScriptWrapper.Clear(frame, "PreClick") + frame:SetAttribute("macrotext1", "") + self.__super:Release() +end + +--- Registers a script handler. +-- @tparam SecureMacroActionButton self The secure macro action button object +-- @tparam string script The script to register for (supported scripts: `PreClick`) +-- @tparam function handler The script handler which will be called with the secure macro action button object followed +-- by any arguments to the script +-- @treturn SecureMacroActionButton The secure macro action button object +function SecureMacroActionButton.SetScript(self, script, handler) + if script == "PreClick" or script == "PostClick" then + self.__super.__super:SetScript(script, handler) + else + error("Unknown SecureActionButton script: "..tostring(script)) + end + return self +end + +--- Sets the macro text which clicking the button executes. +-- @tparam SecureMacroActionButton self The secure macro action button object +-- @tparam string text THe macro text +-- @treturn SecureMacroActionButton The secure macro action button object +function SecureMacroActionButton.SetMacroText(self, text) + self:_GetBaseFrame():SetAttribute("macrotext1", text) + return self +end diff --git a/Core/UI/Elements/SelectionDropdown.lua b/Core/UI/Elements/SelectionDropdown.lua new file mode 100644 index 0000000..0d68086 --- /dev/null +++ b/Core/UI/Elements/SelectionDropdown.lua @@ -0,0 +1,121 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Selection Dropdown UI Element Class. +-- A dropdown element allows the user to select from a dialog list. It is a subclass of the @{BaseDropdown} class. +-- @classmod SelectionDropdown + +local _, TSM = ... +local Table = TSM.Include("Util.Table") +local SelectionDropdown = TSM.Include("LibTSMClass").DefineClass("SelectionDropdown", TSM.UI.BaseDropdown) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(SelectionDropdown) +TSM.UI.SelectionDropdown = SelectionDropdown + + + +-- ============================================================================ +-- Meta Class Methods +-- ============================================================================ + +function SelectionDropdown.__init(self) + self.__super:__init() + self._selectedItem = nil + self._settingTable = nil + self._settingKey = nil +end + +function SelectionDropdown.Release(self) + self._selectedItem = nil + self._settingTable = nil + self._settingKey = nil + self.__super:Release() +end + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +--- Set the currently selected item. +-- @tparam SelectionDropdown self The dropdown object +-- @tparam ?string item The selected item or nil if nothing should be selected +-- @tparam[opt=false] boolean silent Don't call the OnSelectionChanged callback +-- @treturn SelectionDropdown The dropdown object +function SelectionDropdown.SetSelectedItem(self, item, silent) + self._selectedItem = item + if self._settingTable then + self._settingTable[self._settingKey] = self._itemKeyLookup[item] + end + if not silent and self._onSelectionChangedHandler then + self:_onSelectionChangedHandler() + end + return self +end + +--- Set the currently selected item by key. +-- @tparam SelectionDropdown self The dropdown object +-- @tparam ?string itemKey The key for the selected item or nil if nothing should be selected +-- @tparam[opt=false] boolean silent Don't call the OnSelectionChanged callback +-- @treturn SelectionDropdown The dropdown object +function SelectionDropdown.SetSelectedItemByKey(self, itemKey, silent) + local item = itemKey and Table.GetDistinctKey(self._itemKeyLookup, itemKey) or nil + self:SetSelectedItem(item, silent) + return self +end + +--- Get the currently selected item. +-- @tparam SelectionDropdown self The dropdown object +-- @treturn ?string The selected item +function SelectionDropdown.GetSelectedItem(self) + return self._selectedItem +end + +--- Get the currently selected item. +-- @tparam SelectionDropdown self The dropdown object +-- @treturn ?string The selected item key +function SelectionDropdown.GetSelectedItemKey(self) + return self._selectedItem and self._itemKeyLookup[self._selectedItem] or nil +end + +--- Sets the setting info. +-- This method is used to have the value of the dropdown automatically correspond with the value of a field in a table. +-- This is useful for dropdowns which are tied directly to settings. +-- @tparam SelectionDropdown self The dropdown object +-- @tparam table tbl The table which the field to set belongs to +-- @tparam string key The key into the table to be set based on the dropdown state +-- @treturn SelectionDropdown The dropdown object +function SelectionDropdown.SetSettingInfo(self, tbl, key) + self._settingTable = tbl + self._settingKey = key + if tbl then + self:SetSelectedItemByKey(tbl[key]) + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SelectionDropdown._AddDialogChildren(self, frame) + frame:AddChild(UIElements.New("DropdownList", "list") + :SetMultiselect(false) + :SetItems(self._items, self._selectedItem) + ) +end + +function SelectionDropdown._GetCurrentSelectionString(self) + return self._selectedItem or self._hintText +end + +function SelectionDropdown._OnListSelectionChanged(self, _, selection) + self:SetOpen(false) + self:SetSelectedItem(selection) +end diff --git a/Core/UI/Elements/SelectionGroupTree.lua b/Core/UI/Elements/SelectionGroupTree.lua new file mode 100644 index 0000000..4cd9ac1 --- /dev/null +++ b/Core/UI/Elements/SelectionGroupTree.lua @@ -0,0 +1,117 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- SelectionGroupTree UI Element Class. +-- A selection group tree allows for selecting a single group within the tree. It is a subclass of the @{GroupTree} class. +-- @classmod SelectionGroupTree + +local _, TSM = ... +local Table = TSM.Include("Util.Table") +local UIElements = TSM.Include("UI.UIElements") +local SelectionGroupTree = TSM.Include("LibTSMClass").DefineClass("SelectionGroupTree", TSM.UI.GroupTree) +UIElements.Register(SelectionGroupTree) +TSM.UI.SelectionGroupTree = SelectionGroupTree + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SelectionGroupTree.__init(self) + self.__super:__init() + + self._selectedGroup = TSM.CONST.ROOT_GROUP_PATH + self._selectedGroupChangedHandler = nil +end + +function SelectionGroupTree.Release(self) + self._selectedGroupChangedHandler = nil + self._selectedGroup = TSM.CONST.ROOT_GROUP_PATH + self.__super:Release() +end + +--- Sets the selected group path. +-- @tparam SelectionGroupTree self The selection group tree object +-- @tparam string groupPath The group path string to select +-- @treturn SelectionGroupTree The application group tree object +function SelectionGroupTree.SetSelection(self, groupPath) + assert(groupPath) + self._selectedGroup = groupPath + wipe(self._contextTable.selected) + self._contextTable.selected[groupPath] = true + local index = Table.KeyByValue(self._data, groupPath) + assert(index) + -- set the scroll so that the selection is visible if necessary + local rowHeight = self._rowHeight + local firstVisibleIndex = ceil(self._vScrollValue / rowHeight) + 1 + local lastVisibleIndex = floor((self._vScrollValue + self:_GetDimension("HEIGHT")) / rowHeight) + if lastVisibleIndex > firstVisibleIndex and (index < firstVisibleIndex or index > lastVisibleIndex) then + self:_OnScrollValueChanged(min((index - 1) * rowHeight, self:_GetMaxScroll())) + end + return self +end + +--- Gets the selected group path. +-- @tparam SelectionGroupTree self The selection group tree object +-- @treturn string The currently selected group path string +function SelectionGroupTree.GetSelection(self) + return self._selectedGroup +end + +--- Sets the context table. +-- This table can be used to preserve selection state across lifecycles of the application group tree and even WoW +-- sessions if it's within the settings DB. +-- @see GroupTree.SetContextTable +-- @tparam SelectionGroupTree self The application group tree object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `selected`, `collapsed`) +-- @treturn SelectionGroupTree The application group tree object +function SelectionGroupTree.SetContextTable(self, tbl, defaultTbl) + assert(type(defaultTbl.selected) == "table") + tbl.selected = tbl.selected or CopyTable(defaultTbl.selected) + self.__super:SetContextTable(tbl, defaultTbl) + return self +end + +--- Registers a script handler. +-- @tparam SelectionGroupTree self The selection group tree object +-- @tparam string script The script to register for (supported scripts: `OnGroupSelectionChanged`) +-- @tparam function handler The script handler which will be called with the selection group tree object followed by any +-- arguments to the script +-- @treturn SelectionGroupTree The selection group tree object +function SelectionGroupTree.SetScript(self, script, handler) + if script == "OnGroupSelectionChanged" then + self._selectedGroupChangedHandler = handler + else + error("Unknown SelectionGroupTree script: "..tostring(script)) + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SelectionGroupTree._HandleRowClick(self, data, mouseButton) + if mouseButton == "RightButton" then + self.__super:_HandleRowClick(data, mouseButton) + return + end + self._selectedGroup = data + wipe(self._contextTable.selected) + self._contextTable.selected[data] = true + self:Draw() + if self._selectedGroupChangedHandler then + self:_selectedGroupChangedHandler(data) + end +end + +function SelectionGroupTree._IsSelected(self, data) + return data == self._selectedGroup +end diff --git a/Core/UI/Elements/SelectionList.lua b/Core/UI/Elements/SelectionList.lua new file mode 100644 index 0000000..8062b71 --- /dev/null +++ b/Core/UI/Elements/SelectionList.lua @@ -0,0 +1,101 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- SelectionList UI Element Class. +-- A selection list is a scrollable list of entries which allows selecting a single one. It is a subclass of the +-- @{ScrollingTable} class. +-- @classmod SelectionList + +local _, TSM = ... +local Theme = TSM.Include("Util.Theme") +local SelectionList = TSM.Include("LibTSMClass").DefineClass("SelectionList", TSM.UI.ScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(SelectionList) +TSM.UI.SelectionList = SelectionList +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SelectionList.__init(self) + self.__super:__init() + self._selectedEntry = nil + self._onEntrySelectedHandler = nil +end + +function SelectionList.Acquire(self) + self._headerHidden = true + self.__super:Acquire() + self:SetSelectionDisabled(true) + self:GetScrollingTableInfo() + :NewColumn("text") + :SetFont("BODY_BODY2") + :SetJustifyH("LEFT") + :SetTextFunction(private.GetText) + :DisableHiding() + :Commit() + :Commit() +end + +function SelectionList.Release(self) + self._selectedEntry = nil + self._onEntrySelectedHandler = nil + self.__super:Release() +end + +--- Sets the entries. +-- @tparam SelectionList self The selection list object +-- @tparam table entries A list of entries +-- @tparam string selectedEntry The selected entry +-- @treturn SelectionList The selection list object +function SelectionList.SetEntries(self, entries, selectedEntry) + wipe(self._data) + for _, entry in ipairs(entries) do + tinsert(self._data, entry) + end + self._selectedEntry = selectedEntry + return self +end + +--- Registers a script handler. +-- @tparam SelectionList self The selection list object +-- @tparam string script The script to register for (supported scripts: `OnEntrySelected`) +-- @tparam function handler The script handler which will be called with the selection list object followed by any +-- arguments to the script +-- @treturn SelectionList The selection list object +function SelectionList.SetScript(self, script, handler) + if script == "OnEntrySelected" then + self._onEntrySelectedHandler = handler + else + error("Unknown SelectionList script: "..tostring(script)) + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SelectionList._HandleRowClick(self, data) + if self._onEntrySelectedHandler then + self:_onEntrySelectedHandler(data) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetText(self, data) + return Theme.GetColor(data == self._selectedEntry and "INDICATOR" or "TEXT"):ColorText(data) +end diff --git a/Core/UI/Elements/SelectionScrollingTable.lua b/Core/UI/Elements/SelectionScrollingTable.lua new file mode 100644 index 0000000..25a8772 --- /dev/null +++ b/Core/UI/Elements/SelectionScrollingTable.lua @@ -0,0 +1,250 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- SelectionScrollingTable UI Element Class. +-- A selection scrolling table is a scrolling table which allows for selecting and deselecting individual rows. It is a +-- subclass of the @{QueryScrollingTable} class. +-- @classmod SelectionScrollingTable + +local _, TSM = ... +local Table = TSM.Include("Util.Table") +local TempTable = TSM.Include("Util.TempTable") +local UIElements = TSM.Include("UI.UIElements") +local SelectionScrollingTable = TSM.Include("LibTSMClass").DefineClass("SelectionScrollingTable", TSM.UI.QueryScrollingTable) +UIElements.Register(SelectionScrollingTable) +TSM.UI.SelectionScrollingTable = SelectionScrollingTable +local private = { + querySelectionScrollingTableLookup = {}, + sortValuesTemp = {}, + tempContextTable = {}, +} +local TEMP_CONTEXT_TABLE_DEFAULT = { + colWidth = { + selected = 16, + }, + colHidden = {}, +} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SelectionScrollingTable.__init(self) + self.__super:__init() + + self._selectedData = {} + self._selectionEnabledFunc = nil + self._rightClickToggle = true +end + +function SelectionScrollingTable.Acquire(self) + self.__super:Acquire() + -- temporarily set a context table so we can create the table columns (should be overridden later) + wipe(private.tempContextTable) + self.__super:SetContextTable(private.tempContextTable, TEMP_CONTEXT_TABLE_DEFAULT) + self:GetScrollingTableInfo() + :NewColumn("selected", true) + :SetTitleIcon("iconPack.14x14/Checkmark/Default") + :SetWidth(14) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("CENTER") + :SetIconInfo(nil, private.GetSelectedIcon) + :DisableHiding() + :Commit() + :Commit() +end + +function SelectionScrollingTable.Release(self) + private.querySelectionScrollingTableLookup[self._query] = nil + wipe(self._selectedData) + self._selectionEnabledFunc = nil + self.__super:Release() +end + +--- Sets the @{DatabaseQuery} source for this table. +-- This query is used to populate the entries in the selection scrolling table. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @tparam DatabaseQuery query The query object +-- @tparam[opt=false] bool redraw Whether or not to redraw the selection scrolling table +-- @treturn SelectionScrollingTable The selection scrolling table object +function SelectionScrollingTable.SetQuery(self, query, redraw) + if self._query then + private.querySelectionScrollingTableLookup[self._query] = nil + end + private.querySelectionScrollingTableLookup[query] = self + self.__super:SetQuery(query, redraw) + return self +end + +--- Selects all items. +-- @tparam SelectionScrollingTable self The selection scrolling table object +function SelectionScrollingTable.SelectAll(self) + for _, uuid in ipairs(self._data) do + self._selectedData[uuid] = true + end + self:_UpdateData() + self:Draw() + if self._onSelectionChangedHandler then + self._onSelectionChangedHandler(self) + end +end + +--- Clear the selection. +-- @tparam SelectionScrollingTable self The selection scrolling table object +function SelectionScrollingTable.ClearSelection(self) + wipe(self._selectedData) + self:_UpdateData() + self:Draw() + if self._onSelectionChangedHandler then + self._onSelectionChangedHandler(self) + end +end + +--- Sets a selection enabled function. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @tparam function func A funciton which gets called with data to determine if it's selectable or not +-- @treturn SelectionScrollingTable The selection scrolling table object +function SelectionScrollingTable.SetIsSelectionEnabledFunc(self, func) + self._selectionEnabledFunc = func + return self +end + +--- Toggles the selection of a record. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @tparam ?table data The record to toggle the selection of +-- @treturn SelectionScrollingTable The selection scrolling table object +function SelectionScrollingTable.SetSelection(self, data) + if data and self._selectionValidator and not self:_selectionValidator(self._query:GetResultRowByUUID(data)) then + assert(not self._selectedData[data]) + return self + end + self._selectedData[data] = not self._selectedData[data] or nil + for _, row in ipairs(self._rows) do + if row:GetData() == data then + self:_SetRowData(row, data) + break + end + end + if self._sortCol == "selected" then + self:_UpdateData() + self:Draw() + end + if self._onSelectionChangedHandler then + self._onSelectionChangedHandler(self) + end + return self +end + +--- Gets whether or not all of the items are currently selected. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @treturn boolean Whether or not all of the selection is selected +function SelectionScrollingTable.IsAllSelected(self) + for _, uuid in ipairs(self._data) do + if not self._selectedData[uuid] then + return false + end + end + return true +end + +--- Gets whether or not the selection is currently cleared. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @treturn boolean Whether or not the selection is cleared +function SelectionScrollingTable.IsSelectionCleared(self) + return not next(self._selectedData) +end + +--- Gets the current selection table. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @treturn table A table where the key is the data and the value is whether or not it's selected (only selected entries +-- are in the table) +function SelectionScrollingTable.SelectionIterator(self) + return private.SelectionIteratorHelper, self +end + +--- Sets the context table. +-- This table can be used to preserve the table configuration across lifecycles of the scrolling table and even WoW +-- sessions if it's within the settings DB. +-- @tparam SelectionScrollingTable self The selection scrolling table object +-- @tparam table tbl The context table +-- @tparam table defaultTbl The default table (required fields: `colWidth`) +-- @treturn SelectionScrollingTable The selection scrolling table object +function SelectionScrollingTable.SetContextTable(self, tbl, defaultTbl) + assert(defaultTbl.colWidth.selected) + self.__super:SetContextTable(tbl, defaultTbl) + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SelectionScrollingTable._IsSelected(self, data) + return self._selectedData[data] == 1 +end + +function SelectionScrollingTable._UpdateData(self) + self.__super:_UpdateData() + -- clear any old selection context + local hasData = TempTable.Acquire() + for _, data in ipairs(self._data) do + hasData[data] = true + end + for data in pairs(self._selectedData) do + if not hasData[data] then + self._selectedData[data] = nil + end + end + TempTable.Release(hasData) + if self._sortCol == "selected" then + local selectedValue = self._sortAscending and -1 or 1 + for _, uuid in ipairs(self._data) do + private.sortValuesTemp[uuid] = self._selectedData[uuid] and selectedValue or 0 + end + Table.SortWithValueLookup(self._data, private.sortValuesTemp) + wipe(private.sortValuesTemp) + end +end + +function SelectionScrollingTable._ToggleSort(self, id) + if id ~= "selected" then + return self.__super:_ToggleSort(id) + end + + if id == self._sortCol then + self._sortAscending = not self._sortAscending + else + self._sortCol = id + self._sortAscending = true + end + + self:_UpdateData() + self:Draw() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SelectionIteratorHelper(self, uuid) + uuid = next(self._selectedData, uuid) + if not uuid then + return + end + return uuid, self._query:GetResultRowByUUID(uuid) +end + +function private.GetSelectedIcon(row) + local self = private.querySelectionScrollingTableLookup[row:GetQuery()] + return self._selectedData[row:GetUUID()] and "iconPack.14x14/Checkmark/Default" or 0 +end diff --git a/Core/UI/Elements/SimpleTabGroup.lua b/Core/UI/Elements/SimpleTabGroup.lua new file mode 100644 index 0000000..0bcbadb --- /dev/null +++ b/Core/UI/Elements/SimpleTabGroup.lua @@ -0,0 +1,105 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- SimpleTabGroup UI Element Class. +-- A simple table group uses text to denote tabs with the selected one colored differently. It is a subclass of the +-- @{ViewContainer} class. +-- @classmod SimpleTabGroup + +local _, TSM = ... +local SimpleTabGroup = TSM.Include("LibTSMClass").DefineClass("SimpleTabGroup", TSM.UI.ViewContainer) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(SimpleTabGroup) +TSM.UI.SimpleTabGroup = SimpleTabGroup +local private = {} +local BUTTON_HEIGHT = 24 +local BUTTON_PADDING_BOTTOM = 2 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SimpleTabGroup.__init(self) + self.__super:__init() + self._buttons = {} +end + +function SimpleTabGroup.Acquire(self) + self.__super.__super:AddChildNoLayout(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :AddAnchor("TOPLEFT") + :AddAnchor("TOPRIGHT") + ) + self.__super:Acquire() +end + +function SimpleTabGroup.Release(self) + wipe(self._buttons) + self.__super:Release() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SimpleTabGroup._GetContentPadding(self, side) + if side == "TOP" then + return BUTTON_HEIGHT + BUTTON_PADDING_BOTTOM + end + return self.__super:_GetContentPadding(side) +end + +function SimpleTabGroup.Draw(self) + self.__super.__super.__super:Draw() + + local selectedPath = self:GetPath() + local buttons = self:GetElement("buttons") + buttons:SetHeight(BUTTON_HEIGHT + BUTTON_PADDING_BOTTOM) + buttons:ReleaseAllChildren() + for i, buttonPath in ipairs(self._pathsList) do + local isSelected = buttonPath == selectedPath + buttons:AddChild(UIElements.New("Button", self._id.."_Tab"..i) + :SetWidth("AUTO") + :SetMargin(8, 8, 0, BUTTON_PADDING_BOTTOM) + :SetJustifyH("LEFT") + :SetFont("BODY_BODY1_BOLD") + :SetTextColor(isSelected and "INDICATOR" or "TEXT_ALT") + :SetContext(self) + :SetText(buttonPath) + :SetScript("OnEnter", not isSelected and private.OnButtonEnter) + :SetScript("OnLeave", not isSelected and private.OnButtonLeave) + :SetScript("OnClick", private.OnButtonClicked) + ) + end + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnButtonEnter(button) + button:SetTextColor("TEXT") + :Draw() +end + +function private.OnButtonLeave(button) + button:SetTextColor("TEXT_ALT") + :Draw() +end + +function private.OnButtonClicked(button) + local self = button:GetContext() + local path = button:GetText() + self:SetPath(path, true) +end diff --git a/Core/UI/Elements/Slider.lua b/Core/UI/Elements/Slider.lua new file mode 100644 index 0000000..8a67278 --- /dev/null +++ b/Core/UI/Elements/Slider.lua @@ -0,0 +1,266 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Slider UI Element Class. +-- A slider allows for selecting a numerical range. It is a subclass of the @{Element} class. +-- @classmod Slider + +local _, TSM = ... +local Math = TSM.Include("Util.Math") +local NineSlice = TSM.Include("Util.NineSlice") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Theme = TSM.Include("Util.Theme") +local Slider = TSM.Include("LibTSMClass").DefineClass("Slider", TSM.UI.Element) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Slider) +TSM.UI.Slider = Slider +local private = {} +local THUMB_WIDTH = 8 +local INPUT_WIDTH = 50 +local INPUT_AREA_SPACE = 128 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Slider.__init(self) + local frame = UIElements.CreateFrame(self, "Frame") + frame:EnableMouse(true) + ScriptWrapper.Set(frame, "OnMouseDown", private.FrameOnMouseDown, self) + ScriptWrapper.Set(frame, "OnMouseUp", private.FrameOnMouseUp, self) + ScriptWrapper.Set(frame, "OnUpdate", private.FrameOnUpdate, self) + + self.__super:__init(frame) + + -- create the textures + frame.barTexture = frame:CreateTexture(nil, "BACKGROUND", nil, 1) + frame.activeBarTexture = frame:CreateTexture(nil, "BACKGROUND", nil, 2) + frame.thumbTextureLeft = frame:CreateTexture(nil, "ARTWORK") + frame.thumbTextureRight = frame:CreateTexture(nil, "ARTWORK") + + frame.inputLeft = CreateFrame("EditBox", nil, frame, nil) + frame.inputLeft:SetJustifyH("CENTER") + frame.inputLeft:SetWidth(INPUT_WIDTH) + frame.inputLeft:SetHeight(24) + frame.inputLeft:SetAutoFocus(false) + frame.inputLeft:SetNumeric(true) + ScriptWrapper.Set(frame.inputLeft, "OnEscapePressed", private.InputOnEscapePressed) + ScriptWrapper.Set(frame.inputLeft, "OnEnterPressed", private.LeftInputOnEnterPressed, self) + + + frame.dash = UIElements.CreateFontString(self, frame) + frame.dash:SetJustifyH("CENTER") + frame.dash:SetJustifyV("MIDDLE") + frame.dash:SetWidth(12) + + frame.inputRight = CreateFrame("EditBox", nil, frame, nil) + frame.inputRight:SetJustifyH("CENTER") + frame.inputRight:SetWidth(INPUT_WIDTH) + frame.inputRight:SetHeight(24) + frame.inputRight:SetNumeric(true) + frame.inputRight:SetAutoFocus(false) + ScriptWrapper.Set(frame.inputRight, "OnEscapePressed", private.InputOnEscapePressed) + ScriptWrapper.Set(frame.inputRight, "OnEnterPressed", private.RightInputOnEnterPressed, self) + + self._inputLeftNineSlice = NineSlice.New(frame.inputLeft) + self._inputRightNineSlice = NineSlice.New(frame.inputRight) + self._leftValue = nil + self._rightValue = nil + self._minValue = nil + self._maxValue = nil + self._dragging = nil +end + +function Slider.Release(self) + self._leftValue = nil + self._rightValue = nil + self._minValue = nil + self._maxValue = nil + self._dragging = nil + self.__super:Release() +end + +--- Set the extends of the possible range. +-- @tparam Slider self The slider object +-- @tparam number minValue The minimum value +-- @tparam number maxValue The maxmimum value +-- @treturn Slider The slider object +function Slider.SetRange(self, minValue, maxValue) + self._minValue = minValue + self._maxValue = maxValue + self._leftValue = minValue + self._rightValue = maxValue + return self +end + +--- Sets the current value. +-- @tparam Slider self The slider object +-- @tparam number leftValue The lower end of the range +-- @tparam number rightValue The upper end of the range +-- @treturn Slider The slider object +function Slider.SetValue(self, leftValue, rightValue) + assert(leftValue < rightValue and leftValue >= self._minValue and rightValue <= self._maxValue) + self._leftValue = leftValue + self._rightValue = rightValue + return self +end + +--- Gets the current value +-- @tparam Slider self The slider object +-- @treturn number The lower end of the range +-- @treturn number The upper end of the range +function Slider.GetValue(self) + return self._leftValue, self._rightValue +end + +function Slider.Draw(self) + self.__super:Draw() + local frame = self:_GetBaseFrame() + + local inputColor = Theme.GetColor("ACTIVE_BG") + self._inputLeftNineSlice:SetStyle("rounded") + self._inputRightNineSlice:SetStyle("rounded") + self._inputLeftNineSlice:SetVertexColor(inputColor:GetFractionalRGBA()) + self._inputRightNineSlice:SetVertexColor(inputColor:GetFractionalRGBA()) + + local sliderHeight = self:_GetDimension("HEIGHT") / 2 + local width = self:_GetDimension("WIDTH") - INPUT_AREA_SPACE + local leftPos = Math.Scale(self._leftValue, self._minValue, self._maxValue, 0, width - THUMB_WIDTH) + local rightPos = Math.Scale(self._rightValue, self._minValue, self._maxValue, 0, width - THUMB_WIDTH) + local fontPath, fontHeight = Theme.GetFont("BODY_BODY1"):GetWowFont() + local textColor = Theme.GetColor("TEXT") + + -- wow renders the font slightly bigger than the designs would indicate, so subtract one from the font height + frame.inputRight:SetFont(fontPath, fontHeight) + frame.inputRight:SetTextColor(textColor:GetFractionalRGBA()) + frame.inputRight:SetPoint("RIGHT", 0) + frame.inputRight:SetNumber(self._rightValue) + + frame.dash:SetFont(fontPath, fontHeight) + frame.dash:SetTextColor(textColor:GetFractionalRGBA()) + frame.dash:SetText("-") + frame.dash:SetPoint("RIGHT", frame.inputRight, "LEFT", 0, 0) + + -- wow renders the font slightly bigger than the designs would indicate, so subtract one from the font height + frame.inputLeft:SetFont(fontPath, fontHeight) + frame.inputLeft:SetTextColor(textColor:GetFractionalRGBA()) + frame.inputLeft:SetPoint("RIGHT", frame.dash, "LEFT", 0) + frame.inputLeft:SetNumber(self._leftValue) + + frame.barTexture:ClearAllPoints() + frame.barTexture:SetPoint("LEFT", 0, 0) + frame.barTexture:SetPoint("RIGHT", frame.inputLeft, "LEFT", -16, 0) + frame.barTexture:SetHeight(sliderHeight / 3) + frame.barTexture:SetColorTexture(Theme.GetColor("FRAME_BG"):GetFractionalRGBA()) + + TSM.UI.TexturePacks.SetTextureAndSize(frame.thumbTextureLeft, "iconPack.14x14/Circle") + frame.thumbTextureLeft:SetPoint("LEFT", frame.barTexture, leftPos, 0) + + TSM.UI.TexturePacks.SetTextureAndSize(frame.thumbTextureRight, "iconPack.14x14/Circle") + frame.thumbTextureRight:SetPoint("LEFT", frame.barTexture, rightPos, 0) + + frame.activeBarTexture:SetPoint("LEFT", frame.thumbTextureLeft, "CENTER") + frame.activeBarTexture:SetPoint("RIGHT", frame.thumbTextureRight, "CENTER") + frame.activeBarTexture:SetHeight(sliderHeight / 3) + frame.activeBarTexture:SetColorTexture(Theme.GetColor("TEXT"):GetFractionalRGBA()) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Slider._GetCursorPositionValue(self) + local frame = self:_GetBaseFrame() + local x = GetCursorPosition() / frame:GetEffectiveScale() + local left = frame:GetLeft() + THUMB_WIDTH / 2 + local right = frame:GetRight() - THUMB_WIDTH - INPUT_AREA_SPACE * 2 / 2 + x = min(max(x, left), right) + local value = Math.Scale(x, left, right, self._minValue, self._maxValue) + return min(max(Math.Round(value), self._minValue), self._maxValue) +end + +function Slider._UpdateLeftValue(self, value) + local newValue = max(min(value, self._rightValue), self._minValue) + if newValue == self._leftValue then + return + end + self._leftValue = newValue + self:Draw() +end + +function Slider._UpdateRightValue(self, value) + local newValue = min(max(value, self._leftValue), self._maxValue) + if newValue == self._rightValue then + return + end + self._rightValue = newValue + self:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.InputOnEscapePressed(input) + input:ClearFocus() +end + +function private.LeftInputOnEnterPressed(self) + local input = self:_GetBaseFrame().inputLeft + self:_UpdateLeftValue(input:GetNumber()) +end + +function private.RightInputOnEnterPressed(self) + local input = self:_GetBaseFrame().inputRight + self:_UpdateRightValue(input:GetNumber()) +end + +function private.FrameOnMouseDown(self) + local frame = self:_GetBaseFrame() + frame.inputLeft:ClearFocus() + frame.inputRight:ClearFocus() + local value = self:_GetCursorPositionValue() + local leftDiff = abs(value - self._leftValue) + local rightDiff = abs(value - self._rightValue) + if value < self._leftValue then + -- clicked to the left of the left thumb, so drag that + self._dragging = "left" + elseif value > self._rightValue then + -- clicked to the right of the right thumb, so drag that + self._dragging = "right" + elseif self._leftValue == self._rightValue then + -- just ignore this click since they clicked on both thumbs + elseif leftDiff < rightDiff then + -- clicked closer to the left thumb, so drag that + self._dragging = "left" + else + -- clicked closer to the right thumb (or right in the middle), so drag that + self._dragging = "right" + end +end + +function private.FrameOnMouseUp(self) + self._dragging = nil +end + +function private.FrameOnUpdate(self) + if not self._dragging then + return + end + if self._dragging == "left" then + self:_UpdateLeftValue(self:_GetCursorPositionValue()) + elseif self._dragging == "right" then + self:_UpdateRightValue(self:_GetCursorPositionValue()) + else + error("Unexpected dragging: "..tostring(self._dragging)) + end +end diff --git a/Core/UI/Elements/SniperScrollingTable.lua b/Core/UI/Elements/SniperScrollingTable.lua new file mode 100644 index 0000000..1d81685 --- /dev/null +++ b/Core/UI/Elements/SniperScrollingTable.lua @@ -0,0 +1,128 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- SniperScrollingTable UI Element Class. +-- A special shopping scrolling table used for sniper which has an extra icon column on the left. It is a subclass of +-- the @{AuctionScrollingTable} class. +-- @classmod SniperScrollingTable + +local _, TSM = ... +local SniperScrollingTable = TSM.Include("LibTSMClass").DefineClass("SniperScrollingTable", TSM.UI.AuctionScrollingTable) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(SniperScrollingTable) +TSM.UI.SniperScrollingTable = SniperScrollingTable +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function SniperScrollingTable.__init(self) + self.__super:__init() + + self._highestBrowseId = 0 + self._onRowRemovedHandler = nil +end + +function SniperScrollingTable.Acquire(self) + self.__super:Acquire() + self:GetScrollingTableInfo() + :NewColumn("icon", true) + :SetTitleIcon("iconPack.14x14/Attention") + :SetIconSize(14) + :SetIconHoverEnabled(true) + :SetIconClickHandler(private.RemoveIconClickHandler) + :SetIconFunction(private.RemoveIconFunction) + :SetJustifyH("CENTER") + :SetFont("BODY_BODY3") + :Commit() + :RemoveColumn("timeLeft") + :Commit() + if TSM.IsWowClassic() then + self._sortCol = "icon" + self._sortAscending = true + end + self._highestBrowseId = 0 +end + +function SniperScrollingTable.Release(self) + self._onRowRemovedHandler = nil + self.__super:Release() +end + +--- Registers a script handler. +-- @tparam SniperScrollingTable self The sniper scrolling table object +-- @tparam string script The script to register for (supported scripts: `OnRowRemoved`) +-- @tparam function handler The script handler which will be called with the sniper scrolling table object followed by +-- any arguments to the script +-- @treturn SniperScrollingTable The sniper scrolling table object +function SniperScrollingTable.SetScript(self, script, handler) + if script == "OnRowRemoved" then + self._onRowRemovedHandler = handler + else + self.__super:SetScript(script, handler) + end + return self +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function SniperScrollingTable._UpdateData(self, queryChanged) + self.__super:_UpdateData(queryChanged) + self._highestBrowseId = 0 + for _, row in ipairs(self._data) do + if row:IsSubRow() then + local _, _, browseId = row:GetListingInfo() + self._highestBrowseId = max(self._highestBrowseId, browseId or 0) + end + end +end + +function SniperScrollingTable._GetSortValue(self, row, id, isAscending) + if id == "icon" then + if not row:IsSubRow() then + return 0 + end + local _, _, browseId = row:GetListingInfo() + return -browseId + else + return self.__super:_GetSortValue(row, id, isAscending) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.RemoveIconClickHandler(self, subRow) + if not subRow:IsSubRow() then + local baseItemString = subRow:GetBaseItemString() + subRow = self._firstSubRowByItem[baseItemString] or subRow + end + if self._onRowRemovedHandler then + self:_onRowRemovedHandler(subRow) + end +end + +function private.RemoveIconFunction(self, row, isMouseOver) + if isMouseOver then + return "iconPack.14x14/Close/Default" + end + local isRecent = true + if row:IsSubRow() then + local _, _, browseId = row:GetListingInfo() + isRecent = self._highestBrowseId == browseId + end + return isRecent and "iconPack.14x14/Attention" or "iconPack.14x14/Close/Default" +end diff --git a/Core/UI/Elements/Spacer.lua b/Core/UI/Elements/Spacer.lua new file mode 100644 index 0000000..e4ba2fa --- /dev/null +++ b/Core/UI/Elements/Spacer.lua @@ -0,0 +1,106 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Spacer UI Element Class. +-- A spacer is a light-weight element which doesn't have any content but can be used to assist with layouts. It is a +-- subclass of the @{Element} class. +-- @classmod Spacer + +local _, TSM = ... +local Spacer = TSM.Include("LibTSMClass").DefineClass("Spacer", TSM.UI.Element) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Spacer) +TSM.UI.Spacer = Spacer + + + +-- ============================================================================ +-- Fake Frame Methods +-- ============================================================================ + +local FAKE_FRAME_MT = { + __index = { + SetParent = function(self, parent) + self._parent = parent + end, + + GetParent = function(self) + return self._parent + end, + + SetScale = function(self, scale) + self._scale = scale + end, + + GetScale = function(self) + return self._scale + end, + + SetWidth = function(self, width) + self._width = width + end, + + GetWidth = function(self) + return self._width + end, + + SetHeight = function(self, height) + self._height = height + end, + + GetHeight = function(self) + return self._height + end, + + Show = function(self) + self._visible = true + end, + + Hide = function(self) + self._visible = false + end, + + IsVisible = function(self) + return self._visible + end, + + ClearAllPoints = function(self) + -- do nothing + end, + + SetPoint = function(self, ...) + -- do nothing + end, + }, +} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Spacer.__init(self) + self.__super:__init(self) + local fakeFrame = { + _parent = nil, + _scale = 1, + _width = 0, + _height = 0, + _visible = false, + } + self._fakeFrame = setmetatable(fakeFrame, FAKE_FRAME_MT) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Spacer._GetBaseFrame(self) + return self._fakeFrame +end diff --git a/Core/UI/Elements/TabGroup.lua b/Core/UI/Elements/TabGroup.lua new file mode 100644 index 0000000..20e6c36 --- /dev/null +++ b/Core/UI/Elements/TabGroup.lua @@ -0,0 +1,113 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- TabGroup UI Element Class. +-- A tab group uses text and a horizontal line to denote the tabs, with coloring indicating the one which is selected. +-- It is a subclass of the @{ViewContainer} class. +-- @classmod TabGroup + +local _, TSM = ... +local TabGroup = TSM.Include("LibTSMClass").DefineClass("TabGroup", TSM.UI.ViewContainer) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(TabGroup) +TSM.UI.TabGroup = TabGroup +local private = {} +local BUTTON_HEIGHT = 24 +local BUTTON_PADDING_BOTTOM = 4 +local LINE_THICKNESS = 2 +local LINE_THICKNESS_SELECTED = 2 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function TabGroup.__init(self) + self.__super:__init() + self._buttons = {} +end + +function TabGroup.Acquire(self) + self.__super.__super:AddChildNoLayout(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :AddAnchor("TOPLEFT") + :AddAnchor("TOPRIGHT") + ) + self.__super:Acquire() +end + +function TabGroup.Release(self) + wipe(self._buttons) + self.__super:Release() +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function TabGroup._GetContentPadding(self, side) + if side == "TOP" then + return BUTTON_HEIGHT + BUTTON_PADDING_BOTTOM + LINE_THICKNESS + end + return self.__super:_GetContentPadding(side) +end + +function TabGroup.Draw(self) + self.__super.__super.__super:Draw() + + local selectedPath = self:GetPath() + local buttons = self:GetElement("buttons") + buttons:SetHeight(BUTTON_HEIGHT + BUTTON_PADDING_BOTTOM + LINE_THICKNESS) + buttons:ReleaseAllChildren() + for i, buttonPath in ipairs(self._pathsList) do + local isSelected = buttonPath == selectedPath + buttons:AddChild(UIElements.New("Frame", self._id.."_Tab"..i) + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Button", "button") + :SetMargin(0, 0, 0, BUTTON_PADDING_BOTTOM) + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetTextColor(isSelected and "INDICATOR" or "TEXT_ALT") + :SetContext(self) + :SetText(buttonPath) + :SetScript("OnEnter", not isSelected and private.OnButtonEnter) + :SetScript("OnLeave", not isSelected and private.OnButtonLeave) + :SetScript("OnClick", private.OnButtonClicked) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(isSelected and LINE_THICKNESS_SELECTED or LINE_THICKNESS) + :SetTexture(isSelected and "INDICATOR" or "TEXT_ALT") + ) + ) + end + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnButtonEnter(button) + button:SetTextColor("TEXT") + :Draw() +end + +function private.OnButtonLeave(button) + button:SetTextColor("TEXT_ALT") + :Draw() +end + +function private.OnButtonClicked(button) + local self = button:GetContext() + local path = button:GetText() + self:SetPath(path, self:GetPath() ~= path) +end diff --git a/Core/UI/Elements/Text.lua b/Core/UI/Elements/Text.lua new file mode 100644 index 0000000..1d14a0d --- /dev/null +++ b/Core/UI/Elements/Text.lua @@ -0,0 +1,217 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Text UI Element Class. +-- A text element simply holds a text string. It is a subclass of the @{Element} class. +-- @classmod Text + +local _, TSM = ... +local Text = TSM.Include("LibTSMClass").DefineClass("Text", TSM.UI.Element) +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Text) +TSM.UI.Text = Text +local STRING_RIGHT_PADDING = 4 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Text.__init(self, frame) + frame = frame or UIElements.CreateFrame(self, "Frame") + self.__super:__init(frame) + frame.text = UIElements.CreateFontString(self, frame) + + self._textStr = "" + self._autoWidth = false + self._textColor = "TEXT" + self._font = nil + self._justifyH = "LEFT" + self._justifyV = "MIDDLE" +end + +function Text.Release(self) + self._textStr = "" + self._autoWidth = false + self._textColor = "TEXT" + self._font = nil + self._justifyH = "LEFT" + self._justifyV = "MIDDLE" + self:_GetBaseFrame().text:SetSpacing(0) + self.__super:Release() +end + +--- Sets the width of the text. +-- @tparam Text self The text object +-- @tparam ?number|string width The width of the text, "AUTO" to set the width based on the length +-- of the text, or nil to have an undefined width +-- @treturn Text The text object +function Text.SetWidth(self, width) + if width == "AUTO" then + self._autoWidth = true + else + self._autoWidth = false + self.__super:SetWidth(width) + end + return self +end + +--- Sets the font. +-- @tparam Text self The text object +-- @tparam string font The font key +-- @treturn Text The text object +function Text.SetFont(self, font) + assert(Theme.GetFont(font)) + self._font = font + return self +end + +--- Sets the color of the text. +-- @tparam Text self The text object +-- @tparam Color|string color The text color as a Color object or a theme color key +-- @treturn Text The text object +function Text.SetTextColor(self, color) + assert((type(color) == "string" and Theme.GetColor(color)) or Color.IsInstance(color)) + self._textColor = color + return self +end + +--- Sets the horizontal justification of the text. +-- @tparam Text self The text object +-- @tparam string justifyH The horizontal justification (either "LEFT", "CENTER" or "RIGHT") +-- @treturn Text The text object +function Text.SetJustifyH(self, justifyH) + assert(justifyH == "LEFT" or justifyH == "CENTER" or justifyH == "RIGHT") + self._justifyH = justifyH + return self +end + +--- Sets the vertical justification of the text. +-- @tparam Text self The text object +-- @tparam string justifyV The vertical justification (either "TOP", "MIDDLE" or "BOTTOM") +-- @treturn Text The text object +function Text.SetJustifyV(self, justifyV) + assert(justifyV == "TOP" or justifyV == "MIDDLE" or justifyV == "BOTTOM") + self._justifyV = justifyV + return self +end + +--- Set the text. +-- @tparam Text self The text object +-- @tparam ?string|number text The text +-- @treturn Text The text object +function Text.SetText(self, text) + if type(text) == "number" then + text = tostring(text) + end + assert(type(text) == "string") + self._textStr = text + return self +end + +--- Set formatted text. +-- @tparam Text self The text object +-- @tparam vararg ... The format string and parameters +-- @treturn Text The text object +function Text.SetFormattedText(self, ...) + self:SetText(format(...)) + return self +end + +--- Gets the text string. +-- @tparam Text self The text object +-- @treturn string The text string +function Text.GetText(self) + return self._textStr +end + +--- Get the rendered text string width. +-- @tparam Text self The text object +-- @treturn number The rendered text string width +function Text.GetStringWidth(self) + local text = self:_GetBaseFrame().text + self:_ApplyFont() + text:SetText(self._textStr) + return text:GetStringWidth() +end + +--- Get the rendered text string height. +-- @tparam Text self The text object +-- @treturn number The rendered text string height +function Text.GetStringHeight(self) + local text = self:_GetBaseFrame().text + self:_ApplyFont() + text:SetText(self._textStr) + return text:GetStringHeight() +end + +function Text.Draw(self) + self.__super:Draw() + + local text = self:_GetBaseFrame().text + text:ClearAllPoints() + text:SetAllPoints() + + -- set the font + self:_ApplyFont() + + -- set the justification + text:SetJustifyH(self._justifyH) + text:SetJustifyV(self._justifyV) + + -- set the text color + text:SetTextColor(self:_GetTextColor():GetFractionalRGBA()) + + -- set the text + text:SetText(self._textStr) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function Text._GetTextColor(self) + if type(self._textColor) == "string" then + return Theme.GetColor(self._textColor) + else + assert(Color.IsInstance(self._textColor)) + return self._textColor + end +end + +function Text._GetMinimumDimension(self, dimension) + if dimension == "WIDTH" and self._autoWidth then + return 0, self._width == nil + else + return self.__super:_GetMinimumDimension(dimension) + end +end + +function Text._GetPreferredDimension(self, dimension) + if dimension == "WIDTH" and self._autoWidth then + return self:GetStringWidth() + STRING_RIGHT_PADDING + else + return self.__super:_GetPreferredDimension(dimension) + end +end + +function Text._ApplyFont(self) + local text = self:_GetBaseFrame().text + local font = Theme.GetFont(self._font) + text:SetFont(font:GetWowFont()) + -- There's a Blizzard bug where spacing incorrectly gets applied to embedded textures, so just set it to 0 in that case + -- TODO: come up with a better fix if we need multi-line text with embedded textures + if strfind(self._textStr, "\124T") then + text:SetSpacing(0) + else + text:SetSpacing(font:GetSpacing()) + end +end diff --git a/Core/UI/Elements/Texture.lua b/Core/UI/Elements/Texture.lua new file mode 100644 index 0000000..74de81d --- /dev/null +++ b/Core/UI/Elements/Texture.lua @@ -0,0 +1,106 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Texture UI Element Class. +-- This is a simple, light-weight element which is used to display a texture. It is a subclass of the @{Element} class. +-- @classmod Texture + +local _, TSM = ... +local Texture = TSM.Include("LibTSMClass").DefineClass("Texture", TSM.UI.Element) +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Texture) +TSM.UI.Texture = Texture +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Texture.__init(self) + local texture = UIParent:CreateTexture() + -- hook SetParent/GetParent since textures can't have a nil parent + texture._oldSetParent = texture.SetParent + texture.SetParent = private.SetParent + texture.GetParent = private.GetParent + self.__super:__init(texture) + self._texture = nil +end + +function Texture.Release(self) + self._texture = nil + self.__super:Release() +end + +--- Sets the texture. +-- @tparam Texture self The texture object +-- @tparam ?string|number texture Either a texture pack string, itemString, WoW file id, or theme color key +-- @treturn Texture The texture object +function Texture.SetTexture(self, texture) + self._texture = texture + return self +end + +--- Sets the texture and size based on a texture pack string. +-- @tparam Texture self The texture object +-- @tparam string texturePack A texture pack string +-- @treturn Texture The texture object +function Texture.SetTextureAndSize(self, texturePack) + self:SetTexture(texturePack) + self:SetSize(TSM.UI.TexturePacks.GetSize(texturePack)) + return self +end + +function Texture.Draw(self) + self.__super:Draw() + + local texture = self:_GetBaseFrame() + texture:SetTexture(nil) + texture:SetTexCoord(0, 1, 0, 1) + texture:SetVertexColor(1, 1, 1, 1) + + if type(self._texture) == "string" and TSM.UI.TexturePacks.IsValid(self._texture) then + -- this is a texture pack + TSM.UI.TexturePacks.SetTexture(texture, self._texture) + elseif type(self._texture) == "string" and strmatch(self._texture, "^[ip]:%d+") then + -- this is an itemString + texture:SetTexture(ItemInfo.GetTexture(self._texture)) + elseif type(self._texture) == "string" then + -- this is a theme color key + texture:SetColorTexture(Theme.GetColor(self._texture):GetFractionalRGBA()) + elseif type(self._texture) == "number" then + -- this is a wow file id + texture:SetTexture(self._texture) + elseif Color.IsInstance(self._texture) then + texture:SetColorTexture(self._texture:GetFractionalRGBA()) + else + error("Invalid texture: "..tostring(self._texture)) + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SetParent(self, parent) + self._parent = parent + if parent then + self:Show() + else + self:Hide() + end + self:_oldSetParent(parent or UIParent) +end + +function private.GetParent(self) + return self._parent +end diff --git a/Core/UI/Elements/Toggle.lua b/Core/UI/Elements/Toggle.lua new file mode 100644 index 0000000..d369af9 --- /dev/null +++ b/Core/UI/Elements/Toggle.lua @@ -0,0 +1,180 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Toggle UI Element Class. +-- A toggle element allows the user to select between a fixed set of options. It is a subclass of the @{Container} class. +-- @classmod Toggle + +local _, TSM = ... +local Toggle = TSM.Include("LibTSMClass").DefineClass("Toggle", TSM.UI.Container) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(Toggle) +TSM.UI.Toggle = Toggle +local private = {} +local BUTTON_PADDING = 16 + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function Toggle.__init(self) + local frame = UIElements.CreateFrame(self, "Frame") + + self.__super:__init(frame) + + self._optionsList = {} + self._buttons = {} + self._onValueChangedHandler = nil + self._selectedOption = nil + self._booleanKey = nil + self._font = "BODY_BODY3" +end + +function Toggle.Release(self) + wipe(self._optionsList) + wipe(self._buttons) + self._onValueChangedHandler = nil + self._selectedOption = nil + self._booleanKey = nil + self._font = "BODY_BODY3" + + self.__super:Release() +end + +--- Add an option. +-- @tparam Toggle self The toggle object +-- @tparam string option The text that goes with the option +-- @tparam boolean setSelected Whether or not to set this as the selected option +-- @treturn Toggle The toggle object +function Toggle.AddOption(self, option, setSelected) + tinsert(self._optionsList, option) + if setSelected then + self:SetOption(option) + end + return self +end + +--- Sets the currently selected option. +-- @tparam Toggle self The toggle object +-- @tparam string option The selected option +-- @tparam boolean redraw Whether or not to redraw the toggle +-- @treturn Toggle The toggle object +function Toggle.SetOption(self, option, redraw) + if option ~= self._selectedOption then + self._selectedOption = option + if self._onValueChangedHandler then + self:_onValueChangedHandler(option) + end + end + if redraw then + self:Draw() + end + return self +end + +--- Clears the currently selected option. +-- @tparam Toggle self The toggle object +-- @tparam boolean redraw Whether or not to redraw the toggle +-- @treturn Toggle The toggle object +function Toggle.ClearOption(self, redraw) + self._selectedOption = nil + if redraw then + self:Draw() + end + return self +end + +--- Sets whether or not the toggle is disabled. +-- @tparam Toggle self The toggle object +-- @tparam boolean disabled Whether or not the toggle is disabled +-- @treturn Toggle The toggle object +function Toggle.SetDisabled(self, disabled) + self._disabled = disabled + return self +end + +--- Registers a script handler. +-- @tparam Toggle self The toggle object +-- @tparam string script The script to register for (supported scripts: `OnValueChanged`) +-- @tparam function handler The script handler which will be called with the toggle object followed by any arguments to +-- the script +-- @treturn Toggle The toggle object +function Toggle.SetScript(self, script, handler) + if script == "OnValueChanged" then + self._onValueChangedHandler = handler + else + error("Unknown Toggle script: "..tostring(script)) + end + return self +end + +function Toggle.SetFont(self, font) + self._font = font + return self +end + +--- Get the selected option. +-- @tparam Toggle self The toggle object +-- @treturn string The selected option +function Toggle.GetValue(self) + return self._selectedOption +end + +function Toggle.Draw(self) + self.__super.__super:Draw() + -- add new buttons if necessary + while #self._buttons < #self._optionsList do + local num = #self._buttons + 1 + local button = UIElements.New("Checkbox", self._id.."_Button"..num) + :SetFont(self._font) + :SetScript("OnValueChanged", private.ButtonOnClick) + self:AddChildNoLayout(button) + tinsert(self._buttons, button) + end + + local selectedPath = self._selectedOption + local height = self:_GetDimension("HEIGHT") + local buttonWidth = (self:_GetDimension("WIDTH") / #self._buttons) + BUTTON_PADDING + local offsetX = 0 + for i, button in ipairs(self._buttons) do + local buttonPath = self._optionsList[i] + if i <= #self._optionsList then + button:SetFont(self._font) + button:SetWidth("AUTO") + button:SetTheme("RADIO") + button:SetCheckboxPosition("LEFT") + button:SetText(buttonPath) + button:SetSize(buttonWidth, height) + button:SetDisabled(self._disabled) + button:WipeAnchors() + button:AddAnchor("TOPLEFT", offsetX, 0) + offsetX = offsetX + buttonWidth + else + button:Hide() + end + + if buttonPath == selectedPath then + button:SetChecked(true, true) + else + button:SetChecked(false, true) + end + end + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.ButtonOnClick(button) + local self = button:GetParentElement() + self:SetOption(button:GetText(), true) +end diff --git a/Core/UI/Elements/ToggleOnOff.lua b/Core/UI/Elements/ToggleOnOff.lua new file mode 100644 index 0000000..7c9b92e --- /dev/null +++ b/Core/UI/Elements/ToggleOnOff.lua @@ -0,0 +1,197 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ToggleOnOff UI Element Class. +-- This is a simple on/off toggle which uses different textures for the different states. It is a subclass of the +-- @{Container} class. +-- @classmod ToggleOnOff + +local _, TSM = ... +local ToggleOnOff = TSM.Include("LibTSMClass").DefineClass("ToggleOnOff", TSM.UI.Container) +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ToggleOnOff) +TSM.UI.ToggleOnOff = ToggleOnOff +local private = {} + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ToggleOnOff.__init(self) + local frame = UIElements.CreateFrame(self, "Frame") + + self.__super:__init(frame) + + self._value = false + self._disabled = false + self._settingTable = nil + self._settingKey = nil + self._onValueChangedHandler = nil +end + +function ToggleOnOff.Acquire(self) + local frame = self:_GetBaseFrame() + self:AddChildNoLayout(UIElements.New("Frame", "toggle") + :SetLayout("HORIZONTAL") + :AddAnchor("TOPLEFT", frame) + :AddAnchor("BOTTOMRIGHT", frame) + :SetContext(self) + :AddChild(UIElements.New("Checkbox", "yes") + :SetWidth("AUTO") + :SetTheme("RADIO") + :SetFont("BODY_BODY2") + :SetText(YES) + :SetCheckboxPosition("LEFT") + :SetScript("OnValueChanged", private.OnYesClickHandler) + ) + :AddChild(UIElements.New("Checkbox", "no") + :SetWidth("AUTO") + :SetTheme("RADIO") + :SetFont("BODY_BODY2") + :SetMargin(8, 0, 0, 0) + :SetText(NO) + :SetCheckboxPosition("LEFT") + :SetScript("OnValueChanged", private.OnNoClickHandler) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + + self.__super:Acquire() +end + +function ToggleOnOff.Release(self) + self._value = false + self._disabled = false + self._settingTable = nil + self._settingKey = nil + self._onValueChangedHandler = nil + --self:_GetBaseFrame():Enable() + + self.__super:Release() +end + +--- Sets the setting info. +-- This method is used to have the value of the toggle automatically correspond with the value of a field in a table. +-- This is useful for toggles which are tied directly to settings. +-- @tparam ToggleOnOff self The toggles object +-- @tparam table tbl The table which the field to set belongs to +-- @tparam string key The key into the table to be set based on the toggle's state +-- @treturn ToggleOnOff The toggles object +function ToggleOnOff.SetSettingInfo(self, tbl, key) + self._settingTable = tbl + self._settingKey = key + self._value = tbl[key] + return self +end + +--- Sets whether or not the toggle is disabled. +-- @tparam ToggleOnOff self The toggles object +-- @tparam boolean disabled Whether or not the toggle is disabled +-- @tparam boolean redraw Whether or not to redraw the toggle +-- @treturn ToggleOnOff The toggles object +function ToggleOnOff.SetDisabled(self, disabled, redraw) + self._disabled = disabled + if disabled then + self:GetElement("toggle.yes"):SetDisabled(true) + self:GetElement("toggle.no"):SetDisabled(true) + else + self:GetElement("toggle.yes"):SetDisabled(false) + self:GetElement("toggle.no"):SetDisabled(false) + end + if redraw then + self:Draw() + end + return self +end + +--- Set the value of the toggle. +-- @tparam ToggleOnOff self The toggles object +-- @tparam boolean value Whether the value is on (true) or off (false) +-- @tparam boolean redraw Whether or not to redraw the toggle +-- @treturn ToggleOnOff The toggles object +function ToggleOnOff.SetValue(self, value, redraw) + if value ~= self._value then + self._value = value + if self._settingTable then + self._settingTable[self._settingKey] = value + end + if self._onValueChangedHandler then + self:_onValueChangedHandler(value) + end + end + if redraw then + self:Draw() + end + return self +end + +--- Registers a script handler. +-- @tparam ToggleOnOff self The toggles object +-- @tparam string script The script to register for (supported scripts: `OnValueChanged`) +-- @tparam function handler The script handler which will be called with the toggles object followed by any +-- arguments to the script +-- @treturn ToggleOnOff The toggles object +function ToggleOnOff.SetScript(self, script, handler) + if script == "OnValueChanged" then + self._onValueChangedHandler = handler + else + error("Unknown ToggleOnOff script: "..tostring(script)) + end + return self +end + +--- Get the value of the toggle. +-- @tparam ToggleOnOff self The toggles object +-- @treturn boolean The value of the toggle +function ToggleOnOff.GetValue(self) + return self._value +end + +function ToggleOnOff.Draw(self) + if self._value then + self:GetElement("toggle.yes"):SetChecked(true, true) + self:GetElement("toggle.no"):SetChecked(false, true) + else + self:GetElement("toggle.yes"):SetChecked(false, true) + self:GetElement("toggle.no"):SetChecked(true, true) + end + + if self._disabled then + self:GetElement("toggle.yes"):SetDisabled(true) + self:GetElement("toggle.no"):SetDisabled(true) + else + self:GetElement("toggle.yes"):SetDisabled(false) + self:GetElement("toggle.no"):SetDisabled(false) + end + + self.__super:Draw() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnYesClickHandler(button) + if not button:IsChecked() then + button:SetChecked(true, true) + return + end + local self = button:GetParentElement():GetContext() + self:SetValue(true, true) +end + +function private.OnNoClickHandler(button) + if not button:IsChecked() then + button:SetChecked(true, true) + return + end + local self = button:GetParentElement():GetContext() + self:SetValue(false, true) +end diff --git a/Core/UI/Elements/ViewContainer.lua b/Core/UI/Elements/ViewContainer.lua new file mode 100644 index 0000000..2f05bd7 --- /dev/null +++ b/Core/UI/Elements/ViewContainer.lua @@ -0,0 +1,238 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- ViewContainer UI Element Class. +-- A view container allows the content to be changed depending on the selected view (called the path). It is a subclass of the @{Container} class. +-- @classmod ViewContainer + +local _, TSM = ... +local ViewContainer = TSM.Include("LibTSMClass").DefineClass("ViewContainer", TSM.UI.Container) +local Table = TSM.Include("Util.Table") +local UIElements = TSM.Include("UI.UIElements") +UIElements.Register(ViewContainer) +TSM.UI.ViewContainer = ViewContainer + + + +-- ============================================================================ +-- Public Class Methods +-- ============================================================================ + +function ViewContainer.__init(self) + local frame = UIElements.CreateFrame(self, "Frame") + self.__super:__init(frame) + self._pathsList = {} + self._contextTable = nil + self._defaultContextTable = nil +end + +function ViewContainer.Acquire(self) + self._path = nil + self._navCallback = nil + self.__super:Acquire() +end + +function ViewContainer.Release(self) + wipe(self._pathsList) + self.__super:Release() + self._contextTable = nil + self._defaultContextTable = nil +end + +function ViewContainer.SetLayout(self, layout) + error("ViewContainer doesn't support this method") +end + +function ViewContainer.AddChild(self, child) + error("ViewContainer doesn't support this method") +end + +function ViewContainer.AddChildNoLayout(self, child) + error("ViewContainer doesn't support this method") +end + +--- Set the navigation callback. +-- @tparam ViewContainer self The view container object +-- @tparam function callback The function called when the selected path changes to get the new content +-- @treturn ViewContainer The view container object +function ViewContainer.SetNavCallback(self, callback) + self._navCallback = callback + return self +end + +--- Add a path (view). +-- @tparam ViewContainer self The view container object +-- @tparam string path The path +-- @tparam[opt=false] boolean setSelected Set this as the selected path (view) +-- @treturn ViewContainer The view container object +function ViewContainer.AddPath(self, path, setSelected) + tinsert(self._pathsList, path) + if self._contextTable then + assert(setSelected == nil, "Cannot set selected path when using a context table") + local newPathIndex = Table.KeyByValue(self._pathsList, path) + if self._contextTable.pathIndex == newPathIndex then + self:SetPath(path) + end + elseif setSelected then + self:SetPath(path) + end + return self +end + +--- Renames a path (view). +-- @tparam ViewContainer self The view container object +-- @tparam string path The new path +-- @tparam number index The index of the path to change +-- @treturn ViewContainer The view container object +function ViewContainer.RenamePath(self, path, index) + local changePath = self._pathsList[index] == self._path + self._pathsList[index] = path + + if changePath then + self:SetPath(path) + end + return self +end + +--- Set the selected path (view). +-- @tparam ViewContainer self The view container object +-- @tparam string path The selected path +-- @tparam boolean redraw Whether or not to redraw the view container +-- @treturn ViewContainer The view container object +function ViewContainer.SetPath(self, path, redraw) + if path ~= self._path then + local child = self:_GetChild() + if child then + assert(#self._layoutChildren == 1) + self:RemoveChild(child) + child:Release() + end + self.__super:AddChild(self:_navCallback(path)) + self._path = path + -- Save the path index of the new selected path to the context table + if self._contextTable then + self._contextTable.pathIndex = Table.KeyByValue(self._pathsList, path) + end + end + if redraw then + self:Draw() + end + return self +end + +--- Reload the current view. +-- @tparam ViewContainer self The view container object +function ViewContainer.ReloadContent(self) + local path = self._path + self._path = nil + self:SetPath(path, true) +end + +--- Get the current path (view). +-- @tparam ViewContainer self The view container object +-- @treturn string The current path +function ViewContainer.GetPath(self) + return self._path +end + +--- Get a list of the paths for the view container. +-- @tparam ViewContainer self The view container object +-- @treturn table The path list +function ViewContainer.GetPathList(self) + return self._pathsList +end + +function ViewContainer.Draw(self) + self.__super.__super:Draw() + local child = self:_GetChild() + local childFrame = child:_GetBaseFrame() + + -- set the child to be full-size + childFrame:ClearAllPoints() + local xOffset, yOffset = child:_GetMarginAnchorOffsets("BOTTOMLEFT") + local paddingXOffset, paddingYOffset = self:_GetPaddingAnchorOffsets("BOTTOMLEFT") + xOffset = xOffset + paddingXOffset - self:_GetContentPadding("LEFT") + yOffset = yOffset + paddingYOffset - self:_GetContentPadding("BOTTOM") + childFrame:SetPoint("BOTTOMLEFT", xOffset, yOffset) + xOffset, yOffset = child:_GetMarginAnchorOffsets("TOPRIGHT") + paddingXOffset, paddingYOffset = self:_GetPaddingAnchorOffsets("TOPRIGHT") + xOffset = xOffset + paddingXOffset - self:_GetContentPadding("RIGHT") + yOffset = yOffset + paddingYOffset - self:_GetContentPadding("TOP") + childFrame:SetPoint("TOPRIGHT", xOffset, yOffset) + child:Draw() + + -- draw the no-layout children + for _, noLayoutChild in ipairs(self._noLayoutChildren) do + noLayoutChild:Draw() + end +end + +--- Sets the context table. +-- This table can be used to save which tab is active, refrenced by the path index +-- @tparam ViewContainer self The view container object +-- @tparam table tbl The context table +-- @tparam table defaultTbl Default values +-- @treturn ViewContainer The view container object +function ViewContainer.SetContextTable(self, tbl, defaultTbl) + assert(defaultTbl.pathIndex ~= nil) + tbl.pathIndex = tbl.pathIndex or defaultTbl.pathIndex + self._contextTable = tbl + self._defaultContextTable = defaultTbl + return self +end + +--- Sets the context table from a settings object. +-- @tparam ViewContainer self The view container object +-- @tparam Settings settings The settings object +-- @tparam string key The setting key +-- @treturn ViewContainer The view container object +function ViewContainer.SetSettingsContext(self, settings, key) + return self:SetContextTable(settings[key], settings:GetDefaultReadOnly(key)) +end + + + +-- ============================================================================ +-- Private Class Methods +-- ============================================================================ + +function ViewContainer._GetMinimumDimension(self, dimension) + if dimension == "WIDTH" then + local width = self._width + if width then + return width, false + else + return self:_GetChild():_GetMinimumDimension(dimension) + end + elseif dimension == "HEIGHT" then + local height = self._height + if height then + return height, false + else + return self:_GetChild():_GetMinimumDimension(dimension) + end + else + error("Invalid dimension: "..tostring(dimension)) + end +end + +function ViewContainer._GetContentPadding(self, side) + if side == "TOP" then + return 0 + elseif side == "BOTTOM" then + return 0 + elseif side == "LEFT" then + return 0 + elseif side == "RIGHT" then + return 0 + else + error("Invalid side: "..tostring(side)) + end +end + +function ViewContainer._GetChild(self) + return self._layoutChildren[1] +end diff --git a/Core/UI/FrameStack.lua b/Core/UI/FrameStack.lua new file mode 100644 index 0000000..1b33cde --- /dev/null +++ b/Core/UI/FrameStack.lua @@ -0,0 +1,339 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- FrameStack Functions +-- @module FrameStack + +local _, TSM = ... +local FrameStack = TSM.UI:NewPackage("FrameStack") +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local Table = TSM.Include("Util.Table") +local Vararg = TSM.Include("Util.Vararg") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local UIElements = TSM.Include("UI.UIElements") +local private = {} +local STRATA_ORDER = {"TOOLTIP", "FULLSCREEN_DIALOG", "FULLSCREEN", "DIALOG", "HIGH", "MEDIUM", "LOW", "BACKGROUND", "WORLD"} +local framesByStrata = { + WORLD = {}, + BACKGROUND = {}, + LOW = {}, + MEDIUM = {}, + HIGH = {}, + DIALOG = {}, + FULLSCREEN = {}, + FULLSCREEN_DIALOG = {}, + TOOLTIP = {}, +} +local ELEMENT_ATTR_KEYS = { + "_hScrollbar", + "_vScrollbar", + "_hScrollFrame", + "_hContent", + "_vScrollFrame", + "_content", + "_header", + "_frame", + "_rows", +} +local COLOR_KEYS = { + FRAME_BG = true, + PRIMARY_BG = true, + PRIMARY_BG_ALT = true, + ACTIVE_BG = true, + ACTIVE_BG_ALT = true, + INDICATOR = true, + INDICATOR_ALT = true, + INDICATOR_DISABLED = true, + TEXT = true, + TEXT_ALT = true, + TEXT_DISABLED = true, +} +local FEEDBACK_COLOR_KEYS = { + RED = true, + YELLOW = true, + GREEN = true, + BLUE = true, + ORANGE = true, +} +local FONT_KEYS = { + HEADING_H5 = true, + BODY_BODY1 = true, + BODY_BODY1_BOLD = true, + BODY_BODY2 = true, + BODY_BODY2_MEDIUM = true, + BODY_BODY2_BOLD = true, + BODY_BODY3 = true, + BODY_BODY3_MEDIUM = true, + ITEM_BODY1 = true, + ITEM_BODY2 = true, + ITEM_BODY3 = true, + TABLE_TABLE1 = true, +} +local ELEMENT_STYLE_KEYS = { + "_background", + "_texture", + "_backgroundColor", + "_borderColor", + "_textStr", + "_textColor", + "_font", + "_roundedCorners", + "_borderSize", +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function FrameStack.Toggle() + if not TSMFrameStackTooltip then + CreateFrame("GameTooltip", "TSMFrameStackTooltip", UIParent, "GameTooltipTemplate") + TSMFrameStackTooltip.highlightFrame = CreateFrame("Frame", nil, nil, TSM.IsShadowlands() and "BackdropTemplate" or nil) + TSMFrameStackTooltip.highlightFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + TSMFrameStackTooltip.highlightFrame:SetBackdropColor(1, 0, 0, 0.3) + TSMFrameStackTooltip:Hide() + ScriptWrapper.Set(TSMFrameStackTooltip, "OnUpdate", private.OnUpdate) + end + if TSMFrameStackTooltip:IsVisible() then + TSMFrameStackTooltip:Hide() + TSMFrameStackTooltip.highlightFrame:Hide() + else + TSMFrameStackTooltip.lastUpdate = 0 + TSMFrameStackTooltip.altDown = nil + TSMFrameStackTooltip.index = 1 + TSMFrameStackTooltip.numFrames = 0 + TSMFrameStackTooltip:SetOwner(UIParent, "ANCHOR_NONE") + TSMFrameStackTooltip:SetPoint("TOPLEFT", 0, 0) + TSMFrameStackTooltip:AddLine("Loading...") + TSMFrameStackTooltip:Show() + TSMFrameStackTooltip.highlightFrame:Show() + end +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.OnUpdate(self) + if self.lastUpdate + 0.05 >= GetTime() then + return + end + self.lastUpdate = GetTime() + + local numFrames = 0 + for _, strata in ipairs(STRATA_ORDER) do + for _, strataFrame in ipairs(framesByStrata[strata]) do + local name = private.GetFrameName(strataFrame) + if not strmatch(name, "innerBorderFrame") then + numFrames = numFrames + 1 + end + end + end + if numFrames ~= TSMFrameStackTooltip.numFrames then + TSMFrameStackTooltip.index = 1 + TSMFrameStackTooltip.numFrames = numFrames + end + + local leftAltDown = IsKeyDown("LALT") + local rightAltDown = IsKeyDown("RALT") + if not self.altDown and leftAltDown and not rightAltDown then + self.altDown = "LEFT" + if TSMFrameStackTooltip.index == TSMFrameStackTooltip.numFrames then + TSMFrameStackTooltip.index = 1 + else + TSMFrameStackTooltip.index = TSMFrameStackTooltip.index + 1 + end + elseif not self.altDown and not leftAltDown and rightAltDown then + self.altDown = "RIGHT" + if TSMFrameStackTooltip.index == 1 then + TSMFrameStackTooltip.index = TSMFrameStackTooltip.numFrames + else + TSMFrameStackTooltip.index = TSMFrameStackTooltip.index - 1 + end + elseif self.altDown == "LEFT" and not leftAltDown then + self.altDown = nil + elseif self.altDown == "RIGHT" and not rightAltDown then + self.altDown = nil + end + + for _, strata in ipairs(STRATA_ORDER) do + wipe(framesByStrata[strata]) + end + + local frame = EnumerateFrames() + while frame do + if frame ~= self.highlightFrame and not frame:IsForbidden() and frame:IsVisible() and MouseIsOver(frame) then + tinsert(framesByStrata[frame:GetFrameStrata()], frame) + for _, region in Vararg.Iterator(frame:GetRegions()) do + if region:IsObjectType("Texture") and not region:IsForbidden() and region:IsVisible() and MouseIsOver(region) and UIElements.GetByFrame(region) then + tinsert(framesByStrata[frame:GetFrameStrata()], region) + end + end + end + frame = EnumerateFrames(frame) + end + + self:ClearLines() + self:AddDoubleLine("TSM Frame Stack", format("%0.2f, %0.2f", GetCursorPosition())) + local currentIndex = 1 + local topFrame = nil + for _, strata in ipairs(STRATA_ORDER) do + if #framesByStrata[strata] > 0 then + sort(framesByStrata[strata], private.FrameLevelSortFunction) + self:AddLine(strata, 0.6, 0.6, 1) + for _, strataFrame in ipairs(framesByStrata[strata]) do + local isTexture = strataFrame:IsObjectType("Texture") + local level = (isTexture and strataFrame:GetParent() or strataFrame):GetFrameLevel() + local width = strataFrame:GetWidth() + local height = strataFrame:GetHeight() + local mouseEnabled = not isTexture and strataFrame:IsMouseEnabled() + local name = private.GetFrameName(strataFrame) + local isIndexedFrame = false + if not strmatch(name, "innerBorderFrame") then + if not topFrame and currentIndex == self.index then + topFrame = strataFrame + isIndexedFrame = true + end + currentIndex = currentIndex + 1 + end + local text = format(" <%d%s> %s (%d, %d)", level, isTexture and "+" or "", name, Math.Round(width), Math.Round(height)) + if isIndexedFrame then + self:AddLine(text, 0.9, 0.9, 0.5) + local element = UIElements.GetByFrame(strataFrame) + if element then + for _, k in ipairs(ELEMENT_STYLE_KEYS) do + local v = element[k] + if v ~= nil then + local vStr = private.GetStyleValueStr(v) + if vStr then + self:AddLine(format(" %s = %s", tostring(k), vStr), 0.7, 0.7, 0.7) + end + end + end + end + elseif mouseEnabled then + self:AddLine(text, 0.6, 1, 1) + else + self:AddLine(text, 0.9, 0.9, 0.9) + end + end + end + end + self.highlightFrame:ClearAllPoints() + self.highlightFrame:SetAllPoints(topFrame) + self.highlightFrame:SetFrameStrata("TOOLTIP") + self:Show() +end + +function private.FrameLevelSortFunction(a, b) + local aLevel = a:IsObjectType("Texture") and (a:GetParent():GetFrameLevel() + 0.1) or a:GetFrameLevel() + local bLevel = b:IsObjectType("Texture") and (b:GetParent():GetFrameLevel() + 0.1) or b:GetFrameLevel() + return aLevel > bLevel +end + +function private.TableValueSearch(tbl, searchValue, currentKey, visited) + visited = visited or {} + for key, value in pairs(tbl) do + if value == searchValue then + return (currentKey and (currentKey..".") or "")..key + elseif type(value) == "table" and (not value.__isa or value:__isa(TSM.UI.Element)) and not visited[value] then + visited[value] = true + local result = private.TableValueSearch(value, searchValue, (currentKey and (currentKey..".") or "")..key, visited) + if result then + return result + end + end + end + for _, key in ipairs(ELEMENT_ATTR_KEYS) do + local value = tbl[key] + if value == searchValue then + return (currentKey and (currentKey..".") or "")..key + elseif type(value) == "table" and (not value.__isa or value:__isa(TSM.UI.Element)) and not visited[value] then + visited[value] = true + local result = private.TableValueSearch(value, searchValue, (currentKey and (currentKey..".") or "")..key, visited) + if result then + return result + end + end + end +end + +function private.GetFrameNodeInfo(frame) + local globalName = not frame:IsObjectType("Texture") and frame:GetName() + if globalName and not strmatch(globalName, "^TSM_UI_ELEMENT:") then + return globalName, frame:GetParent() + end + + local parent = frame:GetParent() + local element = UIElements.GetByFrame(frame) + if element then + return element._id, parent + end + + if parent then + -- check if this exists as an attribute of the parent table + local parentKey = Table.KeyByValue(parent, frame) + if parentKey then + return tostring(parentKey), parent + end + + -- find the nearest element to which this frame belongs + local parentElement = nil + local testFrame = parent + while testFrame and not parentElement do + parentElement = UIElements.GetByFrame(testFrame) + testFrame = testFrame:GetParent() + end + if parentElement then + -- check if this exists as an attribute of this element + local tableKey = private.TableValueSearch(parentElement, frame) + if tableKey then + return tableKey, parentElement._frame + end + end + end + + return nil, parent +end + +function private.GetFrameName(frame) + local name, parent = private.GetFrameNodeInfo(frame) + local parentName = parent and (private.GetFrameName(parent)..".") or "" + name = name or gsub(tostring(frame), ": ?0*", ":") + return parentName..name +end + +function private.GetStyleValueStr(value) + for key in pairs(COLOR_KEYS) do + if value == Theme.GetColor(key) then + return "ThemeColor<"..key..">" + end + end + for key in pairs(FEEDBACK_COLOR_KEYS) do + if value == Theme.GetFeedbackColor(key) then + return "ThemeColor<"..key..">" + end + end + if value == Theme.GetBlizzardColor() then + return "ThemeColor" + end + for key in pairs(FONT_KEYS) do + if value == Theme.GetFont(key) then + return "ThemeFont<"..key..">" + end + end + if type(value) == "string" then + return "\""..value.."\"" + elseif value ~= false then + return tostring(value) + end + return nil +end diff --git a/Core/UI/MailingUI/Core.lua b/Core/UI/MailingUI/Core.lua new file mode 100644 index 0000000..b0e682d --- /dev/null +++ b/Core/UI/MailingUI/Core.lua @@ -0,0 +1,243 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local MailingUI = TSM.UI:NewPackage("MailingUI") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local FSM = TSM.Include("Util.FSM") +local Event = TSM.Include("Util.Event") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + topLevelPages = {}, + frame = nil, + fsm = nil, + defaultUISwitchBtn = nil, + isVisible = false, +} +local MIN_FRAME_SIZE = { width = 575, height = 400 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function MailingUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mailingUIContext", "showDefault") + :AddKey("global", "mailingUIContext", "frame") + private.FSMCreate() +end + +function MailingUI.OnDisable() + -- hide the frame + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function MailingUI.RegisterTopLevelPage(name, callback) + tinsert(private.topLevelPages, { name = name, callback = callback }) +end + +function MailingUI.IsVisible() + return private.isVisible +end + +function MailingUI.SetSelectedTab(buttonText, redraw) + private.frame:SetSelectedNavButton(buttonText, redraw) +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("mailing") + -- Always show the Inbox first + private.settings.frame.page = 1 + local frame = UIElements.New("LargeApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :AddSwitchButton(private.SwitchBtnOnClick) + :SetScript("OnHide", private.BaseFrameOnHide) + + for _, info in ipairs(private.topLevelPages) do + frame:AddNavButton(info.name, info.callback) + end + + private.frame = frame + + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide() + TSM.UI.AnalyticsRecordClose("mailing") + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.SwitchBtnOnClick(button) + private.settings.showDefault = button ~= private.defaultUISwitchBtn + private.fsm:ProcessEvent("EV_SWITCH_BTN_CLICKED") +end + +function private.SwitchButtonOnEnter(button) + button:SetTextColor("TEXT") + :Draw() +end + +function private.SwitchButtonOnLeave(button) + button:SetTextColor("TEXT_ALT") + :Draw() +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local function MailShowDelayed() + private.fsm:ProcessEvent("EV_MAIL_SHOW") + end + Event.Register("MAIL_SHOW", function() + Delay.AfterFrame("MAIL_SHOW_DELAYED", 0, MailShowDelayed) + end) + Event.Register("MAIL_CLOSED", function() + private.fsm:ProcessEvent("EV_MAIL_CLOSED") + end) + + MailFrame:UnregisterEvent("MAIL_SHOW") + CancelEmote() + + local fsmContext = { + frame = nil, + } + + ScriptWrapper.Set(MailFrame, "OnHide", function() + private.fsm:ProcessEvent("EV_FRAME_HIDE") + end) + + private.fsm = FSM.New("MAILING_UI") + :AddState(FSM.NewState("ST_CLOSED") + :AddTransition("ST_DEFAULT_OPEN") + :AddTransition("ST_FRAME_OPEN") + :AddEvent("EV_FRAME_TOGGLE", function(context) + assert(not private.settings.showDefault) + return "ST_FRAME_OPEN" + end) + :AddEvent("EV_MAIL_SHOW", function(context) + if private.settings.showDefault then + return "ST_DEFAULT_OPEN" + else + return "ST_FRAME_OPEN" + end + end) + ) + :AddState(FSM.NewState("ST_DEFAULT_OPEN") + :SetOnEnter(function(context, isIgnored) + MailFrame_OnEvent(MailFrame, "MAIL_SHOW") + + if not private.defaultUISwitchBtn then + private.defaultUISwitchBtn = UIElements.New("ActionButton", "switchBtn") + :SetSize(60, TSM.IsWowClassic() and 16 or 15) + :SetFont("BODY_BODY3") + :AddAnchor("TOPRIGHT", TSM.IsWowClassic() and -26 or -27, TSM.IsWowClassic() and -3 or -4) + :DisableClickCooldown() + :SetText(L["TSM4"]) + :SetScript("OnClick", private.SwitchBtnOnClick) + :SetScript("OnEnter", private.SwitchButtonOnEnter) + :SetScript("OnLeave", private.SwitchButtonOnLeave) + private.defaultUISwitchBtn:_GetBaseFrame():SetParent(MailFrame) + end + + if isIgnored then + private.defaultUISwitchBtn:Hide() + else + private.defaultUISwitchBtn:Show() + private.defaultUISwitchBtn:Draw() + end + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_FRAME_OPEN") + :AddEvent("EV_FRAME_HIDE", function(context) + OpenMailFrame:Hide() + CloseMail() + + return "ST_CLOSED" + end) + :AddEventTransition("EV_MAIL_CLOSED", "ST_CLOSED") + :AddEvent("EV_SWITCH_BTN_CLICKED", function() + OpenMailFrame:Hide() + return "ST_FRAME_OPEN" + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPEN") + :SetOnEnter(function(context) + OpenAllBags() + CheckInbox() + DoEmote("READ", nil, true) + HideUIPanel(MailFrame) + + assert(not context.frame) + context.frame = private.CreateMainFrame() + context.frame:Show() + context.frame:Draw() + private.isVisible = true + end) + :SetOnExit(function(context) + if context.frame then + context.frame:Hide() + context.frame:Release() + context.frame = nil + end + private.isVisible = false + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_DEFAULT_OPEN") + :AddEvent("EV_FRAME_HIDE", function(context) + CancelEmote() + CloseAllBags() + CloseMail() + + return "ST_CLOSED" + end) + :AddEvent("EV_MAIL_SHOW", function(context) + OpenAllBags() + CheckInbox() + + if not context.frame then + DoEmote("READ", nil, true) + context.frame = private.CreateMainFrame() + context.frame:Draw() + private.isVisible = true + end + end) + :AddEvent("EV_MAIL_CLOSED", function(context) + CancelEmote() + CloseAllBags() + + return "ST_CLOSED" + end) + :AddEvent("EV_SWITCH_BTN_CLICKED", function() + return "ST_DEFAULT_OPEN" + end) + ) + :Init("ST_CLOSED", fsmContext) +end diff --git a/Core/UI/MailingUI/Groups.lua b/Core/UI/MailingUI/Groups.lua new file mode 100644 index 0000000..6df37ec --- /dev/null +++ b/Core/UI/MailingUI/Groups.lua @@ -0,0 +1,266 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM.UI.MailingUI:NewPackage("Groups") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local Log = TSM.Include("Util.Log") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + filterText = "", + fsm = nil +} +local SECONDS_PER_MINUTE = 60 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.settings = Settings.NewView() + :AddKey("char", "mailingUIContext", "groupTree") + :AddKey("global", "mailingOptions", "resendDelay") + private.FSMCreate() + TSM.UI.MailingUI.RegisterTopLevelPage(L["Groups"], private.GetGroupsFrame) +end + + + +-- ============================================================================ +-- Groups UI +-- ============================================================================ + +function private.GetGroupsFrame() + TSM.UI.AnalyticsRecordPathChange("mailing", "groups") + return UIElements.New("Frame", "groups") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "search") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert(true) + :SetHintText(L["Search Groups"]) + :SetValue(private.filterText) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetMargin(0, 0, 0, 1) + :SetSettingsContext(private.settings, "groupTree") + :SetQuery(TSM.Groups.CreateQuery(), "Mailing") + :SetSearchString(private.filterText) + :SetScript("OnGroupSelectionChanged", private.GroupTreeOnGroupSelectionChanged) + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(26) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "groupsText") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(format(L["%d Groups Selected"], 0)) + ) + :AddChild(UIElements.New("Texture", "vline") + :SetWidth(1) + :SetMargin(8, 8, 2, 2) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Text", "itemsText") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Total Items"]..": ".."0") + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "mailGroupBtn") + :SetHeight(24) + :SetText(L["Mail Selected Groups"]) + :SetScript("OnClick", private.MailBtnOnClick) + :SetModifierText(L["Mail Selected Groups (Dry Run)"], "CTRL") + :SetModifierText(L["Mail Selected Groups (Auto Resend)"], "SHIFT") + :SetModifierText(L["Mail Selected Groups (Dry Run + Auto Resend)"], "SHIFT", "CTRL") + :SetTooltip(format(L["Hold SHIFT to automatically resend items after '%s', and CTRL to perform a dry-run where no items are actually mailed, but chat messages will still display the result."], SecondsToTime(private.settings.resendDelay * SECONDS_PER_MINUTE))) + ) + ) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.GroupTreeOnGroupSelectionChanged(frame:GetElement("groupTree")) + private.fsm:ProcessEvent("EV_FRAME_SHOW", frame) +end + +function private.FrameOnHide(frame) + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.GroupSearchOnValueChanged(input) + local text = strlower(input:GetValue()) + if text == private.filterText then + return + end + private.filterText = text + + input:GetElement("__parent.__parent.__parent.groupTree") + :SetSearchString(private.filterText) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.GroupTreeOnGroupSelectionChanged(groupTree) + groupTree:GetElement("__parent.bottom.mailGroupBtn") + :SetDisabled(groupTree:IsSelectionCleared()) + :Draw() + + local numGroups, numItems = 0, 0 + for _, groupPath in groupTree:SelectedGroupsIterator() do + numGroups = numGroups + 1 + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + -- TODO + else + for _ in TSM.Groups.ItemIterator(groupPath) do + numItems = numItems + 1 + end + end + end + groupTree:GetElement("__parent.footer.groupsText") + :SetText(format(L["%d Groups Selected"], numGroups)) + groupTree:GetElement("__parent.footer.itemsText") + :SetText(L["Total Items"]..": "..numItems) + + groupTree:GetElement("__parent.footer") + :Draw() +end + +function private.MailBtnOnClick(button) + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", IsShiftKeyDown(), IsControlKeyDown()) +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + frame = nil, + sending = false + } + + local function UpdateButton(context) + context.frame:GetElement("bottom.mailGroupBtn") + :SetText(context.sending and L["Sending..."] or L["Mail Selected Groups"]) + :SetPressed(context.sending) + :Draw() + end + + private.fsm = FSM.New("MAILING_GROUPS") + :AddState(FSM.NewState("ST_HIDDEN") + :SetOnEnter(function(context) + TSM.Mailing.Send.KillThread() + TSM.Mailing.Groups.KillThread() + context.frame = nil + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_FRAME_SHOW", "ST_SHOWN") + ) + :AddState(FSM.NewState("ST_SHOWN") + :SetOnEnter(function(context, frame) + if not context.frame then + context.frame = frame + end + UpdateButton(context) + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_SENDING_START") + :AddEventTransition("EV_BUTTON_CLICKED", "ST_SENDING_START") + ) + :AddState(FSM.NewState("ST_SENDING_START") + :SetOnEnter(function(context, sendRepeat, isDryRun) + context.sending = true + local groups = {} + for _, groupPath in context.frame:GetElement("groupTree"):SelectedGroupsIterator() do + tinsert(groups, groupPath) + end + if isDryRun then + Log.PrintUser(L["Performing a dry-run of your Mailing operations for the selected groups."]) + end + TSM.Mailing.Groups.StartSending(private.FSMGroupsCallback, groups, sendRepeat, isDryRun) + UpdateButton(context) + end) + :SetOnExit(function(context) + context.sending = false + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_SENDING_DONE", "ST_SHOWN") + ) + :AddDefaultEventTransition("EV_FRAME_HIDE", "ST_HIDDEN") + :Init("ST_HIDDEN", fsmContext) +end + +function private.FSMGroupsCallback() + private.fsm:ProcessEvent("EV_SENDING_DONE") +end diff --git a/Core/UI/MailingUI/Inbox.lua b/Core/UI/MailingUI/Inbox.lua new file mode 100644 index 0000000..e0d24f5 --- /dev/null +++ b/Core/UI/MailingUI/Inbox.lua @@ -0,0 +1,1124 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Inbox = TSM.UI.MailingUI:NewPackage("Inbox") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Event = TSM.Include("Util.Event") +local Money = TSM.Include("Util.Money") +local Sound = TSM.Include("Util.Sound") +local FSM = TSM.Include("Util.FSM") +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local MailTracking = TSM.Include("Service.MailTracking") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + fsm = nil, + frame = nil, + view = nil, + inboxQuery = nil, + itemsQuery = nil, + selectedMail = nil, + nextUpdate = nil, + filterText = "" +} +local PLAYER_NAME = UnitName("player") +local MAIL_REFRESH_TIME = TSM.IsWowClassic() and 60 or 15 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Inbox.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mailingUIContext", "mailsScrollingTable") + :AddKey("global", "mailingOptions", "openMailSound") + private.FSMCreate() + TSM.UI.MailingUI.RegisterTopLevelPage(INBOX, private.GetInboxFrame) +end + +function Inbox.IsMailOpened() + if not private.view or not private.view:HasChildById("view") then + return + end + + return private.view:GetElement("view"):GetPath() == "items" +end + + + +-- ============================================================================ +-- Inbox UI +-- ============================================================================ + +function private.GetInboxFrame() + TSM.UI.AnalyticsRecordPathChange("mailing", "inbox") + local frame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("ViewContainer", "view") + :SetNavCallback(private.GetViewContentFrame) + :AddPath("mails", true) + :AddPath("items") + ) + + private.view = frame + + return frame +end + +function private.GetViewContentFrame(viewContainer, path) + if path == "mails" then + private.selectedMail = nil + return private.GetInboxMailsFrame() + elseif path == "items" then + return private.GetInboxItemsFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetInboxMailsFrame() + private.inboxQuery = private.inboxQuery or TSM.Mailing.Inbox.CreateQuery() + private.inboxQuery:ResetFilters() + private.inboxQuery:ResetOrderBy() + private.inboxQuery:OrderBy("index", true) + + private.filterText = "" + + local frame = UIElements.New("Frame", "inbox") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "top") + :SetLayout("HORIZONTAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Input", "filterInput") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :AllowItemInsert() + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Search items in mailbox"]) + :SetDisabled(false) + :SetScript("OnValueChanged", private.SearchOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "reload") + :SetSize(110, 24) + :SetFormattedText(L["Reload UI (%d)"], MAIL_REFRESH_TIME) + :SetScript("OnClick", ReloadUI) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "mails") + :SetSettingsContext(private.settings, "mailsScrollingTable") + :GetScrollingTableInfo() + :NewColumn("items") + :SetTitle(L["Items"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.FormatItem) + :SetTooltipInfo("itemString") + :SetActionIconInfo(1, 12, private.GetMailTypeIcon) + :SetTooltipLinkingDisabled(true) + :DisableHiding() + :Commit() + :NewColumn("sender") + :SetTitle(L["Sender"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("sender") + :Commit() + :NewColumn("expires") + :SetTitle(L["Expires"]) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("RIGHT") + :SetTextInfo("expires", private.FormatDaysLeft) + :Commit() + :NewColumn("money") + :SetTitle(L["Gold"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.FormatMoney) + :Commit() + :Commit() + :SetQuery(private.inboxQuery) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.QueryOnRowClick) + :SetScript("OnDataUpdated", private.InboxOnDataUpdated) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(26) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "mailNum") + :SetWidth(200) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + ) + :AddChild(UIElements.New("Texture", "vline") + :SetWidth(1) + :SetMargin(8, 8, 3, 3) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Text", "itemNum") + :SetWidth(130) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(format(L["%s Items Total"], Theme.GetFeedbackColor("GREEN"):ColorText(0))) + ) + :AddChild(UIElements.New("Texture", "vline") + :SetWidth(1) + :SetMargin(8, 8, 3, 3) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetWidth(190) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "totalText") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Total Gold"]..": ") + ) + :AddChild(UIElements.New("Text", "gold") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(0)) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + if TSM.IsWowClassic() then + frame:AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(74) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "openAllMail") + :SetHeight(26) + :SetText(L["Open Mail"]) + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Open All Mail"], "SHIFT") + :SetModifierText(L["Open Mail Without Money"], "CTRL") + :SetModifierText(L["Open All Mail Without Money"], "SHIFT", "CTRL") + :SetTooltip(L["Hold SHIFT to continue after the inbox refreshes and CTRL to not open mail with money attached."]) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("ActionButton", "openAllSales") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Sold"]) + :SetContext("SALE") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["All Sold"], "SHIFT") + :SetTooltip(L["Hold SHIFT to continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllBuys") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Bought"]) + :SetContext("BUY") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["All Bought"], "SHIFT") + :SetTooltip(L["Hold SHIFT to continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllCancels") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Cancelled"]) + :SetContext("CANCEL") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["All Cancelled"], "SHIFT") + :SetTooltip(L["Hold SHIFT to continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllExpires") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Expired"]) + :SetContext("EXPIRE") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["All Expired"], "SHIFT") + :SetTooltip(L["Hold SHIFT to continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllOthers") + :SetFont("BODY_BODY2") + :SetText(L["Other"]) + :SetContext("OTHER") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["All Other"], "SHIFT") + :SetTooltip(L["Hold SHIFT to continue after the inbox refreshes"]) + ) + ) + ) + else + frame:AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(74) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "openAllMail") + :SetHeight(26) + :SetText(L["Open All Mail"]) + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Open Mail"], "SHIFT") + :SetModifierText(L["Open All Mail Without Money"], "CTRL") + :SetModifierText(L["Open Mail Without Money"], "SHIFT", "CTRL") + :SetTooltip(L["Hold SHIFT to not continue after the inbox refreshes and CTRL to not open mail with money attached."]) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("ActionButton", "openAllSales") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["All Sold"]) + :SetContext("SALE") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Sold"], "SHIFT") + :SetTooltip(L["Hold SHIFT to not continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllBuys") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["All Bought"]) + :SetContext("BUY") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Bought"], "SHIFT") + :SetTooltip(L["Hold SHIFT to not continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllCancels") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["All Cancelled"]) + :SetContext("CANCEL") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Cancelled"], "SHIFT") + :SetTooltip(L["Hold SHIFT to not continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllExpires") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["All Expired"]) + :SetContext("EXPIRE") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Expired"], "SHIFT") + :SetTooltip(L["Hold SHIFT to not continue after the inbox refreshes"]) + ) + :AddChild(UIElements.New("ActionButton", "openAllOthers") + :SetFont("BODY_BODY2") + :SetText(L["All Other"]) + :SetContext("OTHER") + :SetScript("OnClick", private.OpenBtnOnClick) + :SetModifierText(L["Other"], "SHIFT") + :SetTooltip(L["Hold SHIFT to not continue after the inbox refreshes"]) + ) + ) + ) + end + frame:SetScript("OnUpdate", private.InboxFrameOnUpdate) + frame:SetScript("OnHide", private.InboxFrameOnHide) + + private.frame = frame + + return frame +end + +function private.GetInboxItemsFrame() + private.itemsQuery = private.itemsQuery or MailTracking.CreateMailItemQuery() + private.itemsQuery:ResetFilters() + :Equal("index", private.selectedMail) + :OrderBy("itemIndex", true) + + local _, _, _, isTakeable = GetInboxText(private.selectedMail) + local _, _, _, _, _, _, _, _, _, _, textCreated = GetInboxHeaderInfo(private.selectedMail) + local frame = UIElements.New("Frame", "items") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "top") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "back") + :SetLayout("VERTICAL") + :SetWidth(100) + :AddChild(UIElements.New("ActionButton", "button") + :SetWidth(64) + :SetMargin(0, 36, 0, 0) + :SetIcon("iconPack.14x14/Chevron/Right@180") + :SetText(BACK) + :SetScript("OnClick", private.ViewBackButtonOnClick) + ) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Open Mail"]) + ) + :AddChild(UIElements.New("Frame", "copy") + :SetLayout("VERTICAL") + :SetWidth(100) + :AddChild(UIElements.New("Button", "button") + :SetFont("BODY_BODY2_MEDIUM") + :SetIcon("iconPack.14x14/Duplicate", "LEFT") + :SetText(L["Copy Letter"]) + :SetDisabled(not isTakeable or textCreated) + :SetScript("OnClick", private.CopyLetterButtonOnClick) + ) + ) + ) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("VERTICAL") + :SetPadding(12, 12, 4, 6) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "top") + :SetLayout("VERTICAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 6) + :AddChild(UIElements.New("Frame", "left") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["From"]..": ") + ) + :AddChild(UIElements.New("Text", "sender") + :SetFont("BODY_BODY2") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("ActionButton", "report") + :SetSize(124, 20) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Report Spam"]) + :SetScript("OnClick", private.ReportSpamOnClick) + ) + ) + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("VERTICAL") + :SetHeight(20) + :AddChild(UIElements.New("Frame", "left") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Subject"]..": ") + ) + :AddChild(UIElements.New("Text", "subject") + :SetFont("BODY_BODY2") + ) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "body") + :SetLayout("VERTICAL") + :SetPadding(4, 4, 8, 12) + :AddChild(UIElements.New("MultiLineInput", "input") + :SetBackgroundColor("PRIMARY_BG") + :SetFont("BODY_BODY2") + :SetValue("") + ) + ) + :AddChild(UIElements.New("Frame", "attachments") + :SetLayout("VERTICAL") + :SetHeight(144) + :SetPadding(12, 12, 0, 8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Attachments"]) + ) + :AddChild(UIElements.New("Button", "takeAll") + :SetWidth("AUTO") + :SetMargin(8, 0, 0, 0) + :SetFont("BODY_BODY3") + :SetTextColor("INDICATOR") + :SetText(L["Move all to Bags"]) + :SetScript("OnClick", private.TakeAllOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "cod") + :SetMargin(0, 5, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["COD"]..":") + ) + :AddChild(UIElements.New("Text", "codAmount") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("ITEM_BODY2") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetHeaderHidden(true) + :GetScrollingTableInfo() + :NewColumn("items") + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.FormatInboxItem) + :SetIconSize(12) + :SetIconInfo("itemLink", ItemInfo.GetTexture) + :SetTooltipInfo(nil, private.GetInboxItemsTooltip) + :SetTooltipLinkingDisabled(true) + :Commit() + :Commit() + :SetQuery(private.itemsQuery) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.ItemQueryOnRowClick) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(42) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "reply") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Reply"]) + :SetScript("OnClick", private.ReplyOnClick) + ) + :AddChild(UIElements.New("ActionButton", "return/send") + :SetWidth(144) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(MAIL_RETURN) + :SetScript("OnClick", private.DeleteMailOnClick) + ) + ) + + private.UpdateInboxItemsFrame(frame) + + return frame +end + +function private.ViewBackButtonOnClick(button) + private.filterText = "" + button:GetElement("__parent.__parent.__parent.__parent.__parent"):SetPath("mails", true) +end + +function private.CopyLetterButtonOnClick(button) + TakeInboxTextItem(private.selectedMail) + button:SetDisabled(true) + :Draw() +end + +function private.GetMailTypeIcon(_, data, iconIndex, isMouseOver) + if iconIndex == 1 then + local mailType = private.inboxQuery:GetResultRowByUUID(data):GetField("icon") + local icon = "" + if mailType == "SALE" then + icon = "iconPack.12x12/Bid" + elseif mailType == "BUY" then + icon = "iconPack.12x12/Shopping" + elseif mailType == "CANCEL" then + icon = "iconPack.12x12/Close/Circle" + elseif mailType == "EXPIRE" then + icon = "iconPack.12x12/Clock" + elseif mailType == "OTHER" then + icon = "iconPack.12x12/Mailing" + else + assert(mailType == "") + end + return true, icon, true + else + error("Invalid index: "..tostring(iconIndex)) + end +end + +function private.ItemQueryOnRowClick(scrollingTable, row) + local index = row:GetField("index") + local _, _, _, _, _, cod = GetInboxHeaderInfo(index) + if cod > 0 then + scrollingTable:GetBaseElement():ShowConfirmationDialog(L["Accept COD?"], format(L["Accepting this item will cost: %s"], Money.ToString(cod)), private.TakeInboxItem, scrollingTable, row) + return + end + + private.TakeInboxItem(scrollingTable, row) +end + +function private.TakeInboxItem(scrollingTable, row) + local index = row:GetField("index") + local itemIndex = row:GetField("itemIndex") + if private.itemsQuery:Count() == 1 then + if itemIndex == 0 then + TakeInboxMoney(index) + else + TakeInboxItem(index, row:GetField("itemIndex")) + end + scrollingTable:GetElement("__parent.__parent.__parent.__parent"):SetPath("mails", true) + else + if itemIndex == 0 then + TakeInboxMoney(index) + else + TakeInboxItem(index, row:GetField("itemIndex")) + end + end +end + +function private.ReportSpamOnClick(button) + if not CanComplainInboxItem(private.selectedMail) then + return + end + local _, _, sender = GetInboxHeaderInfo(private.selectedMail) + local dialog = StaticPopup_Show("CONFIRM_REPORT_SPAM_MAIL", sender) + if dialog then + dialog.data = private.selectedMail + end +end + +function private.TakeAllOnClick(button) + local _, _, _, _, _, cod = GetInboxHeaderInfo(private.selectedMail) + if cod > 0 then + button:GetBaseElement():ShowConfirmationDialog(L["Accept COD?"], format(L["Accepting this item will cost: %s"], Money.ToString(cod)), private.AutoLootMailItem, button) + else + private.AutoLootMailItem(button) + end +end + +function private.AutoLootMailItem(button) + -- marks the mail as read + GetInboxText(private.selectedMail) + AutoLootMailItem(private.selectedMail) + button:GetElement("__parent.__parent.__parent.__parent.__parent"):SetPath("mails", true) +end + +function private.ReplyOnClick(button) + local _, _, sender = GetInboxHeaderInfo(private.selectedMail) + TSM.UI.MailingUI.Send.SetSendRecipient(sender) + TSM.UI.MailingUI.SetSelectedTab(L["Send"], true) +end + +function private.DeleteMailOnClick(button) + local _, _, _, _, money, _, _, itemCount = GetInboxHeaderInfo(private.selectedMail) + if InboxItemCanDelete(private.selectedMail) then + if itemCount and itemCount > 0 then + return + elseif money and money > 0 then + return + else + DeleteInboxItem(private.selectedMail) + end + else + ReturnInboxItem(private.selectedMail) + end + button:GetElement("__parent.__parent.__parent"):SetPath("mails", true) +end + +function private.UpdateInboxItemsFrame(frame) + local _, _, sender, subject, money, cod, _, itemCount, _, _, _, canReply, isGM = GetInboxHeaderInfo(private.selectedMail) + frame:GetElement("content.header.top.left.sender"):SetText(sender or UNKNOWN) + frame:GetElement("content.header.bottom.left.subject"):SetText(subject) + + if isGM then + frame:GetElement("content.header.top.left.sender"):SetTextColor(Theme.GetBlizzardColor()) + :Draw() + else + frame:GetElement("content.header.top.left.sender"):SetTextColor("TEXT_ALT") + :Draw() + end + + if CanComplainInboxItem(private.selectedMail) then + frame:GetElement("content.header.top.left.report"):Show() + else + frame:GetElement("content.header.top.left.report"):Hide() + end + + if cod and cod > 0 then + frame:GetElement("content.attachments.header.cod"):Show() + frame:GetElement("content.attachments.header.codAmount"):SetText(Money.ToString(cod, Theme.GetFeedbackColor("RED"):GetTextColorPrefix())) + frame:GetElement("content.attachments.header.codAmount"):Show() + else + frame:GetElement("content.attachments.header.cod"):Hide() + frame:GetElement("content.attachments.header.codAmount"):Hide() + end + + if itemCount and itemCount > 0 then + frame:GetElement("content.attachments.header.takeAll"):Show() + else + frame:GetElement("content.attachments.header.takeAll"):Hide() + end + + if not sender or not canReply or sender == PLAYER_NAME then + frame:GetElement("footer.reply"):SetDisabled(true) + else + frame:GetElement("footer.reply"):SetDisabled(false) + end + + if InboxItemCanDelete(private.selectedMail) then + frame:GetElement("footer.return/send"):SetText(DELETE) + if (itemCount and itemCount > 0) or (money and money > 0) then + frame:GetElement("footer.return/send"):SetDisabled(true) + else + frame:GetElement("footer.return/send"):SetDisabled(false) + end + else + frame:GetElement("footer.return/send"):SetText(MAIL_RETURN) + frame:GetElement("footer.return/send"):SetDisabled(false) + end + + local body, _, _, _, isInvoice = GetInboxText(private.selectedMail) + if isInvoice then + local invoiceType, itemName, playerName, bid, buyout, deposit, consignment, _, etaHour, etaMin = GetInboxInvoiceInfo(private.selectedMail) + playerName = playerName or (invoiceType == "buyer" and AUCTION_HOUSE_MAIL_MULTIPLE_SELLERS or AUCTION_HOUSE_MAIL_MULTIPLE_BUYERS) + local purchaseType = bid == buyout and BUYOUT or HIGH_BIDDER + if invoiceType == "buyer" then + body = ITEM_PURCHASED_COLON.." "..itemName.."\n"..SOLD_BY_COLON.." "..playerName.." ("..purchaseType..")".."\n\n"..AMOUNT_PAID_COLON.." "..Money.ToString(bid) + elseif invoiceType == "seller" then + body = ITEM_SOLD_COLON.." "..itemName.."\n"..PURCHASED_BY_COLON.." "..playerName.." ("..purchaseType..")".."\n\n"..L["Sale Price"]..": "..Money.ToString(bid).."\n"..L["Deposit"]..": +"..Money.ToString(deposit).."\n"..L["Auction House Cut"]..": -"..Money.ToString(consignment, Theme.GetFeedbackColor("RED"):GetTextColorPrefix()).."\n\n"..AMOUNT_RECEIVED_COLON.." "..Money.ToString(bid + deposit - consignment) + elseif invoiceType == "seller_temp_invoice" then + body = ITEM_SOLD_COLON.." "..itemName.."\n"..PURCHASED_BY_COLON.." "..playerName.." ("..purchaseType..")".."\n\n"..AUCTION_INVOICE_PENDING_FUNDS_COLON.." "..Money.ToString(bid + deposit - consignment).."\n"..L["Estimated deliver time"]..": "..GameTime_GetFormattedTime(etaHour, etaMin, true) + end + end + frame:GetElement("content.body.input") + :SetValue(body or "") + :Draw() + + if not body then + frame:GetElement("content.body.input"):Hide() + frame:GetElement("content.attachments.header.text"):SetText(L["Attachments"]) + frame:GetElement("content.attachments") + :SetHeight(nil) + :Draw() + elseif private.itemsQuery:Count() == 0 then + frame:GetElement("content.body.input"):Show() + frame:GetElement("content.attachments.header.text"):SetText(L["No Attachments"]) + frame:GetElement("content.attachments") + :SetHeight(48) + :Draw() + else + frame:GetElement("content.body.input"):Show() + frame:GetElement("content.attachments.header.text"):SetText(L["Attachments"]) + frame:GetElement("content.attachments") + :SetHeight(144) + :Draw() + end +end + +function private.FormatInboxItem(row) + local itemIndex = row:GetField("itemIndex") + if itemIndex == 0 then + return L["Gold"]..": "..Money.ToString(row:GetField("itemLink"), Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix()) + end + + local coloredItem = TSM.UI.GetColoredItemName(row:GetField("itemLink")) or "" + local quantity = row:GetField("quantity") + + local item = "" + if quantity > 1 then + item = coloredItem..Theme.GetFeedbackColor("YELLOW"):ColorText(" (x"..quantity..")") + else + item = coloredItem + end + + return item +end + + +function private.GetInboxItemsTooltip(row) + return row:GetField("itemIndex") ~= 0 and row:GetField("itemLink") +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.InboxFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.UpdateCountDown(true) + Delay.AfterTime("mailUpdateCounter", 0, private.UpdateCountDown, 1) + private.fsm:ProcessEvent("EV_FRAME_SHOW", frame, private.filterText) +end + +function private.InboxFrameOnHide(frame) + assert(frame == private.frame) + private.frame = nil + + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.InboxOnDataUpdated() + if not private.frame then + return + end + + private.fsm:ProcessEvent("EV_MAIL_DATA_UPDATED", private.filterText) +end + +function private.OpenBtnOnClick(button) + local filterType = button:GetContext() + button:SetPressed(true) + local openAll = nil + if TSM.IsWowClassic() then + openAll = IsShiftKeyDown() + else + openAll = not IsShiftKeyDown() + end + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", openAll, not filterType and IsControlKeyDown(), private.filterText, filterType) +end + +function private.QueryOnRowClick(scrollingTable, row, button) + if button ~= "LeftButton" then + return + end + + if IsShiftKeyDown() then + local index = row:GetField("index") + local _, _, _, _, _, cod = GetInboxHeaderInfo(index) + if cod <= 0 then + -- marks the mail as read + GetInboxText(index) + AutoLootMailItem(index) + end + else + TSM.Mailing.Open.KillThread() + private.selectedMail = row:GetField("index") + scrollingTable:GetElement("__parent.__parent"):SetPath("items", true) + end +end + +function private.SearchOnValueChanged(input) + local text = input:GetValue() + if text == private.filterText then + return + end + private.filterText = text + + private.inboxQuery:ResetFilters() + :Or() + :Matches("itemList", private.filterText) + :Matches("subject", private.filterText) + :End() + + input:GetElement("__parent.__parent.mails"):UpdateData(true) +end + +function private.FormatItem(row) + private.itemsQuery = private.itemsQuery or MailTracking.CreateMailItemQuery() + + private.itemsQuery:ResetFilters() + :Equal("index", row:GetField("index")) + :GreaterThan("itemIndex", 0) + :ResetOrderBy() + :OrderBy("itemIndex", true) + + local items = "" + local item = nil + local same = true + local qty = 0 + for _, itemsRow in private.itemsQuery:Iterator() do + local coloredItem = TSM.UI.GetColoredItemName(itemsRow:GetField("itemLink")) or "" + local quantity = itemsRow:GetField("quantity") + + if not item then + item = coloredItem + end + + if quantity > 1 then + items = items..coloredItem..Theme.GetFeedbackColor("YELLOW"):ColorText(" (x"..quantity..")")..", " + else + items = items..coloredItem..", " + end + + if item == coloredItem then + qty = qty + quantity + else + same = false + end + end + items = strtrim(items, ", ") + + if same and item then + if qty > 1 then + items = item..Theme.GetFeedbackColor("YELLOW"):ColorText(" (x"..qty..")") + else + items = item + end + end + + if not items or items == "" then + local subject = row:GetField("subject") + if subject ~= "" then + items = gsub(row:GetField("subject"), strtrim(AUCTION_SOLD_MAIL_SUBJECT, "%s.*"), "") or "--" + else + local _, _, sender = GetInboxHeaderInfo(row:GetField("index")) + items = sender or UNKNOWN + end + end + + return items +end + +function private.FormatDaysLeft(timeLeft) + local color = nil + if timeLeft >= 1 then + if timeLeft <= 5 then + color = Theme.GetFeedbackColor("YELLOW") + timeLeft = floor(timeLeft).." "..DAYS + else + color = Theme.GetFeedbackColor("GREEN") + timeLeft = floor(timeLeft).." "..DAYS + end + else + color = Theme.GetFeedbackColor("RED") + local hoursLeft = floor(timeLeft * 24) + if hoursLeft > 1 then + timeLeft = hoursLeft.." "..L["Hrs"] + elseif hoursLeft == 1 then + timeLeft = hoursLeft.." "..L["Hr"] + else + timeLeft = floor(hoursLeft / 60).." "..L["Min"] + end + end + return color:ColorText(timeLeft) +end + +function private.FormatMoney(row) + local money = row:GetField("money") + local cod = row:GetField("cod") + + local gold = nil + if cod > 0 then + gold = Money.ToString(cod, Theme.GetFeedbackColor("RED"):GetTextColorPrefix()) + elseif money > 0 then + gold = Money.ToString(money, Theme.GetFeedbackColor("GREEN"):GetTextColorPrefix()) + end + + return gold or "--" +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateCountDown(force) + if not private.frame then + return + end + + local nextUpdate = MAIL_REFRESH_TIME - (time() - TSM.Mailing.Open.GetLastCheckTime()) + if nextUpdate <= 0 then + nextUpdate = MAIL_REFRESH_TIME + end + + if nextUpdate ~= private.nextUpdate or force then + private.frame:GetElement("top.reload"):SetFormattedText(L["Reload UI (%02d)"], Math.Round(nextUpdate)) + :Draw() + private.nextUpdate = nextUpdate + end +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + frame = nil, + opening = false + } + + Event.Register("PLAYER_REPORT_SUBMITTED", function() + if not private.view or not private.view:HasChildById("view") then + return + end + private.view:GetElement("view"):SetPath("mails", true) + end) + + local function UpdateText(context, filterText) + local text = nil + local numMail, totalMail = GetInboxNumItems() + if filterText == "" then + if totalMail > numMail then + text = L["Showing %s of %d Mails"] + elseif (numMail == 0 and totalMail == 0) or (numMail == 1 and totalMail == 1) then + text = L["Showing %s Mail"] + else + text = L["Showing all %s Mails"] + end + else + numMail = private.inboxQuery:Count() + if (numMail == 0 and totalMail == 0) or (numMail == 1 and totalMail == 1) then + text = L["Showing %s of %d Mail"] + else + text = L["Showing %s of %d Mails"] + end + end + + local totalItems, totalGold = 0, 0 + for _, row in private.inboxQuery:Iterator() do + totalItems = totalItems + row:GetField("itemCount") + totalGold = totalGold + row:GetField("money") + end + + context.frame:GetElement("footer.mailNum") + :SetText(format(text, Theme.GetFeedbackColor("GREEN"):ColorText(tostring(numMail)), totalMail)) + :Draw() + context.frame:GetElement("footer.itemNum") + :SetText(format(L["%s Items Total"], Theme.GetFeedbackColor("GREEN"):ColorText(totalItems))) + context.frame:GetElement("footer.content.gold") + :SetText(Money.ToString(totalGold)) + + context.frame:GetElement("footer") + :Draw() + end + + local function UpdateButtons(context) + if not context.frame or not context.frame:HasChildById("top.filterInput") then + return + end + + if context.opening then + context.frame:GetElement("bottom.openAllMail") + :SetDisabled(true) + context.frame:GetElement("bottom.buttons.openAllSales") + :SetDisabled(true) + context.frame:GetElement("bottom.buttons.openAllBuys") + :SetDisabled(true) + context.frame:GetElement("bottom.buttons.openAllCancels") + :SetDisabled(true) + context.frame:GetElement("bottom.buttons.openAllExpires") + :SetDisabled(true) + context.frame:GetElement("bottom.buttons.openAllOthers") + :SetDisabled(true) + + context.frame:GetElement("bottom") + :Draw() + else + local all, sales, buys, cancels, expires, other = 0, 0, 0, 0, 0, 0 + for _, row in private.inboxQuery:Iterator() do + local iconType = row:GetField("icon") + all = all + 1 + if iconType == "SALE" then + sales = sales + 1 + elseif iconType == "BUY" then + buys = buys + 1 + elseif iconType == "CANCEL" then + cancels = cancels + 1 + elseif iconType == "EXPIRE" then + expires = expires + 1 + elseif iconType == "OTHER" then + other = other + 1 + end + end + + context.frame:GetElement("bottom.openAllMail") + :SetDisabled(all == 0) + :SetPressed(false) + context.frame:GetElement("bottom.buttons.openAllSales") + :SetDisabled(sales == 0) + :SetPressed(false) + context.frame:GetElement("bottom.buttons.openAllBuys") + :SetDisabled(buys == 0) + :SetPressed(false) + context.frame:GetElement("bottom.buttons.openAllCancels") + :SetDisabled(cancels == 0) + :SetPressed(false) + context.frame:GetElement("bottom.buttons.openAllExpires") + :SetDisabled(expires == 0) + :SetPressed(false) + context.frame:GetElement("bottom.buttons.openAllOthers") + :SetDisabled(other == 0) + :SetPressed(false) + + context.frame:GetElement("bottom") + :Draw() + end + end + + private.fsm = FSM.New("MAILING_INBOX") + :AddState(FSM.NewState("ST_HIDDEN") + :SetOnEnter(function(context) + TSM.Mailing.Open.KillThread() + Delay.Cancel("mailUpdateCounter") + context.frame = nil + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_FRAME_SHOW", "ST_SHOWN") + ) + :AddState(FSM.NewState("ST_SHOWN") + :SetOnEnter(function(context, frame, filterText) + if not context.frame then + context.frame = frame + UpdateText(context, filterText) + UpdateButtons(context) + end + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_OPENING_START") + :AddEvent("EV_MAIL_DATA_UPDATED", function(context, filterText) + UpdateText(context, filterText) + UpdateButtons(context) + end) + :AddEventTransition("EV_BUTTON_CLICKED", "ST_OPENING_START") + ) + :AddState(FSM.NewState("ST_OPENING_START") + :SetOnEnter(function(context, autoRefresh, keepMoney, filterText, filterType) + context.opening = true + UpdateButtons(context) + TSM.Mailing.Open.StartOpening(private.FSMOpenCallback, autoRefresh, keepMoney, filterText, filterType) + end) + :SetOnExit(function(context) + context.opening = false + UpdateButtons(context) + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEvent("EV_MAIL_DATA_UPDATED", function(context, filterText) + UpdateText(context, filterText) + end) + :AddEventTransition("EV_OPENING_DONE", "ST_SHOWN") + ) + :AddDefaultEventTransition("EV_FRAME_HIDE", "ST_HIDDEN") + :Init("ST_HIDDEN", fsmContext) +end + +function private.FSMOpenCallback() + private.fsm:ProcessEvent("EV_OPENING_DONE") + + Sound.PlaySound(private.settings.openMailSound) +end diff --git a/Core/UI/MailingUI/Other.lua b/Core/UI/MailingUI/Other.lua new file mode 100644 index 0000000..ac8c53a --- /dev/null +++ b/Core/UI/MailingUI/Other.lua @@ -0,0 +1,362 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM.UI.MailingUI:NewPackage("Other") +local L = TSM.Include("Locale").GetTable() +local FSM = TSM.Include("Util.FSM") +local Event = TSM.Include("Util.Event") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local ItemInfo = TSM.Include("Service.ItemInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local UIElements = TSM.Include("UI.UIElements") +local private = { + frame = nil, + fsm = nil, +} + +local PLAYER_NAME = UnitName("player") +local PLAYER_NAME_REALM = string.gsub(PLAYER_NAME.."-"..GetRealmName(), "%s+", "") + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.FSMCreate() + TSM.UI.MailingUI.RegisterTopLevelPage(OTHER, private.GetOtherFrame) +end + + + +-- ============================================================================ +-- Other UI +-- ============================================================================ + +function private.GetOtherFrame() + TSM.UI.AnalyticsRecordPathChange("mailing", "other") + local frame = UIElements.New("Frame", "other") + :SetLayout("VERTICAL") + :SetPadding(10) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "enchant") + :SetHeight(24) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Mail Disenchantables"]) + ) + :AddChild(UIElements.New("Text", "enchantDesc") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :SetFont("BODY_BODY2") + :SetText(L["Quickly mail all excess disenchantable items to a character"]) + ) + :AddChild(UIElements.New("Text", "enchantRecipient") + :SetHeight(15) + :SetMargin(0, 0, 12, 0) + :SetFont("BODY_BODY2") + :SetText(L["Recipient"]) + ) + :AddChild(UIElements.New("Frame", "enchantHeader") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Input", "recipient") + :SetMargin(0, 8, 0, 0) + :SetAutoComplete(PlayerInfo.GetConnectedAlts()) + :SetClearButtonEnabled(true) + :SetValue(TSM.db.factionrealm.internalData.mailDisenchantablesChar) + :SetScript("OnValueChanged", private.EchantRecipientOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "send") + :SetSize(186, 24) + :SetDisabled(true) + :SetText(L["Send Disenchantables"]) + :SetScript("OnClick", private.EnchantSendBtnOnClick) + ) + ) + :AddChild(UIElements.New("Text", "gold") + :SetHeight(24) + :SetMargin(0, 0, 24, 0) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Send Excess Gold to Banker"]) + ) + :AddChild(UIElements.New("Text", "goldDesc") + :SetHeight(18) + :SetMargin(0, 0, 4, 0) + :SetFont("BODY_BODY2") + :SetText(L["Quickly mail all excess gold (limited to a certain amount) to a character"]) + ) + :AddChild(UIElements.New("Frame", "goldTextHeader") + :SetLayout("HORIZONTAL") + :SetHeight(13) + :SetMargin(0, 0, 12, 0) + :AddChild(UIElements.New("Text", "recipient") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Recipient"]) + ) + :AddChild(UIElements.New("Text", "limit") + :SetWidth(128) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Limit"]) + ) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(106) + ) + ) + :AddChild(UIElements.New("Frame", "goldHeader") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Input", "recipient") + :SetMargin(0, 8, 0, 0) + :SetAutoComplete(PlayerInfo.GetConnectedAlts()) + :SetClearButtonEnabled(true) + :SetValue(TSM.db.factionrealm.internalData.mailExcessGoldChar) + :SetScript("OnValueChanged", private.GoldRecipientOnValueChanged) + ) + :AddChild(UIElements.New("Input", "limit") + :SetSize(128, 24) + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetJustifyH("RIGHT") + :SetValidateFunc(private.LimitValidateFunc) + :SetValue(Money.ToString(TSM.db.factionrealm.internalData.mailExcessGoldLimit, nil, "OPT_TRIM")) + :SetScript("OnValueChanged", private.LimitOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "send") + :SetSize(106, 24) + :SetDisabled(true) + :SetText(L["Send Gold"]) + :SetScript("OnClick", private.GoldSendBtnOnClick) + ) + ) + :AddChild(UIElements.New("Spacer", "spacer") + ) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) + + private.frame = frame + + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + + private.UpdateEnchantButton() + private.UpdateGoldButton() + + private.fsm:ProcessEvent("EV_FRAME_SHOW", frame) +end + +function private.FrameOnHide(frame) + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.EchantRecipientOnValueChanged(input) + local value = input:GetValue() + if value == TSM.db.factionrealm.internalData.mailDisenchantablesChar then + return + end + TSM.db.factionrealm.internalData.mailDisenchantablesChar = value + private.UpdateEnchantButton() +end + +function private.EnchantSendBtnOnClick(button) + local items = {} + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("itemString", "quantity") + :Equal("isBoP", false) + :Equal("isBoA", false) + for _, itemString, quantity in query:Iterator() do + if ItemInfo.IsDisenchantable(itemString) and not ItemInfo.IsSoulbound(itemString) then + local quality = ItemInfo.GetQuality(itemString) + if quality <= TSM.db.global.mailingOptions.deMaxQuality then + items[itemString] = (items[itemString] or 0) + quantity + end + end + end + query:Release() + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", TSM.db.factionrealm.internalData.mailDisenchantablesChar, 0, items) +end + +function private.GoldRecipientOnValueChanged(input) + local value = input:GetValue() + if value == TSM.db.factionrealm.internalData.mailExcessGoldChar then + return + end + TSM.db.factionrealm.internalData.mailExcessGoldChar = value + private.UpdateGoldButton() +end + +function private.GoldSendBtnOnClick(button) + local money = private.GetSendMoney() + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", TSM.db.factionrealm.internalData.mailExcessGoldChar, money) +end + +function private.ConvertLimitValue(value) + value = gsub(value, String.Escape(LARGE_NUMBER_SEPERATOR), "") + value = tonumber(value) or Money.FromString(value) + if not value then + return nil + end + return value > 0 and value <= MAXIMUM_BID_PRICE and value or nil +end + +function private.LimitValidateFunc(_, value) + return private.ConvertLimitValue(value) and true or false +end + +function private.LimitOnValueChanged(input) + local value = private.ConvertLimitValue(input:GetValue()) + assert(value) + if value == TSM.db.factionrealm.internalData.mailExcessGoldLimit then + return + end + TSM.db.factionrealm.internalData.mailExcessGoldLimit = value + private.UpdateGoldButton() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetSendMoney() + local money = GetMoney() - 30 - TSM.db.factionrealm.internalData.mailExcessGoldLimit + if money < 0 then + money = 0 + end + + return money +end + +function private.UpdateEnchantButton() + local recipient = TSM.db.factionrealm.internalData.mailDisenchantablesChar + local enchantButton = private.frame:GetElement("enchantHeader.send") + + if recipient == "" or recipient == PLAYER_NAME or recipient == PLAYER_NAME_REALM then + enchantButton:SetDisabled(true) + :Draw() + return + else + enchantButton:SetDisabled(false) + :Draw() + end +end + +function private.UpdateGoldButton() + local recipient = TSM.db.factionrealm.internalData.mailExcessGoldChar + local goldButton = private.frame:GetElement("goldHeader.send") + + if recipient == "" or recipient == PLAYER_NAME or recipient == PLAYER_NAME_REALM then + goldButton:SetDisabled(true) + :Draw() + return + end + + if private.GetSendMoney() > 0 then + goldButton:SetDisabled(false) + else + goldButton:SetDisabled(true) + end + + goldButton:Draw() +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + Event.Register("PLAYER_MONEY", function() + private.fsm:ProcessEvent("EV_PLAYER_MONEY_UPDATE") + end) + + local fsmContext = { + frame = nil, + sending = false + } + + local function UpdateEnchant(context) + context.frame:GetElement("enchantHeader.send"):SetText(context.sending and L["Sending..."] or L["Send Disenchantables"]) + :SetPressed(context.sending) + :Draw() + end + + local function UpdateGold(context) + private.UpdateGoldButton() + + context.frame:GetElement("goldHeader.send"):SetText(context.sending and L["Sending..."] or L["Send Gold"]) + :SetPressed(context.sending) + :Draw() + end + + private.fsm = FSM.New("MAILING_GROUPS") + :AddState(FSM.NewState("ST_HIDDEN") + :SetOnEnter(function(context) + context.frame = nil + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_FRAME_SHOW", "ST_SHOWN") + ) + :AddState(FSM.NewState("ST_SHOWN") + :SetOnEnter(function(context, frame) + if not context.frame then + context.frame = frame + end + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_SENDING_START") + :AddEvent("EV_PLAYER_MONEY_UPDATE", function(context) + UpdateGold(context) + end) + :AddEventTransition("EV_BUTTON_CLICKED", "ST_SENDING_START") + ) + :AddState(FSM.NewState("ST_SENDING_START") + :SetOnEnter(function(context, recipient, money, items) + context.sending = true + if money > 0 then + TSM.Mailing.Send.StartSending(private.FSMOthersCallback, recipient, "TSM Mailing: Excess Gold", "", money) + UpdateGold(context) + elseif items then + TSM.Mailing.Send.StartSending(private.FSMOthersCallback, recipient, "TSM Mailing: Disenchantables", "", money, items) + UpdateEnchant(context) + end + end) + :SetOnExit(function(context) + context.sending = false + UpdateEnchant(context) + UpdateGold(context) + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_SENDING_DONE", "ST_SHOWN") + ) + :AddDefaultEventTransition("EV_FRAME_HIDE", "ST_HIDDEN") + :Init("ST_HIDDEN", fsmContext) +end + +function private.FSMOthersCallback() + private.fsm:ProcessEvent("EV_SENDING_DONE") +end diff --git a/Core/UI/MailingUI/Send.lua b/Core/UI/MailingUI/Send.lua new file mode 100644 index 0000000..01b3c35 --- /dev/null +++ b/Core/UI/MailingUI/Send.lua @@ -0,0 +1,839 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Send = TSM.UI.MailingUI:NewPackage("Send") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local Money = TSM.Include("Util.Money") +local FSM = TSM.Include("Util.FSM") +local Database = TSM.Include("Util.Database") +local String = TSM.Include("Util.String") +local Event = TSM.Include("Util.Event") +local ItemString = TSM.Include("Util.ItemString") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local InventoryInfo = TSM.Include("Service.InventoryInfo") +local BagTracking = TSM.Include("Service.BagTracking") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local UIElements = TSM.Include("UI.UIElements") +local private = { + fsm = nil, + frame = nil, + db = nil, + query = nil, + recipient = "", + subject = "", + body = "", + money = 0, + isMoney = true, + isCOD = false, +} +local PLAYER_NAME = UnitName("player") +local PLAYER_NAME_REALM = gsub(PLAYER_NAME.."-"..GetRealmName(), "%s+", "") +local MAX_COD_AMOUNT = 10000 * COPPER_PER_GOLD + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Send.OnInitialize() + private.FSMCreate() + TSM.UI.MailingUI.RegisterTopLevelPage(L["Send"], private.GetSendFrame) + + private.db = Database.NewSchema("MAILTRACKING_SEND_INFO") + :AddStringField("itemString") + :AddNumberField("quantity") + :Commit() + private.query = private.db:NewQuery() +end + +function Send.SetSendRecipient(recipient) + private.recipient = recipient +end + + + +-- ============================================================================ +-- Send UI +-- ============================================================================ + +function private.GetSendFrame() + TSM.UI.AnalyticsRecordPathChange("mailing", "send") + local frame = UIElements.New("Frame", "send") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "recipient") + :SetMargin(8, 8, 8, 8) + :SetHeight(24) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Recipient"]) + ) + :AddChild(UIElements.New("Frame", "name") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("Input", "input") + :SetHintText(L["Enter recipient name"]) + :SetAutoComplete(PlayerInfo.GetConnectedAlts()) + :SetClearButtonEnabled(true) + :SetValue(private.recipient) + :SetScript("OnValueChanged", private.RecipientOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "contacts") + :SetWidth(152) + :SetMargin(8, 0, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Contacts"]) + :SetScript("OnClick", private.ContactsBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "subject") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 16) + :AddChild(UIElements.New("Button", "icon") + :SetMargin(4, 4, 0, 0) + :SetBackgroundAndSize("iconPack.12x12/Add/Circle") + :SetScript("OnClick", private.SubjectBtnOnClick) + ) + :AddChild(UIElements.New("Button", "text") + :SetWidth("AUTO") + :SetText(L["Add subject & description (optional)"]) + :SetFont("BODY_BODY2") + :SetScript("OnClick", private.SubjectBtnOnClick) + ) + :AddChild(UIElements.New("Button", "button") + :SetWidth("AUTO") + :SetMargin(8, 0, 0, 0) + :SetFont("BODY_BODY2") + :SetTextColor("INDICATOR") + :SetText(L["Edit"]) + :SetScript("OnClick", private.SubjectBtnOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 4) + :AddChild(UIElements.New("Text", "items") + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Select Items to Attach"]) + ) + ) + :AddChild(UIElements.New("Frame", "dragBox") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :RegisterForDrag("LeftButton") + :SetScript("OnReceiveDrag", private.DragBoxOnItemRecieve) + :SetScript("OnMouseUp", private.DragBoxOnItemRecieve) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Items"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetTooltipLinkingDisabled(true) + :DisableHiding() + :Commit() + :NewColumn("quantity") + :SetTitle(L["Amount"]) + :SetWidth(60) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("quantity") + :DisableHiding() + :Commit() + :Commit() + :SetQuery(private.query) + :SetScript("OnRowClick", private.QueryOnRowClick) + :SetScript("OnDataUpdated", private.SendOnDataUpdated) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(26) + :SetPadding(8, 8, 3, 3) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "items") + :SetWidth(144) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :Hide() + ) + :AddChild(UIElements.New("Texture", "vline") + :SetWidth(1) + :SetMargin(8, 8, 3, 3) + :SetTexture("ACTIVE_BG_ALT") + :Hide() + ) + :AddChild(UIElements.New("Text", "postage") + :SetWidth(150) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Total Postage"]..": "..Money.ToString(30)) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "check") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(8, 0, 8, 6) + :AddChild(UIElements.New("Checkbox", "sendCheck") + :SetWidth("AUTO") + :SetMargin(0, 0, 1, 0) + :SetFont("BODY_BODY2") + :SetCheckboxPosition("LEFT") + :SetChecked(private.isMoney) + :SetText(L["Send Money"]) + :SetScript("OnValueChanged", private.SendOnValueChanged) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "checkbox") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("Checkbox", "cod") + :SetSize("AUTO", 20) + :SetFont("BODY_BODY2") + :SetCheckboxPosition("LEFT") + :SetChecked(private.isCOD) + :SetText(L["Make Cash On Delivery?"]) + :SetDisabled(true) + :SetScript("OnValueChanged", private.CODOnValueChanged) + ) + :AddChild(UIElements.New("Text", "amountText") + :SetHeight(20) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Amount"]..":") + ) + :AddChild(UIElements.New("Input", "moneyInput") + :SetWidth(160) + :SetBackgroundColor("ACTIVE_BG") + :SetFont("BODY_BODY2_MEDIUM") + :SetValidateFunc(private.MoneyValidateFunc) + :SetJustifyH("RIGHT") + :SetValue(Money.ToString(private.money)) + :SetScript("OnValueChanged", private.MoneyOnValueChanged) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "sendMail") + :SetHeight(24) + :SetText(L["Send Mail"]) + :SetScript("OnClick", private.SendMail) + :SetDisabled(private.recipient == "") + ) + :AddChild(UIElements.New("Button", "clear") + :SetWidth("AUTO") + :SetHeight(24) + :SetMargin(16, 10, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("LEFT") + :SetText(L["Clear All"]) + :SetScript("OnClick", private.ClearOnClick) + ) + ) + :SetScript("OnUpdate", private.SendFrameOnUpdate) + :SetScript("OnHide", private.SendFrameOnHide) + + private.frame = frame + + return frame +end + +function private.SubjectBtnOnClick(button) + button:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(478, 314) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 14) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(20) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Add Subject / Description"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.CloseDialog) + ) + ) + :AddChild(UIElements.New("Text", "subjectText") + :SetMargin(0, 0, 0, 4) + :SetHeight(20) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Subject"]) + ) + :AddChild(UIElements.New("Input", "subjectInput") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetMaxLetters(64) + :SetClearButtonEnabled(true) + :SetValue(private.subject) + :SetTabPaths("__parent.descriptionInput", "__parent.descriptionInput") + :SetScript("OnValueChanged", private.SubjectOnValueChanged) + ) + :AddChild(UIElements.New("Text", "descriptionText") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(DESCRIPTION) + ) + :AddChild(UIElements.New("MultiLineInput", "descriptionInput") + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetIgnoreEnter() + :SetMaxLetters(500) + :SetValue(private.body) + :SetTabPaths("__parent.subjectInput", "__parent.subjectInput") + :SetScript("OnValueChanged", private.DesciptionOnValueChanged) + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetMargin(0, 0, 4, 12) + :AddChild(UIElements.New("Text", "title") + :SetHeight(20) + :SetMargin(0, 4, 0, 0) + :SetFont("BODY_BODY3") + :SetJustifyH("RIGHT") + :SetText(format(L["(%d/500 Characters)"], #private.body)) + ) + :AddChild(UIElements.New("Button", "clearAll") + :SetSize("AUTO", 20) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("LEFT") + :SetText(L["Clear All"]) + :SetDisabled(private.subject == "" and private.body == "") + :SetScript("OnClick", private.SubjectClearAllBtnOnClick) + ) + ) + :AddChild(UIElements.New("ActionButton", "addMailBtn") + :SetHeight(24) + :SetText(L["Add to Mail"]) + :SetScript("OnClick", private.CloseDialog) + :SetDisabled(private.subject == "" and private.body == "") + ) + :SetScript("OnHide", private.DialogOnHide) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.SendFrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.fsm:ProcessEvent("EV_FRAME_SHOW", frame) +end + +function private.SendFrameOnHide(frame) + assert(frame == private.frame) + private.frame = nil + + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.ClearOnClick(button) + private.fsm:ProcessEvent("EV_MAIL_CLEAR", true) +end + +function private.CloseDialog(button) + button:GetBaseElement():HideDialog() + + private.fsm:ProcessEvent("EV_DIALOG_HIDDEN") +end + +function private.DialogOnHide(button) + private.fsm:ProcessEvent("EV_DIALOG_HIDDEN") +end + +function private.ContactsBtnOnClick(button) + TSM.UI.Util.Contacts.ShowDialog(button, button:GetElement("__parent.input"), private.RecipientOnValueChanged) +end + +function private.DragBoxOnItemRecieve(frame, button) + if not CursorHasItem() then + ClearCursor() + return + end + + if private.query:Count() >= 12 then + ClearCursor() + UIErrorsFrame:AddMessage(ERR_MAIL_INVALID_ATTACHMENT_SLOT, 1.0, 0.1, 0.1, 1.0) + return + end + + local _, _, subType = GetCursorInfo() + local itemString = ItemString.Get(subType) + local stackSize = nil + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("bag", "slot", "quantity") + :Equal("isBoP", false) + :Equal("itemString", itemString) + for _, bag, slot, quantity in query:Iterator() do + if InventoryInfo.IsBagSlotLocked(bag, slot) then + stackSize = quantity + end + end + query:Release() + ClearCursor() + if not stackSize then + return + end + + private.DatabaseNewRow(itemString, stackSize) +end + +function private.QueryOnRowClick(scrollingTable, row, button) + if button == "RightButton" then + private.db:DeleteRow(row) + end +end + +function private.SendOnDataUpdated() + private.fsm:ProcessEvent("EV_MAIL_DATA_UPDATED") +end + +function private.SubjectClearAllBtnOnClick(button) + private.subject = "" + private.body = "" + + button:GetElement("__parent.__parent.subjectInput") + :SetFocused(false) + :SetValue(private.subject) + :Draw() + button:GetElement("__parent.__parent.descriptionInput") + :SetFocused(false) + :SetValue(private.body) + :Draw() + button:GetElement("__parent.title") + :SetText(format(L["(%d/500 Characters)"], 0)) + :Draw() + button:SetDisabled(true) + :Draw() + button:GetElement("__parent.__parent.addMailBtn") + :SetDisabled(true) + :Draw() +end + +function private.RecipientOnValueChanged(input) + local value = input:GetValue() + if value == private.recipient then + return + end + private.recipient = value + private.UpdateSendFrame() +end + +function private.SubjectOnValueChanged(input) + local value = input:GetValue() + if value == private.subject then + return + end + private.subject = value + input:GetElement("__parent.footer.clearAll") + :SetDisabled(private.subject == "" and private.body == "") + :Draw() + input:GetElement("__parent.addMailBtn") + :SetDisabled(private.subject == "" and private.body == "") + :Draw() +end + +function private.DesciptionOnValueChanged(input) + local text = input:GetValue() + if text == private.body then + return + end + private.body = text + input:GetElement("__parent.footer.title") + :SetText(format(L["(%d/500 Characters)"], #private.body)) + :Draw() + input:GetElement("__parent.footer.clearAll") + :SetDisabled(private.subject == "" and private.body == "") + :Draw() + input:GetElement("__parent.addMailBtn") + :SetDisabled(private.subject == "" and private.body == "") + :Draw() +end + +function private.SendOnValueChanged(checkbox) + if checkbox:IsChecked() then + checkbox:GetElement("__parent.__parent.checkbox.cod"):SetChecked(false) + :Draw() + + private.isMoney = true + private.isCOD = false + else + private.isMoney = false + end +end + +function private.CODOnValueChanged(checkbox) + if checkbox:IsChecked() then + checkbox:GetElement("__parent.__parent.check.sendCheck"):SetChecked(false) + :Draw() + + local input = checkbox:GetElement("__parent.moneyInput") + local value = private.ConvertMoneyValue(input:GetValue()) + private.money = private.isCOD and min(value, MAX_COD_AMOUNT) or value + input:SetValue(Money.ToString(private.money)) + :Draw() + + private.isMoney = false + private.isCOD = true + else + private.isCOD = false + end +end + +function private.ConvertMoneyValue(value) + value = gsub(value, String.Escape(LARGE_NUMBER_SEPERATOR), "") + value = tonumber(value) or Money.FromString(value) + if not value then + return nil + end + local maxVal = private.isCOD and MAX_COD_AMOUNT or MAXIMUM_BID_PRICE + return value >= 0 and value <= maxVal and value or nil +end + +function private.MoneyValidateFunc(_, value) + return private.ConvertMoneyValue(value) and true or false +end + +function private.MoneyOnValueChanged(input) + local value = private.ConvertMoneyValue(input:GetValue()) + assert(value) + if value == private.money then + return + end + private.money = value + input:SetValue(Money.ToString(value)) + :Draw() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SendMail(button) + local money = 0 + if private.money > 0 and private.isMoney then + money = private.money + elseif private.money > 0 and private.isCOD then + money = private.money * -1 + end + + button:GetElement("__parent.__parent.container.name.input"):SetFocused(false) + private.UpdateRecentlyMailed(private.recipient) + + if private.query:Count() > 0 then + local items = {} + for _, row in private.query:Iterator() do + local itemString = row:GetField("itemString") + local quantity = row:GetField("quantity") + if items[itemString] then + items[itemString] = items[itemString] + quantity + else + items[itemString] = quantity + end + end + + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", IsShiftKeyDown(), private.recipient, private.subject, private.body, money, items) + else + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", IsShiftKeyDown(), private.recipient, private.subject, private.body, money) + end +end + +function private.UpdateRecentlyMailed(recipient) + if recipient == UnitName("player") or recipient == PLAYER_NAME_REALM then + return + end + + local size = 0 + local oldestName = nil + local oldestTime = nil + for k, v in pairs(TSM.db.global.mailingOptions.recentlyMailedList) do + size = size + 1 + if not oldestName or not oldestTime or oldestTime > v then + oldestName = k + oldestTime = v + end + end + + if size >= 20 then + TSM.db.global.mailingOptions.recentlyMailedList[oldestName] = nil + end + + TSM.db.global.mailingOptions.recentlyMailedList[recipient] = time() +end + +function private.UpdateSendFrame() + if not private.frame then + return + end + + local sendMail = private.frame:GetElement("footer.sendMail") + if private.recipient ~= "" then + sendMail:SetDisabled(false) + else + sendMail:SetDisabled(true) + end + sendMail:Draw() +end + +function private.DatabaseNewRow(itemString, stackSize) + private.db:NewRow() + :SetField("itemString", itemString) + :SetField("quantity", stackSize) + :Create() +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + Event.Register("MAIL_CLOSED", function() + private.fsm:ProcessEvent("EV_MAIL_CLEAR") + end) + Event.Register("MAIL_SEND_INFO_UPDATE", function() + private.fsm:ProcessEvent("EV_MAIL_INFO_UPDATE") + end) + Event.Register("MAIL_FAILED", function() + private.fsm:ProcessEvent("EV_SENDING_DONE") + end) + + local fsmContext = { + frame = nil, + sending = false, + keepInfo = false + } + + local function UpdateFrame(context) + if not context.frame then + return + end + + local subject = context.frame:GetElement("container.subject") + if private.subject == "" and private.body == "" then + subject:GetElement("icon") + :SetBackgroundAndSize("iconPack.12x12/Add/Circle") + :Draw() + subject:GetElement("text") + :SetText(L["Add subject & description (optional)"]) + :Draw() + subject:GetElement("button") + :Hide() + else + subject:GetElement("icon") + :SetBackgroundAndSize("iconPack.12x12/Checkmark/Default") + :Draw() + subject:GetElement("text") + :SetText(L["Subject & Description added"]) + :Draw() + subject:GetElement("button") + :Show() + end + subject:Draw() + + local items = context.frame:GetElement("container.dragBox.footer.items") + local line = context.frame:GetElement("container.dragBox.footer.vline") + local postage = context.frame:GetElement("container.dragBox.footer.postage") + local send = context.frame:GetElement("container.check.sendCheck") + local cod = context.frame:GetElement("container.checkbox.cod") + + local size = private.query:Count() + if size > 0 then + postage:SetText(L["Total Postage"]..": "..Money.ToString(30 * size)) + :Draw() + items:SetText(format(L["%s Items Selected"], Theme.GetFeedbackColor("GREEN"):ColorText(size.."/"..ATTACHMENTS_MAX_SEND))) + :Show() + :Draw() + line:Show() + cod:SetDisabled(false) + :Draw() + else + postage:SetText(L["Total Postage"]..": "..Money.ToString(30)) + :Draw() + items:Hide() + line:Hide() + cod:SetDisabled(true) + :Draw() + send:SetChecked(true) + :SetDisabled(false) + :Draw() + end + end + + local function UpdateButton(context) + context.frame:GetElement("footer.sendMail") + :SetText(context.sending and L["Sending..."] or L["Send Mail"]) + :SetPressed(context.sending) + :Draw() + end + + local function UpdateSendMailInfo(context) + if private.query:Count() >= 12 then + UIErrorsFrame:AddMessage(ERR_MAIL_INVALID_ATTACHMENT_SLOT, 1.0, 0.1, 0.1, 1.0) + else + for i = 1, ATTACHMENTS_MAX_SEND do + local itemName, _, _, stackCount = GetSendMailItem(i) + if itemName and stackCount then + local itemLink = GetSendMailItemLink(i) + local itemString = ItemString.Get(itemLink) + + private.DatabaseNewRow(itemString, stackCount) + + break + end + end + end + + ClearSendMail() + end + + local function ClearMail(context, keepInfo, redraw) + if not keepInfo then + private.recipient = "" + end + private.subject = "" + private.body = "" + private.money = 0 + private.isMoney = true + private.isCOD = false + + private.db:Truncate() + + if redraw and context.frame then + context.frame:GetElement("container.name.input") + :SetValue(private.recipient) + :Draw() + context.frame:GetElement("container.checkbox.moneyInput") + :SetValue(Money.ToString(private.money)) + :Draw() + if not keepInfo then + context.frame:GetElement("footer.sendMail") + :SetDisabled(true) + :Draw() + end + end + + UpdateFrame(context) + end + + local function SendMailShowing() + SetSendMailShowing(true) + end + + private.fsm = FSM.New("MAILING_SEND") + :AddState(FSM.NewState("ST_HIDDEN") + :SetOnEnter(function(context) + TSM.Mailing.Send.KillThread() + SetSendMailShowing(false) + context.frame = nil + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_FRAME_SHOW", "ST_SHOWN") + ) + :AddState(FSM.NewState("ST_SHOWN") + :SetOnEnter(function(context, frame) + if not context.frame then + context.frame = frame + UpdateFrame(context) + Delay.AfterFrame("setMailShowing", 2, SendMailShowing) + end + UpdateButton(context) + end) + :AddTransition("ST_HIDDEN") + :AddTransition("ST_SENDING_START") + :AddEvent("EV_DIALOG_HIDDEN", function(context) + UpdateFrame(context) + end) + :AddEvent("EV_MAIL_INFO_UPDATE", function(context) + UpdateSendMailInfo(context) + UpdateFrame(context) + end) + :AddEvent("EV_MAIL_DATA_UPDATED", function(context) + UpdateFrame(context) + end) + :AddEvent("EV_MAIL_CLEAR", function(context, redraw) + ClearMail(context, IsShiftKeyDown(), redraw) + end) + :AddEventTransition("EV_BUTTON_CLICKED", "ST_SENDING_START") + ) + :AddState(FSM.NewState("ST_SENDING_START") + :SetOnEnter(function(context, keepInfo, recipient, subject, body, money, items) + context.sending = true + context.keepInfo = keepInfo + private.db:SetQueryUpdatesPaused(true) + TSM.Mailing.Send.StartSending(private.FSMSendCallback, recipient, subject, body, money, items) + UpdateButton(context) + end) + :SetOnExit(function(context) + context.sending = false + private.db:SetQueryUpdatesPaused(false) + ClearMail(context, context.keepInfo, true) + UpdateFrame(context) + end) + :AddTransition("ST_SHOWN") + :AddTransition("ST_HIDDEN") + :AddEventTransition("EV_SENDING_DONE", "ST_SHOWN") + ) + :AddDefaultEvent("EV_FRAME_HIDE", function(context) + context.frame = nil + return "ST_HIDDEN" + end) + :Init("ST_HIDDEN", fsmContext) +end + +function private.FSMSendCallback() + private.fsm:ProcessEvent("EV_SENDING_DONE") +end diff --git a/Core/UI/MainUI/Core.lua b/Core/UI/MainUI/Core.lua new file mode 100644 index 0000000..24dd680 --- /dev/null +++ b/Core/UI/MainUI/Core.lua @@ -0,0 +1,91 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local MainUI = TSM:NewPackage("MainUI") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + topLevelPages = {}, + frame = nil, +} +local MIN_FRAME_SIZE = { width = 720, height = 588 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function MainUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "frame") +end + +function MainUI.OnDisable() + -- hide the frame + if private.frame then + MainUI.Toggle() + end +end + +function MainUI.RegisterTopLevelPage(name, callback) + tinsert(private.topLevelPages, { name = name, callback = callback }) +end + +function MainUI.Toggle() + if private.frame then + -- it's already shown, so hide it + private.frame:Hide() + assert(not private.frame) + else + private.frame = private.CreateMainFrame() + private.frame:Draw() + private.frame:Show() + end +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("main") + -- Always show the Dashboard first + private.settings.frame.page = 1 + local frame = UIElements.New("LargeApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :AddPlayerGold() + :AddAppStatusIcon() + :SetScript("OnHide", private.BaseFrameOnHide) + for _, info in ipairs(private.topLevelPages) do + frame:AddNavButton(info.name, info.callback) + end + local whatsNewDialog = TSM.UI.WhatsNew.GetDialog() + if whatsNewDialog then + frame:ShowDialogFrame(whatsNewDialog) + end + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide(frame) + assert(frame == private.frame) + frame:Release() + private.frame = nil + TSM.UI.AnalyticsRecordClose("main") +end diff --git a/Core/UI/MainUI/Dashboard.lua b/Core/UI/MainUI/Dashboard.lua new file mode 100644 index 0000000..139e1f7 --- /dev/null +++ b/Core/UI/MainUI/Dashboard.lua @@ -0,0 +1,936 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Dashboard = TSM.MainUI:NewPackage("Dashboard") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local Analytics = TSM.Include("Util.Analytics") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + characterGuilds = {}, + tempTimeTable = {}, + selectedTimeRange = nil, +} +local SECONDS_PER_DAY = 60 * 60 * 24 +local MIN_GRAPH_STEP_SIZE = TSM.IsWowClassic() and COPPER_PER_GOLD or (COPPER_PER_GOLD * 1000) +local TIME_RANGE_LOOKUP = { + ["1d"] = SECONDS_PER_DAY, + ["1w"] = SECONDS_PER_DAY * 7, + ["1m"] = SECONDS_PER_DAY * 30, + ["3m"] = SECONDS_PER_DAY * 91, + ["6m"] = SECONDS_PER_DAY * 183, + ["1y"] = SECONDS_PER_DAY * 365, + ["2y"] = SECONDS_PER_DAY * 730, + ["all"] = -1, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Dashboard.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "dashboardDividedContainer") + :AddKey("global", "mainUIContext", "dashboardUnselectedCharacters") + :AddKey("global", "mainUIContext", "dashboardTimeRange") + private.selectedTimeRange = private.settings.dashboardTimeRange + TSM.MainUI.RegisterTopLevelPage(L["Dashboard"], private.GetDashboardFrame) +end + + +-- ============================================================================ +-- Dashboard UI +-- ============================================================================ + +function private.GetDashboardFrame() + TSM.UI.AnalyticsRecordPathChange("main", "dashboard") + + private.selectedTimeRange = private.settings.dashboardTimeRange + wipe(private.characterGuilds) + local prevUnselectedCharacters = TempTable.Acquire() + for characterGuild in pairs(private.settings.dashboardUnselectedCharacters) do + prevUnselectedCharacters[characterGuild] = true + end + wipe(private.settings.dashboardUnselectedCharacters) + for characterGuild in TSM.Accounting.GoldTracker.CharacterGuildIterator() do + tinsert(private.characterGuilds, characterGuild) + private.settings.dashboardUnselectedCharacters[characterGuild] = prevUnselectedCharacters[characterGuild] or nil + end + TempTable.Release(prevUnselectedCharacters) + + local frame = UIElements.New("DividedContainer", "dashboard") + :SetSettingsContext(private.settings, "dashboardDividedContainer") + :SetMinWidth(200, 407) + :SetBackgroundColor("PRIMARY_BG") + :SetLeftChild(UIElements.New("Frame", "news") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "text") + :SetSize("AUTO", 24) + :SetMargin(8) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["News & Information"]) + ) + :AddChild(UIElements.New("ScrollFrame", "content") + :SetPadding(8, 8, 0, 0) + ) + ) + :SetRightChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :SetScript("OnUpdate", private.ContentOnUpdate) + :AddChild(UIElements.New("Frame", "goldHeader") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Player Gold"]) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "playerDropdown") + :SetSize(157, 22) + :SetItems(private.characterGuilds, private.characterGuilds) + :SetUnselectedItemKeys(private.settings.dashboardUnselectedCharacters) + :SetSelectionText(L["No Players"], L["%d Players"], L["All Players"]) + :SetScript("OnSelectionChanged", private.DropdownOnSelectionChanged) + ) + :AddChild(UIElements.New("Spacer")) + :AddChild(UIElements.New("Text", "hoverTime") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText("") + ) + :AddChild(UIElements.New("Frame", "timeBtns") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Button", "1d") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["1d"]) + :SetText(L["1D"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "1w") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["1w"]) + :SetText(L["1W"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "1m") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["1m"]) + :SetText(L["1M"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "3m") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["3m"]) + :SetText(L["3M"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "6m") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["6m"]) + :SetText(L["6M"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "1y") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["1y"]) + :SetText(L["1Y"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "2y") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetContext(TIME_RANGE_LOOKUP["2y"]) + :SetText(L["2Y"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "all") + :SetMargin(8, 0, 0, 0) + :SetSize(20, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("TEXT") + :SetContext(TIME_RANGE_LOOKUP["all"]) + :SetText(ALL) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + :AddChild(UIElements.New("Button", "resetZoom") + :SetMargin(8, 0, 0, 0) + :SetSize(100, 20) + :SetFont("TABLE_TABLE1") + :SetTextColor("TEXT") + :SetContext(TIME_RANGE_LOOKUP["all"]) + :SetText(L["Reset Zoom"]) + :SetScript("OnClick", private.TimeBtnOnClick) + ) + ) + ) + :AddChild(UIElements.New("Graph", "goldGraph") + :SetMargin(0, 0, 8, 8) + :SetAxisStepFunctions(private.GraphXStepFunc, private.GraphYStepFunc) + :SetXRange(TSM.Accounting.GoldTracker.GetGraphTimeRange(private.settings.dashboardUnselectedCharacters)) + :SetYValueFunction(private.GetGraphYValue) + :SetFormatFunctions(private.GraphFormatX, private.GraphFormatY) + :SetScript("OnZoomChanged", private.GraphOnZoomChanged) + :SetScript("OnHoverUpdate", private.GraphOnHoverUpdate) + ) + :AddChild(UIElements.New("Frame", "summary") + :SetLayout("HORIZONTAL") + :SetHeight(48) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Frame", "range") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 2, 2) + :AddChild(UIElements.New("Frame", "high") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["HIGH"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "low") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["LOW"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + ) + :AddChild(UIElements.New("Texture", "line1") + :SetWidth(1) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "daily") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 2, 2) + :AddChild(UIElements.New("Frame", "sales") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["DAILY SALES"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "purchases") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["DAILY PURCHASES"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + ) + :AddChild(UIElements.New("Texture", "line2") + :SetWidth(1) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "top") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 2, 2) + :AddChild(UIElements.New("Frame", "sale") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["TOP SALE"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "expense") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["TOP PURCHASE"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + ) + ) + :AddChild(UIElements.New("Frame", "details") + :SetLayout("VERTICAL") + :SetMargin(0, 0, 8, 0) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Text", "salesLabel") + :SetHeight(20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["SALES"]) + ) + :AddChild(UIElements.New("Frame", "salesTotal") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Total Gold Earned"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "amount") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "salesAvg") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Average Earned per Day"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "amount") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "salesTop") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Top Item"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "item") + :SetWidth("AUTO") + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Texture", "line1") + :SetHeight(1) + :SetMargin(-8, -8, 4, 4) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "expensesLabel") + :SetHeight(20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["EXPENSES"]) + ) + :AddChild(UIElements.New("Frame", "expensesTotal") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Total Gold Spent"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "amount") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "expensesAvg") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Average Spent per Day"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "amount") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "expensesTop") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Top Item"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "item") + :SetWidth("AUTO") + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Texture", "line2") + :SetHeight(1) + :SetMargin(-8, -8, 4, 4) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "profitLabel") + :SetHeight(20) + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["PROFIT"]) + ) + :AddChild(UIElements.New("Frame", "profitTotal") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Total Profit"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "amount") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "profitAvg") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Average Profit per Day"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "amount") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + ) + ) + :AddChild(UIElements.New("Frame", "profitTop") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 4, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Top Item"]..":") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "item") + :SetWidth("AUTO") + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + ) + ) + ) + ) + frame:GetElement("content.goldHeader.timeBtns.resetZoom"):Hide() + frame:GetElement("content.goldHeader.hoverTime"):Hide() + + local newsContent = frame:GetElement("news.content") + local newsEntries = TSM.GetAppNews() + if newsEntries then + for i, info in ipairs(newsEntries) do + newsContent:AddChild(UIElements.New("Frame", "news"..i) + :SetLayout("VERTICAL") + :SetPadding(0, 0, i == 1 and 6 or 12, 0) + :AddChild(UIElements.New("Text", "date") + :SetHeight(20) + :SetFont("BODY_BODY3") + :SetText(date("%b %d, %Y", info.timestamp)) + ) + :AddChild(UIElements.New("Text", "title") + :SetHeight(20) + :SetFont("BODY_BODY2_BOLD") + :SetText(info.title) + ) + :AddChild(UIElements.New("Text", "content") + :SetHeight(80) + :SetPadding(0, 0, 4, 0) + :SetFont("BODY_BODY3") + :SetText(info.content) + ) + :AddChild(UIElements.New("Text", "readMore") + :SetHeight(20) + :SetPadding(0, 0, 4, 0) + :SetFont("BODY_BODY3") + :SetTextColor("INDICATOR") + :SetText(L["Read More"]) + ) + ) + :AddChildNoLayout(UIElements.New("Button", "btn") + :AddAnchor("TOPLEFT", "news"..i) + :AddAnchor("BOTTOMRIGHT", "news"..i) + :SetContext(info) + :SetScript("OnClick", private.ButtonOnClick) + ) + end + end + + return frame +end + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.ButtonOnClick(button) + local info = button:GetContext() + Analytics.Action("NEWS_READ_MORE", info.title) + button:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(600, 450) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG") + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Text", "title") + :SetHeight(44) + :SetMargin(16, 16, 16, 8) + :SetFont("BODY_BODY1_BOLD") + :SetJustifyH("CENTER") + :SetText(info.title) + ) + :AddChild(UIElements.New("Input", "linkInput") + :SetHeight(26) + :SetMargin(16, 16, 0, 16) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValidateFunc(private.LinkValidateFunc) + :SetContext(info.link) + :SetValue(info.link) + ) + :AddChild(UIElements.New("Text", "content") + :SetMargin(16, 16, 0, 16) + :SetFont("BODY_BODY3") + :SetJustifyV("TOP") + :SetText(info.content) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetHeight(26) + :SetMargin(16, 16, 0, 16) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("ActionButton", "confirmBtn") + :SetWidth(126) + :SetText(CLOSE) + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + ) +end + +function private.LinkValidateFunc(input, value) + return value == input:GetContext() +end + +function private.DialogCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GraphFormatX(timestamp, suggestedStep) + if suggestedStep > SECONDS_PER_DAY * 14 then + return date("%b '%y", timestamp) + elseif suggestedStep > SECONDS_PER_DAY * 2 then + return date("%b %d", timestamp) + elseif suggestedStep > SECONDS_PER_DAY / 6 then + return date("%a", timestamp) + else + if GetCVar("timeMgrUseMilitaryTime") == "1" then + return date("%H:%M", timestamp) + else + return strtrim(date("%I %p", timestamp), "0") + end + end +end + +function private.GraphFormatY(value, suggestedStep, isTooltip) + if isTooltip then + return Money.ToString(value, nil, "OPT_TRIM") + end + if TSM.IsWowClassic() and value < COPPER_PER_GOLD * 1000 then + -- "###g" + return floor(value / COPPER_PER_GOLD)..Money.GetGoldText() + elseif TSM.IsWowClassic() and value < COPPER_PER_GOLD * 1000 * 10 then + -- "#.##Kg" + return format("%.2f", value / (COPPER_PER_GOLD * 1000)).."k"..Money.GetGoldText() + elseif value < COPPER_PER_GOLD * 1000 * 1000 then + -- "###Kg" + return floor(value / (COPPER_PER_GOLD * 1000)).."k"..Money.GetGoldText() + elseif value < COPPER_PER_GOLD * 1000 * 1000 * 10 then + -- "#.##Mg" + return format("%.2f", value / (COPPER_PER_GOLD * 1000 * 1000)).."M"..Money.GetGoldText() + else + -- "###Mg" + return floor(value / (COPPER_PER_GOLD * 1000 * 1000)).."M"..Money.GetGoldText() + end +end + +function private.GetGraphYValue(xValue) + return TSM.Accounting.GoldTracker.GetGoldAtTime(xValue, private.settings.dashboardUnselectedCharacters) +end + +function private.ContentOnUpdate(contentFrame) + contentFrame:SetScript("OnUpdate", nil) + private.UpdateTimeButtons(contentFrame:GetElement("goldHeader.timeBtns")) + private.UpdateGraph(contentFrame) +end + +function private.DropdownOnSelectionChanged(dropdown) + for _, key in ipairs(private.characterGuilds) do + private.settings.dashboardUnselectedCharacters[key] = not dropdown:ItemIsSelectedByKey(key) or nil + end + private.UpdateGraph(dropdown:GetParentElement():GetParentElement()) +end + +function private.TimeBtnOnClick(button) + local timeRange = button:GetContext() + assert(timeRange) + private.selectedTimeRange = timeRange + private.settings.dashboardTimeRange = timeRange + private.UpdateTimeButtons(button:GetParentElement()) + private.UpdateGraph(button:GetParentElement():GetParentElement():GetParentElement()) +end + +function private.UpdateGraph(contentFrame) + -- update the graph + local minTime, maxTime = TSM.Accounting.GoldTracker.GetGraphTimeRange(private.settings.dashboardUnselectedCharacters) + local goldGraph = contentFrame:GetElement("goldGraph") + local zoomStart, zoomEnd = goldGraph:GetZoom() + if private.selectedTimeRange == TIME_RANGE_LOOKUP["all"] then + zoomStart = minTime + zoomEnd = maxTime + elseif private.selectedTimeRange then + zoomStart = max(time() - private.selectedTimeRange, minTime) + zoomEnd = time() + end + goldGraph:SetXRange(minTime, maxTime) + :SetZoom(zoomStart, zoomEnd) + :Draw() + private.PopulateDetails(contentFrame) +end + +function private.GraphOnZoomChanged(graph) + private.selectedTimeRange = nil + private.settings.dashboardTimeRange = -1 + private.UpdateTimeButtons(graph:GetElement("__parent.goldHeader.timeBtns")) + private.PopulateDetails(graph:GetElement("__parent")) +end + +function private.GraphOnHoverUpdate(graph, hoverTime) + local goldHeader = graph:GetElement("__parent.goldHeader") + if hoverTime then + local timeStr = nil + if GetCVar("timeMgrUseMilitaryTime") == "1" then + timeStr = date("%H:%M %b %d, %Y", hoverTime) + else + timeStr = gsub(date("%I:%M %p %b %d, %Y", hoverTime), "^0", "") + end + goldHeader:GetElement("timeBtns"):Hide() + goldHeader:GetElement("hoverTime") + :SetText(timeStr) + :Show() + else + goldHeader:GetElement("timeBtns"):Show() + goldHeader:GetElement("hoverTime"):Hide() + private.UpdateTimeButtons(goldHeader:GetElement("timeBtns")) + end + goldHeader:Draw() +end + +function private.UpdateTimeButtons(frame) + frame:ShowAllChildren() + local resetButton = frame:GetElement("resetZoom") + if private.selectedTimeRange then + for _, button in frame:LayoutChildrenIterator() do + if button ~= resetButton then + button:SetTextColor(private.selectedTimeRange == button:GetContext() and "TEXT" or "ACTIVE_BG_ALT") + end + end + resetButton:Hide() + else + for _, button in frame:LayoutChildrenIterator() do + button:Hide() + end + resetButton:Show() + end + frame:GetParentElement():Draw() +end + +function private.GraphXStepFunc(prevValue, suggestedStep) + local year, day, month, hour, min, sec = strsplit(",", date("%Y,%d,%m,%H,%M,%S", prevValue)) + private.tempTimeTable.year = tonumber(year) + private.tempTimeTable.day = tonumber(day) + private.tempTimeTable.month = tonumber(month) + private.tempTimeTable.hour = tonumber(hour) + private.tempTimeTable.min = tonumber(min) + private.tempTimeTable.sec = tonumber(sec) + if suggestedStep > SECONDS_PER_DAY * 14 then + private.tempTimeTable.month = private.tempTimeTable.month + 1 + private.tempTimeTable.day = 1 + private.tempTimeTable.hour = 0 + private.tempTimeTable.min = 0 + private.tempTimeTable.sec = 0 + elseif suggestedStep > SECONDS_PER_DAY / 6 then + private.tempTimeTable.day = private.tempTimeTable.day + 1 + if private.tempTimeTable.hour == 23 then + -- add an extra hour to avoid DST issues + private.tempTimeTable.hour = 1 + else + private.tempTimeTable.hour = 0 + end + private.tempTimeTable.min = 0 + private.tempTimeTable.sec = 0 + else + private.tempTimeTable.hour = private.tempTimeTable.hour + 1 + private.tempTimeTable.min = 0 + private.tempTimeTable.sec = 0 + end + local newValue = time(private.tempTimeTable) + assert(newValue > prevValue) + return newValue +end + +function private.GraphYStepFunc(mode, ...) + if mode == "RANGE" then + local yMin, yMax, maxNumSteps = ... + -- find the smallest 10^X step size which still looks good + local minStep = max((yMax - yMin) / maxNumSteps / 10, yMax / 200) + local stepSize = MIN_GRAPH_STEP_SIZE + while stepSize < minStep do + stepSize = stepSize * 10 + end + yMin = Math.Floor(yMin, stepSize) + yMax = Math.Ceil(yMax + stepSize / 3, stepSize) + if yMin == yMax then + yMax = yMax + stepSize + end + return yMin, yMax + elseif mode == "NEXT" then + local prevValue, yMax = ... + local stepSize = MIN_GRAPH_STEP_SIZE + while stepSize < yMax / 1000 do + stepSize = stepSize * 10 + end + return Math.Floor(prevValue, stepSize) + stepSize + else + error("Invalid mode") + end +end + +function private.PopulateDetails(contentFrame) + local goldGraph = contentFrame:GetElement("goldGraph") + local unselectedCharacters = next(private.settings.dashboardUnselectedCharacters) and private.settings.dashboardUnselectedCharacters or nil + local timeFilterStart, timeFilterEnd, numDays = nil, nil, nil + if private.selectedTimeRange and private.selectedTimeRange ~= -1 then + timeFilterStart = time() - private.selectedTimeRange + timeFilterEnd = time() + numDays = ceil(private.selectedTimeRange / SECONDS_PER_DAY) + elseif not private.selectedTimeRange then + timeFilterStart, timeFilterEnd = goldGraph:GetZoom() + numDays = ceil((timeFilterEnd - timeFilterStart) / SECONDS_PER_DAY) + else + local timeStart, timeEnd = goldGraph:GetXRange() + numDays = ceil((timeEnd - timeStart) / SECONDS_PER_DAY) + end + numDays = max(numDays, 1) + + local saleTotal, salePerDay, saleTopItem, saleTopValue, saleTotalQuantity = 0, nil, nil, 0, 0 + local buyTotal, buyPerDay, buyTopItem, buyTopValue, buyTotalQuantity = 0, nil, nil, 0, 0 + local profitTopItem = nil + local query = TSM.Accounting.GetSummaryQuery(timeFilterStart, timeFilterEnd, unselectedCharacters) + local saleNumDays, buyNumDays = 1, 1 + local saleItemTotals = TempTable.Acquire() + local buyItemTotals = TempTable.Acquire() + local saleItemNum = TempTable.Acquire() + local buyItemNum = TempTable.Acquire() + for _, recordType, itemString, price, quantity, timestamp in query:Iterator() do + if recordType == "sale" then + local daysAgo = floor((time() - timestamp) / (24 * 60 * 60)) + saleNumDays = max(saleNumDays, daysAgo) + saleItemTotals[itemString] = (saleItemTotals[itemString] or 0) + price * quantity + saleTopValue = max(saleTopValue, price) + saleTotalQuantity = saleTotalQuantity + quantity + saleItemNum[itemString] = (saleItemNum[itemString] or 0) + quantity + elseif recordType == "buy" then + local daysAgo = floor((time() - timestamp) / (24 * 60 * 60)) + buyNumDays = max(buyNumDays, daysAgo) + buyItemTotals[itemString] = (buyItemTotals[itemString] or 0) + price * quantity + buyTopValue = max(buyTopValue, price) + buyTotalQuantity = buyTotalQuantity + quantity + buyItemNum[itemString] = (buyItemNum[itemString] or 0) + quantity + else + error("Invalid recordType: "..tostring(recordType)) + end + end + query:Release() + + local topSaleItemTotal = 0 + for itemString, itemTotal in pairs(saleItemTotals) do + saleTotal = saleTotal + itemTotal + if itemTotal > topSaleItemTotal then + saleTopItem = itemString + topSaleItemTotal = itemTotal + end + end + salePerDay = Math.Round(saleTotal / saleNumDays) + + local topBuyItemTotal = 0 + for itemString, itemTotal in pairs(buyItemTotals) do + buyTotal = buyTotal + itemTotal + if itemTotal > topBuyItemTotal then + buyTopItem = itemString + topBuyItemTotal = itemTotal + end + end + buyPerDay = Math.Round(buyTotal / buyNumDays) + + local topItemProfit = 0 + for itemString in pairs(saleItemNum) do + if buyItemNum[itemString] then + local profit = (saleItemTotals[itemString] / saleItemNum[itemString] - buyItemTotals[itemString] / buyItemNum[itemString]) * min(saleItemNum[itemString], buyItemNum[itemString]) + if profit > topItemProfit then + profitTopItem = itemString + topItemProfit = profit + end + end + end + + TempTable.Release(saleItemTotals) + TempTable.Release(buyItemTotals) + TempTable.Release(saleItemNum) + TempTable.Release(buyItemNum) + + local profitTotal = saleTotal - buyTotal + local profitPerDay = salePerDay - buyPerDay + + local rangeLow, rangeHigh = goldGraph:GetYRange() + contentFrame:GetElement("summary.range.low.value") + :SetText(Money.ToString(rangeLow, nil, "OPT_TRIM") or "-") + contentFrame:GetElement("summary.range.high.value") + :SetText(Money.ToString(rangeHigh, nil, "OPT_TRIM") or "-") + + contentFrame:GetElement("summary.daily.sales.value") + :SetText(saleTotalQuantity and Math.Round(saleTotalQuantity / numDays) or "-") + contentFrame:GetElement("summary.daily.purchases.value") + :SetText(buyTotalQuantity and Math.Round(buyTotalQuantity / numDays) or "-") + + contentFrame:GetElement("summary.top.sale.value") + :SetText(Money.ToString(Math.Round(saleTopValue, TSM.IsWowClassic() and 1 or COPPER_PER_GOLD), nil, "OPT_TRIM") or "-") + contentFrame:GetElement("summary.top.expense.value") + :SetText(Money.ToString(Math.Round(buyTopValue, TSM.IsWowClassic() and 1 or COPPER_PER_GOLD), nil, "OPT_TRIM") or "-") + + contentFrame:GetElement("details.salesTotal.amount") + :SetText(Money.ToString(saleTotal)) + contentFrame:GetElement("details.salesAvg.amount") + :SetText(Money.ToString(salePerDay)) + contentFrame:GetElement("details.salesTop.item") + :SetText(TSM.UI.GetColoredItemName(saleTopItem) or "-") + :SetTooltip(saleTopItem) + + contentFrame:GetElement("details.expensesTotal.amount") + :SetText(Money.ToString(buyTotal)) + contentFrame:GetElement("details.expensesAvg.amount") + :SetText(Money.ToString(buyPerDay)) + contentFrame:GetElement("details.expensesTop.item") + :SetText(TSM.UI.GetColoredItemName(buyTopItem) or "-") + :SetTooltip(buyTopItem) + + contentFrame:GetElement("details.profitTotal.amount") + :SetText(Money.ToString(profitTotal, profitTotal < 0 and Theme.GetFeedbackColor("RED"):GetTextColorPrefix() or nil)) + contentFrame:GetElement("details.profitAvg.amount") + :SetText(Money.ToString(profitPerDay, profitPerDay < 0 and Theme.GetFeedbackColor("RED"):GetTextColorPrefix() or nil)) + contentFrame:GetElement("details.profitTop.item") + :SetText(TSM.UI.GetColoredItemName(profitTopItem) or "-") + :SetTooltip(profitTopItem) + + contentFrame:Draw() +end diff --git a/Core/UI/MainUI/Groups.lua b/Core/UI/MainUI/Groups.lua new file mode 100644 index 0000000..aaac00c --- /dev/null +++ b/Core/UI/MainUI/Groups.lua @@ -0,0 +1,1870 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM.MainUI:NewPackage("Groups") +local L = TSM.Include("Locale").GetTable() +local Analytics = TSM.Include("Util.Analytics") +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local Theme = TSM.Include("Util.Theme") +local ItemString = TSM.Include("Util.ItemString") +local Database = TSM.Include("Util.Database") +local ItemInfo = TSM.Include("Service.ItemInfo") +local ItemFilter = TSM.Include("Service.ItemFilter") +local CustomPrice = TSM.Include("Service.CustomPrice") +local BagTracking = TSM.Include("Service.BagTracking") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + currentGroupPath = TSM.CONST.ROOT_GROUP_PATH, + moveGroupPath = nil, + itemFilter = ItemFilter.New(), + groupedItemList = {}, + ungroupedItemList = { {}, {} }, + moduleCollapsed = {}, + results = { {}, {} }, + resultsRaw = {}, + filterText = "", + groupSearch = "", + itemSearch = "", + frame = nil, + operationQuery = nil, + importExportGroupDB = nil, + exportSubGroups = {}, + importGroupTreeContext = {}, +} +local DRAG_SCROLL_SPEED_FACTOR = 12 +local OPERATION_LABELS = { + Auctioning = L["Auctioning operations control posting to and canceling from the AH."], + Crafting = L["Crafting operations control how queuing profession crafts."], + Mailing = L["Mailing operations control mailing to other characters."], + Shopping = L["Shopping operations control buyout from the AH."], + Sniper = L["Sniper operations control sniping from the AH."], + Vendoring = L["Vendoring operations control selling to and buying from a vendor."], + Warehousing = L["Warehousing operations control moving in and out of the bank."], +} +local DEFAULT_IMPORT_GROUP_TREE_CONTEXT = { unselected = {}, collapsed = {} } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "coreOptions", "groupPriceSource") + :AddKey("global", "mainUIContext", "groupsDividedContainer") + :AddKey("char", "mainUIContext", "groupsManagementGroupTree") + :AddKey("profile", "userData", "groups") + TSM.MainUI.RegisterTopLevelPage(L["Groups"], private.GetGroupsFrame) + private.itemFilter:ParseStr("") + private.importExportGroupDB = Database.NewSchema("IMPORT_EXPORT_GROUPS") + :AddStringField("groupPath") + :AddStringField("orderStr") + :AddIndex("groupPath") + :Commit() +end + +function Groups.ShowGroupSettings(baseFrame, groupPath) + baseFrame:SetSelectedNavButton(L["Groups"], true) + baseFrame:GetElement("content.groups.groupSelection.groupTree"):SetSelectedGroup(groupPath, true) +end + + + +-- ============================================================================ +-- Groups UI +-- ============================================================================ + +function private.GetGroupsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "groups") + private.currentGroupPath = TSM.CONST.ROOT_GROUP_PATH + private.moveGroupPath = nil + local frame = UIElements.New("DividedContainer", "groups") + :SetSettingsContext(private.settings, "groupsDividedContainer") + :SetMinWidth(250, 250) + :SetLeftChild(UIElements.New("Frame", "groupSelection") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "search") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "input") + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert(true) + :SetValue(private.groupSearch) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 0, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 0, 0, 0) + :SetBackground("iconPack.18x18/Import") + :SetScript("OnClick", private.ImportGroupBtnOnClick) + :SetTooltip(L["Import group"]) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("ManagementGroupTree", "groupTree") + :SetSettingsContext(private.settings, "groupsManagementGroupTree") + :SetQuery(TSM.Groups.CreateQuery()) + :SetSelectedGroup(private.currentGroupPath) + :SetSearchString(private.groupSearch) + :SetScript("OnGroupSelected", private.GroupTreeOnGroupSelected) + :SetScript("OnNewGroup", private.GroupTreeOnNewGroup) + ) + ) + :SetRightChild(UIElements.New("ViewContainer", "view") + :SetNavCallback(private.GetViewContainerContent) + :AddPath("content", true) + :AddPath("search") + ) + frame:GetElement("view.content.header.title.renameBtn"):Hide() + private.frame = frame + return frame +end + +function private.GetViewContainerContent(viewContainer, path) + if path == "content" then + wipe(private.results[1]) + wipe(private.results[2]) + return private.GetContentFrame() + elseif path == "search" then + return private.GetSearchFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetContentFrame() + local frame = UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("VERTICAL") + :SetSize("EXPAND", 40) + :SetPadding(8) + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetWidth("EXPAND") + :AddChild(UIElements.New("Texture", "icon") + :SetMargin(0, 8, 0, 0) + :SetTextureAndSize(TSM.UI.TexturePacks.GetColoredKey("iconPack.18x18/Folder", "TEXT")) + ) + :AddChild(UIElements.New("EditableText", "text") + :SetWidth("AUTO") + :AllowItemInsert(true) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Base Group"]) + :SetScript("OnValueChanged", private.GroupNameChanged) + :SetScript("OnEditingChanged", private.NameOnEditingChanged) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "renameBtn") + :SetMargin(8, 0, 0, 0) + :SetBackgroundAndSize("iconPack.18x18/Edit") + :SetScript("OnClick", private.RenameBtnOnClick) + :SetTooltip(L["Rename this group"]) + ) + :AddChild(UIElements.New("Button", "exportBtn") + :SetMargin(8, 4, 0, 0) + :SetBackgroundAndSize("iconPack.18x18/Export") + :SetScript("OnClick", private.ExportBtnOnClick) + :SetTooltip(L["Export this group"]) + ) + ) + ) + :AddChild(UIElements.New("TabGroup", "buttons") + :SetMargin(0, 0, 6, 0) + :SetNavCallback(private.GetGroupsPage) + :AddPath(L["Information"], true) + :AddPath(L["Operations"]) + ) + frame:GetElement("header.title.renameBtn"):Hide() + frame:GetElement("header.title.exportBtn"):Hide() + return frame +end + +function private.GetSearchFrame() + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :AddChild(UIElements.New("ActionButton", "button") + :SetWidth(64) + :SetMargin(0, 8, 0, 0) + :SetIcon("iconPack.14x14/Chevron/Right@180") + :SetText(BACK) + :SetScript("OnClick", private.SearchBackButtonOnClick) + ) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :AllowItemInsert() + :SetClearButtonEnabled(true) + :SetValidateFunc(private.ValidateBaseSearchValue) + :SetValue(private.itemSearch) + :SetScript("OnValueChanged", private.BaseSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllResultsOnClick) + :SetTooltip(L["Select / Deselect All Results"]) + ) + ) + :AddChild(UIElements.New("Frame", "header2") + :SetLayout("HORIZONTAL") + :SetHeight(30) + :SetPadding(8, 8, 5, 5) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY3") + :SetText(format(L["%d Results"], 0)) + ) + :AddChild(UIElements.New("Checkbox", "ignoreItemVariations") + :SetSize("AUTO", 24) + :SetFont("BODY_BODY1") + :SetCheckboxPosition("LEFT") + :SetText(L["Ignore variations"]) + :SetSettingInfo(private.settings.groups[private.currentGroupPath], "ignoreItemVariations") + :SetScript("OnValueChanged", private.IgnoreSearchRandomOnValueChanged) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ItemList", "itemList") + :SetScript("OnSelectionChanged", private.ItemListOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("GroupSelector", "group") + :SetMargin(0, 8, 0, 0) + :SetHintText(L["Select Group"]) + :SetSingleSelection(true) + :AddCreateNew() + :SetSelection(TSM.Groups.Exists(private.moveGroupPath) and private.moveGroupPath) + :SetScript("OnSelectionChanged", private.BaseGroupOnSelectionChanged) + ) + :AddChild(UIElements.New("ActionButton", "move") + :SetWidth(124) + :SetDisabled(true) + :SetText(L["Move Item"]) + :SetScript("OnClick", private.BaseMoveItemOnClick) + ) + ) +end + +function private.GetGroupsPage(_, button) + private.itemSearch = "" + private.itemFilter:ParseStr("") + if button == L["Information"] then + TSM.UI.AnalyticsRecordPathChange("main", "groups", "information") + return UIElements.New("Frame", "items") + :SetLayout("VERTICAL") + :SetPadding(0, 0, 9, 0) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "text") + :SetHeight(42) + :SetMargin(8, 8, 0, 8) + :SetFont("BODY_BODY3") + :SetText(L["The Base Group contains all ungrouped items in the game. Use the search and filter controls to find items to add to other groups."]) + ) + :AddChild(UIElements.New("Frame", "filter") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("Input", "filter") + :SetMargin(0, 12, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :AllowItemInsert() + :SetClearButtonEnabled(true) + :SetValidateFunc(private.ValidateBaseSearchValue) + :SetHintText(L["Search items"]) + :SetScript("OnValueChanged", private.InformationSearchOnValueChanged) + ) + :AddChild(UIElements.New("Checkbox", "ignoreItemVariations") + :SetSize("AUTO", 24) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY1") + :SetCheckboxPosition("LEFT") + :SetText(L["Ignore variations"]) + :SetSettingInfo(private.settings.groups[private.currentGroupPath], "ignoreItemVariations") + :SetScript("OnValueChanged", private.IgnoreBaseRandomOnValueChanged) + ) + ) + :AddChild(UIElements.New("ItemList", "itemList") + :SetItems(private.GetUngroupedBagItemList()) + :SetFilterFunction(private.ItemListItemIsFiltered) + :SetScript("OnSelectionChanged", private.ItemListOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "bottom") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("GroupSelector", "group") + :SetMargin(0, 8, 0, 0) + :SetHintText(L["Select Group"]) + :SetSingleSelection(true) + :AddCreateNew() + :SetSelection(TSM.Groups.Exists(private.moveGroupPath) and private.moveGroupPath) + :SetScript("OnSelectionChanged", private.GroupOnSelectionChanged) + ) + :AddChild(UIElements.New("ActionButton", "move") + :SetWidth(124) + :SetDisabled(true) + :SetText(L["Move Item"]) + :SetScript("OnClick", private.MoveItemOnClick) + ) + ) + elseif button == L["Items"] then + TSM.UI.AnalyticsRecordPathChange("main", "groups", "items") + assert(private.currentGroupPath ~= TSM.CONST.ROOT_GROUP_PATH) + local parentGroup = TSM.Groups.Path.GetParent(private.currentGroupPath) + parentGroup = parentGroup ~= TSM.CONST.ROOT_GROUP_PATH and parentGroup or nil + return UIElements.New("Frame", "items") + :SetLayout("VERTICAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "filter") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Input", "filter") + :SetMargin(0, 12, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetValidateFunc(private.ItemFilterValidateFunc) + :SetHintText(L["Search items in group"]) + :SetScript("OnValueChanged", private.ItemFilterOnValueChanged) + ) + :AddChild(UIElements.New("Checkbox", "ignoreItemVariations") + :SetSize("AUTO", 24) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY1") + :SetCheckboxPosition("LEFT") + :SetText(L["Ignore variations"]) + :SetSettingInfo(private.settings.groups[private.currentGroupPath], "ignoreItemVariations") + :SetScript("OnValueChanged", private.IgnoreRandomOnValueChanged) + ) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Frame", "ungrouped") + :SetLayout("VERTICAL") + :SetMargin(0, 8, 0, 0) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Text", "text") + :SetMargin(8, 0, 4, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Ungrouped Items"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(20, 20) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.ItemListSelectAllOnClick) + :SetTooltip(L["Select / Deselect All Items"]) + ) + ) + -- draw a line along the bottom to hide the rounded corners at the bottom of the header frame + :AddChildNoLayout(UIElements.New("Texture", "line") + :AddAnchor("BOTTOMLEFT", "header") + :AddAnchor("BOTTOMRIGHT", "header") + :SetHeight(4) + :SetTexture("FRAME_BG") + ) + :AddChild(UIElements.New("ItemList", "itemList") + :SetMargin(0, 0, 0, 7) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetItems(private.GetUngroupedItemList()) + :SetFilterFunction(private.ItemListItemIsFiltered) + :SetScript("OnSelectionChanged", private.UngroupedItemsOnSelectionChanged) + ) + ) + :AddChild(UIElements.New("ActionButton", "btn") + :SetHeight(26) + :SetMargin(0, 0, 10, 0) + :SetText(L["Add"]) + :SetDisabled(true) + :SetScript("OnClick", private.AddItemsOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "grouped") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Text", "text") + :SetMargin(8, 0, 4, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Grouped Items"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(20, 20) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.ItemListSelectAllOnClick) + :SetTooltip(L["Select / Deselect All Items"]) + ) + ) + -- draw a line along the bottom to hide the rounded corners at the bottom of the header frame + :AddChildNoLayout(UIElements.New("Texture", "line") + :AddAnchor("BOTTOMLEFT", "header") + :AddAnchor("BOTTOMRIGHT", "header") + :SetHeight(4) + :SetTexture("FRAME_BG") + ) + :AddChild(UIElements.New("ItemList", "itemList") + :SetMargin(0, 0, 0, 7) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetItems(private.GetGroupedItemList()) + :SetFilterFunction(private.ItemListItemIsFiltered) + :SetScript("OnSelectionChanged", private.GroupedItemsOnSelectionChanged) + ) + ) + :AddChildIf(parentGroup, UIElements.New("ActionButton", "btn") + :SetHeight(26) + :SetMargin(0, 0, 10, 0) + :SetText(L["Remove"]) + :SetDisabled(true) + :SetModifierText(L["Move to Parent Group"], "SHIFT") + :SetScript("OnClick", private.RemoveItemsOnClick) + :SetTooltip(L["Hold shift to move the items to the parent group instead of removing them."]) + ) + :AddChildIf(not parentGroup, UIElements.New("ActionButton", "btn") + :SetHeight(26) + :SetMargin(0, 0, 10, 0) + :SetText(L["Remove"]) + :SetDisabled(true) + :SetScript("OnClick", private.RemoveItemsOnClick) + ) + ) + ) + elseif button == L["Operations"] then + TSM.UI.AnalyticsRecordPathChange("main", "groups", "operations") + return UIElements.New("ScrollFrame", "operations") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(private.GetModuleOperationFrame("Auctioning")) + :AddChild(private.GetModuleOperationFrame("Crafting")) + :AddChild(private.GetModuleOperationFrame("Mailing")) + :AddChild(private.GetModuleOperationFrame("Shopping")) + :AddChild(private.GetModuleOperationFrame("Sniper")) + :AddChild(private.GetModuleOperationFrame("Vendoring")) + :AddChild(private.GetModuleOperationFrame("Warehousing")) + else + error("Unknown button!") + end +end + +function private.GetModuleOperationFrame(moduleName) + local override = TSM.Groups.HasOperationOverride(private.currentGroupPath, moduleName) or private.currentGroupPath == TSM.CONST.ROOT_GROUP_PATH + local numGroupOperations = 0 + for _ in TSM.Groups.OperationIterator(private.currentGroupPath, moduleName) do + numGroupOperations = numGroupOperations + 1 + end + + return UIElements.New("CollapsibleContainer", "operationInfo"..moduleName) + :SetLayout("VERTICAL") + :SetMargin(0, 0, 0, moduleName == "Warehousing" and 0 or 8) + :SetContext(moduleName) + :SetContextTable(private.moduleCollapsed, moduleName) + :SetHeadingText(format(L["%s Operations"], moduleName)) + :AddChild(UIElements.New("Text", "moduleDesc") + :SetSize("AUTO", 20) + :SetFont("BODY_BODY3") + :SetText(OPERATION_LABELS[moduleName]) + ) + :AddChildIf(private.currentGroupPath ~= TSM.CONST.ROOT_GROUP_PATH, UIElements.New("Checkbox", "overrideCheckbox") + :SetHeight(20) + :SetMargin(0, 0, 6, 0) + :SetFont("BODY_BODY2") + :SetCheckboxPosition("LEFT") + :SetText(L["Override Parent Operations"]) + :SetChecked(override) + :SetScript("OnValueChanged", private.OverrideToggleOnValueChanged) + ) + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetContext(moduleName) + :SetHeight(36 * numGroupOperations) + :AddChildrenWithFunction(private.AddOperationRows) + ) + :AddChildIf(override and numGroupOperations < TSM.Operations.GetMaxNumber(moduleName), UIElements.New("Frame", "addMore") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "button") + :SetWidth("AUTO") + :SetMargin(0, 2, 0, 0) + :SetFont("BODY_BODY2") + :SetTextColor("INDICATOR") + :SetText(L["Add More Operations"]) + :SetContext(moduleName) + :SetScript("OnClick", private.AddOperationButtonOnClick) + ) + ) +end + +function private.AddOperationRows(container) + local moduleName = container:GetContext() + local override = TSM.Groups.HasOperationOverride(private.currentGroupPath, moduleName) or private.currentGroupPath == TSM.CONST.ROOT_GROUP_PATH + for i, operationName in TSM.Groups.OperationIterator(private.currentGroupPath, moduleName) do + container:AddChild(UIElements.New("Frame", "operation"..i) + :SetLayout("HORIZONTAL") + :SetHeight(28) + :SetMargin(0, 0, 8, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "operationNum") + :SetWidth(20) + :SetMargin(2, 8, 0, 0) + :SetFont("BODY_BODY1_BOLD") + :SetText(i) + ) + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetPadding(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG", true) + :AddChildNoLayout(UIElements.New("Button", "dragFrame") + :SetSize(15, 24) + :AddAnchor("LEFT", 1, 0) + :SetContext(i) + :SetScript("OnMouseDown", override and private.DragButtonOnMouseDown or nil) + :SetScript("OnMouseUp", override and private.DragButtonOnMouseUp or nil) + ) + :AddChild(UIElements.New("Frame", "handleBackground") + :SetSize(16, 28) + :SetBackgroundColor("FRAME_BG", true) + -- draw a line along the right to hide the rounded corners at the bottom of the header frame + :AddChildNoLayout(UIElements.New("Texture", "line") + :AddAnchor("TOPRIGHT") + :AddAnchor("BOTTOMRIGHT") + :SetWidth(4) + :SetTexture("FRAME_BG") + ) + :AddChildNoLayout(UIElements.New("Texture", "texture") + :AddAnchor("LEFT") + :SetTextureAndSize("iconPack.18x18/Grip") + ) + ) + :AddChild(UIElements.New("Text", "name") + :SetWidth("AUTO") + :SetMargin(8, 0, 0, 0) + :SetFont("BODY_BODY2") + :SetText(operationName) + ) + :AddChild(UIElements.New("Frame", "spacer")) + :AddChild(UIElements.New("Button", "configBtn") + :SetBackgroundAndSize("iconPack.18x18/Popout") + :SetContext(operationName) + :SetScript("OnClick", private.ConfigOperationOnClick) + ) + :AddChildIf(override, UIElements.New("Button", "removeBtn") + :SetMargin(4, 0, 0, 0) + :SetBackgroundAndSize("iconPack.18x18/Close/Default") + :SetContext(i) + :SetScript("OnClick", private.RemoveOperationOnClick) + ) + ) + ) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.DragButtonOnMouseDown(button) + local frame = button:GetParentElement() + frame:SetRelativeLevel(20) + frame:StartMoving() + button:SetScript("OnUpdate", private.DragFrameOnUpdate) + button:GetElement("__parent.name") + :SetTextColor("INDICATOR") + frame:SetWidth(250) + :Draw() +end + +function private.DragButtonOnMouseUp(button) + button:SetScript("OnUpdate", nil) + local frame = button:GetParentElement() + frame:StopMovingOrSizing() + frame:SetRelativeLevel(1) + local container = frame:GetParentElement():GetParentElement() + + local moduleName = container:GetParentElement():GetParentElement():GetContext() + local index = button:GetContext() + -- TODO: refactor + for k, v in container:LayoutChildrenIterator() do + if k ~= index and v:IsMouseOver() then + TSM.Groups.SwapOperation(private.currentGroupPath, moduleName, index, k) + end + end + for i, operationName in TSM.Groups.OperationIterator(private.currentGroupPath, moduleName) do + container:GetElement("operation"..i..".title.name"):SetText(operationName) + end + + frame:SetWidth(nil) + frame:GetElement("name") + :SetTextColor("TEXT") + frame:GetParentElement():GetParentElement() + :Draw() +end + +function private.DragFrameOnUpdate(frame) + local scrollFrame = frame:GetElement("__parent.__parent.__parent.__parent.__parent.__parent") + local uiScale = UIParent:GetEffectiveScale() + local x, y = GetCursorPosition() + x = x / uiScale + y = y / uiScale + + -- TODO: refactor + local top = scrollFrame:_GetBaseFrame():GetTop() + local bottom = scrollFrame:_GetBaseFrame():GetBottom() + if y > top then + scrollFrame._scrollAmount = top - y + elseif y < bottom then + scrollFrame._scrollAmount = bottom - y + else + scrollFrame._scrollAmount = 0 + end + + scrollFrame._scrollbar:SetValue(scrollFrame._scrollbar:GetValue() + scrollFrame._scrollAmount / DRAG_SCROLL_SPEED_FACTOR) +end + +function private.GroupSearchOnValueChanged(input) + local groupsContentFrame = input:GetElement("__parent.__parent.__parent.view.content") + -- Copy search filter + local text = strlower(input:GetValue()) + + if private.groupSearch == text then + return + end + + private.groupSearch = text + local searchStr = String.Escape(private.groupSearch) + -- Check if the selection is being filtered out + if strmatch(strlower(private.currentGroupPath), searchStr) then + local titleFrame = groupsContentFrame:GetElement("header.title") + local buttonsFrame = groupsContentFrame:GetElement("buttons") + input:GetElement("__parent.__parent.groupTree"):SetSelectedGroup(private.currentGroupPath) + if private.currentGroupPath == TSM.CONST.ROOT_GROUP_PATH then + titleFrame:GetElement("text") + :SetTextColor("TEXT") + :SetText(L["Base Group"]) + else + local groupColor = Theme.GetGroupColor(select('#', strsplit(TSM.CONST.GROUP_SEP, private.currentGroupPath))) + titleFrame:GetElement("text") + :SetTextColor(groupColor) + :SetText(TSM.Groups.Path.GetName(private.currentGroupPath)) + end + titleFrame:GetElement("renameBtn"):Show() + titleFrame:GetElement("exportBtn"):Show() + buttonsFrame:Show() + buttonsFrame:Draw() + titleFrame:Draw() + else + local titleFrame = groupsContentFrame:GetElement("header.title") + local buttonsFrame = groupsContentFrame:GetElement("buttons") + input:GetElement("__parent.__parent.groupTree"):SetSelectedGroup(TSM.CONST.ROOT_GROUP_PATH) + titleFrame:GetElement("text") + :SetText(L["No group selected"]) + :SetEditing(false) + -- Hide the content + titleFrame:GetElement("renameBtn"):Hide() + titleFrame:GetElement("exportBtn"):Hide() + buttonsFrame:Hide() + buttonsFrame:Draw() + titleFrame:Draw() + end + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(private.groupSearch) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.ImportGroupBtnOnClick(button) + local dialogFrame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(658, 250) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(20, -4, -4, 12) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Import Groups & Operations"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.ImportExportCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Text", "desc") + :SetHeight(40) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY3") + :SetText(L["You can import groups by pasting an import string into the box below. Group import strings can be found at: https://tradeskillmaster.com/group-maker/all"]) + ) + :AddChild(UIElements.New("Text", "text") + :SetHeight(20) + :SetMargin(0, 0, 0, 8) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Import String"]) + ) + :AddChild(UIElements.New("MultiLineInput", "input") + :SetMargin(0, 0, 0, 12) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetFocused(true) + :SetPasteMode() + :SetScript("OnValueChanged", private.ImportInputOnValueChanged) + ) + :SetScript("OnHide", private.ImportOnHide) + button:GetBaseElement():ShowDialogFrame(dialogFrame) + dialogFrame:Draw() +end + +function private.GetImportSummaryDialog() + wipe(private.importGroupTreeContext) + local dialogFrame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(612, 437) + :SetPadding(8) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(20, -4, -4, 16) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Import Summary"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.ImportExportCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "nav") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Spacer", "spacer")) + -- TODO: add the other tabs (and OnClick handlers for these buttons) + :AddChild(UIElements.New("Button", "groups") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(format(L["%d Groups"], 0)) + ) + :AddChild(UIElements.New("Texture", "line") + :SetWidth(2) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Button", "operations") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(format(L["%d Operations"], 0)) + ) + :AddChild(UIElements.New("Texture", "line2") + :SetWidth(2) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Button", "items") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(format(L["%d Items"], 0)) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetPadding(2) + :SetMargin(0, 0, 0, 12) + :SetBackgroundColor("PRIMARY_BG") + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "input") + :AllowItemInsert(true) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.ImportFilterOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ImportExpandAllOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.ImportSelectAllOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetContextTable(private.importGroupTreeContext, DEFAULT_IMPORT_GROUP_TREE_CONTEXT) + :SetQuery(private.CreateImportExportDBQuery()) + :SetScript("OnGroupSelectionChanged", private.ImportGroupTreeOnGroupSelectionChanged) + ) + ) + :AddChild(UIElements.New("Frame", "items") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "moveCheckbox") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetCheckboxPosition("LEFT") + :SetChecked(true) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "operations") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "includeCheckbox") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetCheckboxPosition("LEFT") + :SetChecked(true) + :SetText(L["Include operations?"]) + :SetScript("OnValueChanged", private.ImportIncludeOperationsCheckboxOnValueChanged) + ) + :AddChild(UIElements.New("Checkbox", "replaceCheckbox") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetCheckboxPosition("LEFT") + :SetChecked(true) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("ActionButton", "btn") + :SetHeight(24) + :SetText(L["Import"]) + :SetScript("OnClick", private.ImportBtnOnClick) + ) + :SetScript("OnHide", private.ImportOnHide) + return dialogFrame +end + +function private.ImportInputOnValueChanged(input) + local baseFrame = input:GetBaseElement() + if not TSM.Groups.ImportExport.ProcessImport(input:GetValue()) then + baseFrame:HideDialog() + Log.PrintUser(L["The pasted value was not valid. Ensure you are pasting the entire import string."]) + return + end + + -- build the import group DB + private.importExportGroupDB:TruncateAndBulkInsertStart() + local importGroupName = TSM.Groups.ImportExport.GetPendingImportGroupName() + for groupPath in TSM.Groups.ImportExport.PendingImportGroupIterator() do + groupPath = groupPath == TSM.CONST.ROOT_GROUP_PATH and importGroupName or TSM.Groups.Path.Join(importGroupName, groupPath) + local orderStr = gsub(groupPath, TSM.CONST.GROUP_SEP, "\001") + orderStr = strlower(orderStr) + private.importExportGroupDB:BulkInsertNewRow(groupPath, orderStr) + end + private.importExportGroupDB:BulkInsertEnd() + + -- clear the OnHide handler so we don't reset the import context + input:GetParentElement():SetScript("OnHide", nil) + baseFrame:HideDialog() + local dialogFrame = private.GetImportSummaryDialog() + baseFrame:ShowDialogFrame(dialogFrame) + private.UpdateImportConfirmationDialog(dialogFrame) +end + +function private.UpdateImportConfirmationDialog(dialogFrame) + local numItems, numGroups, numExistingItems, numOperations, numExistingOperations, numExistingCustomSources = TSM.Groups.ImportExport.GetImportTotals() + dialogFrame:GetElement("nav.groups") + :SetText(format(L["%d Groups"], numGroups)) + dialogFrame:GetElement("nav.operations") + :SetText(format(L["%d Operations"], numOperations)) + dialogFrame:GetElement("nav.items") + :SetText(format(L["%d Items"], numItems)) + if numExistingItems > 0 then + dialogFrame:GetElement("items.moveCheckbox") + :SetText(format(L["Move %d already grouped items?"], numExistingItems)) + :SetChecked(true) + dialogFrame:GetElement("items"):Show() + else + dialogFrame:GetElement("items"):Hide() + end + if numOperations > 0 then + dialogFrame:GetElement("operations.includeCheckbox") + :SetChecked(true) + dialogFrame:GetElement("operations.replaceCheckbox") + :SetChecked(true) + if numExistingOperations > 0 then + if numExistingCustomSources > 0 then + dialogFrame:GetElement("operations.replaceCheckbox") + :SetText(format(L["Replace %d existing operations and %d existing custom sources?"], numExistingOperations, numExistingCustomSources)) + else + dialogFrame:GetElement("operations.replaceCheckbox") + :SetText(format(L["Replace %d existing operations?"], numExistingOperations)) + end + dialogFrame:GetElement("operations.replaceCheckbox"):Show() + else + dialogFrame:GetElement("operations.replaceCheckbox"):Hide() + end + dialogFrame:GetElement("operations"):Show() + else + dialogFrame:GetElement("operations"):Hide() + end + dialogFrame:Draw() +end + +function private.ImportFilterOnValueChanged(input) + input:GetElement("__parent.__parent.groupTree") + :SetSearchString(strlower(input:GetValue())) + :Draw() +end + +function private.ImportExpandAllOnClick(button) + button:GetElement("__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.ImportSelectAllOnClick(button) + local importGroupName = TSM.Groups.ImportExport.GetPendingImportGroupName() + local groupTree = button:GetElement("__parent.__parent.groupTree") + groupTree:SetGroupSelected(importGroupName, false) + groupTree:ToggleSelectAll() +end + +function private.ImportGroupTreeOnGroupSelectionChanged(groupTree) + local importGroupName = TSM.Groups.ImportExport.GetPendingImportGroupName() + groupTree:SetGroupSelected(importGroupName, true) + -- make sure the parent of any selected groups are also selected + for relativeGroupPath in TSM.Groups.ImportExport.PendingImportGroupIterator() do + local groupPath = relativeGroupPath == TSM.CONST.ROOT_GROUP_PATH and importGroupName or TSM.Groups.Path.Join(importGroupName, relativeGroupPath) + local isSelected = groupTree:IsGroupSelected(groupPath) + TSM.Groups.ImportExport.SetGroupFiltered(relativeGroupPath, not isSelected) + if isSelected then + local tempGroupPath = TSM.Groups.Path.Split(groupPath) + while tempGroupPath do + groupTree:SetGroupSelected(tempGroupPath, true) + tempGroupPath = TSM.Groups.Path.Split(tempGroupPath) + end + end + end + for relativeGroupPath in TSM.Groups.ImportExport.PendingImportGroupIterator() do + local groupPath = relativeGroupPath == TSM.CONST.ROOT_GROUP_PATH and importGroupName or TSM.Groups.Path.Join(importGroupName, relativeGroupPath) + local isSelected = groupTree:IsGroupSelected(groupPath) + TSM.Groups.ImportExport.SetGroupFiltered(relativeGroupPath, not isSelected) + end + private.UpdateImportConfirmationDialog(groupTree:GetParentElement():GetParentElement()) +end + +function private.ImportIncludeOperationsCheckboxOnValueChanged(checkbox) + if checkbox:IsChecked() then + checkbox:GetElement("__parent.replaceCheckbox"):Show() + else + checkbox:GetElement("__parent.replaceCheckbox"):Hide() + end +end + +function private.ImportBtnOnClick(button) + local moveExistingItems = button:GetElement("__parent.items.moveCheckbox"):IsChecked() + local includeOperations = button:GetElement("__parent.operations.includeCheckbox"):IsChecked() + local replaceOperations = button:GetElement("__parent.operations.replaceCheckbox"):IsChecked() + TSM.Groups.ImportExport.CommitImport(moveExistingItems, includeOperations, replaceOperations) + button:GetBaseElement():HideDialog() +end + +function private.ImportOnHide() + TSM.Groups.ImportExport.ClearImportContext() +end + +function private.GroupTreeOnGroupSelected(groupTree, path) + local view = groupTree:GetElement("__parent.__parent.view") + if view ~= "content" then + view:SetPath("content", true) + end + + private.currentGroupPath = path + local contentFrame = view:GetElement("content") + local titleFrame = contentFrame:GetElement("header.title") + local buttonsFrame = contentFrame:GetElement("buttons") + + if path == TSM.CONST.ROOT_GROUP_PATH then + titleFrame:GetElement("icon") + :SetTextureAndSize(TSM.UI.TexturePacks.GetColoredKey("iconPack.18x18/Folder", "TEXT")) + titleFrame:GetElement("text") + :SetTextColor("TEXT") + :SetText(L["Base Group"]) + :SetEditing(false) + titleFrame:GetElement("renameBtn"):Hide() + titleFrame:GetElement("exportBtn"):Hide() + buttonsFrame:RenamePath(L["Information"], 1) + else + local groupColor = Theme.GetGroupColor(select('#', strsplit(TSM.CONST.GROUP_SEP, path))) + titleFrame:GetElement("icon") + :SetTextureAndSize(TSM.UI.TexturePacks.GetColoredKey("iconPack.18x18/Folder", groupColor)) + titleFrame:GetElement("text") + :SetTextColor(groupColor) + :SetText(TSM.Groups.Path.GetName(path)) + :SetEditing(false) + titleFrame:GetElement("renameBtn"):Show() + titleFrame:GetElement("exportBtn"):Show() + buttonsFrame:RenamePath(L["Items"], 1) + end + -- Show the frame in case it is hidden by filter + buttonsFrame:Show() + buttonsFrame:Draw() + titleFrame:Draw() + contentFrame:GetElement("buttons"):ReloadContent() +end + +function private.GroupTreeOnNewGroup(groupTree) + groupTree:GetElement("__parent.__parent.view.content.header.title.text"):SetEditing(true) +end + +function private.GroupNameChanged(text, newValue) + newValue = strtrim(newValue) + local parent = TSM.Groups.Path.GetParent(private.currentGroupPath) + local newPath = parent and parent ~= TSM.CONST.ROOT_GROUP_PATH and (parent..TSM.CONST.GROUP_SEP..newValue) or newValue + if newPath == private.currentGroupPath then + -- didn't change + text:Draw() + elseif strfind(newValue, TSM.CONST.GROUP_SEP) or newValue == "" then + Log.PrintUser(L["Invalid group name."]) + text:Draw() + elseif TSM.Groups.Exists(newPath) then + Log.PrintUser(L["Group already exists."]) + text:Draw() + else + TSM.Groups.Move(private.currentGroupPath, newPath) + Analytics.Action("MOVED_GROUP", private.currentGroupPath, newPath) + text:GetElement("__parent.__parent.__parent.__parent.__parent.groupSelection.groupTree"):SetSelectedGroup(newPath, true) + end +end + +function private.NameOnEditingChanged(text, editing) + if editing then + text:GetElement("__parent.renameBtn"):Hide() + text:GetElement("__parent.exportBtn"):Hide() + else + text:GetElement("__parent.renameBtn"):Show() + text:GetElement("__parent.exportBtn"):Show() + end +end + +function private.RenameBtnOnClick(button) + button:GetElement("__parent.text"):SetEditing(true) +end + +function private.ExportBtnOnClick(button) + -- build the export DB + wipe(private.exportSubGroups) + private.importExportGroupDB:TruncateAndBulkInsertStart() + for _, groupPath in TSM.Groups.GroupIterator() do + local relGroupPath = nil + if TSM.Groups.Path.IsChild(groupPath, private.currentGroupPath) then + relGroupPath = TSM.Groups.Path.GetRelative(groupPath, private.currentGroupPath) + end + if relGroupPath then + private.exportSubGroups[relGroupPath] = true + local orderStr = gsub(relGroupPath, TSM.CONST.GROUP_SEP, "\001") + orderStr = strlower(orderStr) + private.importExportGroupDB:BulkInsertNewRow(relGroupPath, orderStr) + end + end + private.importExportGroupDB:BulkInsertEnd() + + local str, numItems, numSubGroups, numOperations, numCustomSources = TSM.Groups.ImportExport.GenerateExport(private.currentGroupPath, private.exportSubGroups, false, false) + local groupColor = Theme.GetGroupColor(select('#', strsplit(TSM.CONST.GROUP_SEP, private.currentGroupPath))) + button:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(658, 408) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(20, -4, -4, 12) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Export"]..": "..groupColor:ColorText(TSM.Groups.Path.GetName(private.currentGroupPath))) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.ImportExportCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Text", "desc") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY3") + :SetText(L["You can use the export string below to share this group with others."]) + ) + :AddChild(UIElements.New("Frame", "options") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("GroupSelector", "subGroups") + :SetWidth(240) + :SetMargin(0, 12, 0, 0) + :SetSelectedText(L["%d subgroups included"]) + :SetCustomQueryFunc(private.CreateImportExportDBQuery) + :SetSelection(private.exportSubGroups) + :SetHintText(L["Select included subgroups"]) + :SetScript("OnSelectionChanged", private.ExportOptionOnValueChanged) + ) + :AddChild(UIElements.New("Checkbox", "excludeOperations") + :SetWidth("AUTO") + :SetMargin(0, 12, 0, 0) + :SetFont("BODY_BODY3") + :SetCheckboxPosition("LEFT") + :SetText(L["Exclude operations?"]) + :SetScript("OnValueChanged", private.ExportOptionOnValueChanged) + ) + :AddChild(UIElements.New("Checkbox", "excludeCustomSources") + :SetWidth("AUTO") + :SetMargin(0, 12, 0, 0) + :SetFont("BODY_BODY3") + :SetCheckboxPosition("LEFT") + :SetText(L["Exclude custom sources?"]) + :SetScript("OnValueChanged", private.ExportOptionOnValueChanged) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Text", "heading") + :SetMargin(0, 0, 0, 8) + :SetHeight(20) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Export String"]) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("MultiLineInput", "input") + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValue(str) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(28) + :SetPadding(8, 8, 4, 4) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "items") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%d Items"], numItems)) + ) + :AddChild(UIElements.New("Texture", "line1") + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "subGroups") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%d Sub-Groups"], numSubGroups)) + ) + :AddChild(UIElements.New("Texture", "line2") + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "operations") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%d Operations"], numOperations)) + ) + :AddChild(UIElements.New("Texture", "line3") + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "customSources") + :SetWidth("AUTO") + :SetMargin(8, 0, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%d Custom Sources"], numCustomSources)) + ) + ) + ) + ) +end + +function private.CreateImportExportDBQuery() + return private.importExportGroupDB:NewQuery() + :OrderBy("orderStr", true) +end + +function private.ImportExportCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.ExportOptionOnValueChanged(element) + wipe(private.exportSubGroups) + for groupPath in element:GetElement("__parent.subGroups"):SelectedGroupIterator() do + private.exportSubGroups[groupPath] = true + end + local excludeOperations = element:GetElement("__parent.excludeOperations"):IsChecked() + local excludeCustomSources = element:GetElement("__parent.excludeCustomSources"):IsChecked() + local str, numItems, numSubGroups, numOperations, numCustomSources = TSM.Groups.ImportExport.GenerateExport(private.currentGroupPath, private.exportSubGroups, excludeOperations, excludeCustomSources) + element:GetElement("__parent.excludeCustomSources") + :SetDisabled(excludeOperations) + :SetChecked(excludeCustomSources and not excludeOperations, true) + :Draw() + local content = element:GetElement("__parent.__parent.content") + content:GetElement("input"):SetValue(str) + content:GetElement("footer.items"):SetText(format(L["%d Items"], numItems)) + content:GetElement("footer.subGroups"):SetText(format(L["%d Sub-Groups"], numSubGroups)) + content:GetElement("footer.operations"):SetText(format(L["%d Operations"], numOperations)) + content:GetElement("footer.customSources"):SetText(format(L["%d Custom Sources"], numCustomSources)) + content:Draw() +end + +function private.ItemFilterValidateFunc(_, value) + return private.itemFilter:ParseStr(value) +end + +function private.ItemFilterOnValueChanged(input) + local text = input:GetValue() + if private.itemSearch == text then + return + end + private.itemSearch = text + + input:GetElement("__parent.__parent.content.ungrouped.content.itemList") + :SetFilterFunction(private.ItemListItemIsFiltered) + :Draw() + input:GetElement("__parent.__parent.content.grouped.content.itemList") + :SetFilterFunction(private.ItemListItemIsFiltered) + :Draw() +end + +function private.UpdateBaseItemList() + wipe(private.results[1]) + wipe(private.results[2]) + local addedItems = TempTable.Acquire() + for _, itemString in ipairs(private.resultsRaw) do + if private.settings.groups[private.currentGroupPath].ignoreItemVariations then + itemString = ItemString.GetBase(itemString) + else + if ItemString.IsPet(itemString) and itemString == ItemString.GetBase(itemString) then + addedItems[itemString] = true + end + end + if not TSM.Groups.IsItemInGroup(itemString) and not addedItems[itemString] then + tinsert(private.results[1], itemString) + addedItems[itemString] = true + end + end + TempTable.Release(addedItems) + private.results[1].header = Theme.GetColor("INDICATOR"):ColorText(L["Search Results"].."|r") +end + +function private.ValidateBaseSearchValue(_, value) + if value == "" then + return true + elseif #value < 3 then + return false, L["The search term must be at least 3 characters."] + elseif not private.itemFilter:ParseStr(value) then + return false, L["Invalid search term."] + elseif private.itemFilter:GetStr() and #private.itemFilter:GetStr() < 3 then + return false, L["The name portion of the search term must be at least 3 characters if present."] + elseif private.itemFilter:GetMinPrice() or private.itemFilter:GetMaxPrice() then + return false, L["Invalid search term. Cannot filter by price here."] + end + return true +end + +function private.InformationSearchOnValueChanged(input) + local value = input:GetValue() + if private.itemSearch == value or value == "" then + return + end + private.BaseSearchOnValueChanged(input) + private.frame:GetBaseElement():GetElement("content.groups.view.content.header.input") + :SetFocused(true) + :ClearHighlight() +end + +function private.BaseSearchOnValueChanged(input) + local value = input:GetValue() + if private.itemSearch == value or value == "" then + return + end + assert(private.itemFilter:ParseStr(value)) + private.itemSearch = value + ItemInfo.MatchItemFilter(private.itemFilter, wipe(private.resultsRaw)) + private.UpdateBaseItemList() + private.frame:GetBaseElement():GetElement("content.groups.view"):SetPath("search", true) + private.frame:GetBaseElement():GetElement("content.groups.view.content.itemList"):SetItems(private.results, true) + private.frame:GetBaseElement():GetElement("content.groups.view.content.header2.label") + :SetText(format(L["%d Results"], #private.results[1])) + :Draw() +end + +function private.SelectAllResultsOnClick(button) + button:GetElement("__parent.__parent.itemList"):ToggleSelectAll() +end + +function private.IgnoreSearchRandomOnValueChanged(checkbox, checked) + private.UpdateBaseItemList() + checkbox:GetElement("__parent.__parent.itemList"):SetItems(private.results, true) + checkbox:GetElement("__parent.label") + :SetText(format(L["%d Results"], #private.results[1])) + :Draw() +end + +function private.SearchBackButtonOnClick(button) + button:GetBaseElement():GetElement("content.groups.view"):SetPath("content", true) +end + +function private.IgnoreBaseRandomOnValueChanged(checkbox, checked) + -- update the base item list + checkbox:GetElement("__parent.__parent.itemList"):SetItems(private.GetUngroupedBagItemList(), true) +end + +function private.ItemListOnSelectionChanged(itemList) + local selection = itemList:GetElement("__parent.bottom.group"):GetSelection() + local button = itemList:GetElement("__parent.bottom.move") + local numSelected = itemList:GetNumSelected() + button:SetDisabled(not selection or numSelected == 0) + :SetText(numSelected == 0 and L["Move Item"] or format(L["Move %d |4Item:Items"], numSelected)) + :Draw() +end + +function private.BaseMoveItemOnClick(button) + assert(private.moveGroupPath) + local itemList = button:GetElement("__parent.__parent.itemList") + local numAdded = 0 + for _, itemLink in ipairs(private.results[1]) do + if itemList:IsItemSelected(itemLink) then + local itemString = ItemString.Get(itemLink) + TSM.Groups.SetItemGroup(itemString, private.moveGroupPath) + numAdded = numAdded + 1 + end + end + Analytics.Action("ADDED_GROUP_ITEMS", private.moveGroupPath, numAdded) + private.UpdateBaseItemList() + itemList:SetItems(private.results, true) + itemList:GetElement("__parent.header2.label") + :SetText(format(L["%d Results"], #private.results[1])) + :Draw() +end + +function private.MoveItemOnClick(button) + assert(private.moveGroupPath) + local itemList = button:GetElement("__parent.__parent.itemList") + local numAdded = 0 + for _, items in ipairs(private.GetUngroupedBagItemList()) do + for _, itemLink in ipairs(items) do + if itemList:IsItemSelected(itemLink) then + local itemString = ItemString.Get(itemLink) + TSM.Groups.SetItemGroup(itemString, private.moveGroupPath) + numAdded = numAdded + 1 + end + end + end + Analytics.Action("ADDED_GROUP_ITEMS", private.moveGroupPath, numAdded) + itemList:SetItems(private.GetUngroupedBagItemList(), true) +end + +function private.BaseGroupOnSelectionChanged(self) + local selection = self:GetSelection() + private.moveGroupPath = selection + local numSelected = self:GetBaseElement():GetElement("content.groups.view.content.itemList"):GetNumSelected() + self:GetBaseElement():GetElement("content.groups.view.content.bottom.move") + :SetDisabled(not selection or numSelected == 0) + :SetText(numSelected == 0 and L["Move Item"] or format(L["Move %d |4Item:Items"], numSelected)) + :Draw() +end + +function private.GroupOnSelectionChanged(self) + local selection = self:GetSelection() + private.moveGroupPath = selection + local numSelected = self:GetBaseElement():GetElement("content.groups.view.content.buttons.items.itemList"):GetNumSelected() + self:GetBaseElement():GetElement("content.groups.view.content.buttons.items.bottom.move") + :SetDisabled(not selection or numSelected == 0) + :SetText(numSelected == 0 and L["Move Item"] or format(L["Move %d |4Item:Items"], numSelected)) + :Draw() +end + +function private.IgnoreRandomOnValueChanged(checkbox, checked) + -- update the ungrouped item list + checkbox:GetElement("__parent.__parent.content.ungrouped.content.itemList"):SetItems(private.GetUngroupedItemList(), true) +end + +function private.AddItemsOnClick(button) + local itemList = button:GetElement("__parent.content.itemList") + local numAdded = 0 + for _, items in ipairs(private.GetUngroupedItemList()) do + for _, itemLink in ipairs(items) do + if itemList:IsItemSelected(itemLink) then + local itemString = ItemString.Get(itemLink) + TSM.Groups.SetItemGroup(itemString, private.currentGroupPath) + numAdded = numAdded + 1 + end + end + end + Analytics.Action("ADDED_GROUP_ITEMS", private.currentGroupPath, numAdded) + + -- update the item lists + itemList:SetItems(private.GetUngroupedItemList(), true) + local otherItemList = button:GetElement("__parent.__parent.grouped.content.itemList") + otherItemList:SetItems(private.GetGroupedItemList(), true) +end + +function private.ItemListSelectAllOnClick(button) + button:GetElement("__parent.__parent.itemList"):ToggleSelectAll() +end + +function private.UngroupedItemsOnSelectionChanged(itemList) + local button = itemList:GetElement("__parent.__parent.btn") + local numSelected = itemList:GetNumSelected() + button:SetDisabled(numSelected == 0) + :SetText(numSelected == 0 and L["Add"] or format(L["Add %d |4Item:Items"], numSelected)) + :Draw() +end + +function private.GroupedItemsOnSelectionChanged(itemList) + local button = itemList:GetElement("__parent.__parent.btn") + local numSelected = itemList:GetNumSelected() + local parentGroup = TSM.Groups.Path.GetParent(private.currentGroupPath) + parentGroup = parentGroup ~= TSM.CONST.ROOT_GROUP_PATH and parentGroup or nil + if parentGroup then + button:SetModifierText(numSelected == 0 and L["Move to Parent Group"] or format(L["Move %d |4Item:Items"], numSelected), "SHIFT") + end + button:SetDisabled(numSelected == 0) + :SetText(numSelected == 0 and L["Remove"] or format(L["Remove %d |4Item:Items"], numSelected)) + :Draw() +end + +function private.RebuildModuleOperations(moduleOperationFrame) + local moduleName = moduleOperationFrame:GetContext() + local override = TSM.Groups.HasOperationOverride(private.currentGroupPath, moduleName) or private.currentGroupPath == TSM.CONST.ROOT_GROUP_PATH + + -- remove the existing operations container and add more row + local content = moduleOperationFrame:GetElement("content") + local container = content:GetElement("container") + content:RemoveChild(container) + container:Release() + if content:HasChildById("addMore") then + local addMore = content:GetElement("addMore") + content:RemoveChild(addMore) + addMore:Release() + end + + local numGroupOperations = 0 + for _ in TSM.Groups.OperationIterator(private.currentGroupPath, moduleName) do + numGroupOperations = numGroupOperations + 1 + end + moduleOperationFrame:AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetContext(moduleName) + :SetHeight(36 * numGroupOperations) + :AddChildrenWithFunction(private.AddOperationRows) + ) + moduleOperationFrame:AddChildIf(override and numGroupOperations < TSM.Operations.GetMaxNumber(moduleName), UIElements.New("Frame", "addMore") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Button", "button") + :SetWidth("AUTO") + :SetMargin(0, 2, 0, 0) + :SetFont("BODY_BODY2") + :SetTextColor("INDICATOR") + :SetText(L["Add More Operations"]) + :SetContext(moduleName) + :SetScript("OnClick", private.AddOperationButtonOnClick) + ) + ) + + moduleOperationFrame:GetParentElement():Draw() +end + +function private.RemoveItemsOnClick(button) + local itemList = button:GetElement("__parent.content.itemList") + local numRemoved = 0 + local parentGroup = TSM.Groups.Path.GetParent(private.currentGroupPath) + parentGroup = parentGroup ~= TSM.CONST.ROOT_GROUP_PATH and parentGroup or nil + local targetGroup = IsShiftKeyDown() and parentGroup or nil + for _, itemLink in ipairs(private.GetGroupedItemList()) do + if itemList:IsItemSelected(itemLink) then + TSM.Groups.SetItemGroup(ItemString.Get(itemLink), targetGroup) + numRemoved = numRemoved + 1 + end + end + Analytics.Action("REMOVED_GROUP_ITEMS", private.currentGroupPath, numRemoved, targetGroup or "") + + -- update the item lists + itemList:SetItems(private.GetGroupedItemList(), true) + local otherItemList = button:GetElement("__parent.__parent.ungrouped.content.itemList") + otherItemList:SetItems(private.GetUngroupedItemList(), true) +end + +function private.OverrideToggleOnValueChanged(checkbox, value) + assert(private.currentGroupPath ~= TSM.CONST.ROOT_GROUP_PATH) + local moduleOperationFrame = checkbox:GetParentElement():GetParentElement() + local moduleName = moduleOperationFrame:GetContext() + TSM.Groups.SetOperationOverride(private.currentGroupPath, moduleName, value) + Analytics.Action("CHANGED_GROUP_OVERRIDE", private.currentGroupPath, moduleName, value) + private.RebuildModuleOperations(moduleOperationFrame) +end + +function private.ConfigOperationOnClick(button) + local moduleName = button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetContext() + local operationName = button:GetContext() + local baseFrame = button:GetBaseElement() + TSM.MainUI.Operations.ShowOperationSettings(baseFrame, moduleName, operationName) +end + +function private.AddOperationButtonOnClick(button) + local moduleName = button:GetContext() + private.operationQuery = private.operationQuery or TSM.Operations.CreateQuery() + private.operationQuery:Reset() + private.operationQuery + :Contains("operationName", private.filterText) + :Equal("moduleName", moduleName) + :OrderBy("operationName", true) + button:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(464, 318) + :SetPadding(12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG") + :SetBorderColor("ACTIVE_BG") + :SetMouseEnabled(true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(24) + ) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(format(L["Add %s Operation"], moduleName)) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.AddOperationCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetPadding(1) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "search") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 8, 12) + :AddChild(UIElements.New("Input", "input") + :SetContext(moduleName) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert(true) + :SetHintText(format(L["Search %s operations"], strlower(moduleName))) + :SetValue(private.filterText) + :SetScript("OnValueChanged", private.OperationSearchOnValueChanged) + ) + ) + :AddChild(UIElements.New("Button", "createOperation") + :SetHeight(24) + :SetPadding(8, 0, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetContext(button) + :SetTextColor("INDICATOR") + :SetJustifyH("LEFT") + :SetText(L["Create New Operation"]) + :SetScript("OnEnter", private.AddOperationCreateBtnOnEnter) + :SetScript("OnLeave", private.AddOperationCreateBtnOnLeave) + :SetScript("OnClick", private.AddOperationCreateBtnOnClick) + ) + :AddChild(UIElements.New("QueryScrollingTable", "list") + :SetContext(moduleName) + :SetHeaderHidden(true) + :SetQuery(private.operationQuery) + :GetScrollingTableInfo() + :NewColumn("name") + :SetTitle("") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("LEFT") + :SetTextInfo("operationName") + :Commit() + :Commit() + :SetScript("OnSelectionChanged", private.AddOperationSelectionChanged) + ) + ) + :AddChild(UIElements.New("ActionButton", "addBtn") + :SetHeight(24) + :SetMargin(0, 0, 8, 0) + :SetDisabled(true) + :SetContext(button) + :SetText(L["Add Operation"]) + :SetScript("OnClick", private.AddOperationBtnOnClick) + ) + ) +end + +function private.AddOperationCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.AddOperationCreateBtnOnEnter(button) + button:SetTextColor("TEXT") + :Draw() +end + +function private.AddOperationCreateBtnOnLeave(button) + button:SetTextColor(Theme.GetGroupColor(1)) + :Draw() +end + +function private.AddOperationCreateBtnOnClick(button) + local scrollingTable = button:GetElement("__parent.list") + local moduleName = scrollingTable:GetContext() + local operationName = L["New Operation"] + local extra = "" + local num = 0 + while TSM.Operations.Exists(moduleName, operationName..extra) do + num = num + 1 + extra = " "..num + end + operationName = operationName..extra + TSM.Operations.Create(moduleName, operationName) + TSM.Groups.AppendOperation(private.currentGroupPath, moduleName, operationName) + Analytics.Action("ADDED_GROUP_OPERATION", private.currentGroupPath, moduleName, operationName) + TSM.MainUI.Operations.ShowOperationSettings(button:GetBaseElement(), moduleName, operationName) +end + +function private.OperationSearchOnValueChanged(input) + local text = strlower(input:GetValue()) + if text == private.filterText then + return + end + private.filterText = text + + private.operationQuery:SetUpdatesPaused(true) + private.operationQuery:ResetFilters() + :Contains("operationName", private.filterText) + :Equal("moduleName", input:GetContext()) + private.operationQuery:SetUpdatesPaused(false) + input:GetElement("__parent.__parent.list"):UpdateData(true) +end + +function private.AddOperationBtnOnClick(button) + local scrollingTable = button:GetElement("__parent.container.list") + local moduleName = scrollingTable:GetContext() + local operationName = scrollingTable:GetSelection():GetField("operationName") + TSM.Groups.AppendOperation(private.currentGroupPath, scrollingTable:GetContext(), operationName) + Analytics.Action("ADDED_GROUP_OPERATION", private.currentGroupPath, moduleName, operationName) + local moduleOperationFrame = button:GetContext():GetElement("__parent.__parent.__parent") + private.RebuildModuleOperations(moduleOperationFrame) + moduleOperationFrame:GetBaseElement():HideDialog() +end + +function private.AddOperationSelectionChanged(scrollingTable) + if scrollingTable:GetSelection() then + scrollingTable:GetElement("__parent.__parent.addBtn") + :SetDisabled(false) + :Draw() + end +end + +function private.RemoveOperationOnClick(button) + local moduleOperationFrame = button:GetElement("__parent.__parent.__parent.__parent.__parent") + local moduleName = moduleOperationFrame:GetContext() + local operationIndex = button:GetContext() + local operationName = nil + for index, name in TSM.Groups.OperationIterator(private.currentGroupPath, moduleName) do + if index == operationIndex then + operationName = name + end + end + assert(operationName) + TSM.Groups.RemoveOperation(private.currentGroupPath, moduleName, operationIndex) + Analytics.Action("REMOVED_GROUP_OPERATION", private.currentGroupPath, moduleName, operationName) + private.RebuildModuleOperations(moduleOperationFrame) +end + + + +-- ============================================================================ +-- Helper Functions +-- ============================================================================ + +function private.ItemSortHelper(a, b) + return (ItemInfo.GetName(a) or "") < (ItemInfo.GetName(b) or "") +end + +function private.GetUngroupedBagItemList() + wipe(private.ungroupedItemList[1]) + wipe(private.ungroupedItemList[2]) + + -- items in bags + local addedItems = TempTable.Acquire() + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("itemString") + :Equal("isBoP", false) + for _, itemString in query:Iterator() do + if private.settings.groups[private.currentGroupPath].ignoreItemVariations then + itemString = ItemString.GetBase(itemString) + end + if not TSM.Groups.IsItemInGroup(itemString) and not addedItems[itemString] then + local itemLink = ItemInfo.GetLink(itemString) + tinsert(private.ungroupedItemList[1], itemLink) + addedItems[itemString] = true + end + end + query:Release() + TempTable.Release(addedItems) + private.ungroupedItemList[1].header = Theme.GetColor("INDICATOR"):ColorText(L["Ungrouped Items in Bags"]) + + sort(private.ungroupedItemList[1], private.ItemSortHelper) + return private.ungroupedItemList +end + +function private.GetUngroupedItemList() + wipe(private.ungroupedItemList[1]) + wipe(private.ungroupedItemList[2]) + + -- items in bags + local addedItems = TempTable.Acquire() + local query = BagTracking.CreateQueryBags() + :OrderBy("slotId", true) + :Select("itemString") + :Equal("isBoP", false) + for _, itemString in query:Iterator() do + if private.settings.groups[private.currentGroupPath].ignoreItemVariations then + itemString = ItemString.GetBase(itemString) + end + if not TSM.Groups.IsItemInGroup(itemString) and not addedItems[itemString] then + local itemLink = ItemInfo.GetLink(itemString) + tinsert(private.ungroupedItemList[1], itemLink) + addedItems[itemString] = true + end + end + query:Release() + TempTable.Release(addedItems) + private.ungroupedItemList[1].header = Theme.GetColor("INDICATOR"):ColorText(L["Ungrouped Items in Bags"]) + + -- items in the parent group + local parentGroupPath = TSM.Groups.Path.GetParent(private.currentGroupPath) + if parentGroupPath ~= TSM.CONST.ROOT_GROUP_PATH then + for _, itemString in TSM.Groups.ItemIterator(parentGroupPath) do + local itemLink = ItemInfo.GetLink(itemString) + tinsert(private.ungroupedItemList[2], itemLink) + end + end + private.ungroupedItemList[2].header = Theme.GetColor("INDICATOR"):ColorText(L["Parent Items"]) + + sort(private.ungroupedItemList[1], private.ItemSortHelper) + sort(private.ungroupedItemList[2], private.ItemSortHelper) + return private.ungroupedItemList +end + +function private.GetGroupedItemList() + wipe(private.groupedItemList) + + -- items in this group or a subgroup + local itemNames = TempTable.Acquire() + for _, itemString in TSM.Groups.ItemIterator(private.currentGroupPath, true) do + local link = ItemInfo.GetLink(itemString) + tinsert(private.groupedItemList, link) + itemNames[link] = ItemInfo.GetName(itemString) or "" + end + + Table.SortWithValueLookup(private.groupedItemList, itemNames) + TempTable.Release(itemNames) + return private.groupedItemList +end + +function private.ItemListItemIsFiltered(itemLink) + local basePrice = CustomPrice.GetValue(private.settings.groupPriceSource, itemLink) + return not private.itemFilter:Matches(itemLink, basePrice) +end diff --git a/Core/UI/MainUI/Ledger/Common/Auctions.lua b/Core/UI/MainUI/Ledger/Common/Auctions.lua new file mode 100644 index 0000000..24d1110 --- /dev/null +++ b/Core/UI/MainUI/Ledger/Common/Auctions.lua @@ -0,0 +1,266 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctions = TSM.MainUI.Ledger.Common:NewPackage("Auctions") +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local String = TSM.Include("Util.String") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local SECONDS_PER_DAY = 24 * 60 * 60 +local private = { + settings = nil, + query = nil, + characters = {}, + characterFilter = {}, + searchFilter = "", + groupFilter = {}, + rarityList = {}, + rarityFilter = {}, + timeFrameFilter = 30 * SECONDS_PER_DAY, + type = nil +} +do + for i = 1, 4 do + tinsert(private.rarityList, _G[format("ITEM_QUALITY%d_DESC", i)]) + private.rarityFilter[i] = true + end +end +local TIME_LIST = { L["All Time"], L["Last 3 Days"], L["Last 7 Days"], L["Last 14 Days"], L["Last 30 Days"], L["Last 60 Days"] } +local TIME_KEYS = { 0, 3 * SECONDS_PER_DAY, 7 * SECONDS_PER_DAY, 14 * SECONDS_PER_DAY, 30 * SECONDS_PER_DAY, 60 * SECONDS_PER_DAY } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctions.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "ledgerAuctionsScrollingTable") + TSM.MainUI.Ledger.FailedAuctions.RegisterPage(L["Expired"], private.DrawExpiredPage) + TSM.MainUI.Ledger.FailedAuctions.RegisterPage(L["Cancelled"], private.DrawCancelledPage) +end + + + +-- ============================================================================ +-- Auctions UIs +-- ============================================================================ + +function private.DrawExpiredPage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "failed_auctions", "expired") + private.type = "expire" + return private.DrawAuctionsPage() +end + +function private.DrawCancelledPage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "failed_auctions", "cancelled") + private.type = "cancel" + return private.DrawAuctionsPage() +end + +function private.DrawAuctionsPage() + private.query = private.query or TSM.Accounting.Auctions.CreateQuery() + + private.query:Reset() + :Equal("type", "cancel") + :Distinct("player") + :Select("player") + wipe(private.characters) + for _, character in private.query:Iterator() do + tinsert(private.characters, character) + private.characterFilter[character] = true + end + + private.query:Reset() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :LeftJoin(TSM.Groups.GetItemDBForJoin(), "itemString") + :OrderBy("time", false) + private.UpdateQuery() + + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "filter") + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert() + :SetHintText(L["Filter by keyword"]) + :SetValue(private.searchFilter) + :SetScript("OnValueChanged", private.SearchFilterChanged) + ) + :AddChild(UIElements.New("GroupSelector", "group") + :SetWidth(240) + :SetHintText(L["Filter by groups"]) + :SetScript("OnSelectionChanged", private.GroupFilterChanged) + ) + ) + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("MultiselectionDropdown", "rarity") + :SetMargin(0, 8, 0, 0) + :SetItems(private.rarityList) + :SetSettingInfo(private, "rarityFilter") + :SetSelectionText(L["No Rarities"], L["%d Rarities"], L["All Rarites"]) + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "character") + :SetMargin(0, 8, 0, 0) + :SetItems(private.characters, private.characters) + :SetSettingInfo(private, "characterFilter") + :SetSelectionText(L["No Characters"], L["%d Characters"], L["All Characters"]) + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + :AddChild(UIElements.New("SelectionDropdown", "time") + :SetItems(TIME_LIST, TIME_KEYS) + :SetSelectedItemByKey(private.timeFrameFilter) + :SetSettingInfo(private, "timeFrameFilter") + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "scrollingTable") + :SetSettingsContext(private.settings, "ledgerAuctionsScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("player") + :SetTitle(PLAYER) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("player") + :SetSortInfo("player") + :Commit() + :NewColumn("stackSize") + :SetTitle(L["Stack"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("stackSize") + :SetSortInfo("stackSize") + :Commit() + :NewColumn("quantity") + :SetTitle(L["Auctions"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.FormatAuctions) + :SetSortInfo("quantity") + :Commit() + :NewColumn("time") + :SetTitle(L["Time Frame"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("time", private.TableGetTimeframeText) + :SetSortInfo("time") + :Commit() + :Commit() + :SetQuery(private.query) + :SetScript("OnRowClick", private.TableSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "num") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(private.type == "expire" and L["%s Items Expired"] or L["%s Items Cancelled"], Theme.GetColor("INDICATOR"):ColorText(FormatLargeNumber(private.query:Sum("quantity") or 0)))) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) +end + + + +-- ============================================================================ +-- Scrolling Table Helper Functions +-- ============================================================================ + +function private.TableGetTimeframeText(record) + return SecondsToTime(time() - record) +end + +function private.FormatAuctions(row) + return row:GetField("quantity") / row:GetField("stackSize") +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.DropdownCommonOnSelectionChanged(dropdown) + private.UpdateQuery() + dropdown:GetElement("__parent.__parent.scrollingTable") + :UpdateData(true) + local footer = dropdown:GetElement("__parent.__parent.footer") + footer:GetElement("num"):SetText(format(private.type == "expire" and L["%s Items Expired"] or L["%s Items Cancelled"], Theme.GetColor("INDICATOR"):ColorText(FormatLargeNumber(private.query:Sum("quantity") or 0)))) + footer:Draw() +end + +function private.SearchFilterChanged(input) + private.searchFilter = input:GetValue() + private.DropdownCommonOnSelectionChanged(input) +end + +function private.GroupFilterChanged(groupSelector) + wipe(private.groupFilter) + for groupPath in groupSelector:SelectedGroupIterator() do + private.groupFilter[groupPath] = true + end + private.DropdownCommonOnSelectionChanged(groupSelector) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateQuery() + private.query:ResetFilters() + :Equal("type", private.type) + if private.searchFilter ~= "" then + private.query:Matches("name", String.Escape(private.searchFilter)) + end + if Table.Count(private.rarityFilter) ~= #private.rarityList then + private.query:InTable("quality", private.rarityFilter) + end + if Table.Count(private.characterFilter) ~= #private.characters then + private.query:InTable("player", private.characterFilter) + end + if private.timeFrameFilter ~= 0 then + private.query:GreaterThanOrEqual("time", time() - private.timeFrameFilter) + end + if next(private.groupFilter) then + private.query:InTable("groupPath", private.groupFilter) + end +end + +function private.TableSelectionChanged(scrollingTable, row) + TSM.MainUI.Ledger.ShowItemDetail(scrollingTable:GetParentElement():GetParentElement(), row:GetField("itemString"), "sale") +end diff --git a/Core/UI/MainUI/Ledger/Common/Core.lua b/Core/UI/MainUI/Ledger/Common/Core.lua new file mode 100644 index 0000000..857a99a --- /dev/null +++ b/Core/UI/MainUI/Ledger/Common/Core.lua @@ -0,0 +1,8 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +TSM.MainUI.Ledger:NewPackage("Common") diff --git a/Core/UI/MainUI/Ledger/Common/Other.lua b/Core/UI/MainUI/Ledger/Common/Other.lua new file mode 100644 index 0000000..71ace8a --- /dev/null +++ b/Core/UI/MainUI/Ledger/Common/Other.lua @@ -0,0 +1,208 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Other = TSM.MainUI.Ledger.Common:NewPackage("Other") +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Money = TSM.Include("Util.Money") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local SECONDS_PER_DAY = 24 * 60 * 60 +local private = { + settings = nil, + query = nil, + characters = {}, + characterFilter = {}, + typeFilter = {}, + recordType = nil, + timeFrameFilter = 30 * SECONDS_PER_DAY, +} +local TIME_LIST = { L["All Time"], L["Last 3 Days"], L["Last 7 Days"], L["Last 14 Days"], L["Last 30 Days"], L["Last 60 Days"] } +local TIME_KEYS = { 0, 3 * SECONDS_PER_DAY, 7 * SECONDS_PER_DAY, 14 * SECONDS_PER_DAY, 30 * SECONDS_PER_DAY, 60 * SECONDS_PER_DAY } +local TYPE_LIST = { + expense = { L["Money Transfer"], L["Postage"], L["Repair Bill"] }, + income = { L["Money Transfer"], L["Garrison"] }, +} +local TYPE_KEYS = { + expense = { "Money Transfer", "Postage", "Repair Bill" }, + income = { "Money Transfer", "Garrison" }, +} +local TYPE_STR_LOOKUP = {} +do + -- populate lookup table + assert(#TYPE_LIST.expense == #TYPE_KEYS.expense) + for i = 1, #TYPE_LIST.expense do + TYPE_STR_LOOKUP[TYPE_KEYS.expense[i]] = TYPE_LIST.expense[i] + end + assert(#TYPE_LIST.income == #TYPE_KEYS.income) + for i = 1, #TYPE_LIST.income do + TYPE_STR_LOOKUP[TYPE_KEYS.income[i]] = TYPE_LIST.income[i] + end +end + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Other.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "ledgerOtherScrollingTable") + TSM.MainUI.Ledger.Expenses.RegisterPage(OTHER, private.DrawOtherExpensesPage) + TSM.MainUI.Ledger.Revenue.RegisterPage(OTHER, private.DrawOtherRevenuePage) +end + + + +-- ============================================================================ +-- Other UIs +-- ============================================================================ + +function private.DrawOtherExpensesPage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "expenses", "other") + return private.DrawOtherPage("expense") +end + +function private.DrawOtherRevenuePage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "revenue", "other") + return private.DrawOtherPage("income") +end + +function private.DrawOtherPage(recordType) + wipe(private.characters) + for _, character in TSM.Accounting.Money.CharacterIterator(recordType) do + tinsert(private.characters, character) + private.characterFilter[character] = true + end + wipe(private.typeFilter) + for _, key in ipairs(TYPE_KEYS[recordType]) do + private.typeFilter[key] = true + end + + if not private.query then + private.query = TSM.Accounting.Money.CreateQuery() + :OrderBy("time", false) + end + private.recordType = recordType + private.UpdateQuery() + + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("MultiselectionDropdown", "type") + :SetMargin(0, 8, 0, 0) + :SetItems(TYPE_LIST[recordType], TYPE_KEYS[recordType]) + :SetSettingInfo(private, "typeFilter") + :SetSelectionText(L["No Types"], L["%d Types"], L["All Types"]) + :SetScript("OnSelectionChanged", private.DropdownChangedCommon) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "character") + :SetMargin(0, 8, 0, 0) + :SetItems(private.characters, private.characters) + :SetSettingInfo(private, "characterFilter") + :SetSelectionText(L["No Characters"], L["%d Characters"], L["All Characters"]) + :SetScript("OnSelectionChanged", private.DropdownChangedCommon) + ) + :AddChild(UIElements.New("SelectionDropdown", "time") + :SetItems(TIME_LIST, TIME_KEYS) + :SetSelectedItemByKey(private.timeFrameFilter) + :SetSettingInfo(private, "timeFrameFilter") + :SetScript("OnSelectionChanged", private.DropdownChangedCommon) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "table") + :SetSettingsContext(private.settings, "ledgerOtherScrollingTable") + :GetScrollingTableInfo() + :NewColumn("type") + :SetTitle(L["Type"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("type", private.TableGetTypeText) + :SetSortInfo("type") + :Commit() + :NewColumn("character") + :SetTitle(L["Character"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("player") + :SetSortInfo("player") + :Commit() + :NewColumn("otherCharacter") + :SetTitle(L["Other Character"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("otherPlayer") + :SetSortInfo("otherPlayer") + :Commit() + :NewColumn("amount") + :SetTitle(L["Amount"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("amount", Money.ToString) + :SetSortInfo("amount") + :Commit() + :NewColumn("time") + :SetTitle(L["Time Frame"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("time", private.TableGetTimeText) + :SetSortInfo("time") + :Commit() + :Commit() + :SetQuery(private.query) + :SetSelectionDisabled(true) + ) +end + + + +-- ============================================================================ +-- Scrolling Table Helper Functions +-- ============================================================================ + +function private.TableGetTypeText(typeValue) + return TYPE_STR_LOOKUP[typeValue] +end + +function private.TableGetTimeText(timevalue) + return SecondsToTime(time() - timevalue) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.DropdownChangedCommon(dropdown) + private.UpdateQuery() + dropdown:GetElement("__parent.__parent.table"):UpdateData(true) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateQuery() + private.query:ResetFilters() + :Equal("recordType", private.recordType) + if Table.Count(private.typeFilter) ~= #TYPE_KEYS[private.recordType] then + private.query:InTable("type", private.typeFilter) + end + if Table.Count(private.characterFilter) ~= #private.characters then + private.query:InTable("player", private.characterFilter) + end + if private.timeFrameFilter ~= 0 then + private.query:GreaterThan("time", time() - private.timeFrameFilter) + end +end diff --git a/Core/UI/MainUI/Ledger/Common/Transactions.lua b/Core/UI/MainUI/Ledger/Common/Transactions.lua new file mode 100644 index 0000000..bd9c93c --- /dev/null +++ b/Core/UI/MainUI/Ledger/Common/Transactions.lua @@ -0,0 +1,329 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Transactions = TSM.MainUI.Ledger.Common:NewPackage("Transactions") +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local Table = TSM.Include("Util.Table") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local SECONDS_PER_DAY = 24 * 60 * 60 +local private = { + settings = nil, + query = nil, + characters = {}, + characterFilter = {}, + typeFilter = {}, + searchFilter = "", + groupFilter = {}, + rarityList = {}, + rarityFilter = {}, + timeFrameFilter = 30 * SECONDS_PER_DAY, + type = nil +} +local TYPE_LIST = { L["Auction"], COD, TRADE, L["Vendor"] } +local TYPE_KEYS = { "Auction", "COD", "Trade", "Vendor" } +do + for _, key in ipairs(TYPE_KEYS) do + private.typeFilter[key] = true + end + for i = 1, 4 do + tinsert(private.rarityList, _G[format("ITEM_QUALITY%d_DESC", i)]) + private.rarityFilter[i] = true + end +end +local TIME_LIST = { L["All Time"], L["Last 3 Days"], L["Last 7 Days"], L["Last 14 Days"], L["Last 30 Days"], L["Last 60 Days"] } +local TIME_KEYS = { 0, 3 * SECONDS_PER_DAY, 7 * SECONDS_PER_DAY, 14 * SECONDS_PER_DAY, 30 * SECONDS_PER_DAY, 60 * SECONDS_PER_DAY } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Transactions.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "ledgerTransactionsScrollingTable") + TSM.MainUI.Ledger.Expenses.RegisterPage(L["Purchases"], private.DrawPurchasesPage) + TSM.MainUI.Ledger.Revenue.RegisterPage(L["Sales"], private.DrawSalesPage) +end + + + +-- ============================================================================ +-- Transactions UIs +-- ============================================================================ + +function private.DrawPurchasesPage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "expenses", "purchases") + private.type = "buy" + return private.DrawTransactionPage() +end + +function private.DrawSalesPage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "revenue", "sales") + private.type = "sale" + return private.DrawTransactionPage() +end + +function private.DrawTransactionPage() + private.query = private.query or TSM.Accounting.Transactions.CreateQuery() + + private.query:Reset() + :Equal("type", private.type) + :Distinct("player") + :Select("player") + wipe(private.characters) + for _, character in private.query:Iterator() do + tinsert(private.characters, character) + private.characterFilter[character] = true + end + + private.query:Reset() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :LeftJoin(TSM.Groups.GetItemDBForJoin(), "itemString") + :VirtualField("total", "number", private.GetTotal) + :VirtualField("auctions", "number", private.GetAuctions) + :OrderBy("time", false) + private.UpdateQuery() + local numItems = private.query:Sum("quantity") or 0 + local total = private.query:Sum("total") or 0 + + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "filter") + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert() + :SetHintText(L["Filter by keyword"]) + :SetValue(private.searchFilter) + :SetScript("OnValueChanged", private.SearchFilterChanged) + ) + :AddChild(UIElements.New("GroupSelector", "group") + :SetWidth(240) + :SetHintText(L["Filter by groups"]) + :SetScript("OnSelectionChanged", private.GroupFilterChanged) + ) + ) + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("MultiselectionDropdown", "type") + :SetMargin(0, 8, 0, 0) + :SetItems(TYPE_LIST, TYPE_KEYS) + :SetSettingInfo(private, "typeFilter") + :SetSelectionText(L["No Types"], L["%d Types"], L["All Types"]) + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "rarity") + :SetMargin(0, 8, 0, 0) + :SetItems(private.rarityList) + :SetSettingInfo(private, "rarityFilter") + :SetSelectionText(L["No Rarities"], L["%d Rarities"], L["All Rarities"]) + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "character") + :SetMargin(0, 8, 0, 0) + :SetItems(private.characters, private.characters) + :SetSettingInfo(private, "characterFilter") + :SetSelectionText(L["No Characters"], L["%d Characters"], L["All Characters"]) + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + :AddChild(UIElements.New("SelectionDropdown", "time") + :SetItems(TIME_LIST, TIME_KEYS) + :SetSelectedItemByKey(private.timeFrameFilter) + :SetSettingInfo(private, "timeFrameFilter") + :SetScript("OnSelectionChanged", private.DropdownCommonOnSelectionChanged) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "scrollingTable") + :SetSettingsContext(private.settings, "ledgerTransactionsScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("player") + :SetTitle(PLAYER) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("otherPlayer") + :SetSortInfo("otherPlayer") + :Commit() + :NewColumn("type") + :SetTitle(L["Type"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("source") + :SetSortInfo("source") + :Commit() + :NewColumn("stack") + :SetTitle(L["Stack"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("stackSize") + :SetSortInfo("stackSize") + :Commit() + :NewColumn("auctions") + :SetTitle(L["Auctions"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("auctions") + :SetSortInfo("auctions") + :Commit() + :NewColumn("perItem") + :SetTitle(L["Per Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("price", private.TableGetPriceText) + :SetSortInfo("price") + :Commit() + :NewColumn("total") + :SetTitle(L["Total"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("total", private.TableGetPriceText) + :SetSortInfo("total") + :Commit() + :NewColumn("time") + :SetTitle(L["Time Frame"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("time", private.TableGetTimeframeText) + :SetSortInfo("time") + :Commit() + :Commit() + :SetQuery(private.query) + :SetScript("OnRowClick", private.TableSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "num") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(private.type == "sale" and L["%s Items Sold"] or L["%s Items Bought"], Theme.GetColor("INDICATOR"):ColorText(FormatLargeNumber(numItems)))) + ) + :AddChild(UIElements.New("Texture", "line") + :SetMargin(4, 8, 0, 0) + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "profit") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%s Total"], Money.ToString(total))) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) +end + + + +-- ============================================================================ +-- Scrolling Table Helper Functions +-- ============================================================================ + +function private.TableGetPriceText(price) + return Money.ToString(price) +end + +function private.TableGetTimeframeText(record) + return SecondsToTime(time() - record) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.DropdownCommonOnSelectionChanged(dropdown) + private.UpdateQuery() + local numItems = private.query:Sum("quantity") or 0 + local total = private.query:Sum("total") or 0 + dropdown:GetElement("__parent.__parent.scrollingTable") + :UpdateData(true) + local footer = dropdown:GetElement("__parent.__parent.footer") + footer:GetElement("num"):SetText(format(private.type == "sale" and L["%s Items Sold"] or L["%s Items Bought"], Theme.GetColor("INDICATOR"):ColorText(FormatLargeNumber(numItems)))) + footer:GetElement("profit"):SetText(format(L["%s Total"], Money.ToString(total))) + footer:Draw() +end + +function private.SearchFilterChanged(input) + private.searchFilter = input:GetValue() + private.DropdownCommonOnSelectionChanged(input) +end + +function private.GroupFilterChanged(groupSelector) + wipe(private.groupFilter) + for groupPath in groupSelector:SelectedGroupIterator() do + private.groupFilter[groupPath] = true + end + private.DropdownCommonOnSelectionChanged(groupSelector) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GetTotal(row) + return row:GetField("price") * row:GetField("quantity") +end + +function private.GetAuctions(row) + return row:GetField("quantity") / row:GetField("stackSize") +end + +function private.UpdateQuery() + private.query:ResetFilters() + :Equal("type", private.type) + if private.searchFilter ~= "" then + private.query:Matches("name", String.Escape(private.searchFilter)) + end + if Table.Count(private.typeFilter) ~= #TYPE_KEYS then + private.query:InTable("source", private.typeFilter) + end + if Table.Count(private.rarityFilter) ~= #private.rarityList then + private.query:InTable("quality", private.rarityFilter) + end + if Table.Count(private.characterFilter) ~= #private.characters then + private.query:InTable("player", private.characterFilter) + end + if private.timeFrameFilter ~= 0 then + private.query:GreaterThan("time", time() - private.timeFrameFilter) + end + if next(private.groupFilter) then + private.query:InTable("groupPath", private.groupFilter) + end +end + +function private.TableSelectionChanged(scrollingTable, row) + TSM.MainUI.Ledger.ShowItemDetail(scrollingTable:GetParentElement():GetParentElement(), row:GetField("itemString"), private.type) +end diff --git a/Core/UI/MainUI/Ledger/Core.lua b/Core/UI/MainUI/Ledger/Core.lua new file mode 100644 index 0000000..4121316 --- /dev/null +++ b/Core/UI/MainUI/Ledger/Core.lua @@ -0,0 +1,524 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Ledger = TSM.MainUI:NewPackage("Ledger") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Money = TSM.Include("Util.Money") +local Theme = TSM.Include("Util.Theme") +local Log = TSM.Include("Util.Log") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local SECONDS_PER_DAY = 24 * 60 * 60 +local private = { + settings = nil, + pages = {}, + childPages = {}, + callback = {}, + contextPath = nil, + contextItemString = nil, + itemDetailType = "sale", +} +local NUM_TOP_PLAYERS = 3 +local PAGE_PATH_SEP = "`" + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Ledger.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "ledgerDetailScrollingTable") + TSM.MainUI.RegisterTopLevelPage(L["Ledger"], private.GetLedgerFrame) +end + +function Ledger.RegisterPage(name, callback) + tinsert(private.pages, name) + private.callback[name] = callback +end + +function Ledger.RegisterChildPage(parentName, childName, callback) + local path = parentName..PAGE_PATH_SEP..childName + private.childPages[parentName] = private.childPages[parentName] or {} + tinsert(private.childPages[parentName], childName) + private.callback[path] = callback +end + +function Ledger.ShowItemDetail(frame, itemString, detailType) + assert(detailType == "sale" or detailType == "buy") + private.contextItemString = itemString + private.itemDetailType = detailType + frame:SetPath("itemDetail", true) +end + + + +-- ============================================================================ +-- Ledger UI +-- ============================================================================ + +function private.GetLedgerFrame() + TSM.UI.AnalyticsRecordPathChange("main", "ledger") + local defaultPage = private.pages[1] + local frame = UIElements.New("Frame", "ledger") + :SetLayout("HORIZONTAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "navigation") + :SetLayout("VERTICAL") + :SetWidth(160) + :SetPadding(12, 12, 1, 9) + ) + :AddChild(UIElements.New("Texture", "divider") + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "contentFrame") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("ViewContainer", "content") + :SetNavCallback(private.ContentNavCallback) + :AddPath("itemDetail") + ) + ) + :SetScript("OnHide", private.NavButtonOnHide) + + local content = frame:GetElement("contentFrame.content") + local navFrame = frame:GetElement("navigation") + for _, pageName in ipairs(private.pages) do + navFrame:AddChild(UIElements.New("Button", pageName) + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("LEFT") + :SetContext(pageName) + :SetText(pageName) + :SetScript("OnClick", private.NavButtonOnClick) + ) + content:AddPath(pageName, pageName == defaultPage) + if private.childPages[pageName] then + for _, childPageName in ipairs(private.childPages[pageName]) do + local path = pageName..PAGE_PATH_SEP..childPageName + navFrame:AddChild(UIElements.New("Button", path) + :SetHeight(20) + :SetMargin(9, 0, 8, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("LEFT") + :SetContext(path) + :SetText(childPageName) + :SetScript("OnClick", private.NavButtonOnClick) + ) + content:AddPath(path, path == defaultPage) + end + end + end + -- make all the navigation align to the top + navFrame:AddChild(UIElements.New("Spacer", "spacer")) + + private.UpdateNavFrame(navFrame, defaultPage) + private.contextPath = L["Inventory"] + return frame +end + +function private.ContentNavCallback(self, path) + if path == "itemDetail" then + return private.GetItemDetail() + else + return private.callback[path]() + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.NavButtonOnClick(button) + local path = button:GetContext() + if private.contextPath == path then + return + end + if private.childPages[path] then + -- select the first child + path = path..PAGE_PATH_SEP..private.childPages[path][1] + end + + local ledgerFrame = button:GetParentElement():GetParentElement() + local contentFrame = ledgerFrame:GetElement("contentFrame") + local navFrame = ledgerFrame:GetElement("navigation") + private.UpdateNavFrame(navFrame, path) + navFrame:Draw() + contentFrame:GetElement("content"):SetPath(path, private.contextPath ~= path) + private.contextPath = path +end + +function private.NavButtonOnHide(button) + private.contextPath = nil +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateNavFrame(navFrame, selectedPath) + local selectedPage = strsplit(PAGE_PATH_SEP, selectedPath) + for _, pageName in ipairs(private.pages) do + navFrame:GetElement(pageName):SetTextColor(pageName == selectedPage and "TEXT" or "ACTIVE_BG_ALT") + if private.childPages[pageName] then + for _, childPageName in ipairs(private.childPages[pageName]) do + local path = pageName..PAGE_PATH_SEP..childPageName + if pageName == selectedPage then + navFrame:GetElement(path) + :SetTextColor(path == selectedPath and "INDICATOR" or "TEXT") + :Show() + else + navFrame:GetElement(path):Hide() + end + end + end + end +end + +function private.GetItemDetail() + local query = TSM.Accounting.Transactions.CreateQuery() + :Equal("itemString", private.contextItemString) + :OrderBy("time", false) + + local topPlayersQuantity = TempTable.Acquire() + local topPlayers = TempTable.Acquire() + for _, row in query:Iterator() do + local recordType, otherPlayer, quantity = row:GetFields("type", "otherPlayer", "quantity") + if recordType == private.itemDetailType then + if not topPlayersQuantity[otherPlayer] then + topPlayersQuantity[otherPlayer] = 0 + tinsert(topPlayers, otherPlayer) + end + topPlayersQuantity[otherPlayer] = topPlayersQuantity[otherPlayer] + quantity + end + end + + Table.SortWithValueLookup(topPlayers, topPlayersQuantity, true) + local numTopPlayers = min(#topPlayers, NUM_TOP_PLAYERS) + local topPlayersText = "" + if numTopPlayers > 0 then + for i = 1, numTopPlayers do + local player = topPlayers[i] + local quantity = topPlayersQuantity[player] + topPlayers[i] = player..Theme.GetColor("INDICATOR_ALT"):ColorText(" (" .. quantity .. ")") + end + topPlayersText = table.concat(topPlayers, ", ", 1, numTopPlayers) + else + topPlayersText = L["None"] + end + TempTable.Release(topPlayers) + TempTable.Release(topPlayersQuantity) + + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "top") + :SetLayout("VERTICAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("ActionButton", "button") + :SetWidth(64) + :SetIcon("iconPack.14x14/Chevron/Right@180") + :SetText(BACK) + :SetScript("OnClick", private.ItemDetailBackButtonOnClick) + ) + :AddChild(UIElements.New("Button", "icon") + :SetSize(24, 24) + :SetMargin(14, 8, 0, 0) + :SetBackground(ItemInfo.GetTexture(private.contextItemString)) + :SetTooltip(private.contextItemString) + ) + :AddChild(UIElements.New("Text", "itemName") + :SetFont("ITEM_BODY1") + :SetText(TSM.UI.GetColoredItemName(private.contextItemString)) + ) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :SetMargin(0, 0, 8, 0) + :SetPadding(12, 12, 8, 10) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Frame", "heading") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Button", "saleBtn") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetTextColor(private.itemDetailType == "sale" and "INDICATOR" or "ACTIVE_BG_ALT") + :SetContext("sale") + :SetText(L["Sale Data"]) + :SetScript("OnClick", private.ItemDetailTabOnClick) + ) + :AddChild(UIElements.New("Button", "buyBtn") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetTextColor(private.itemDetailType == "buy" and "INDICATOR" or "ACTIVE_BG_ALT") + :SetContext("buy") + :SetText(L["Purchase Data"]) + :SetScript("OnClick", private.ItemDetailTabOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "total") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Total"]) + ) + :AddChild(UIElements.New("Text", "last7") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Last 7 Days"]) + ) + :AddChild(UIElements.New("Text", "last30") + :SetWidth(120) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("RIGHT") + :SetText(L["Last 30 Days"]) + ) + ) + :AddChild(UIElements.New("Frame", "quantity") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Text", "label") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetTextColor("TEXT_ALT") + :SetText(private.itemDetailType == "sale" and L["Quantity Sold:"] or L["Quantity Purchased:"]) + ) + :AddChild(UIElements.New("Text", "total") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(TSM.Accounting.Transactions.GetQuantity(private.contextItemString, nil, private.itemDetailType)) + ) + :AddChild(UIElements.New("Text", "last7") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(TSM.Accounting.Transactions.GetQuantity(private.contextItemString, SECONDS_PER_DAY * 7, private.itemDetailType)) + ) + :AddChild(UIElements.New("Text", "last30") + :SetWidth(120) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(TSM.Accounting.Transactions.GetQuantity(private.contextItemString, SECONDS_PER_DAY * 30, private.itemDetailType)) + ) + ) + :AddChild(UIElements.New("Frame", "avgPrice") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Text", "label") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetTextColor("TEXT_ALT") + :SetText(L["Average Prices:"]) + ) + :AddChild(UIElements.New("Text", "total") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(TSM.Accounting.Transactions.GetAveragePrice(private.contextItemString, nil, private.itemDetailType))) + ) + :AddChild(UIElements.New("Text", "last7") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(TSM.Accounting.Transactions.GetAveragePrice(private.contextItemString, SECONDS_PER_DAY * 7, private.itemDetailType))) + ) + :AddChild(UIElements.New("Text", "last30") + :SetWidth(120) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(TSM.Accounting.Transactions.GetAveragePrice(private.contextItemString, SECONDS_PER_DAY * 30, private.itemDetailType))) + ) + ) + :AddChild(UIElements.New("Frame", "totalPrice") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Text", "label") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetTextColor("TEXT_ALT") + :SetText(L["Total Prices:"]) + ) + :AddChild(UIElements.New("Text", "total") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(TSM.Accounting.Transactions.GetTotalPrice(private.contextItemString, nil, private.itemDetailType))) + ) + :AddChild(UIElements.New("Text", "last7") + :SetWidth(120) + :SetMargin(0, 8, 0, 0) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(TSM.Accounting.Transactions.GetTotalPrice(private.contextItemString, SECONDS_PER_DAY * 7, private.itemDetailType))) + ) + :AddChild(UIElements.New("Text", "last30") + :SetWidth(120) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(Money.ToString(TSM.Accounting.Transactions.GetTotalPrice(private.contextItemString, SECONDS_PER_DAY * 30, private.itemDetailType))) + ) + ) + :AddChild(UIElements.New("Frame", "top") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3") + :SetTextColor("TEXT_ALT") + :SetText(private.itemDetailType == "sale" and L["Top Buyers"]..":" or L["Top Sellers"]..":") + ) + :AddChild(UIElements.New("Text", "value") + :SetFont("BODY_BODY3") + :SetText(topPlayersText) + ) + ) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "scrollingTable") + :SetSettingsContext(private.settings, "ledgerDetailScrollingTable") + :GetScrollingTableInfo() + :NewColumn("activityType") + :SetTitle(L["Activity Type"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("type", private.TableGetActivityTypeText) + :Commit() + :NewColumn("source") + :SetTitle(L["Source"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("source") + :Commit() + :NewColumn("buyerSeller") + :SetTitle(L["Buyer/Seller"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("otherPlayer") + :Commit() + :NewColumn("qty") + :SetTitle(L["Qty"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("quantity") + :Commit() + :NewColumn("perItem") + :SetTitle(L["Per Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.TableGetPerItemText) + :Commit() + :NewColumn("totalPrice") + :SetTitle(L["Total Price"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.TableGetTotalPriceText) + :Commit() + :NewColumn("time") + :SetTitle(L["Time"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("time", private.TableGetTimeframeText) + :Commit() + :Commit() + :SetQuery(query) + :SetAutoReleaseQuery(true) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.ItemDetailScrollingTableOnRowClick) + ) +end + +function private.ItemDetailBackButtonOnClick(button) + button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():SetPath(private.contextPath, true) +end + +function private.ItemDetailTabOnClick(button) + private.itemDetailType = button:GetContext() + button:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement():ReloadContent() +end + +function private.ItemDetailScrollingTableOnRowClick(scrollingTable, row, button) + if button ~= "RightButton" then + return + elseif not TSM.Accounting.Transactions.CanDeleteByUUID(row:GetUUID()) then + Log.PrintUser(L["This record belongs to another account and can only be deleted on that account."]) + return + end + local subtitle = nil + local recordType, itemString, quantity, otherPlayer, price = row:GetFields("type", "itemString", "quantity", "otherPlayer", "price") + local name = TSM.UI.GetColoredItemName(itemString) or "?" + local amount = Money.ToString(price * quantity) + if recordType == "sale" then + subtitle = format(L["Sold %d of %s to %s for %s"], quantity, name, otherPlayer, amount) + elseif recordType == "buy" then + subtitle = format(L["Bought %d of %s from %s for %s"], quantity, name, otherPlayer, amount) + else + error("Unexpected Type: "..tostring(recordType)) + end + scrollingTable:GetBaseElement():ShowConfirmationDialog(L["Delete Record?"], subtitle, private.DeleteRecordConfirmed, row:GetUUID()) +end + +function private.DeleteRecordConfirmed(uuid) + TSM.Accounting.Transactions.RemoveRowByUUID(uuid) +end + + + +-- ============================================================================ +-- Scrolling Table Helper Functions +-- ============================================================================ + +function private.TableGetActivityTypeText(recordType) + if recordType == "sale" then + return L["Sale"] + elseif recordType == "buy" then + return L["Buy"] + else + error("Unexpected Type: "..tostring(recordType)) + end +end + +function private.TableGetTimeframeText(timestamp) + return SecondsToTime(time() - timestamp) +end + +function private.TableGetTotalPriceText(row) + return Money.ToString(row:GetField("price") * row:GetField("quantity")) +end + +function private.TableGetPerItemText(row) + return Money.ToString(row:GetField("price")) +end diff --git a/Core/UI/MainUI/Ledger/Expenses/Core.lua b/Core/UI/MainUI/Ledger/Expenses/Core.lua new file mode 100644 index 0000000..525b154 --- /dev/null +++ b/Core/UI/MainUI/Ledger/Expenses/Core.lua @@ -0,0 +1,23 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Expenses = TSM.MainUI.Ledger:NewPackage("Expenses") +local L = TSM.Include("Locale").GetTable() + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Expenses.OnInitialize() + TSM.MainUI.Ledger.RegisterPage(L["Expenses"]) +end + +function Expenses.RegisterPage(name, callback) + TSM.MainUI.Ledger.RegisterChildPage(L["Expenses"], name, callback) +end diff --git a/Core/UI/MainUI/Ledger/FailedAuctions/Core.lua b/Core/UI/MainUI/Ledger/FailedAuctions/Core.lua new file mode 100644 index 0000000..e438185 --- /dev/null +++ b/Core/UI/MainUI/Ledger/FailedAuctions/Core.lua @@ -0,0 +1,23 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local FailedAuctions = TSM.MainUI.Ledger:NewPackage("FailedAuctions") +local L = TSM.Include("Locale").GetTable() + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function FailedAuctions.OnInitialize() + TSM.MainUI.Ledger.RegisterPage(L["Failed Auctions"]) +end + +function FailedAuctions.RegisterPage(name, callback) + TSM.MainUI.Ledger.RegisterChildPage(L["Failed Auctions"], name, callback) +end diff --git a/Core/UI/MainUI/Ledger/Inventory.lua b/Core/UI/MainUI/Ledger/Inventory.lua new file mode 100644 index 0000000..47ee9bd --- /dev/null +++ b/Core/UI/MainUI/Ledger/Inventory.lua @@ -0,0 +1,329 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Inventory = TSM.MainUI.Ledger:NewPackage("Inventory") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local Math = TSM.Include("Util.Math") +local Database = TSM.Include("Util.Database") +local ItemInfo = TSM.Include("Service.ItemInfo") +local CustomPrice = TSM.Include("Service.CustomPrice") +local BagTracking = TSM.Include("Service.BagTracking") +local GuildTracking = TSM.Include("Service.GuildTracking") +local AuctionTracking = TSM.Include("Service.AuctionTracking") +local MailTracking = TSM.Include("Service.MailTracking") +local AltTracking = TSM.Include("Service.AltTracking") +local InventoryService = TSM.Include("Service.Inventory") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + db = nil, + query = nil, + searchFilter = "", + groupFilter = {}, + valuePriceSource = "dbmarket", -- luacheck: ignore 1005 - hidden modify via SetSettingInfo() +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Inventory.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "ledgerInventoryScrollingTable") + TSM.MainUI.Ledger.RegisterPage(L["Inventory"], private.DrawInventoryPage) +end + +function Inventory.OnEnable() + private.db = Database.NewSchema("LEDGER_INVENTORY") + :AddUniqueStringField("itemString") + :Commit() + private.query = private.db:NewQuery() + :VirtualField("bagQuantity", "number", BagTracking.GetBagsQuantityByBaseItemString, "itemString") + :VirtualField("guildQuantity", "number", private.GuildQuantityVirtualField, "itemString") + :VirtualField("auctionQuantity", "number", AuctionTracking.GetQuantityByBaseItemString, "itemString") + :VirtualField("mailQuantity", "number", MailTracking.GetQuantityByBaseItemString, "itemString") + :VirtualField("altQuantity", "number", AltTracking.GetQuantityByBaseItemString, "itemString") + :VirtualField("totalQuantity", "number", private.TotalQuantityVirtualField) + :VirtualField("totalValue", "number", private.TotalValueVirtualField) + :VirtualField("totalBankQuantity", "number", private.GetTotalBankQuantity) + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :LeftJoin(TSM.Groups.GetItemDBForJoin(), "itemString") + :OrderBy("name", true) +end + + + +-- ============================================================================ +-- Inventory UI +-- ============================================================================ + +function private.DrawInventoryPage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "inventory") + local items = TempTable.Acquire() + for _, itemString in BagTracking.BaseItemIterator() do + items[itemString] = true + end + for _, itemString in GuildTracking.BaseItemIterator() do + items[itemString] = true + end + for _, itemString in AuctionTracking.BaseItemIterator() do + items[itemString] = true + end + for _, itemString in MailTracking.BaseItemIterator() do + items[itemString] = true + end + for _, itemString in AltTracking.BaseItemIterator() do + items[itemString] = true + end + private.db:TruncateAndBulkInsertStart() + for itemString in pairs(items) do + private.db:BulkInsertNewRow(itemString) + end + private.db:BulkInsertEnd() + TempTable.Release(items) + private.UpdateQuery() + + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "filter") + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert() + :SetHintText(L["Filter by keyword"]) + :SetValue(private.searchFilter) + :SetScript("OnValueChanged", private.SearchFilterChanged) + ) + :AddChild(UIElements.New("GroupSelector", "group") + :SetWidth(240) + :SetHintText(L["Filter by groups"]) + :SetScript("OnSelectionChanged", private.GroupFilterChanged) + ) + ) + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetText(L["Value Price Source"]) + ) + :AddChild(UIElements.New("Input", "input") + :SetMargin(4, 8, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetBorderColor("ACTIVE_BG") + :SetFont("TABLE_TABLE1") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(private, "valuePriceSource") + :SetScript("OnValueChanged", private.FilterChangedCommon) + ) + :AddChild(UIElements.New("Frame", "value") + :SetLayout("HORIZONTAL") + :SetWidth(240) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetMargin(0, 4, 0, 0) + :SetText(L["Total Value"]..":") + ) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetText(Money.ToString(private.GetTotalValue())) + ) + ) + ) + :AddChild(UIElements.New("Frame", "accountingScrollingTableFrame") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("QueryScrollingTable", "scrollingTable") + :SetSettingsContext(private.settings, "ledgerInventoryScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :NewColumn("totalItems") + :SetTitle(L["Total"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("totalQuantity") + :SetSortInfo("totalQuantity") + :Commit() + :NewColumn("bags") + :SetTitle(L["Bags"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("bagQuantity") + :SetSortInfo("bagQuantity") + :Commit() + :NewColumn("banks") + :SetTitle(L["Banks"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("totalBankQuantity") + :SetSortInfo("totalBankQuantity") + :Commit() + :NewColumn("mail") + :SetTitle(L["Mail"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("mailQuantity") + :SetSortInfo("mailQuantity") + :Commit() + :NewColumn("alts") + :SetTitle(L["Alts"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("altQuantity") + :SetSortInfo("altQuantity") + :Commit() + :NewColumn("guildVault") + :SetTitle(L["GVault"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("guildQuantity") + :SetSortInfo("guildQuantity") + :Commit() + :NewColumn("auctionHouse") + :SetTitle(L["AH"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("auctionQuantity") + :SetSortInfo("auctionQuantity") + :Commit() + :NewColumn("totalValue") + :SetTitle(L["Value"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("totalValue", private.TableGetTotalValueText) + :SetSortInfo("totalValue") + :Commit() + :Commit() + :SetSelectionDisabled(true) + :SetQuery(private.query) + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FilterChangedCommon(element) + private.UpdateQuery() + element:GetElement("__parent.__parent.accountingScrollingTableFrame.scrollingTable") + :SetQuery(private.query, true) + element:GetElement("__parent.__parent.row2.value.value") + :SetText(Money.ToString(private.GetTotalValue())) + :Draw() +end + +function private.SearchFilterChanged(input) + private.searchFilter = input:GetValue() + private.FilterChangedCommon(input) +end + +function private.GroupFilterChanged(groupSelector) + wipe(private.groupFilter) + for groupPath in groupSelector:SelectedGroupIterator() do + private.groupFilter[groupPath] = true + end + private.FilterChangedCommon(groupSelector) +end + + + +-- ============================================================================ +-- Scrolling Table Helper Functions +-- ============================================================================ + +function private.TableGetTotalValueText(totalValue) + return Math.IsNan(totalValue) and "" or Money.ToString(totalValue) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GuildQuantityVirtualField(itemString) + local totalNum = 0 + for guildName in pairs(TSM.db.factionrealm.internalData.guildVaults) do + local guildQuantity = InventoryService.GetGuildQuantity(itemString, guildName) + totalNum = totalNum + guildQuantity + end + return totalNum +end + +function private.TotalQuantityVirtualField(row) + local bagQuantity, totalBankQuantity, guildQuantity, auctionQuantity, mailQuantity, altQuantity = row:GetFields("bagQuantity", "totalBankQuantity", "guildQuantity", "auctionQuantity", "mailQuantity", "altQuantity") + return bagQuantity + totalBankQuantity + guildQuantity + auctionQuantity + mailQuantity + altQuantity +end + +function private.TotalValueVirtualField(row) + local itemString, totalQuantity = row:GetFields("itemString", "totalQuantity") + local price = CustomPrice.GetValue(private.valuePriceSource, itemString) + if not price then + return Math.GetNan() + end + return price * totalQuantity +end + +function private.GetTotalBankQuantity(row) + local itemString = row:GetField("itemString") + local bankQuantity = BagTracking.GetBankQuantityByBaseItemString(itemString) + local reagentBankQuantity = BagTracking.GetReagentBankQuantityByBaseItemString(itemString) + return bankQuantity + reagentBankQuantity +end + +function private.GetTotalValue() + -- can't lookup the value of items while the query is iteratoring, so grab the list of items first + local itemQuantities = TempTable.Acquire() + for _, row in private.query:Iterator() do + local itemString, total = row:GetFields("itemString", "totalQuantity") + itemQuantities[itemString] = total + end + local totalValue = 0 + for itemString, total in pairs(itemQuantities) do + local price = CustomPrice.GetValue(private.valuePriceSource, itemString) + if price then + totalValue = totalValue + price * total + end + end + TempTable.Release(itemQuantities) + return totalValue +end + +function private.UpdateQuery() + private.query:ResetFilters() + if private.searchFilter ~= "" then + private.query:Matches("name", String.Escape(private.searchFilter)) + end + if next(private.groupFilter) then + private.query:InTable("groupPath", private.groupFilter) + end +end diff --git a/Core/UI/MainUI/Ledger/Revenue/Core.lua b/Core/UI/MainUI/Ledger/Revenue/Core.lua new file mode 100644 index 0000000..848451c --- /dev/null +++ b/Core/UI/MainUI/Ledger/Revenue/Core.lua @@ -0,0 +1,23 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Revenue = TSM.MainUI.Ledger:NewPackage("Revenue") +local L = TSM.Include("Locale").GetTable() + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Revenue.OnInitialize() + TSM.MainUI.Ledger.RegisterPage(L["Revenue"]) +end + +function Revenue.RegisterPage(name, callback) + TSM.MainUI.Ledger.RegisterChildPage(L["Revenue"], name, callback) +end diff --git a/Core/UI/MainUI/Ledger/Revenue/Resale.lua b/Core/UI/MainUI/Ledger/Revenue/Resale.lua new file mode 100644 index 0000000..d15bbbe --- /dev/null +++ b/Core/UI/MainUI/Ledger/Revenue/Resale.lua @@ -0,0 +1,299 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Resale = TSM.MainUI.Ledger.Revenue:NewPackage("Resale") +local L = TSM.Include("Locale").GetTable() +local Table = TSM.Include("Util.Table") +local Money = TSM.Include("Util.Money") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local SECONDS_PER_DAY = 24 * 60 * 60 +local private = { + settings = nil, + summaryQuery = nil, + characters = {}, + characterFilter = {}, + typeFilter = {}, + rarityList = {}, + rarityFilter = {}, + groupFilter = {}, + searchFilter = "", + timeFrameFilter = 30 * SECONDS_PER_DAY +} +local TYPE_LIST = { L["Auction"], COD, TRADE, L["Vendor"] } +local TYPE_KEYS = { "Auction", "COD", "Trade", "Vendor" } +do + for _, key in ipairs(TYPE_KEYS) do + private.typeFilter[key] = true + end + for i = 1, 4 do + tinsert(private.rarityList, _G[format("ITEM_QUALITY%d_DESC", i)]) + private.rarityFilter[i] = true + end +end +local TIME_LIST = { L["All Time"], L["Last 3 Days"], L["Last 7 Days"], L["Last 14 Days"], L["Last 30 Days"], L["Last 60 Days"] } +local TIME_KEYS = { 0, 3 * SECONDS_PER_DAY, 7 * SECONDS_PER_DAY, 14 * SECONDS_PER_DAY, 30 * SECONDS_PER_DAY, 60 * SECONDS_PER_DAY } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Resale.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "ledgerResaleScrollingTable") + TSM.MainUI.Ledger.Revenue.RegisterPage(L["Resale"], private.DrawResalePage) +end + + + +-- ============================================================================ +-- Resale UI +-- ============================================================================ + +function private.DrawResalePage() + TSM.UI.AnalyticsRecordPathChange("main", "ledger", "revenue", "resale") + wipe(private.characters) + TSM.Accounting.Transactions.GetCharacters(private.characters) + for _, character in ipairs(private.characters) do + private.characterFilter[character] = true + end + + private.summaryQuery = private.summaryQuery or TSM.Accounting.Transactions.CreateSummaryQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + :OrderBy("name", true) + private.UpdateQuery() + local totalProfit = 0 + local numItems = 0 + for _, row in private.summaryQuery:Iterator() do + totalProfit = totalProfit + row:GetField("totalProfit") + numItems = numItems + min(row:GetFields("sold", "bought")) + end + + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8) + :AddChild(UIElements.New("Input", "filter") + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert() + :SetHintText(L["Filter by keyword"]) + :SetValue(private.searchFilter) + :SetScript("OnValueChanged", private.SearchFilterChanged) + ) + :AddChild(UIElements.New("GroupSelector", "group") + :SetWidth(240) + :SetHintText(L["Filter by groups"]) + :SetScript("OnSelectionChanged", private.GroupFilterChanged) + ) + ) + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(8, 8, 0, 8) + :AddChild(UIElements.New("MultiselectionDropdown", "type") + :SetMargin(0, 8, 0, 0) + :SetItems(TYPE_LIST, TYPE_KEYS) + :SetSettingInfo(private, "typeFilter") + :SetSelectionText(L["No Types"], L["%d Types"], L["All Types"]) + :SetScript("OnSelectionChanged", private.FilterChangedCommon) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "rarity") + :SetMargin(0, 8, 0, 0) + :SetItems(private.rarityList) + :SetSettingInfo(private, "rarityFilter") + :SetSelectionText(L["No Rarities"], L["%d Rarities"], L["All Rarities"]) + :SetScript("OnSelectionChanged", private.FilterChangedCommon) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "character") + :SetMargin(0, 8, 0, 0) + :SetItems(private.characters, private.characters) + :SetSettingInfo(private, "characterFilter") + :SetSelectionText(L["No Characters"], L["%d Characters"], L["All Characters"]) + :SetScript("OnSelectionChanged", private.FilterChangedCommon) + ) + :AddChild(UIElements.New("SelectionDropdown", "time") + :SetItems(TIME_LIST, TIME_KEYS) + :SetSelectedItemByKey(private.timeFrameFilter) + :SetSettingInfo(private, "timeFrameFilter") + :SetScript("OnSelectionChanged", private.FilterChangedCommon) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "scrollingTable") + :SetSettingsContext(private.settings, "ledgerResaleScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetSortInfo("name") + :SetTooltipInfo("itemString") + :DisableHiding() + :Commit() + :NewColumn("bought") + :SetTitle(L["Bought"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("bought") + :SetSortInfo("bought") + :Commit() + :NewColumn("avgBuyPrice") + :SetTitle(L["Avg Buy Price"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("avgBuyPrice", private.GetMoneyText) + :SetSortInfo("avgBuyPrice") + :Commit() + :NewColumn("sold") + :SetTitle(L["Sold"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("sold") + :SetSortInfo("sold") + :Commit() + :NewColumn("avgSellPrice") + :SetTitle(L["Avg Sell Price"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("avgSellPrice", private.GetMoneyText) + :SetSortInfo("avgSellPrice") + :Commit() + :NewColumn("avgProfit") + :SetTitle(L["Avg Profit"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("avgProfit", private.GetColoredMoneyText) + :SetSortInfo("avgProfit") + :Commit() + :NewColumn("totalProfit") + :SetTitle(L["Total Profit"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("totalProfit", private.GetColoredMoneyText) + :SetSortInfo("totalProfit") + :Commit() + :NewColumn("profitPct") + :SetTitle("%") + :SetFont("ITEM_BODY3") + :SetJustifyH("RIGHT") + :SetTextInfo("profitPct", private.GetPctText) + :SetSortInfo("profitPct") + :Commit() + :Commit() + :SetQuery(private.summaryQuery) + :SetScript("OnRowClick", private.TableSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Text", "num") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%s Items Resold"], Theme.GetColor("INDICATOR"):ColorText(FormatLargeNumber(numItems)))) + ) + :AddChild(UIElements.New("Texture", "line") + :SetMargin(4, 8, 0, 0) + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Text", "profit") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["%s Total Profit"], Money.ToString(totalProfit))) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) +end + + + +-- ============================================================================ +-- Scrolling Table Helper Functions +-- ============================================================================ + +function private.GetMoneyText(value) + return Money.ToString(value) +end + +function private.GetColoredMoneyText(value) + return Money.ToString(value, Theme.GetFeedbackColor(value >= 0 and "GREEN" or "RED"):GetTextColorPrefix()) +end + +function private.GetPctText(value) + return Theme.GetFeedbackColor(value >= 0 and "GREEN" or "RED"):ColorText(value.."%") +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FilterChangedCommon(dropdown) + private.UpdateQuery() + local totalProfit = 0 + local numItems = 0 + for _, row in private.summaryQuery:Iterator() do + totalProfit = totalProfit + row:GetField("totalProfit") + numItems = numItems + min(row:GetFields("sold", "bought")) + end + dropdown:GetElement("__parent.__parent.scrollingTable"):UpdateData(true) + local footer = dropdown:GetElement("__parent.__parent.footer") + footer:GetElement("num"):SetText(format(L["%s Items Resold"], Theme.GetColor("INDICATOR"):ColorText(FormatLargeNumber(numItems)))) + footer:GetElement("profit"):SetText(format(L["%s Total Profit"], Money.ToString(totalProfit))) + footer:Draw() +end + +function private.SearchFilterChanged(input) + private.searchFilter = input:GetValue() + private.FilterChangedCommon(input) +end + +function private.GroupFilterChanged(groupSelector) + wipe(private.groupFilter) + for groupPath in groupSelector:SelectedGroupIterator() do + private.groupFilter[groupPath] = true + end + private.FilterChangedCommon(groupSelector) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateQuery() + private.summaryQuery:ResetFilters() + local groupFilter = next(private.groupFilter) and private.groupFilter or nil + local searchFilter = private.searchFilter ~= "" and private.searchFilter or nil + local typeFilter = Table.Count(private.typeFilter) ~= #TYPE_KEYS and private.typeFilter or nil + local characterFilter = Table.Count(private.characterFilter) ~= #private.characters and private.characterFilter or nil + local minTime = private.timeFrameFilter ~= 0 and (time() - private.timeFrameFilter) or nil + TSM.Accounting.Transactions.UpdateSummaryData(groupFilter, searchFilter, typeFilter, characterFilter, minTime) + if Table.Count(private.rarityFilter) ~= #private.rarityList then + private.summaryQuery:InTable("quality", private.rarityFilter) + end +end + +function private.TableSelectionChanged(scrollingTable, row) + TSM.MainUI.Ledger.ShowItemDetail(scrollingTable:GetParentElement():GetParentElement(), row:GetField("itemString"), "sale") +end diff --git a/Core/UI/MainUI/Operations/Auctioning.lua b/Core/UI/MainUI/Operations/Auctioning.lua new file mode 100644 index 0000000..52579e6 --- /dev/null +++ b/Core/UI/MainUI/Operations/Auctioning.lua @@ -0,0 +1,360 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctioning = TSM.MainUI.Operations:NewPackage("Auctioning") +local L = TSM.Include("Locale").GetTable() +local String = TSM.Include("Util.String") +local Money = TSM.Include("Util.Money") +local Vararg = TSM.Include("Util.Vararg") +local CustomPrice = TSM.Include("Service.CustomPrice") +local UIElements = TSM.Include("UI.UIElements") +local private = { + currentOperationName = nil, + currentTab = nil, +} +local IGNORE_DURATION_OPTIONS = { + L["None"], + AUCTION_TIME_LEFT1.." ("..AUCTION_TIME_LEFT1_DETAIL..")", + AUCTION_TIME_LEFT2.." ("..AUCTION_TIME_LEFT2_DETAIL..")", + AUCTION_TIME_LEFT3.." ("..AUCTION_TIME_LEFT3_DETAIL..")", +} +local BELOW_MIN_ITEMS = { L["Don't Post Items"], L["Post at Minimum Price"], L["Post at Maximum Price"], L["Post at Normal Price"], L["Ignore Auctions Below Min"] } +local BELOW_MIN_KEYS = { "none", "minPrice", "maxPrice", "normalPrice", "ignore" } +local ABOVE_MAX_ITEMS = { L["Don't Post Items"], L["Post at Minimum Price"], L["Post at Maximum Price"], L["Post at Normal Price"] } +local ABOVE_MAX_KEYS = { "none", "minPrice", "maxPrice", "normalPrice" } +local BAD_PRICE_SOURCES = { auctioningopmin = true, auctioningopmax = true, auctioningopnormal = true } + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctioning.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Auctioning", private.GetAuctioningOperationSettings) +end + + + +-- ============================================================================ +-- Auctioning Operation Settings UI +-- ============================================================================ + +function private.GetAuctioningOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "auctioning") + private.currentOperationName = operationName + private.currentTab = private.currentTab or L["Details"] + return UIElements.New("TabGroup", "tabs") + :SetMargin(0, 0, 8, 0) + :SetNavCallback(private.GetAuctioningSettings) + :AddPath(L["Details"]) + :AddPath(L["Posting"]) + :AddPath(L["Canceling"]) + :SetPath(private.currentTab) +end + +function private.GetDetailsSettings() + TSM.UI.AnalyticsRecordPathChange("main", "operations", "auctioning", "details") + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Auctioning", "generalOptions", L["General Options"], L["Adjust some general settings."]) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChildrenWithFunction(private.AddMaxStackSizeSetting) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("ignoreLowDuration", L["Ignore auctions by duration"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetHeight(24) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "ignoreLowDuration")) + :SetItems(IGNORE_DURATION_OPTIONS) + :SetSelectedItemByKey(operation.ignoreLowDuration + 1, true) + :SetScript("OnSelectionChanged", private.IgnoreLowDurationOnSelectionChanged) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("blacklist", L["Blacklisted players"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetHintText(L["Enter player name"]) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "blacklist")) + :SetScript("OnEnterPressed", private.BlacklistInputOnEnterPressed) + ) + ) + :AddChildrenWithFunction(private.AddBlacklistPlayers) + ) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Auctioning", private.currentOperationName)) +end + +function private.AddMaxStackSizeSetting(frame) + if TSM.IsWowClassic() then + frame:AddChild(private.CreateToggleLine("matchStackSize", L["Match stack size"])) + end +end + +function private.GetPostingSettings() + TSM.UI.AnalyticsRecordPathChange("main", "operations", "auctioning", "posting") + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Auctioning", "postingSettings", L["Posting Options"], L["Adjust the settings below to set how groups attached to this operation will be auctioned."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("duration", L["Auction duration"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetHeight(24) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "duration")) + :SetItems(TSM.CONST.AUCTION_DURATIONS) + :SetSelectedItemByKey(operation.duration) + :SetScript("OnSelectionChanged", private.SetAuctioningDuration) + ) + + ) + :AddChild(private.CreateInputLine("postCap", L["Post cap"], false, true)) + :AddChildrenWithFunction(private.AddStackSizeSettings) + :AddChild(private.CreateInputLine("keepQuantity", L["Amount kept in bags"], false, true)) + :AddChild(private.CreateInputLine("maxExpires", L["Don't post after this many expires"])) + ) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Auctioning", "priceSettings", L["Posting Price"], L["Adjust the settings below to set how groups attached to this operation will be priced."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("bidPercent", L["Set bid as percentage of buyout"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc(private.BidPercentValidateFunc) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "bidPercent")) + :SetValue((operation.bidPercent * 100).."%") + :SetScript("OnValueChanged", private.BidPercentOnValueChanged) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetFormattedText(L["Enter a value from %d - %d%%"], 0, 100) + :SetTextColor(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "bidPercent") and "TEXT_DISABLED" or "TEXT") + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("undercut", L["Undercut amount"], 66, private.CheckUndercut) + :SetMargin(0, 0, 0, 12) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("minPrice", L["Minimum price"], 126, BAD_PRICE_SOURCES)) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("priceReset", L["When below minimum:"]) + :SetMargin(0, 0, 12, 12) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetWidth(240) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "priceReset")) + :SetItems(BELOW_MIN_ITEMS, BELOW_MIN_KEYS) + :SetSettingInfo(operation, "priceReset") + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("maxPrice", L["Maximum price"], 126, BAD_PRICE_SOURCES)) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("aboveMax", L["When above maximum:"]) + :SetMargin(0, 0, 12, 12) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetWidth(240) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, "aboveMax")) + :SetItems(ABOVE_MAX_ITEMS, ABOVE_MAX_KEYS) + :SetSettingInfo(operation, "aboveMax") + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("normalPrice", L["Normal price"], 126, BAD_PRICE_SOURCES)) + ) +end + +function private.AddStackSizeSettings(frame) + if not TSM.IsWowClassic() then + return + end + frame:AddChild(private.CreateInputLine("stackSize", L["Stack size"], false, true)) + frame:AddChild(private.CreateToggleLine("stackSizeIsCap", L["Allow partial stack"])) +end + +function private.GetCancelingSettings() + TSM.UI.AnalyticsRecordPathChange("main", "operations", "auctioning", "canceling") + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Auctioning", "priceSettings", L["Canceling Options"], L["Adjust the settings below to set how groups attached to this operation will be cancelled."]) + :AddChild(private.CreateToggleLine("cancelUndercut", L["Cancel undercut auctions"])) + :AddChild(private.CreateToggleLine("cancelRepost", L["Cancel to repost higher"])) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("cancelRepostThreshold", L["Repost threshold"], 66)) + ) +end + +function private.GetAuctioningSettings(self, button) + private.currentTab = button + if button == L["Details"] then + return private.GetDetailsSettings() + elseif button == L["Posting"] then + return private.GetPostingSettings() + elseif button == L["Canceling"] then + return private.GetCancelingSettings() + else + error("Unknown button!") + end +end + +function private.AddBlacklistPlayers(frame) + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + if operation.blacklist == "" then return end + local containerFrame = UIElements.New("Frame", "blacklistFrame") + :SetLayout("FLOW") + for index, player in Vararg.Iterator(strsplit(",", operation.blacklist)) do + containerFrame:AddChild(UIElements.New("Frame", "blacklist" .. index) + :SetLayout("HORIZONTAL") + :SetSize(100, 20) + :SetMargin(0, 12, 0, 0) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetMargin(0, 2, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(player) + ) + :AddChild(UIElements.New("Button", "removeBtn") + :SetBackgroundAndSize("iconPack.14x14/Close/Circle") + :SetContext(player) + :SetScript("OnClick", private.RemoveBlacklistOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + end + frame:AddChild(containerFrame) +end + +function private.CreateInputLine(key, label, disabled, margin) + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + local hasRelationship = TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, key) + return TSM.MainUI.Operations.CreateLinkedSettingLine(key, label, disabled) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, margin and 12 or 4) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetContext(key) + :SetDisabled(hasRelationship or disabled) + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, key) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor((hasRelationship or disabled) and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Supported value range: %d - %d"], TSM.Operations.Auctioning.GetMinMaxValues(key)) + ) + ) +end + +function private.CreateToggleLine(key, label) + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + return TSM.MainUI.Operations.CreateLinkedSettingLine(key, label) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetDisabled(TSM.Operations.HasRelationship("Auctioning", private.currentOperationName, key)) + :SetSettingInfo(operation, key) + ) +end + +function private.CheckUndercut(_, value) + if not TSM.IsWowClassic() and Money.FromString(Money.ToString(value) or value) == 0 then + return true + elseif not TSM.IsWowClassic() and (Money.FromString(Money.ToString(value) or value) or math.huge) < COPPER_PER_SILVER then + return false, L["Invalid undercut. To post below the cheapest auction without a significant undercut, set your undercut to 0c."] + else + local isValid, err = CustomPrice.Validate(value) + if isValid then + return true + end + return false, L["Invalid custom price."].." "..err + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.IgnoreLowDurationOnSelectionChanged(self) + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + operation.ignoreLowDuration = self:GetSelectedItemKey() - 1 +end + +function private.BlacklistInputOnEnterPressed(input) + local newPlayer = input:GetValue() + if newPlayer == "" or strfind(newPlayer, ",") or newPlayer ~= String.Escape(newPlayer) then + -- this is an invalid player name + return + end + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + local found = false + for _, player in Vararg.Iterator(strsplit(",", operation.blacklist)) do + if newPlayer == player then + -- this player is already added + input:SetValue("") + found = true + end + end + if found then + return + end + operation.blacklist = (operation.blacklist == "") and newPlayer or (operation.blacklist..","..newPlayer) + input:GetElement("__parent.__parent.__parent.__parent.__parent.__parent"):ReloadContent() +end + +function private.RemoveBlacklistOnClick(button) + local player = button:GetContext() + -- FIXME: This sort of logic should go within some Auctioning-specific operation setting wrapper code + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + if operation.blacklist == player then + operation.blacklist = "" + else + -- handle cases where this entry is at the start, in the middle, and at the end + operation.blacklist = gsub(operation.blacklist, "^"..player..",", "") + operation.blacklist = gsub(operation.blacklist, ","..player..",", ",") + operation.blacklist = gsub(operation.blacklist, ","..player.."$", "") + end + button:GetElement("__parent.__parent.__parent.__parent.__parent.__parent.__parent"):ReloadContent() +end + +function private.SetAuctioningDuration(dropdown) + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + operation.duration = dropdown:GetSelectedItemKey() +end + +function private.BidPercentValidateFunc(_, value) + value = strmatch(value, "^([0-9]+) *%%?$") + value = tonumber(value) + if not value or value < 0 or value > 100 then + return false, L["Bid percent must be between 0 and 100."] + end + return true +end + +function private.BidPercentOnValueChanged(input) + local operation = TSM.Operations.GetSettings("Auctioning", private.currentOperationName) + local value = strmatch(input:GetValue(), "^([0-9]+) *%%?$") + value = tonumber(value) / 100 + operation.bidPercent = value +end diff --git a/Core/UI/MainUI/Operations/Core.lua b/Core/UI/MainUI/Operations/Core.lua new file mode 100644 index 0000000..fbb9b53 --- /dev/null +++ b/Core/UI/MainUI/Operations/Core.lua @@ -0,0 +1,867 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Operations = TSM.MainUI:NewPackage("Operations") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local Theme = TSM.Include("Util.Theme") +local Money = TSM.Include("Util.Money") +local TempTable = TSM.Include("Util.TempTable") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + moduleNames = {}, + moduleCollapsed = {}, + moduleCallbacks = {}, + currentModule = nil, + currentOperationName = nil, + playerList = {}, + linkMenuEntries = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Operations.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "mainUIContext", "operationsDividedContainer") + :AddKey("global", "mainUIContext", "operationsSummaryScrollingTable") + TSM.MainUI.RegisterTopLevelPage(L["Operations"], private.GetOperationsFrame) +end + +function Operations.RegisterModule(name, callback) + tinsert(private.moduleNames, name) + private.moduleCallbacks[name] = callback +end + +function Operations.ShowOperationSettings(baseFrame, moduleName, operationName) + baseFrame:SetSelectedNavButton(L["Operations"], true) + baseFrame:GetElement("content.operations.selection.operationTree"):SetSelectedOperation(moduleName, operationName) +end + +function Operations.GetOperationManagementElements(moduleName, operationName) + local operation = TSM.Operations.GetSettings(private.currentModule, private.currentOperationName) + wipe(private.playerList) + for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + tinsert(private.playerList, character.." - "..factionrealm) + end + end + return UIElements.New("Frame", "management") + :SetLayout("VERTICAL") + :AddChild(Operations.CreateExpandableSection(moduleName, "managementOptions", L["Management Options"], L["Below you can ignore this operation on certain characters or realms."]) + :AddChild(Operations.CreateSettingLine("ignoreFactionRealms", L["Ignore operation on faction-realms"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("MultiselectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(TSM.db:GetScopeKeys("factionrealm"), TSM.db:GetScopeKeys("factionrealm")) + :SetSelectionText(L["No Faction-Realms"], L["%d Faction-Realms"], L["All Faction-Realms"]) + :SetSettingInfo(operation, "ignoreFactionrealm") + ) + ) + :AddChild(Operations.CreateSettingLine("ignoreCharacters", L["Ignore operation on characters"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :AddChild(UIElements.New("MultiselectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(private.playerList, private.playerList) + :SetSelectionText(L["No Characters"], L["%d Characters"], L["All Characters"]) + :SetSettingInfo(operation, "ignorePlayer") + ) + ) + ) + :AddChild(Operations.CreateExpandableSection(moduleName, "groupManagement", L["Group Management"], L["Here you can add/remove what groups this operation is attached to."]) + :AddChild(Operations.CreateSettingLine("applyNewGroup", L["Apply operation to group"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :AddChild(UIElements.New("GroupSelector", "group") + :SetHintText(L["Add operation to groups"]) + :SetScript("OnSelectionChanged", private.GroupSelectionChanged) + ) + ) + :AddChildrenWithFunction(private.AddOperationGroups) + ) +end + +function Operations.CreateExpandableSection(moduleName, id, text, description) + return UIElements.New("CollapsibleContainer", id) + :SetLayout("VERTICAL") + :SetMargin(0, 0, 0, 8) + :SetContextTable(private.moduleCollapsed, moduleName..text) + :SetHeadingText(text) + :AddChild(UIElements.New("Text", "description") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY3") + :SetText(description) + ) +end + +function Operations.CreateLinkedSettingLine(settingKey, labelText, disabled, alternateName) + local relationshipSet = TSM.Operations.HasRelationship(private.currentModule, private.currentOperationName, settingKey) + return UIElements.New("Frame", alternateName or settingKey) + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Frame", "line") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor((relationshipSet or disabled) and "TEXT_DISABLED" or "TEXT") + :SetText(labelText) + ) + :AddChild(private.CreateLinkButton(disabled, settingKey)) + :AddChild(UIElements.New("Spacer", "spacer")) + ) +end + +function Operations.CreateSettingLine(id, labelText, disabled) + return UIElements.New("Frame", id) + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor(disabled and "TEXT_DISABLED" or "TEXT") + :SetText(labelText) + ) +end + +function Operations.CreateLinkedPriceInput(settingKey, label, height, validate, defaultValue) + local isDisabled = TSM.Operations.HasRelationship(private.currentModule, private.currentOperationName, settingKey) + local operation = TSM.Operations.GetSettings(private.currentModule, private.currentOperationName) + local value = operation[settingKey] + if defaultValue ~= nil and (not value or value == "") then + isDisabled = true + value = defaultValue + end + local validateFunc, validateContext = nil, nil + if type(validate) == "table" then + validateFunc = "CUSTOM_PRICE" + validateContext = validate + elseif type(validate) == "function" then + validateFunc = validate + elseif validate == nil then + validateFunc = "CUSTOM_PRICE" + else + error("Invalid validate: "..tostring(validate)) + end + return Operations.CreateLinkedSettingLine(settingKey, label) + :SetLayout("VERTICAL") + :SetHeight(height) + :AddChild(UIElements.New("MultiLineInput", "input") + :SetHeight(height - 24) + :SetDisabled(isDisabled) + :SetValidateFunc(validateFunc, validateContext) + :SetSettingInfo(operation, settingKey) + :SetValue(Money.ToString(value) or value) + ) +end + + + +-- ============================================================================ +-- Operations UI +-- ============================================================================ + +function private.GetOperationsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "operations") + local frame = UIElements.New("DividedContainer", "operations") + :SetSettingsContext(private.settings, "operationsDividedContainer") + :SetMinWidth(250, 250) + :SetLeftChild(UIElements.New("Frame", "selection") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Input", "search") + :SetHeight(24) + :SetMargin(8, 8, 8, 16) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert(true) + :SetHintText(L["Search Operations"]) + :SetScript("OnValueChanged", private.OperationSearchOnValueChanged) + ) + :AddChild(UIElements.New("OperationTree", "operationTree") + :SetScript("OnOperationAdded", private.OperationTreeOnOperationAdded) + :SetScript("OnOperationDeleted", private.OperationTreeOnOperationConfirmDelete) + :SetScript("OnOperationSelected", private.OperationTreeOnOperationSelected) + ) + ) + :SetRightChild(UIElements.New("ViewContainer", "content") + :SetNavCallback(private.GetOperationsContent) + :AddPath("none", true) + :AddPath("summary") + :AddPath("operation") + ) + return frame +end + +function private.GetOperationsContent(_, path) + if path == "none" then + return UIElements.New("Frame", "settings") + :SetLayout("VERTICAL") + :SetWidth("EXPAND") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :AddChild(UIElements.New("Texture", "icon") + :SetMargin(0, 8, 0, 0) + :SetTextureAndSize(TSM.UI.TexturePacks.GetColoredKey("iconPack.18x18/Operation", "TEXT")) + ) + :AddChild(UIElements.New("Text", "text") + :SetFont("BODY_BODY1_BOLD") + :SetText(L["No Operation Selected"]) + ) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + elseif path == "summary" then + return UIElements.New("Frame", "settings") + :SetLayout("VERTICAL") + :SetWidth("EXPAND") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY1_BOLD") + ) + :AddChild(UIElements.New("Spacer")) + :AddChild(UIElements.New("Button", "addBtn") + :SetWidth("AUTO") + :SetMargin(12, 12, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetIcon("iconPack.14x14/Add/Circle", "LEFT") + :SetText(L["Create New"]) + :SetScript("OnClick", private.CreateNewOperationOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + -- will be filled in by the operation selection callback + ) + elseif path == "operation" then + return UIElements.New("Frame", "settings") + :SetLayout("VERTICAL") + :SetWidth("EXPAND") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "title") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :AddChild(UIElements.New("Texture", "icon") + :SetMargin(0, 8, 0, 0) + :SetTextureAndSize(TSM.UI.TexturePacks.GetColoredKey("iconPack.18x18/Operation", "TEXT")) + ) + :AddChild(UIElements.New("EditableText", "text") + :SetWidth("AUTO") + :AllowItemInsert(true) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["No Operation Selected"]) + :SetScript("OnValueChanged", private.OperationNameChanged) + :SetScript("OnEditingChanged", private.NameOnEditingChanged) + ) + :AddChild(UIElements.New("Spacer")) + :AddChild(UIElements.New("Button", "renameBtn") + :SetWidth("AUTO") + :SetMargin(12, 12, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetIcon("iconPack.14x14/Edit", "LEFT") + :SetText(L["Rename"]) + :SetScript("OnClick", private.RenameOperationOnClick) + ) + :AddChild(UIElements.New("Button", "resetBtn") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetIcon("iconPack.14x14/Reset", "LEFT") + :SetText(L["Reset"]) + :SetScript("OnClick", private.ResetOperationOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + -- will be filled in by the operation selection callback + ) + else + error("Invalid path: "..tostring(path)) + end +end + +function private.GetSummaryContent() + local query = TSM.Operations.CreateQuery() + :Equal("moduleName", private.currentModule) + :VirtualField("numGroups", "number", private.NumGroupsVirtualField) + :VirtualField("numItems", "number", private.NumItemsVirtualField) + :OrderBy("operationName", true) + local mostGroupsName, mostGroupsValue = "---", -math.huge + local leastGroupsName, leastGroupsValue = "---", math.huge + local mostItemsName, mostItemsValue = "---", -math.huge + local leastItemsName, leastItemsValue = "---", math.huge + for _, row in query:Iterator() do + local operationName, numGroups, numItems = row:GetFields("operationName", "numGroups", "numItems") + if numGroups > mostGroupsValue then + mostGroupsValue = numGroups + mostGroupsName = operationName + end + if numGroups < leastGroupsValue then + leastGroupsValue = numGroups + leastGroupsName = operationName + end + if numItems > mostItemsValue then + mostItemsValue = numItems + mostItemsName = operationName + end + if numItems < leastItemsValue then + leastItemsValue = numItems + leastItemsName = operationName + end + end + return UIElements.New("Frame", "summary") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("Frame", "summary") + :SetLayout("HORIZONTAL") + :SetHeight(48) + :SetMargin(8, 8, 0, 16) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Frame", "groups") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 2, 2) + :AddChild(UIElements.New("Frame", "most") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["MOST GROUPS"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(mostGroupsName) + ) + ) + :AddChild(UIElements.New("Frame", "least") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["LEAST GROUPS"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(leastGroupsName) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line1") + :SetWidth(1) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "items") + :SetLayout("VERTICAL") + :SetPadding(8, 8, 2, 2) + :AddChild(UIElements.New("Frame", "most") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["MOST ITEMS"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(mostItemsName) + ) + ) + :AddChild(UIElements.New("Frame", "least") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetTextColor("ACTIVE_BG_ALT") + :SetText(L["LEAST ITEMS"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "value") + :SetWidth("AUTO") + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetText(leastItemsName) + ) + ) + ) + ) + :AddChild(UIElements.New("SelectionScrollingTable", "list") + :SetSettingsContext(private.settings, "operationsSummaryScrollingTable") + :GetScrollingTableInfo() + :NewColumn("name") + :SetTitle(L["Operation"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("operationName") + :SetSortInfo("operationName") + :SetActionIconInfo(1, 12, private.GetConfigureIcon, true) + :SetActionIconClickHandler(private.OnConfigureIconClick) + :DisableHiding() + :Commit() + :NewColumn("groups") + :SetTitle(L["Groups Using"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("numGroups") + :SetSortInfo("numGroups") + :Commit() + :NewColumn("items") + :SetTitle(L["Items Using"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("LEFT") + :SetTextInfo("numItems", private.GetNumItemsText) + :SetSortInfo("numItems") + :SetTooltipInfo("numItems", private.GetNumItemsTooltip) + :Commit() + :Commit() + :SetQuery(query) + :SetContext(query) + :SetAutoReleaseQuery(true) + :SetScript("OnSelectionChanged", private.OperationListOnSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "deleteSelected") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetDisabled(true) + :SetText(L["Delete Operations"]) + :SetScript("OnClick", private.DeleteSelectedOnClick) + ) + :AddChild(UIElements.New("Button", "selectAll") + :SetSize("AUTO", 20) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Select All"]) + :SetScript("OnClick", private.SelectAllOnClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetSize(2, 20) + :SetMargin(0, 8, 0, 0) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Button", "clearAll") + :SetSize("AUTO", 20) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Clear All"]) + :SetDisabled(true) + :SetScript("OnClick", private.ClearAllOnClick) + ) + ) +end + +function private.AddOperationGroups(frame) + for _, groupPath in TSM.Operations.GroupIterator(private.currentModule, private.currentOperationName, true) do + frame:AddChild(private.CreateGroupOperationLine(groupPath)) + end +end + +function private.CreateGroupOperationLine(groupPath) + local groupName = groupPath == TSM.CONST.ROOT_GROUP_PATH and L["Base Group"] or TSM.Groups.Path.GetName(groupPath) + local level = select('#', strsplit(TSM.CONST.GROUP_SEP, groupPath)) + return UIElements.New("Frame", "group") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(2, 0, 0, 0) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetTextColor(Theme.GetGroupColor(level)) + :SetText(groupName) + ) + :AddChild(UIElements.New("Button", "viewBtn") + :SetMargin(2, 2, 0, 0) + :SetBackgroundAndSize("iconPack.14x14/Groups") + :SetContext(groupPath) + :SetScript("OnClick", private.ViewGroupOnClick) + ) + :AddChild(UIElements.New("Button", "removeBtn") + :SetBackgroundAndSize("iconPack.14x14/Close/Default") + :SetContext(groupPath) + :SetScript("OnClick", private.RemoveOperationGroupOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) +end + +function private.CreateLinkButton(disabled, settingKey) + local relationshipSet = TSM.Operations.HasRelationship(private.currentModule, private.currentOperationName, settingKey) + local linkTexture = nil + if disabled and relationshipSet then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "INDICATOR_DISABLED") + elseif disabled then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "TEXT_DISABLED") + elseif relationshipSet then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "INDICATOR") + else + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "TEXT") + end + return UIElements.New("Button", "linkBtn") + :SetMargin(4, 4, 0, 0) + :SetBackgroundAndSize(linkTexture) + :SetDisabled(disabled) + :SetContext(settingKey) + :SetScript("OnClick", private.LinkBtnOnClick) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OperationSearchOnValueChanged(input) + local filter = strlower(input:GetValue()) + input:GetElement("__parent.operationTree"):SetOperationNameFilter(filter) +end + +function private.OperationTreeOnOperationAdded(operationTree, moduleName, operationName, copyOperationName) + -- clear the filter + operationTree:GetElement("__parent.search") + :SetValue("") + :Draw() + operationTree:SetOperationNameFilter("") + TSM.Operations.Create(moduleName, operationName) + if copyOperationName then + TSM.Operations.Copy(moduleName, operationName, copyOperationName) + end +end + +function private.OperationTreeOnOperationConfirmDelete(self, moduleName, operationName) + self:GetBaseElement():ShowConfirmationDialog(L["Delete Operation?"], L["Are you sure you want to delete this operation?"], private.OperationTreeOnOperationDeleted, self, moduleName, operationName) +end + +function private.OperationTreeOnOperationDeleted(self, moduleName, operationName) + TSM.Operations.Delete(moduleName, operationName) + local operationTree = self:GetElement("__parent.operationTree") + operationTree:SetSelectedOperation(moduleName, nil) + :Draw() +end + +function private.OperationTreeOnOperationSelected(self, moduleName, operationName) + private.currentModule = moduleName + private.currentOperationName = operationName + + local viewContainer = self:GetParentElement():GetParentElement():GetElement("content") + if moduleName and operationName then + TSM.Operations.Update(moduleName, operationName) + viewContainer:SetPath("operation") + viewContainer:GetElement("settings.title.text"):SetText(operationName) + local contentFrame = viewContainer:GetElement("settings.content") + contentFrame:ReleaseAllChildren() + contentFrame:AddChild(private.moduleCallbacks[moduleName](operationName)) + elseif moduleName then + local numOperations = 0 + for _ in TSM.Operations.OperationIterator(moduleName) do + numOperations = numOperations + 1 + end + TSM.UI.AnalyticsRecordPathChange("main", "operations", "summary") + viewContainer:SetPath("summary") + viewContainer:GetElement("settings.title.text"):SetText(format(L["%s %s Operations"], Theme.GetColor("INDICATOR"):ColorText(numOperations), moduleName)) + local contentFrame = viewContainer:GetElement("settings.content") + contentFrame:ReleaseAllChildren() + contentFrame:AddChild(private.GetSummaryContent()) + else + TSM.UI.AnalyticsRecordPathChange("main", "operations", "none") + viewContainer:SetPath("none") + viewContainer:GetElement("settings.title.text"):SetText(L["No Operation Selected"]) + end + viewContainer:Draw() +end + +function private.CreateNewOperationOnClick(button) + local operationName = "New Operation" + local num = 1 + while TSM.Operations.Exists(private.currentModule, operationName.." "..num) do + num = num + 1 + end + operationName = operationName .. " " .. num + TSM.Operations.Create(private.currentModule, operationName) + button:GetElement("__parent.__parent.__parent.__parent.selection.operationTree") + :SetSelectedOperation(private.currentModule, operationName) + :Draw() +end + +function private.OperationNameChanged(text, newValue) + newValue = strtrim(newValue) + if newValue == private.currentOperationName then + -- didn't change + text:Draw() + elseif newValue == "" then + Log.PrintUser(L["Invalid operation name."]) + text:Draw() + elseif TSM.Operations.Exists(private.currentModule, newValue) then + Log.PrintUser(L["Group already exists."]) + text:Draw() + else + TSM.Operations.Rename(private.currentModule, private.currentOperationName, newValue) + text:GetElement("__parent.__parent.__parent.__parent.selection.operationTree") + :SetSelectedOperation(private.currentModule, newValue) + :Draw() + end +end + +function private.NameOnEditingChanged(text, editing) + if editing then + text:GetElement("__parent.renameBtn"):Hide() + else + text:GetElement("__parent.renameBtn"):Show() + end +end + +function private.RenameOperationOnClick(button) + button:GetElement("__parent.text"):SetEditing(true) +end + +function private.ResetOperationOnClick(button) + button:GetBaseElement():ShowConfirmationDialog(L["Reset Operation?"], L["Resetting the operation will return all inputs back to default and cannot be unddone. Click confirm to reset."], private.ConfirmResetOnClick, button) +end + +function private.ConfirmResetOnClick(button) + TSM.Operations.Reset(private.currentModule, private.currentOperationName) + local settingsFrame = button:GetBaseElement():GetElement("content.operations.content.settings") + local contentFrame = settingsFrame:GetElement("content") + contentFrame:ReleaseAllChildren() + TSM.Operations.Update(private.currentModule, private.currentOperationName) + contentFrame:AddChild(private.moduleCallbacks[private.currentModule](private.currentOperationName)) + button:GetBaseElement():HideDialog() + settingsFrame:Draw() + Log.PrintfUser(L["%s - %s has been reset to default values."], private.currentModule, Theme.GetColor("INDICATOR_ALT"):ColorText(private.currentOperationName)) +end + +function private.GroupSelectionChanged(groupSelector) + for groupPath in groupSelector:SelectedGroupIterator() do + if not TSM.Operations.GroupHasOperation(private.currentModule, groupPath, private.currentOperationName) then + local parentElement = groupSelector:GetParentElement():GetParentElement() + if groupPath ~= TSM.CONST.ROOT_GROUP_PATH then + TSM.Groups.SetOperationOverride(groupPath, private.currentModule, true) + end + local numOperations = 0 + local lastOperationName = nil + for _, groupOperationName in TSM.Groups.OperationIterator(groupPath, private.currentModule) do + lastOperationName = groupOperationName + numOperations = numOperations + 1 + end + if numOperations == TSM.Operations.GetMaxNumber(private.currentModule) then + -- replace the last operation since we're already at the max number of operations + TSM.Groups.RemoveOperation(groupPath, private.currentModule, numOperations) + Log.PrintfUser(L["%s previously had the max number of operations, so removed %s."], Log.ColorUserAccentText(TSM.Groups.Path.Format(groupPath)), Log.ColorUserAccentText(lastOperationName)) + end + TSM.Groups.AppendOperation(groupPath, private.currentModule, private.currentOperationName) + Log.PrintfUser(L["Added %s to %s."], Log.ColorUserAccentText(private.currentOperationName), Log.ColorUserAccentText(groupPath == TSM.CONST.ROOT_GROUP_PATH and L["Base Group"] or TSM.Groups.Path.Format(groupPath))) + parentElement:AddChild(private.CreateGroupOperationLine(groupPath)) + end + end + groupSelector:ClearSelectedGroups(true) + groupSelector:GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement():GetParentElement():Draw() +end + +function private.ViewGroupOnClick(button) + local baseFrame = button:GetBaseElement() + TSM.MainUI.Groups.ShowGroupSettings(baseFrame, button:GetContext()) +end + +function private.RemoveOperationGroupOnClick(self) + local groupPath = self:GetContext() + TSM.Groups.RemoveOperationByName(groupPath, private.currentModule, private.currentOperationName) + + -- remove the line for this group + local removeElement = self:GetParentElement() + local removeElementParent = removeElement:GetParentElement() + removeElementParent:RemoveChild(removeElement) + removeElement:Release() + removeElementParent:GetParentElement():GetParentElement():GetParentElement():Draw() +end + +function private.LinkBtnOnClick(button) + local settingKey = button:GetContext() + wipe(private.linkMenuEntries) + for _, operationName in TSM.Operations.OperationIterator(private.currentModule) do + if operationName ~= private.currentOperationName and not TSM.Operations.IsCircularRelationship(private.currentModule, private.currentOperationName, settingKey) then + tinsert(private.linkMenuEntries, operationName) + end + end + sort(private.linkMenuEntries) + button:GetBaseElement():ShowDialogFrame(UIElements.New("PopupFrame", "linkDialog") + :SetLayout("VERTICAL") + :SetSize(263, 243) + :AddAnchor("TOPRIGHT", button:_GetBaseFrame(), "BOTTOM", 22, -16) + :AddChild(UIElements.New("Frame", "titleFrame") + :SetLayout("VERTICAL") + :SetHeight(37) + :AddChild(UIElements.New("Text", "title") + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("CENTER") + :SetText(L["Link to Another Operation"]) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("TEXT") + ) + :AddChild(UIElements.New("SelectionList", "list") + :SetContext(settingKey) + :SetMargin(2, 2, 0, 3) + :SetEntries(private.linkMenuEntries, TSM.Operations.GetRelationship(private.currentModule, private.currentOperationName, settingKey)) + :SetScript("OnEntrySelected", private.ListOnEntrySelected) + ) + ) +end + +function private.ListOnEntrySelected(list, operationName) + local settingKey = list:GetContext() + local previousValue = TSM.Operations.GetRelationship(private.currentModule, private.currentOperationName, settingKey) + if operationName == previousValue then + TSM.Operations.SetRelationship(private.currentModule, private.currentOperationName, settingKey, nil) + else + TSM.Operations.SetRelationship(private.currentModule, private.currentOperationName, settingKey, operationName) + end + + local baseFrame = list:GetBaseElement() + baseFrame:HideDialog() + Operations.ShowOperationSettings(baseFrame, private.currentModule, private.currentOperationName) +end + +function private.OperationListOnSelectionChanged(scrollingTable) + local selectionCleared = scrollingTable:IsSelectionCleared() + local numSelected = 0 + for _ in scrollingTable:SelectionIterator() do + numSelected = numSelected + 1 + end + local footer = scrollingTable:GetElement("__parent.footer") + footer:GetElement("deleteSelected") + :SetText(numSelected > 0 and format(L["Delete %d Operations"], numSelected) or L["Delete Operations"]) + :SetDisabled(selectionCleared) + footer:GetElement("selectAll") + :SetDisabled(scrollingTable:IsAllSelected()) + footer:GetElement("clearAll") + :SetDisabled(selectionCleared) + footer:Draw() +end + +function private.SelectAllOnClick(button) + button:GetElement("__parent.__parent.list"):SelectAll() +end + +function private.ClearAllOnClick(button) + button:GetElement("__parent.__parent.list"):ClearSelection() +end + +function private.DeleteSelectedOnClick(button) + local scrollingTable = button:GetElement("__parent.__parent.list") + button:GetBaseElement():ShowConfirmationDialog(L["Delete Operations?"], L["Are you sure you want to delete the selected operations?"], private.DeleteSelectedOperations, scrollingTable) +end + +function private.DeleteSelectedOperations(scrollingTable) + local toDelete = TempTable.Acquire() + for _, row in scrollingTable:SelectionIterator() do + local moduleName, operationName = row:GetFields("moduleName", "operationName") + assert(moduleName == private.currentModule) + tinsert(toDelete, operationName) + end + TSM.Operations.DeleteList(private.currentModule, toDelete) + TempTable.Release(toDelete) + scrollingTable:UpdateData(true) + private.OperationListOnSelectionChanged(scrollingTable) + scrollingTable:GetElement("__parent.__parent.__parent.__parent.__parent.selection.operationTree"):UpdateData(true) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.NumGroupsVirtualField(row) + local num = 0 + for _ in TSM.Operations.GroupIterator(row:GetField("moduleName"), row:GetField("operationName")) do + num = num + 1 + end + return num +end + +function private.NumItemsVirtualField(row) + local includesBaseGroup = false + local num = 0 + for _, groupPath in TSM.Operations.GroupIterator(row:GetField("moduleName"), row:GetField("operationName")) do + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + includesBaseGroup = true + else + num = num + TSM.Groups.GetNumItems(groupPath) + end + end + if includesBaseGroup then + num = num + 0.9 + end + return num +end + +function private.GetConfigureIcon(_, _, iconIndex) + assert(iconIndex == 1) + return true, "iconPack.12x12/Popout", false +end + +function private.OnConfigureIconClick(scrollingTable, data, iconIndex) + assert(iconIndex == 1) + local operationName = scrollingTable:GetContext():GetResultRowByUUID(data):GetField("operationName") + scrollingTable:GetElement("__parent.__parent.__parent.__parent.__parent.selection.operationTree") + :SetSelectedOperation(private.currentModule, operationName) +end + +function private.GetNumItemsText(numItems) + if numItems == floor(numItems) then + return numItems + else + return floor(numItems).."*" + end +end + +function private.GetNumItemsTooltip(numItems) + if numItems == floor(numItems) then + return nil + end + return L["This operation is applied to the base group which includes every item not in another group."] +end diff --git a/Core/UI/MainUI/Operations/Crafting.lua b/Core/UI/MainUI/Operations/Crafting.lua new file mode 100644 index 0000000..e25dfc8 --- /dev/null +++ b/Core/UI/MainUI/Operations/Crafting.lua @@ -0,0 +1,159 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Crafting = TSM.MainUI.Operations:NewPackage("Crafting") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = { + currentOperationName = nil, +} +local BAD_CRAFT_VALUE_PRICE_SOURCES = { + crafting = true, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Crafting.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Crafting", private.GetCraftingOperationSettings) +end + + + +-- ============================================================================ +-- Crafting Operation Settings UI +-- ============================================================================ + +function private.GetCraftingOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "crafting") + private.currentOperationName = operationName + local operation = TSM.Operations.GetSettings("Crafting", private.currentOperationName) + local frame = UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Crafting", "restockQuantity", L["Restock Options"], L["Adjust how crafted items are restocked."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("minRestock", L["Min restock quantity"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "minRestock") + :SetDisabled(TSM.Operations.HasRelationship("Crafting", private.currentOperationName, "minRestock")) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor(TSM.Operations.HasRelationship("Crafting", private.currentOperationName, "minRestock") and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Supported range: %d - %d"], TSM.Operations.Crafting.GetRestockRange()) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("maxRestock", L["Max restock quantity"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "maxRestock") + :SetDisabled(TSM.Operations.HasRelationship("Crafting", private.currentOperationName, "maxRestock")) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor(TSM.Operations.HasRelationship("Crafting", private.currentOperationName, "maxRestock") and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Supported range: %d - %d"], TSM.Operations.Crafting.GetRestockRange()) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("minProfit", L["Set min profit"], nil, "minProfitToggle") + :SetLayout("VERTICAL") + :SetHeight(42) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetValue(operation.minProfit ~= "") + :SetDisabled(TSM.Operations.HasRelationship("Crafting", private.currentOperationName, "minProfit")) + :SetScript("OnValueChanged", private.MinProfitToggleOnValueChanged) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Crafting", "priceSettings", L["Crafting Value"], L["Adjust how TSM values crafted items when calculating profit."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("craftPriceMethod", L["Override default craft value"], nil, "craftPriceMethodToggle") + :SetLayout("VERTICAL") + :SetHeight(42) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetValue(operation.craftPriceMethod ~= "") + :SetDisabled(TSM.Operations.HasRelationship("Crafting", private.currentOperationName, "craftPriceMethod")) + :SetScript("OnValueChanged", private.CraftPriceToggleOnValueChanged) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Crafting", private.currentOperationName)) + + if operation.minProfit ~= "" then + frame:GetElement("restockQuantity.content.minProfitToggle"):SetMargin(0, 0, 0, 12) + frame:GetElement("restockQuantity"):AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("minProfit", L["Min profit amount"], 80)) + end + if operation.craftPriceMethod ~= "" then + frame:GetElement("priceSettings.content.craftPriceMethodToggle"):SetMargin(0, 0, 0, 12) + frame:GetElement("priceSettings"):AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("craftPriceMethod", L["Craft Value"], 80, BAD_CRAFT_VALUE_PRICE_SOURCES, TSM.db.global.craftingOptions.defaultCraftPriceMethod)) + end + + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.MinProfitToggleOnValueChanged(toggle, value) + local operation = TSM.Operations.GetSettings("Crafting", private.currentOperationName) + local defaultValue = TSM.Operations.GetSettingDefault("Crafting", "minProfit") + operation.minProfit = value and defaultValue or "" + local settingsFrame = toggle:GetParentElement():GetParentElement() + if value then + settingsFrame:GetElement("minProfitToggle"):SetMargin(0, 0, 0, 12) + settingsFrame:GetParentElement():AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("minProfit", L["Min profit amount"], 80)) + else + settingsFrame:GetElement("minProfitToggle"):SetMargin(0, 0, 0, 0) + local linkedPriceLine = settingsFrame:GetElement("minProfit") + settingsFrame:RemoveChild(linkedPriceLine) + linkedPriceLine:Release() + end + settingsFrame:GetParentElement():GetParentElement():Draw() +end + +function private.CraftPriceToggleOnValueChanged(toggle, value) + local operation = TSM.Operations.GetSettings("Crafting", private.currentOperationName) + operation.craftPriceMethod = value and TSM.db.global.craftingOptions.defaultCraftPriceMethod or "" + local settingsFrame = toggle:GetParentElement():GetParentElement() + if value then + settingsFrame:GetElement("craftPriceMethodToggle"):SetMargin(0, 0, 0, 12) + settingsFrame:GetParentElement():AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("craftPriceMethod", L["Craft Value"], 80, BAD_CRAFT_VALUE_PRICE_SOURCES, TSM.db.global.craftingOptions.defaultCraftPriceMethod)) + else + settingsFrame:GetElement("craftPriceMethodToggle"):SetMargin(0, 0, 0, 0) + local linkedPriceLine = settingsFrame:GetElement("craftPriceMethod") + settingsFrame:RemoveChild(linkedPriceLine) + linkedPriceLine:Release() + end + settingsFrame:GetParentElement():GetParentElement():Draw() +end diff --git a/Core/UI/MainUI/Operations/Mailing.lua b/Core/UI/MainUI/Operations/Mailing.lua new file mode 100644 index 0000000..4e8929b --- /dev/null +++ b/Core/UI/MainUI/Operations/Mailing.lua @@ -0,0 +1,220 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Mailing = TSM.MainUI.Operations:NewPackage("Mailing") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = { + currentOperationName = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Mailing.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Mailing", private.GetMailingOperationSettings) +end + + + +-- ============================================================================ +-- Mailing Operation Settings UI +-- ============================================================================ + +function private.GetMailingOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "mailing") + private.currentOperationName = operationName + local operation = TSM.Operations.GetSettings("Mailing", private.currentOperationName) + return UIElements.New("ScrollFrame", "content") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Mailing", "generalOptions", L["General Options"], L["Adjust how items are mailed."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("target", L["Target character"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :SetMargin(0, 8, 0, 0) + :SetHintText(L["Enter player name"]) + :SetAutoComplete(PlayerInfo.GetConnectedAlts()) + :SetClearButtonEnabled(true) + :SetDisabled(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "target")) + :SetSettingInfo(operation, "target") + ) + :AddChild(UIElements.New("ActionButton", "contacts") + :SetSize(152, 24) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Contacts"]) + :SetScript("OnClick", private.ContactsBtnOnClick) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("keepQty", L["Keep this amount"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "0:50000") + :SetSettingInfo(operation, "keepQty") + :SetDisabled(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "keepQty")) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "keepQty") and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Enter a value from %d - %d"], 0, 50000) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("maxQtyEnabled", L["Set max quantity"]) + :SetLayout("VERTICAL") + :SetHeight(42) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetSettingInfo(operation, "maxQtyEnabled") + :SetDisabled(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "maxQtyEnabled")) + :SetScript("OnValueChanged", private.MaxQuantityToggleOnValueChanged) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("maxQty", L["Max quantity"], not operation.maxQtyEnabled) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "1:50000") + :SetDisabled(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "maxQty") or not operation.maxQtyEnabled) + :SetSettingInfo(operation, "maxQty") + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor((TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "maxQty") or not operation.maxQtyEnabled) and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Enter a value from %d - %d"], 1, 50000) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("restock", L["Restock target to max quantity"], not operation.maxQtyEnabled) + :SetLayout("VERTICAL") + :SetHeight(42) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetSettingInfo(operation, "restock") + :SetDisabled(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "restock") or not operation.maxQtyEnabled) + :SetScript("OnValueChanged", private.RestockToggleOnValueChanged) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("restockSources", L["Sources to include for restock"], not operation.restock or not operation.maxQtyEnabled) + :SetLayout("VERTICAL") + :SetHeight(48) + :AddChild(UIElements.New("MultiselectionDropdown", "dropdown") + :SetHeight(24) + :AddItem(BANK, "bank") + :AddItem(GUILD, "guild") + :SetSettingInfo(operation, "restockSources") + :SetSelectionText(L["No Sources"], L["%d Sources"], L["All Sources"]) + :SetDisabled(TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "restockSources") or not operation.restock or not operation.maxQtyEnabled) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Mailing", private.currentOperationName)) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.ContactsBtnOnClick(button) + TSM.UI.Util.Contacts.ShowDialog(button, button:GetElement("__parent.input")) +end + + +function private.MaxQuantityToggleOnValueChanged(toggle, value) + local settingsFrame = toggle:GetElement("__parent.__parent") + local restockValue = settingsFrame:GetElement("restock.toggle"):GetValue() + local relationshipSet, linkTexture, textColor = TSM.Operations.GetRelationshipColors("Mailing", private.currentOperationName, "maxQty", value) + settingsFrame:GetElement("maxQty.line.linkBtn") + :SetBackground(linkTexture) + :SetDisabled(not value) + settingsFrame:GetElement("maxQty.line.label") + :SetTextColor(textColor) + settingsFrame:GetElement("maxQty.content.label") + :SetTextColor(textColor) + settingsFrame:GetElement("maxQty.content.input") + :SetDisabled(relationshipSet or not value) + + relationshipSet, linkTexture, textColor = TSM.Operations.GetRelationshipColors("Mailing", private.currentOperationName, "restock", value) + settingsFrame:GetElement("restock.line.linkBtn") + :SetBackground(linkTexture) + :SetDisabled(not value) + settingsFrame:GetElement("restock.line.label") + :SetTextColor(textColor) + settingsFrame:GetElement("restock.toggle") + :SetDisabled(relationshipSet or not value) + + relationshipSet = TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "restockSources") + if relationshipSet and value and restockValue then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "INDICATOR") + elseif (relationshipSet and not value and restockValue) or (relationshipSet and value and not restockValue) or (relationshipSet and not value and not restockValue) then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "INDICATOR_DISABLED") + elseif (value and not restockValue) or (not value and restockValue) or (not value and not restockValue) then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "TEXT_DISABLED") + else + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "TEXT") + end + settingsFrame:GetElement("restockSources.line.linkBtn") + :SetBackground(linkTexture) + :SetDisabled(not value) + settingsFrame:GetElement("restockSources.line.label") + :SetTextColor(relationshipSet and "TEXT_DISABLED" or ((value and restockValue) and "TEXT" or "TEXT_DISABLED")) + settingsFrame:GetElement("restockSources.dropdown") + :SetDisabled(relationshipSet or not value or not restockValue) + settingsFrame:Draw() +end + +function private.RestockToggleOnValueChanged(toggle, value) + local settingsFrame = toggle:GetElement("__parent.__parent") + local maxQtyEnabled = settingsFrame:GetElement("maxQtyEnabled.toggle"):GetValue() + local relationshipSet = TSM.Operations.HasRelationship("Mailing", private.currentOperationName, "restockSources") + local linkTexture = nil + if relationshipSet and value and maxQtyEnabled then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "INDICATOR") + elseif (relationshipSet and not value and maxQtyEnabled) or (relationshipSet and value and not maxQtyEnabled) or (relationshipSet and not value and not maxQtyEnabled) then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "INDICATOR_DISABLED") + elseif (value and not maxQtyEnabled) or (not value and maxQtyEnabled) or (not value and not maxQtyEnabled) then + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "TEXT_DISABLED") + else + linkTexture = TSM.UI.TexturePacks.GetColoredKey("iconPack.14x14/Link", "TEXT") + end + settingsFrame:GetElement("restockSources.line.linkBtn") + :SetBackground(linkTexture) + :SetDisabled(not value) + settingsFrame:GetElement("restockSources.line.label") + :SetTextColor(relationshipSet and "TEXT_DISABLED" or ((value and maxQtyEnabled) and "TEXT" or "TEXT_DISABLED")) + settingsFrame:GetElement("restockSources.dropdown") + :SetDisabled(relationshipSet or not value or not maxQtyEnabled) + settingsFrame:Draw() +end diff --git a/Core/UI/MainUI/Operations/Shopping.lua b/Core/UI/MainUI/Operations/Shopping.lua new file mode 100644 index 0000000..11a1368 --- /dev/null +++ b/Core/UI/MainUI/Operations/Shopping.lua @@ -0,0 +1,88 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Shopping = TSM.MainUI.Operations:NewPackage("Shopping") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = { + currentOperationName = nil, +} +local RESTOCK_SOURCES = { L["Alts"], L["Auctions"], BANK, GUILD } +local RESTOCK_SOURCES_KEYS = { "alts", "auctions", "bank", "guild" } +local BAD_PRICE_SOURCES = { shoppingopmax = true } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Shopping.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Shopping", private.GetShoppingOperationSettings) +end + + + +-- ============================================================================ +-- Shopping Operation Settings UI +-- ============================================================================ + +function private.GetShoppingOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "shopping") + private.currentOperationName = operationName + local operation = TSM.Operations.GetSettings("Shopping", private.currentOperationName) + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Shopping", "generalOptions", L["General Options"], L["Set what items are shown during a Shopping scan."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("maxPrice", L["Maximum auction price"], 124, BAD_PRICE_SOURCES)) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("showAboveMaxPrice", L["Show auctions above max price"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 12, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetSettingInfo(operation, "showAboveMaxPrice") + :SetDisabled(TSM.Operations.HasRelationship("Shopping", private.currentOperationName, "showAboveMaxPrice")) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("restockQuantity", L["Maximum restock quantity"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "restockQuantity") + :SetDisabled(TSM.Operations.HasRelationship("Shopping", private.currentOperationName, "restockQuantity")) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor(TSM.Operations.HasRelationship("Shopping", private.currentOperationName, "restockQuantity") and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Supported range: %d - %d"], TSM.Operations.Shopping.GetRestockRange()) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("restockSources", L["Sources to include for restock"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :AddChild(UIElements.New("MultiselectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(RESTOCK_SOURCES, RESTOCK_SOURCES_KEYS) + :SetSettingInfo(operation, "restockSources") + :SetSelectionText(L["No Sources"], L["%d Sources"], L["All Sources"]) + :SetDisabled(TSM.Operations.HasRelationship("Shopping", private.currentOperationName, "restockSources")) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Shopping", private.currentOperationName)) +end diff --git a/Core/UI/MainUI/Operations/Sniper.lua b/Core/UI/MainUI/Operations/Sniper.lua new file mode 100644 index 0000000..f6a8869 --- /dev/null +++ b/Core/UI/MainUI/Operations/Sniper.lua @@ -0,0 +1,42 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sniper = TSM.MainUI.Operations:NewPackage("Sniper") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = { + currentOperationName = nil, +} +local BAD_PRICE_SOURCES = { sniperopmax = true } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Sniper.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Sniper", private.GetSniperOperationSettings) +end + + + +-- ============================================================================ +-- Sniper Operation Settings UI +-- ============================================================================ + +function private.GetSniperOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "sniper") + private.currentOperationName = operationName + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :SetBackgroundColor("PRIMARY_BG") + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Sniper", "settings", L["General Options"], L["Set what items are shown during a Sniper scan."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedPriceInput("belowPrice", L["Maximum price"], 124, BAD_PRICE_SOURCES)) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Sniper", private.currentOperationName)) +end diff --git a/Core/UI/MainUI/Operations/Vendoring.lua b/Core/UI/MainUI/Operations/Vendoring.lua new file mode 100644 index 0000000..9d85a27 --- /dev/null +++ b/Core/UI/MainUI/Operations/Vendoring.lua @@ -0,0 +1,268 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Vendoring = TSM.MainUI.Operations:NewPackage("Vendoring") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = { + currentOperationName = nil, +} +local RESTOCK_SOURCES = { BANK, GUILD, L["Alts"], L["Alts AH"], L["AH"], L["Mail"] } +local RESTOCK_SOURCES_KEYS = { "bank", "guild", "alts", "alts_ah", "ah", "mail" } +local SETTING_INFO = { + restockQty = "INPUT_LABEL", + restockSources = "DROPDOWN", + keepQty = "INPUT_LABEL", + sellAfterExpired = "INPUT_LABEL", + vsMarketValue = "INPUT", + vsMaxMarketValue = "INPUT", + vsDestroyValue = "INPUT", + vsMaxDestroyValue = "INPUT", + sellSoulbound = "TOGGLE", +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Vendoring.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Vendoring", private.GetVendoringOperationSettings) +end + + + +-- ============================================================================ +-- Vendoring Operation Settings UI +-- ============================================================================ + +function private.GetVendoringOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "vendoring") + private.currentOperationName = operationName + + local operation = TSM.Operations.GetSettings("Vendoring", private.currentOperationName) + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Vendoring", "buyOptionsHeading", L["Buy Options"], L["Set what is bought from a vendor."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("enableBuy", L["Enable buying"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetSettingInfo(operation, "enableBuy") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "enableBuy")) + :SetScript("OnValueChanged", private.EnableBuyingToggleOnValueChanged) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("restockQty", L["Restock quantity"], not operation.enableBuy) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "0:50000") + :SetSettingInfo(operation, "restockQty") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "restockQty") or not operation.enableBuy) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor((TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "restockQty") or not operation.enableBuy) and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Enter a value from %d - %d"], 0, 50000) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("restockSources", L["Sources to include for restock"], not operation.enableBuy) + :SetLayout("VERTICAL") + :SetHeight(48) + :AddChild(UIElements.New("MultiselectionDropdown", "dropdown") + :SetHeight(24) + :SetItems(RESTOCK_SOURCES, RESTOCK_SOURCES_KEYS) + :SetSettingInfo(operation, "restockSources") + :SetSelectionText(L["No Sources"], L["%d Sources"], L["All Sources"]) + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "restockSources") or not operation.enableBuy) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Vendoring", "sellOptionsHeading", L["Sell Options"], L["Set what is sold to a vendor."]) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("enableSell", L["Enable selling"]) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetSettingInfo(operation, "enableSell") + :SetScript("OnValueChanged", private.EnableSellingToggleOnValueChanged) + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "enableSell")) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("keepQty", L["Keep quantity"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "0:50000") + :SetSettingInfo(operation, "keepQty") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "keepQty") or not operation.enableSell) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor((TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "keepQty") or not operation.enableSell) and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Enter a value from %d - %d"], 0, 50000) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("sellAfterExpired", L["Min number of expires"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "0:50000") + :SetSettingInfo(operation, "sellAfterExpired") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "sellAfterExpired") or not operation.enableSell) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor((TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "sellAfterExpired") or not operation.enableSell) and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Enter a value from %d - %d"], 0, 50000) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("vsMarketValue", L["Market value"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "vsMarketValue") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "vsMarketValue") or not operation.enableSell) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("vsMaxMarketValue", L["Max market value (Enter '0c' to disable)"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "vsMaxMarketValue") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "vsMaxMarketValue") or not operation.enableSell) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("vsDestroyValue", L["Destroy value"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "vsDestroyValue") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "vsDestroyValue") or not operation.enableSell) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("vsMaxDestroyValue", L["Max destroy value (Enter '0c' to disable)"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("CUSTOM_PRICE") + :SetSettingInfo(operation, "vsMaxDestroyValue") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "vsMaxDestroyValue") or not operation.enableSell) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine("sellSoulbound", L["Sell soulbound items"], not operation.enableSell) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 0) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetSettingInfo(operation, "sellSoulbound") + :SetDisabled(TSM.Operations.HasRelationship("Vendoring", private.currentOperationName, "sellSoulbound") or not operation.enableSell) + ) + ) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Vendoring", private.currentOperationName)) +end + + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.UpdateSettingState(settingsFrame, key, operation, value) + local relationshipSet, linkTexture, textColor = TSM.Operations.GetRelationshipColors("Vendoring", private.currentOperationName, key, value) + local settingKeyFrame = settingsFrame:GetElement(key) + settingKeyFrame:GetElement("line.linkBtn") + :SetBackground(linkTexture) + :SetDisabled(not value) + settingKeyFrame:GetElement("line.label") + :SetTextColor(textColor) + local settingType = SETTING_INFO[key] + if settingType == "INPUT_LABEL" then + settingKeyFrame:GetElement("content.input") + :SetDisabled(relationshipSet or not value) + :SetValue(operation[key] or "") + settingKeyFrame:GetElement("content.label") + :SetTextColor(textColor) + elseif settingType == "INPUT" then + settingKeyFrame:GetElement("input") + :SetDisabled(relationshipSet or not value) + elseif settingType == "TOGGLE" then + settingKeyFrame:GetElement("toggle") + :SetDisabled(relationshipSet or not value) + elseif settingType == "DROPDOWN" then + settingKeyFrame:GetElement("dropdown") + :SetDisabled(relationshipSet or not value) + else + error("Invalid settingType: "..tostring(settingType)) + end +end + +function private.EnableBuyingToggleOnValueChanged(toggle, value) + local operation = TSM.Operations.GetSettings("Vendoring", private.currentOperationName) + local settingsFrame = toggle:GetElement("__parent.__parent") + private.UpdateSettingState(settingsFrame, "restockQty", operation, value) + private.UpdateSettingState(settingsFrame, "restockSources", operation, value) + settingsFrame:Draw() +end + +function private.EnableSellingToggleOnValueChanged(toggle, value) + local operation = TSM.Operations.GetSettings("Vendoring", private.currentOperationName) + local settingsFrame = toggle:GetElement("__parent.__parent") + for key in pairs(SETTING_INFO) do + if key ~= "restockQty" and key ~= "restockSources" then + private.UpdateSettingState(settingsFrame, key, operation, value) + end + end + settingsFrame:Draw() +end diff --git a/Core/UI/MainUI/Operations/Warehousing.lua b/Core/UI/MainUI/Operations/Warehousing.lua new file mode 100644 index 0000000..4f6626c --- /dev/null +++ b/Core/UI/MainUI/Operations/Warehousing.lua @@ -0,0 +1,108 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Warehousing = TSM.MainUI.Operations:NewPackage("Warehousing") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = { currentOperationName = nil } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Warehousing.OnInitialize() + TSM.MainUI.Operations.RegisterModule("Warehousing", private.GetWarehousingOperationSettings) +end + + + +-- ============================================================================ +-- Warehousing Operation Settings UI +-- ============================================================================ + +function private.GetWarehousingOperationSettings(operationName) + TSM.UI.AnalyticsRecordPathChange("main", "operations", "warehousing") + private.currentOperationName = operationName + return UIElements.New("ScrollFrame", "settings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Warehousing", "moveSettings", L["Move Quantity Options"], L["Set how items are moved out of the bank."]) + :AddChild(private.CreateEnabledSettingLine("moveQuantity", L["Set move quantity"], L["Quantity to move"], 0, 50000, true)) + :AddChild(private.CreateEnabledSettingLine("stackSize", L["Set stack size"], L["Stack size multiple"], 0, 200, true)) + :AddChild(private.CreateEnabledSettingLine("keepBagQuantity", L["Set keep in bags quantity"], L["Keep in bags quantity"], 0, 50000, true)) + :AddChild(private.CreateEnabledSettingLine("keepBankQuantity", L["Set keep in bank quantity"], L["Keep in bank quantity"], 0, 50000)) + ) + :AddChild(TSM.MainUI.Operations.CreateExpandableSection("Warehousing", "restockSettings", L["Restock Options"], L["Set how items are restocked from the bank."]) + :AddChild(private.CreateEnabledSettingLine("restockQuantity", L["Enable restock"], L["Restock quantity"], 0, 50000, true)) + :AddChild(private.CreateEnabledSettingLine("restockStackSize", L["Set stack size for restock"], L["Stack size multiple"], 0, 200, true)) + :AddChild(private.CreateEnabledSettingLine("restockKeepBankQuantity", L["Set keep in bank quantity"], L["Keep in bank quantity"], 0, 50000)) + ) + :AddChild(TSM.MainUI.Operations.GetOperationManagementElements("Warehousing", private.currentOperationName)) +end + +function private.CreateEnabledSettingLine(key, enableText, text, minValue, maxValue, margin) + local operation = TSM.Operations.GetSettings("Warehousing", private.currentOperationName) + local hasRelationship = TSM.Operations.HasRelationship("Warehousing", private.currentOperationName, key) + return UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(TSM.MainUI.Operations.CreateLinkedSettingLine(key, text) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("ToggleOnOff", "toggle") + :SetHeight(18) + :SetValue(operation[key] ~= 0) + :SetDisabled(hasRelationship) + :SetContext(key) + :SetScript("OnValueChanged", private.EnabledSettingEnableOnValueChanged) + ) + ) + :AddChild(TSM.MainUI.Operations.CreateSettingLine("content", text, hasRelationship or operation[key] == 0) + :SetLayout("VERTICAL") + :SetHeight(48) + :SetMargin(0, 0, 0, margin and 12 or 4) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", minValue..":"..maxValue) + :SetSettingInfo(operation, key) + :SetDisabled(hasRelationship or operation[key] == 0) + ) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetTextColor((hasRelationship or operation[key] == 0) and "TEXT_DISABLED" or "TEXT") + :SetFormattedText(L["Enter a value from %d - %d"], minValue, maxValue) + ) + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.EnabledSettingEnableOnValueChanged(toggle, value) + local key = toggle:GetContext() + local operation = TSM.Operations.GetSettings("Warehousing", private.currentOperationName) + operation[key] = value and 1 or 0 + local settingFrame = toggle:GetElement("__parent.__parent.content") + settingFrame:GetElement("label") + :SetTextColor(value and "TEXT" or "TEXT_DISABLED") + settingFrame:GetElement("content.input") + :SetDisabled(not value) + :SetValue(operation[key]) + settingFrame:GetElement("content.label") + :SetTextColor(value and "TEXT" or "TEXT_DISABLED") + settingFrame:Draw() +end diff --git a/Core/UI/MainUI/Settings/Accounting.lua b/Core/UI/MainUI/Settings/Accounting.lua new file mode 100644 index 0000000..a22f72e --- /dev/null +++ b/Core/UI/MainUI/Settings/Accounting.lua @@ -0,0 +1,105 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Accounting = TSM.MainUI.Settings:NewPackage("Accounting") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local UIElements = TSM.Include("UI.UIElements") +local private = {} +local DAYS_OLD_OPTIONS = { 0, 15, 30, 45, 60, 75, 90, 180, 360 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Accounting.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Accounting"], "middle", private.GetAccountingSettingsFrame) +end + + + +-- ============================================================================ +-- Accounting Settings UI +-- ============================================================================ + +function private.GetAccountingSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "accounting") + return UIElements.New("ScrollFrame", "accountingSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Accounting", "accounting", L["General Options"], L["Some general Accounting options are below."]) + :AddChild(UIElements.New("Frame", "check1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "tradeCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Track Sales / Purchases via trade"]) + :SetSettingInfo(TSM.db.global.accountingOptions, "trackTrades") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "check2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "tradePromptCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Don't prompt to record trades"]) + :SetSettingInfo(TSM.db.global.accountingOptions, "autoTrackTrades") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Accounting", "accounting", L["Clear Old Data"], L["You can clear old Accounting data below to keep things running smoothly."]) + :AddChild(UIElements.New("Text", "daysOldLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Remove Data Older Than (Days)"]) + ) + :AddChild(UIElements.New("Frame", "daysOld") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetMargin(0, 8, 0, 0) + :SetHintText(L["None Selected"]) + :SetItems(DAYS_OLD_OPTIONS) + :SetScript("OnSelectionChanged", private.DaysOldDropdownOnSelectionChanged) + ) + :AddChild(UIElements.New("ActionButton", "clearBtn") + :SetWidth(107) + :SetDisabled(true) + :SetText(L["Clear Data"]) + :SetScript("OnClick", private.ClearBtnOnClick) + ) + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.DaysOldDropdownOnSelectionChanged(dropdown) + dropdown:GetElement("__parent.clearBtn") + :SetDisabled(false) + :Draw() +end + +function private.ClearBtnOnClick(button) + local days = button:GetElement("__parent.dropdown"):GetSelectedItem() + button:GetBaseElement():ShowConfirmationDialog(L["Clear Old Data?"], L["Are you sure you want to clear old accounting data?"], private.ClearDataConfirmed, days) +end + +function private.ClearDataConfirmed(days) + Log.PrintfUser(L["Removed a total of %s old records."], TSM.Accounting.Transactions.RemoveOldData(days) + TSM.Accounting.Money.RemoveOldData(days) + TSM.Accounting.Auctions.RemoveOldData(days)) +end diff --git a/Core/UI/MainUI/Settings/Appearance.lua b/Core/UI/MainUI/Settings/Appearance.lua new file mode 100644 index 0000000..113e484 --- /dev/null +++ b/Core/UI/MainUI/Settings/Appearance.lua @@ -0,0 +1,182 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Appearance = TSM.MainUI.Settings:NewPackage("Appearance") +local L = TSM.Include("Locale").GetTable() +local Theme = TSM.Include("Util.Theme") +local LibDBIcon = LibStub("LibDBIcon-1.0") +local UIElements = TSM.Include("UI.UIElements") +local private = { + colorSetKeys = {}, + colorSetNames = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Appearance.OnInitialize() + for _, key, name in TSM.UI.Util.ColorSetIterator() do + tinsert(private.colorSetKeys, key) + tinsert(private.colorSetNames, name) + end + TSM.MainUI.Settings.RegisterSettingPage(L["Appearance"], "middle", private.GetSettingsFrame) +end + + + +-- ============================================================================ +-- Appearance Settings UI +-- ============================================================================ + +function private.GetSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "appearance") + return UIElements.New("ScrollFrame", "generalSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Appearance", "appearance", L["General Options"], L["Some general appearance options are below."]) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "minimapCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Hide minimap icon"]) + :SetSettingInfo(TSM.db.global.coreOptions.minimapIcon, "hide") + :SetScript("OnValueChanged", private.MinimapOnValueChanged) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "taskListLockCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Lock task list's background"]) + :SetSettingInfo(TSM.db.global.appearanceOptions, "taskListBackgroundLock") + :SetScript("OnValueChanged", private.TaskListLockOnValueChanged) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "showTotalMoneyCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Show total gold in header"]) + :SetSettingInfo(TSM.db.global.appearanceOptions, "showTotalMoney") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(UIElements.New("Text", "label") + :SetHeight(24) + :SetMargin(12, 0, 4, 12) + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Themes"]) + ) + :AddChild(UIElements.New("Frame", "theme") + :SetLayout("FLOW") + :AddChildrenWithFunction(private.AddTheme) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.MinimapOnValueChanged(_, value) + if value then + LibDBIcon:Hide("TradeSkillMaster") + else + LibDBIcon:Show("TradeSkillMaster") + end +end + +function private.TaskListLockOnValueChanged(_, value) + TSM.db.global.appearanceOptions.taskListBackgroundLock = value + if TSM.UI.TaskListUI.IsVisible() then + TSM.UI.TaskListUI.UpdateFrame() + end +end + +function private.AddTheme(frame) + for _, key, name in TSM.UI.Util.ColorSetIterator() do + frame:AddChild(UIElements.New("Frame", name) + :SetLayout("VERTICAL") + :SetSize(198, 140) + :SetPadding(0, 0, 12, 8) + :SetMargin(0, 12, 0, 8) + :SetBackgroundColor(Theme.GetColor("FRAME_BG", key), true) + :SetBorderColor(Theme.GetColor("ACTIVE_BG_ALT", key)) + :SetContext(key) + :AddChild(UIElements.New("Frame", "top") + :SetLayout("HORIZONTAL") + :SetHeight(36) + :SetMargin(8, 8, 0, 12) + :AddChild(UIElements.New("Frame", "left") + :SetSize(36, 36) + :SetMargin(0, 12, 0, 0) + :SetBackgroundColor(Theme.GetColor("ACTIVE_BG_ALT", key), true) + ) + :AddChild(UIElements.New("Frame", "right") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "line1") + :SetHeight(12) + :SetMargin(0, 0, 0, 12) + :SetBackgroundColor(Theme.GetColor("ACTIVE_BG", key), true) + ) + :AddChild(UIElements.New("Frame", "line2") + :SetHeight(12) + :SetBackgroundColor(Theme.GetColor("PRIMARY_BG_ALT", key), true) + ) + ) + ) + :AddChild(UIElements.New("Frame", "line3") + :SetMargin(8, 8, 0, 12) + :SetBackgroundColor(Theme.GetColor("PRIMARY_BG", key), true) + ) + :AddChild(UIElements.New("Texture", "divider") + :SetHeight(1) + :SetTexture(Theme.GetColor("ACTIVE_BG_ALT", key)) + ) + :AddChild(UIElements.New("Toggle", "toggle") + :SetHeight(20) + :SetMargin(8, 0, 8, 0) + :SetFont("BODY_BODY2_MEDIUM") + :AddOption(Theme.GetThemeName(key), TSM.db.global.appearanceOptions.colorSet == key) + :SetScript("OnValueChanged", private.ThemeButtonOnClick) + ) + :AddChildNoLayout(UIElements.New("Button", "btn") + :AddAnchor("TOPLEFT") + :AddAnchor("BOTTOMRIGHT") + :SetScript("OnClick", private.ThemeButtonOnClick) + ) + ) + end +end + +function private.ThemeButtonOnClick(buttonToggle) + local selectedKey = buttonToggle:GetParentElement():GetContext() + for _, key, name in TSM.UI.Util.ColorSetIterator() do + local toggle = buttonToggle:GetElement("__parent.__parent."..name..".toggle") + if key == selectedKey then + toggle:SetOption(name, true) + else + toggle:ClearOption(true) + end + end + TSM.db.global.appearanceOptions.colorSet = selectedKey + Theme.SetActiveColorSet(selectedKey) +end diff --git a/Core/UI/MainUI/Settings/Auctioning.lua b/Core/UI/MainUI/Settings/Auctioning.lua new file mode 100644 index 0000000..0cedd09 --- /dev/null +++ b/Core/UI/MainUI/Settings/Auctioning.lua @@ -0,0 +1,225 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Auctioning = TSM.MainUI.Settings:NewPackage("Auctioning") +local L = TSM.Include("Locale").GetTable() +local Sound = TSM.Include("Util.Sound") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local UIElements = TSM.Include("UI.UIElements") +local private = { + sounds = {}, + soundkeys = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Auctioning.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Auctioning"], "middle", private.GetAuctioningSettingsFrame) + for key, name in pairs(Sound.GetSounds()) do + tinsert(private.sounds, name) + tinsert(private.soundkeys, key) + end +end + + + +-- ============================================================================ +-- Auctioning Settings UI +-- ============================================================================ + +function private.GetAuctioningSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "auctioning") + return UIElements.New("ScrollFrame", "auctioningSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Auctioning", "auctioning", L["General Options"], L["Some general Auctioning options are below."]) + :AddChild(UIElements.New("Frame", "check1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "cancelBids") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Cancel auctions with bids"]) + :SetSettingInfo(TSM.db.global.auctioningOptions, "cancelWithBid") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "check2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "invalidPrice") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Disable invalid price warnings"]) + :SetSettingInfo(TSM.db.global.auctioningOptions, "disableInvalidMsg") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Auctioning", "sounds", L["Auction House Sounds"], L["Setup various sounds that play when doing Auctioning scans."]) + :AddChild(UIElements.New("Frame", "labelLine1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "scan") + :SetMargin(0, 12, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Scan complete sound"]) + ) + :AddChild(UIElements.New("Text", "confirm") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Confirm complete sound"]) + ) + ) + :AddChild(UIElements.New("Frame", "dropdownLine1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("SelectionDropdown", "scanComplete") + :SetMargin(0, 12, 0, 0) + :SetItems(private.sounds, private.soundkeys) + :SetSettingInfo(TSM.db.global.auctioningOptions, "scanCompleteSound") + :SetScript("OnSelectionChanged", private.SoundOnSelectionChanged) + ) + :AddChild(UIElements.New("SelectionDropdown", "confirmComplete") + :SetItems(private.sounds, private.soundkeys) + :SetSettingInfo(TSM.db.global.auctioningOptions, "confirmCompleteSound") + :SetScript("OnSelectionChanged", private.SoundOnSelectionChanged) + ) + ) + :AddChild(UIElements.New("Text", "saleLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Auction sale sound"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "saleDropdown") + :SetHeight(24) + :SetItems(private.sounds, private.soundkeys) + :SetSettingInfo(TSM.db.global.coreOptions, "auctionSaleSound") + :SetScript("OnSelectionChanged", private.SoundOnSelectionChanged) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Auctioning", "whitelist", L["Whitelist"], L["TSM will not undercut any players you add to your whitelist."]) + :AddChild(UIElements.New("Text", "matchLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Match whitelisted players"]) + ) + :AddChild(UIElements.New("ToggleOnOff", "matchToggle") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :SetSettingInfo(TSM.db.global.auctioningOptions, "matchWhitelist") + ) + :AddChild(UIElements.New("Text", "addLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Whitelisted characters"]) + ) + :AddChild(UIElements.New("Input", "newPlayerInput") + :SetHeight(24) + :SetMargin(0, 0, 0, 4) + :SetBackgroundColor("ACTIVE_BG") + :SetHintText(L["Enter player name"]) + :SetScript("OnEnterPressed", private.NewPlayerOnEnterPressed) + ) + :AddChild(UIElements.New("Frame", "whitelistFrame") + :SetLayout("FLOW") + :SetHeight(60) + :AddChildrenWithFunction(private.AddWhitelistRows) + ) + ) +end + +function private.AddWhitelistRows(containerFrame) + for player in pairs(TSM.db.factionrealm.auctioningOptions.whitelist) do + private.AddWhitelistRow(containerFrame, player) + end +end + +function private.AddWhitelistRow(frame, player) + frame:AddChild(UIElements.New("Frame", "whitelist_"..player) + :SetLayout("HORIZONTAL") + :SetSize(100, 20) + :SetMargin(0, 12, 0, 0) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetMargin(0, 2, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(player) + ) + :AddChild(UIElements.New("Button", "removeBtn") + :SetBackgroundAndSize("iconPack.14x14/Close/Circle") + :SetContext(player) + :SetScript("OnClick", private.RemoveWhitelistOnClick) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.SoundOnSelectionChanged(self) + Sound.PlaySound(self:GetSelectedItemKey()) +end + +function private.NewPlayerOnEnterPressed(input) + local newPlayer = strlower(input:GetValue()) + input:SetValue("") + input:Draw() + if newPlayer == "" or strfind(newPlayer, ",") or newPlayer ~= String.Escape(newPlayer) then + Log.PrintfUser(L["Invalid player name."]) + return + elseif TSM.db.factionrealm.auctioningOptions.whitelist[newPlayer] then + Log.PrintfUser(L["The player \"%s\" is already on your whitelist."], TSM.db.factionrealm.auctioningOptions.whitelist[newPlayer]) + return + end + + local isAlt = false + for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + if strlower(newPlayer) == strlower(character) then + Log.PrintfUser(L["You do not need to add \"%s\", alts are whitelisted automatically."], newPlayer) + isAlt = true + end + end + end + + if isAlt then + return + end + + TSM.db.factionrealm.auctioningOptions.whitelist[newPlayer] = newPlayer + + -- add a new row to the UI + local frame = input:GetElement("__parent.whitelistFrame") + private.AddWhitelistRow(frame, newPlayer) + frame:Draw() +end + +function private.RemoveWhitelistOnClick(self) + local player = self:GetContext() + TSM.db.factionrealm.auctioningOptions.whitelist[player] = nil + + -- remove this row + local row = self:GetParentElement() + local frame = row:GetParentElement() + frame:RemoveChild(row) + row:Release() + frame:Draw() +end diff --git a/Core/UI/MainUI/Settings/Core.lua b/Core/UI/MainUI/Settings/Core.lua new file mode 100644 index 0000000..45bc228 --- /dev/null +++ b/Core/UI/MainUI/Settings/Core.lua @@ -0,0 +1,287 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Settings = TSM.MainUI:NewPackage("Settings") +local L = TSM.Include("Locale").GetTable() +local Wow = TSM.Include("Util.Wow") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settingPages = { + top = {}, + middle = {}, + bottom = {}, + }, + callback = {}, + childSettingsPages = {}, + sectionCollapsed = {}, +} +local SECTIONS = { "top", "middle" } +local SETTING_PATH_SEP = "`" +local SETTING_LABEL_WIDTH = 400 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Settings.OnInitialize() + TSM.MainUI.RegisterTopLevelPage(L["Settings"], private.GetSettingsFrame) +end + +function Settings.RegisterSettingPage(name, section, callback) + assert(tContains(SECTIONS, section)) + tinsert(private.settingPages[section], name) + private.callback[name] = callback +end + +function Settings.RegisterChildSettingPage(parentName, childName, callback) + local path = parentName..SETTING_PATH_SEP..childName + private.childSettingsPages[parentName] = private.childSettingsPages[parentName] or {} + tinsert(private.childSettingsPages[parentName], childName) + private.callback[path] = callback +end + +function Settings.CreateSettingLine(id, labelText, width) + width = width or SETTING_LABEL_WIDTH + + return UIElements.New("Frame", id) + :SetLayout("HORIZONTAL") + :SetHeight(26) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Text", "label") + :SetWidth(width) + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor("TEXT_ALT") + :SetText(labelText) + ) +end + +function Settings.CreateHeading(id, text) + return UIElements.New("Text", id) + :SetHeight(19) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY1_BOLD") + :SetText(text) +end + +function Settings.CreateInputWithReset(id, label, context, validate) + local scope, namespace, key = strsplit(".", context) + local validateFunc, validateContext = nil, nil + if type(validate) == "table" then + validateFunc = "CUSTOM_PRICE" + validateContext = validate + elseif type(validate) == "function" then + validateFunc = validate + elseif validate == nil then + validateFunc = "CUSTOM_PRICE" + else + error("Invalid validate: "..tostring(validate)) + end + return UIElements.New("Frame", id) + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Text", "label") + :SetHeight(18) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor("TEXT_ALT") + :SetText(label) + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc(validateFunc, validateContext) + :SetSettingInfo(TSM.db[scope][namespace], key) + :SetScript("OnValueChanged", private.InputOnValueChanged) + ) + :AddChild(UIElements.New("ActionButton", "resetButton") + :SetWidth(108) + :SetText(L["Reset"]) + :SetDisabled(TSM.db[scope][namespace][key] == TSM.db:GetDefault(scope, namespace, key)) + :SetScript("OnClick", private.ResetBtnOnClick) + :SetContext(context) + ) + ) +end + +function Settings.CreateExpandableSection(pageName, id, text, description, descriptionHeight) + return UIElements.New("CollapsibleContainer", id) + :SetLayout("VERTICAL") + :SetMargin(0, 0, 0, 8) + :SetContextTable(private.sectionCollapsed, pageName..text) + :SetHeadingText(text) + :AddChild(UIElements.New("Text", "description") + :SetHeight(descriptionHeight or 20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY3") + :SetText(description) + ) +end + +function Settings.PromptToReload() + StaticPopupDialogs["TSMReloadPrompt"] = StaticPopupDialogs["TSMReloadPrompt"] or { + text = L["You must reload your UI for these settings to take effect. Reload now?"], + button1 = YES, + button2 = NO, + timeout = 0, + OnAccept = ReloadUI, + } + Wow.ShowStaticPopupDialog("TSMReloadPrompt") +end + + + +-- ============================================================================ +-- Settings UI +-- ============================================================================ + +function private.GetSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings") + local defaultPage = private.settingPages.top[1] + + local frame = UIElements.New("Frame", "settings") + :SetLayout("HORIZONTAL") + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "settingNavigation") + :SetLayout("VERTICAL") + :SetWidth(160) + :SetPadding(12, 12, 1, 9) + :AddChild(UIElements.New("Frame", "top") + :SetLayout("VERTICAL") + ) + :AddChild(UIElements.New("Texture", "vline") + :SetHeight(1) + :SetMargin(0, 0, 8, 8) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChild(UIElements.New("Frame", "middle") + :SetLayout("VERTICAL") + ) + :AddChild(UIElements.New("Spacer", "spacer") + -- make all the navigation align to the top + ) + ) + :AddChild(UIElements.New("Texture", "divider") + :SetWidth(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "contentFrame") + :SetLayout("VERTICAL") + :SetBackgroundColor("PRIMARY_BG") + :AddChild(UIElements.New("ViewContainer", "content") + :SetNavCallback(private.ContentNavCallback) + ) + ) + + local content = frame:GetElement("contentFrame.content") + local settingNav = frame:GetElement("settingNavigation") + for _, location in ipairs(SECTIONS) do + local navFrame = settingNav:GetElement(location) + for _, settingName in ipairs(private.settingPages[location]) do + navFrame:AddChild(UIElements.New("Button", settingName) + :SetHeight(20) + :SetMargin(0, 0, 8, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("LEFT") + :SetContext(settingName) + :SetText(settingName) + :SetScript("OnClick", private.NavButtonOnClick) + ) + content:AddPath(settingName, settingName == defaultPage) + if private.childSettingsPages[settingName] then + for _, childSettingName in ipairs(private.childSettingsPages[settingName]) do + local path = settingName..SETTING_PATH_SEP..childSettingName + navFrame:AddChild(UIElements.New("Button", path) + :SetHeight(20) + :SetMargin(9, 0, 8, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetJustifyH("LEFT") + :SetContext(path) + :SetText(strupper(childSettingName)) + :SetScript("OnClick", private.NavButtonOnClick) + ) + content:AddPath(path, path == defaultPage) + end + end + end + end + private.UpdateNavFrame(settingNav, defaultPage) + return frame +end + +function private.ContentNavCallback(content, path) + return private.callback[path]() +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.NavButtonOnClick(button) + local path = button:GetContext() + if private.childSettingsPages[path] then + -- select the first child + path = path..SETTING_PATH_SEP..private.childSettingsPages[path][1] + end + + local contentFrame = button:GetElement("__parent.__parent.__parent.contentFrame") + local navFrame = contentFrame:GetElement("__parent.settingNavigation") + private.UpdateNavFrame(navFrame, path) + navFrame:Draw() + contentFrame:GetElement("content"):SetPath(path, true) +end + +function private.InputOnValueChanged(input) + local button = input:GetElement("__parent.resetButton") + local scope, namespace, key = strsplit(".", button:GetContext()) + button:SetDisabled(TSM.db[scope][namespace][key] == TSM.db:GetDefault(scope, namespace, key)) + :Draw() +end + +function private.ResetBtnOnClick(button) + local scope, namespace, key = strsplit(".", button:GetContext()) + local defaultValue = TSM.db:GetDefault(scope, namespace, key) + TSM.db:Set(scope, nil, namespace, key, defaultValue) + button:GetElement("__parent.input") + :SetValue(defaultValue) + :Draw() + button:SetDisabled(true) + :Draw() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.UpdateNavFrame(navFrame, selectedPath) + local selectedSetting = strsplit(SETTING_PATH_SEP, selectedPath) + for _, location in ipairs(SECTIONS) do + for _, settingName in ipairs(private.settingPages[location]) do + navFrame:GetElement(location ..".".. settingName) + :SetTextColor(settingName == selectedSetting and "TEXT" or "ACTIVE_BG_ALT") + if private.childSettingsPages[settingName] then + for _, childSettingName in ipairs(private.childSettingsPages[settingName]) do + local path = settingName..SETTING_PATH_SEP..childSettingName + if settingName == selectedSetting then + navFrame:GetElement(location ..".".. path) + :SetTextColor(path == selectedPath and "INDICATOR" or "TEXT") + :Show() + else + navFrame:GetElement(location ..".".. path):Hide() + end + end + end + end + end +end diff --git a/Core/UI/MainUI/Settings/Crafting.lua b/Core/UI/MainUI/Settings/Crafting.lua new file mode 100644 index 0000000..b536eb4 --- /dev/null +++ b/Core/UI/MainUI/Settings/Crafting.lua @@ -0,0 +1,142 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Crafting = TSM.MainUI.Settings:NewPackage("Crafting") +local L = TSM.Include("Locale").GetTable() +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local UIElements = TSM.Include("UI.UIElements") +local private = { + altCharacters = {}, + altGuilds = {}, +} +local BAD_MAT_PRICE_SOURCES = { + matprice = true, +} +local BAD_CRAFT_VALUE_PRICE_SOURCES = { + crafting = true, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Crafting.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Crafting"], "middle", private.GetCraftingSettingsFrame) +end + + + +-- ============================================================================ +-- Crafting Settings UI +-- ============================================================================ + +function private.GetCraftingSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "crafting") + wipe(private.altCharacters) + wipe(private.altGuilds) + for _, character in PlayerInfo.CharacterIterator(true) do + tinsert(private.altCharacters, character) + end + for name in PlayerInfo.GuildIterator() do + tinsert(private.altGuilds, name) + end + + return UIElements.New("ScrollFrame", "craftingSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Crafting", "inventory", L["Inventory Options"], "") + :AddChild(UIElements.New("Frame", "inventoryOptionsLabels") + :SetLayout("HORIZONTAL") + :SetMargin(0, 0, 0, 4) + :SetHeight(20) + :AddChild(UIElements.New("Text", "label") + :SetMargin(0, 12, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Ignore Characters"]) + ) + :AddChild(UIElements.New("Text", "label") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Ignore Guilds"]) + ) + ) + :AddChild(UIElements.New("Frame", "inventoryOptionsDropdowns") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("MultiselectionDropdown", "charDropdown") + :SetMargin(0, 12, 0, 0) + :SetItems(private.altCharacters, private.altCharacters) + :SetSettingInfo(TSM.db.global.craftingOptions, "ignoreCharacters") + :SetSelectionText(L["No Characters"], L["%d Characters"], L["All Characters"]) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "guildDropdown") + :SetItems(private.altGuilds, private.altGuilds) + :SetSettingInfo(TSM.db.global.craftingOptions, "ignoreGuilds") + :SetSelectionText(L["No Guilds"], L["%d Guilds"], L["All Guilds"]) + ) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Crafting", "price", L["Default price configuration"], "") + :AddChild(UIElements.New("Text", "matCostLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor("TEXT_ALT") + :SetText(L["Default material cost method"]) + ) + :AddChild(UIElements.New("MultiLineInput", "matCostInput") + :SetHeight(70) + :SetMargin(0, 0, 0, 12) + :SetValidateFunc("CUSTOM_PRICE", BAD_MAT_PRICE_SOURCES) + :SetSettingInfo(TSM.db.global.craftingOptions, "defaultMatCostMethod") + ) + :AddChild(UIElements.New("Text", "craftValueLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetTextColor("TEXT_ALT") + :SetText(L["Default craft value method"]) + ) + :AddChild(UIElements.New("MultiLineInput", "matCostInput") + :SetHeight(70) + :SetValidateFunc("CUSTOM_PRICE", BAD_CRAFT_VALUE_PRICE_SOURCES) + :SetSettingInfo(TSM.db.global.craftingOptions, "defaultCraftPriceMethod") + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Crafting", "cooldowns", L["Ignored Cooldowns"], L["Use this list to manage what cooldowns you'd like TSM to ignore from crafting."]) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetHeight(126) + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Cooldown"]) + :SetFont("BODY_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.CooldownGetText) + :DisableHiding() + :Commit() + :Commit() + :SetQuery(TSM.Crafting.CreateIgnoredCooldownQuery()) + :SetAutoReleaseQuery(true) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.IgnoredCooldownOnRowClick) + ) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.CooldownGetText(row) + return row:GetField("characterKey").." - "..TSM.Crafting.GetName(row:GetField("spellId")) +end + +function private.IgnoredCooldownOnRowClick(_, row) + TSM.Crafting.RemoveIgnoredCooldown(row:GetFields("characterKey", "spellId")) +end diff --git a/Core/UI/MainUI/Settings/CustomSources.lua b/Core/UI/MainUI/Settings/CustomSources.lua new file mode 100644 index 0000000..98fd11c --- /dev/null +++ b/Core/UI/MainUI/Settings/CustomSources.lua @@ -0,0 +1,297 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local CustomSources = TSM.MainUI.Settings:NewPackage("CustomSources") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local CustomPrice = TSM.Include("Service.CustomPrice") +local UIElements = TSM.Include("UI.UIElements") +local private = {} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function CustomSources.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Custom Sources"], "middle", private.GetCustomSourcesSettingsFrame) +end + + + +-- ============================================================================ +-- Custom Sources Settings UI +-- ============================================================================ + +function private.GetCustomSourcesSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "custom_sources") + return UIElements.New("ScrollFrame", "content") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Custom Price", "general", L["Custom Sources"], format(L["Custom sources allow you to create more advanced prices for use throughout the addon. You'll be able to use these new variables in the same way you can use the built-in price sources such as %s and %s."], Theme.GetColor("INDICATOR"):ColorText("vendorsell"), Theme.GetColor("INDICATOR"):ColorText("vendorbuy")), 60) + :AddChild(UIElements.New("Frame", "tableHeading") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "col1") + :SetWidth(162) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["Name"]) + ) + :AddChild(UIElements.New("Text", "col2") + :SetFont("BODY_BODY3_MEDIUM") + :SetText(L["String"]) + ) + ) + :AddChild(UIElements.New("Texture", "line1") + :SetHeight(1) + :SetTexture("ACTIVE_BG") + ) + :AddChildrenWithFunction(private.AddCustomPriceRows) + :AddChild(UIElements.New("ActionButton", "addNewBtn") + :SetHeight(24) + :SetMargin(0, 0, 32, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Add a new custom source"]) + :SetScript("OnClick", private.AddNewButtonOnClick) + ) + ) +end + +function private.CreateCustomPriceRow(name) + local priceString = TSM.db.global.userData.customPriceSources[name] + local row = UIElements.New("Frame", "row_"..name) + :SetLayout("HORIZONTAL") + :SetHeight(28) + :SetMargin(-12, -12, 0, 0) + :SetPadding(12, 12, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetContext(name) + :SetScript("OnEnter", private.CustomPriceRowOnEnter) + :SetScript("OnLeave", private.CustomPriceRowOnLeave) + :AddChild(UIElements.New("Text", "nameText") + :SetWidth(162) + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(name) + ) + :AddChild(UIElements.New("Text", "valueText") + :SetFont("BODY_BODY3") + :SetText(priceString) + ) + :AddChild(UIElements.New("Button", "editBtn") + :SetMargin(4, 0, 0, 0) + :SetBackgroundAndSize("iconPack.18x18/Edit") + :SetScript("OnClick", private.EditCustomPriceOnClick) + :PropagateScript("OnEnter") + :PropagateScript("OnLeave") + ) + :AddChild(UIElements.New("Button", "deleteBtn") + :SetMargin(4, 0, 0, 0) + :SetBackgroundAndSize("iconPack.18x18/Delete") + :SetScript("OnClick", private.DeleteCustomPriceOnClick) + :PropagateScript("OnEnter") + :PropagateScript("OnLeave") + ) + row:GetElement("editBtn"):Hide() + row:GetElement("deleteBtn"):Hide() + return row +end + +function private.AddCustomPriceRows(frame) + local names = TempTable.Acquire() + for name in pairs(TSM.db.global.userData.customPriceSources) do + tinsert(names, name) + end + sort(names) + for _, name in ipairs(names) do + frame:AddChild(private.CreateCustomPriceRow(name)) + end + TempTable.Release(names) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.CustomPriceRowOnEnter(frame) + frame:SetBackgroundColor("FRAME_BG") + frame:GetElement("editBtn"):Show() + frame:GetElement("deleteBtn"):Show() + frame:Draw() +end + +function private.CustomPriceRowOnLeave(frame) + frame:SetBackgroundColor("PRIMARY_BG_ALT") + frame:GetElement("editBtn"):Hide() + frame:GetElement("deleteBtn"):Hide() + frame:Draw() +end + +function private.EditCustomPriceOnClick(button) + private.ShowEditDialog(button) +end + +function private.ShowEditDialog(editBtn) + local dialogFrame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(478, 314) + :SetPadding(12) + :AddAnchor("CENTER") + :SetMouseEnabled(true) + :SetBackgroundColor("FRAME_BG", true) + :SetContext(editBtn) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 14) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(20) + ) + :AddChild(UIElements.New("Text", "title") + :SetJustifyH("CENTER") + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Edit Custom Source"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.EditPriceCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(20) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("LEFT") + :SetText(L["Name"]) + ) + :AddChild(UIElements.New("Input", "nameInput") + :SetHeight(24) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValue(editBtn:GetElement("__parent.nameText"):GetText()) + :SetTabPaths("__parent.valueInput", "__parent.valueInput") + :SetValidateFunc(private.NameValidateFunc) + :SetScript("OnValidationChanged", private.ConfirmOnValidationChanged) + ) + :AddChild(UIElements.New("Text", "string") + :SetHeight(20) + :SetMargin(0, 0, 6, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetJustifyH("LEFT") + :SetText(L["String"]) + ) + :AddChild(UIElements.New("MultiLineInput", "valueInput") + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetValue(editBtn:GetElement("__parent.valueText"):GetText()) + :SetTabPaths("__parent.nameInput", "__parent.nameInput") + :SetValidateFunc(private.ValueValidateFunc) + :SetScript("OnValidationChanged", private.ConfirmOnValidationChanged) + ) + :AddChild(UIElements.New("ActionButton", "confirm") + :SetHeight(24) + :SetMargin(0, 0, 12, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Confirm"]) + :SetContext(editBtn:GetElement("__parent.__parent")) + :SetScript("OnClick", private.ConfirmOnClick) + ) + editBtn:GetBaseElement():ShowDialogFrame(dialogFrame) + return dialogFrame +end + +function private.EditPriceCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.NameValidateFunc(input, value) + if value == "" then + return false + elseif gsub(value, "([a-z]+)", "") ~= "" then + return false, L["Custom price names can only contain lowercase letters."] + elseif value ~= input:GetParentElement():GetContext():GetParentElement():GetContext() then + return CustomPrice.ValidateName(value) + end + return true +end + +function private.ConfirmOnValidationChanged(input) + input:GetElement("__parent.confirm") + :SetDisabled(not input:IsValid()) + :Draw() +end + +function private.ValueValidateFunc(input, value) + value = strlower(strtrim(value)) + local isValid, errMsg = CustomPrice.Validate(value) + if not isValid and value ~= "" then + return false, errMsg + end + return true +end + +function private.ConfirmOnClick(button) + local baseElement = button:GetBaseElement() + local oldName = button:GetParentElement():GetContext():GetParentElement():GetContext() + local newName = button:GetElement("__parent.nameInput"):GetValue() + if oldName ~= newName then + CustomPrice.RenameCustomPriceSource(oldName, newName) + CustomPrice.SetCustomPriceSource(newName, button:GetElement("__parent.valueInput"):GetValue()) + local generalContainer = button:GetParentElement():GetContext():GetParentElement():GetParentElement() + local rowFrame = button:GetParentElement():GetContext():GetParentElement() + generalContainer:AddChildBeforeById("addNewBtn", private.CreateCustomPriceRow(newName)) + generalContainer:RemoveChild(rowFrame) + rowFrame:Release() + generalContainer:GetElement("__parent.__parent") + :Draw() + else + CustomPrice.SetCustomPriceSource(newName, button:GetElement("__parent.valueInput"):GetValue()) + button:GetParentElement():GetContext():GetElement("__parent.nameText") + :SetText(newName) + :Draw() + button:GetParentElement():GetContext():GetElement("__parent.valueText") + :SetText(button:GetElement("__parent.valueInput"):GetValue()) + :Draw() + end + baseElement:HideDialog() +end + +function private.DeleteCustomPriceOnClick(button) + CustomPrice.DeleteCustomPriceSource(button:GetParentElement():GetContext()) + local rowFrame = button:GetParentElement() + local parentFrame = rowFrame:GetParentElement() + parentFrame:RemoveChild(rowFrame) + rowFrame:Release() + parentFrame:GetElement("__parent.__parent") + :Draw() +end + +function private.AddNewButtonOnClick(button) + -- generate a placeholder name + local newName = nil + local suffix = "" + while not newName do + for i = strbyte("a"), strbyte("z") do + newName = "customprice"..suffix..strchar(i) + if not TSM.db.global.userData.customPriceSources[newName] then + break + end + newName = nil + end + suffix = suffix..strchar(random(strbyte("a"), strbyte("z"))) + end + + CustomPrice.CreateCustomPriceSource(newName, "") + button:GetParentElement() + :AddChildBeforeById("addNewBtn", private.CreateCustomPriceRow(newName)) + button:GetElement("__parent.__parent.__parent") + :Draw() + local dialogFrame = private.ShowEditDialog(button:GetElement("__parent.row_"..newName..".editBtn")) + dialogFrame:GetElement("valueInput"):SetFocused(true) +end diff --git a/Core/UI/MainUI/Settings/Destroying.lua b/Core/UI/MainUI/Settings/Destroying.lua new file mode 100644 index 0000000..e8a50d9 --- /dev/null +++ b/Core/UI/MainUI/Settings/Destroying.lua @@ -0,0 +1,122 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Destroying = TSM.MainUI.Settings:NewPackage("Destroying") +local L = TSM.Include("Locale").GetTable() +local UIElements = TSM.Include("UI.UIElements") +local private = {} +local ITEM_QUALITY_DESCS = { ITEM_QUALITY2_DESC, ITEM_QUALITY3_DESC, ITEM_QUALITY4_DESC } +local ITEM_QUALITY_KEYS = { 2, 3, 4 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Destroying.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage("Destroying", "middle", private.GetDestroyingSettingsFrame) +end + + + +-- ============================================================================ +-- Destroying Settings UI +-- ============================================================================ + +function private.GetDestroyingSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "destroying") + return UIElements.New("ScrollFrame", "destroyingSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Destroying", "general", L["General Options"], "") + :AddChild(UIElements.New("Frame", "check1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "autoStackCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Enable automatic stack combination"]) + :SetSettingInfo(TSM.db.global.destroyingOptions, "autoStack") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "check2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "autoShowCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Show destroying frame automatically"]) + :SetSettingInfo(TSM.db.global.destroyingOptions, "autoShow") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "check3") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "includeSoulboundCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Include soulbound items"]) + :SetSettingInfo(TSM.db.global.destroyingOptions, "includeSoulbound") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Destroying", "disenchanting", L["Disenchanting Options"], "") + :AddChild(UIElements.New("Text", "label") + :SetHeight(18) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Maximum disenchant quality"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "maxQualityDropDown") + :SetHeight(26) + :SetMargin(0, 0, 0, 12) + :SetItems(ITEM_QUALITY_DESCS, ITEM_QUALITY_KEYS) + :SetSettingInfo(TSM.db.global.destroyingOptions, "deMaxQuality") + ) + :AddChild(TSM.MainUI.Settings.CreateInputWithReset("deDisenchantPriceField", L["Only show items with disenchant values above this price"], "global.destroyingOptions.deAbovePrice")) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Destroying", "ignore", L["Ignored Items"], L["Use this list to manage what items you'd like TSM to ignore from destroying."]) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetHeight(136) + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("texture") + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :Commit() + :SetQuery(TSM.Destroying.CreateIgnoreQuery()) + :SetAutoReleaseQuery(true) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.IgnoredItemsOnRowClick) + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.IgnoredItemsOnRowClick(_, record, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + TSM.Destroying.ForgetIgnoreItemPermanent(record:GetField("itemString")) +end diff --git a/Core/UI/MainUI/Settings/General.lua b/Core/UI/MainUI/Settings/General.lua new file mode 100644 index 0000000..ff1e061 --- /dev/null +++ b/Core/UI/MainUI/Settings/General.lua @@ -0,0 +1,672 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local General = TSM.MainUI.Settings:NewPackage("General") +local L = TSM.Include("Locale").GetTable() +local Log = TSM.Include("Util.Log") +local TempTable = TSM.Include("Util.TempTable") +local Table = TSM.Include("Util.Table") +local Theme = TSM.Include("Util.Theme") +local Settings = TSM.Include("Service.Settings") +local Sync = TSM.Include("Service.Sync") +local PlayerInfo = TSM.Include("Service.PlayerInfo") +local Tooltip = TSM.Include("UI.Tooltip") +local UIElements = TSM.Include("UI.UIElements") +local private = { + frame = nil, + characterList = {}, + guildList = {}, + chatFrameList = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function General.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["General Settings"], "top", private.GetGeneralSettingsFrame) + Sync.RegisterConnectionChangedCallback(private.SyncConnectionChangedCallback) +end + + + +-- ============================================================================ +-- General Settings UI +-- ============================================================================ + +function private.GetGeneralSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "general") + wipe(private.chatFrameList) + local defaultChatFrame = nil + for i = 1, NUM_CHAT_WINDOWS do + local name = strlower(GetChatWindowInfo(i) or "") + if DEFAULT_CHAT_FRAME == _G["ChatFrame"..i] then + defaultChatFrame = name + end + if name ~= "" and _G["ChatFrame"..i.."Tab"]:IsVisible() then + tinsert(private.chatFrameList, name) + end + end + if not tContains(private.chatFrameList, TSM.db.global.coreOptions.chatFrame) then + if tContains(private.chatFrameList, defaultChatFrame) then + TSM.db.global.coreOptions.chatFrame = defaultChatFrame + Log.SetChatFrame(defaultChatFrame) + else + -- all chat frames are hidden, so just disable the setting + wipe(private.chatFrameList) + end + end + + wipe(private.characterList) + for _, character in PlayerInfo.CharacterIterator(true) do + if character ~= UnitName("player") then + tinsert(private.characterList, character) + end + end + + wipe(private.guildList) + for guild in PlayerInfo.GuildIterator(true) do + tinsert(private.guildList, guild) + end + + return UIElements.New("ScrollFrame", "generalSettings") + :SetPadding(8, 8, 8, 0) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("General", "general", L["General Options"], L["Some general TSM options are below."]) + :AddChild(UIElements.New("Frame", "check1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "globalOperations") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Store operations globally"]) + :SetChecked(TSM.Operations.IsStoredGlobally()) + :SetScript("OnValueChanged", private.GlobalOperationsOnValueChanged) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "check2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "protectAuctionHouse") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Prevent closing the Auction House with the esc key"]) + :SetSettingInfo(TSM.db.global.coreOptions, "protectAuctionHouse") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(TSM.MainUI.Settings.CreateInputWithReset("generalGroupPriceField", L["Filter group item lists based on the following price source"], "global.coreOptions.groupPriceSource")) + :AddChild(UIElements.New("Frame", "dropdownLabelLine") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 12, 4) + :AddChild(UIElements.New("Text", "chatTabLabel") + :SetMargin(0, 12, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Chat Tab"]) + ) + :AddChild(UIElements.New("Text", "forgetLabel") + :SetMargin(0, 12, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Forget Character"]) + ) + :AddChild(UIElements.New("Text", "ignoreLabel") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Ignore Guilds"]) + ) + ) + :AddChild(UIElements.New("Frame", "dropdownLabelLine") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("SelectionDropdown", "chatTabDropdown") + :SetMargin(0, 16, 0, 0) + :SetItems(private.chatFrameList, private.chatFrameList) + :SetSettingInfo(next(private.chatFrameList) and TSM.db.global.coreOptions or nil, "chatFrame") + :SetScript("OnSelectionChanged", private.ChatTabOnSelectionChanged) + ) + :AddChild(UIElements.New("SelectionDropdown", "forgetDropdown") + :SetMargin(0, 16, 0, 0) + :SetItems(private.characterList, private.characterList) + :SetScript("OnSelectionChanged", private.ForgetCharacterOnSelectionChanged) + ) + :AddChild(UIElements.New("MultiselectionDropdown", "ignoreDropdown") + :SetItems(private.guildList, private.guildList) + :SetSettingInfo(TSM.db.factionrealm.coreOptions, "ignoreGuilds") + :SetSelectionText(L["No Guilds"], L["%d Guilds"], L["All Guilds"]) + ) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("General", "profiles", L["Profiles"], L["Set your active profile or create a new one."]) + :AddChildrenWithFunction(private.AddProfileRows) + :AddChild(UIElements.New("Text", "profileLabel") + :SetHeight(20) + :SetMargin(0, 0, 4, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Create new profile"]) + ) + :AddChild(UIElements.New("Input", "newProfileInput") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetHintText(L["Enter profile name"]) + :SetScript("OnEnterPressed", private.NewProfileInputOnEnterPressed) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("General", "accountSync", L["Account Syncing"], L["TSM can automatically sync data between multiple WoW accounts."]) + :AddChildrenWithFunction(private.AddAccountSyncRows) + :AddChild(UIElements.New("Text", "profileLabel") + :SetHeight(20) + :SetMargin(0, 0, 4, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Add account"]) + ) + :AddChild(UIElements.New("Input", "newProfileInput") + :SetHeight(24) + :SetBackgroundColor("ACTIVE_BG") + :SetHintText(L["Enter name of logged-in character on other account"]) + :SetScript("OnEnterPressed", private.NewAccountSyncInputOnEnterPressed) + ) + ) +end + +function private.AddProfileRows(frame) + for index, profileName in TSM.db:ProfileIterator() do + local isCurrentProfile = profileName == TSM.db:GetCurrentProfile() + local row = UIElements.New("Frame", "profileRow_"..index) + :SetLayout("HORIZONTAL") + :SetHeight(28) + :SetMargin(0, 0, 0, 8) + :SetPadding(8, 8, 4, 4) + :SetBackgroundColor(isCurrentProfile and "ACTIVE_BG" or "PRIMARY_BG_ALT", true) + :SetContext(profileName) + :SetScript("OnEnter", private.ProfileRowOnEnter) + :SetScript("OnLeave", private.ProfileRowOnLeave) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetWidth("AUTO") + :SetCheckboxPosition("LEFT") + :SetText(profileName) + :SetFont("BODY_BODY2") + :SetChecked(isCurrentProfile) + :SetScript("OnValueChanged", private.ProfileCheckboxOnValueChanged) + :PropagateScript("OnEnter") + :PropagateScript("OnLeave") + ) + :PropagateScript("OnEnter") + :PropagateScript("OnLeave") + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Button", "resetBtn") + :SetBackgroundAndSize("iconPack.18x18/Reset") + :SetMargin(4, 0, 0, 0) + :SetScript("OnClick", private.ResetProfileOnClick) + :SetScript("OnEnter", private.ResetProfileOnEnter) + :SetScript("OnLeave", private.ResetProfileOnLeave) + ) + :AddChild(UIElements.New("Button", "renameBtn") + :SetBackgroundAndSize("iconPack.18x18/Edit") + :SetMargin(4, 0, 0, 0) + :SetScript("OnClick", private.RenameProfileOnClick) + :SetScript("OnEnter", private.RenameProfileOnEnter) + :SetScript("OnLeave", private.RenameProfileOnLeave) + ) + :AddChild(UIElements.New("Button", "duplicateBtn") + :SetBackgroundAndSize("iconPack.18x18/Duplicate") + :SetMargin(4, 0, 0, 0) + :SetScript("OnClick", private.DuplicateProfileOnClick) + :SetScript("OnEnter", private.DuplicateProfileOnEnter) + :SetScript("OnLeave", private.DuplicateProfileOnLeave) + ) + :AddChild(UIElements.New("Button", "deleteBtn") + :SetBackgroundAndSize("iconPack.18x18/Delete") + :SetMargin(4, 0, 0, 0) + :SetScript("OnClick", private.DeleteProfileOnClick) + :SetScript("OnEnter", private.DeleteProfileOnEnter) + :SetScript("OnLeave", private.DeleteProfileOnLeave) + ) + row:GetElement("deleteBtn"):Hide() + if not isCurrentProfile then + row:GetElement("resetBtn"):Hide() + row:GetElement("renameBtn"):Hide() + row:GetElement("duplicateBtn"):Hide() + end + frame:AddChild(row) + end +end + +function private.AddAccountSyncRows(frame) + local newAccountStatusText = Sync.GetNewAccountStatus() + if newAccountStatusText then + local row = private.CreateAccountSyncRow("new", newAccountStatusText) + row:GetElement("sendProfileBtn"):Hide() + row:GetElement("removeBtn"):Hide() + frame:AddChild(row) + end + + for _, account in TSM.db:SyncAccountIterator() do + local characters = TempTable.Acquire() + for _, character in Settings.CharacterByAccountFactionrealmIterator(account) do + tinsert(characters, character) + end + sort(characters) + local isConnected, connectedCharacter = Sync.GetConnectionStatus(account) + local statusText = nil + if isConnected then + statusText = Theme.GetFeedbackColor("GREEN"):ColorText(format(L["Connected to %s"], connectedCharacter)) + else + statusText = Theme.GetFeedbackColor("RED"):ColorText(L["Offline"]) + end + statusText = statusText.." | "..table.concat(characters, ", ") + TempTable.Release(characters) + + local row = private.CreateAccountSyncRow("accountSyncRow_"..account, statusText) + row:SetContext(account) + row:GetElement("sendProfileBtn"):Hide() + row:GetElement("removeBtn"):Hide() + frame:AddChild(row) + end +end + +function private.CreateAccountSyncRow(id, statusText) + local row = UIElements.New("Frame", id) + :SetLayout("HORIZONTAL") + :SetHeight(28) + :SetMargin(0, 0, 0, 8) + :SetPadding(8, 8, 4, 4) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :SetScript("OnEnter", private.AccountSyncRowOnEnter) + :SetScript("OnLeave", private.AccountSyncRowOnLeave) + :AddChild(UIElements.New("Text", "status") + :SetFont("BODY_BODY2") + :SetText(statusText) + :SetScript("OnEnter", private.AccountSyncTextOnEnter) + :SetScript("OnLeave", private.AccountSyncTextOnLeave) + ) + :AddChild(UIElements.New("Button", "sendProfileBtn") + :SetBackgroundAndSize("iconPack.18x18/Operation") + :SetMargin(4, 0, 0, 0) + :SetScript("OnClick", private.SendProfileOnClick) + :SetScript("OnEnter", private.SendProfileOnEnter) + :SetScript("OnLeave", private.SendProfileOnLeave) + ) + :AddChild(UIElements.New("Button", "removeBtn") + :SetBackgroundAndSize("iconPack.18x18/Delete") + :SetMargin(4, 0, 0, 0) + :SetScript("OnClick", private.RemoveAccountSyncOnClick) + :SetScript("OnEnter", private.RemoveAccountOnEnter) + :SetScript("OnLeave", private.RemoveAccountOnLeave) + ) + return row +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.SyncConnectionChangedCallback() + if private.frame then + private.frame:GetParentElement():ReloadContent() + end +end + +function private.FrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.frame = frame +end + +function private.FrameOnHide(frame) + private.frame = nil +end + +function private.GlobalOperationsOnValueChanged(checkbox, value) + -- restore the previous value until it's confirmed + checkbox:SetChecked(not value, true) + local desc = L["If you have multiple profiles set up with operations, enabling this will cause all but the current profile's operations to be irreversibly lost."] + checkbox:GetBaseElement():ShowConfirmationDialog(L["Make Operations Global?"], desc, private.GlobalOperationsConfirmed, checkbox, value) +end + +function private.GlobalOperationsConfirmed(checkbox, newValue) + checkbox:SetChecked(newValue, true) + :Draw() + TSM.Operations.SetStoredGlobally(newValue) +end + +function private.ChatTabOnSelectionChanged(dropdown) + Log.SetChatFrame(dropdown:GetSelectedItem()) +end + +function private.ForgetCharacterOnSelectionChanged(self) + local character = self:GetSelectedItem() + if not character then return end + TSM.db:RemoveSyncCharacter(character) + TSM.db.factionrealm.internalData.pendingMail[character] = nil + TSM.db.factionrealm.internalData.characterGuilds[character] = nil + Log.PrintfUser(L["%s removed."], character) + assert(Table.RemoveByValue(private.characterList, character) == 1) + self:SetSelectedItem(nil) + :SetItems(private.characterList) + :Draw() +end + +function private.ProfileRowOnEnter(frame) + local isCurrentProfile = frame:GetContext() == TSM.db:GetCurrentProfile() + frame:SetBackgroundColor("ACTIVE_BG", true) + if not isCurrentProfile then + frame:GetElement("resetBtn"):Show() + frame:GetElement("renameBtn"):Show() + frame:GetElement("duplicateBtn"):Show() + frame:GetElement("deleteBtn"):Show() + end + frame:Draw() +end + +function private.ProfileRowOnLeave(frame) + local isCurrentProfile = frame:GetContext() == TSM.db:GetCurrentProfile() + frame:SetBackgroundColor(isCurrentProfile and "ACTIVE_BG" or "PRIMARY_BG_ALT", true) + if not isCurrentProfile then + frame:GetElement("resetBtn"):Hide() + frame:GetElement("renameBtn"):Hide() + frame:GetElement("duplicateBtn"):Hide() + frame:GetElement("deleteBtn"):Hide() + end + frame:Draw() +end + +function private.ProfileCheckboxOnValueChanged(checkbox, value) + if not value then + -- can't uncheck profile checkboxes + checkbox:SetChecked(true, true) + checkbox:Draw() + return + end + -- uncheck the current profile row + local currentProfileIndex = nil + for index, profileName in TSM.db:ProfileIterator() do + if profileName == TSM.db:GetCurrentProfile() then + assert(not currentProfileIndex) + currentProfileIndex = index + end + end + local prevRow = checkbox:GetElement("__parent.__parent.__parent.profileRow_"..currentProfileIndex) + prevRow:GetElement("content.checkbox") + :SetChecked(false, true) + prevRow:GetElement("resetBtn"):Hide() + prevRow:GetElement("renameBtn"):Hide() + prevRow:GetElement("duplicateBtn"):Hide() + prevRow:GetElement("deleteBtn"):Hide() + prevRow:SetBackgroundColor("PRIMARY_BG_ALT", true) + prevRow:Draw() + -- set the profile + TSM.db:SetProfile(checkbox:GetText()) + -- set this row as the current one + local newRow = checkbox:GetElement("__parent.__parent") + newRow:SetBackgroundColor("ACTIVE_BG", true) + newRow:GetElement("resetBtn"):Show() + newRow:GetElement("renameBtn"):Show() + newRow:GetElement("duplicateBtn"):Show() + newRow:GetElement("deleteBtn"):Hide() + newRow:Draw() +end + +function private.RenameProfileOnClick(button) + local profileName = button:GetParentElement():GetContext() + local dialogFrame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(600, 187) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG") + :SetBorderColor("ACTIVE_BG") + :AddChild(UIElements.New("Text", "title") + :SetHeight(44) + :SetMargin(16, 16, 24, 16) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("CENTER") + :SetText(L["Rename Profile"]) + ) + :AddChild(UIElements.New("Input", "nameInput") + :SetHeight(26) + :SetMargin(16, 16, 0, 25) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetContext(profileName) + :SetValue(profileName) + :SetScript("OnEnterPressed", private.RenameProfileInputOnEnterPressed) + ) + :AddChild(UIElements.New("Frame", "buttons") + :SetLayout("HORIZONTAL") + :SetMargin(16, 16, 0, 16) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("ActionButton", "closeBtn") + :SetSize(126, 26) + :SetText(CLOSE) + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + button:GetBaseElement():ShowDialogFrame(dialogFrame) + dialogFrame:GetElement("nameInput"):SetFocused(true) +end + +function private.DialogCloseBtnOnClick(button) + private.RenameProfileInputOnEnterPressed(button:GetElement("__parent.__parent.nameInput")) +end + +function private.RenameProfileInputOnEnterPressed(input) + local profileName = input:GetValue() + local prevProfileName = input:GetContext() + if not TSM.db:IsValidProfileName(profileName) then + Log.PrintUser(L["This is not a valid profile name. Profile names must be at least one character long and may not contain '@' characters."]) + return + elseif TSM.db:ProfileExists(profileName) then + Log.PrintUser(L["A profile with this name already exists."]) + return + end + + -- create a new profile, copy over the settings, then delete the old one + local currentProfileName = TSM.db:GetCurrentProfile() + TSM.db:SetProfile(profileName) + TSM.db:CopyProfile(prevProfileName) + TSM.db:DeleteProfile(prevProfileName, profileName) + if currentProfileName ~= prevProfileName then + TSM.db:SetProfile(currentProfileName) + end + + -- hide the dialog and refresh the settings content + local baseElement = input:GetBaseElement() + baseElement:HideDialog() + baseElement:GetElement("content.settings.contentFrame.content"):ReloadContent() +end + +function private.RenameProfileOnEnter(button) + button:ShowTooltip(L["Rename the profile"]) + private.ProfileRowOnEnter(button:GetParentElement()) +end + +function private.RenameProfileOnLeave(button) + Tooltip.Hide() + private.ProfileRowOnLeave(button:GetParentElement()) +end + +function private.DuplicateProfileOnClick(button) + local profileName = button:GetParentElement():GetContext() + local newName = profileName + while TSM.db:ProfileExists(newName) do + newName = newName.." Copy" + end + local activeProfile = TSM.db:GetCurrentProfile() + TSM.db:SetProfile(newName) + TSM.db:CopyProfile(profileName) + TSM.db:SetProfile(activeProfile) + button:GetBaseElement():GetElement("content.settings.contentFrame.content"):ReloadContent() +end + +function private.DuplicateProfileOnEnter(button) + button:ShowTooltip(L["Duplicate the profile"]) + private.ProfileRowOnEnter(button:GetParentElement()) +end + +function private.DuplicateProfileOnLeave(button) + Tooltip.Hide() + private.ProfileRowOnLeave(button:GetParentElement()) +end + +function private.ResetProfileOnClick(button) + local profileName = button:GetParentElement():GetContext() + local desc = format(L["This will reset all groups and operations (if not stored globally) to be wiped from '%s'."], profileName) + button:GetBaseElement():ShowConfirmationDialog(L["Reset Profile?"], desc, private.ResetProfileConfirmed, profileName) +end + +function private.ResetProfileConfirmed(profileName) + local activeProfile = TSM.db:GetCurrentProfile() + TSM.db:SetProfile(profileName) + TSM.db:ResetProfile() + TSM.db:SetProfile(activeProfile) +end + +function private.ResetProfileOnEnter(button) + button:ShowTooltip(L["Reset the current profile to default settings"]) + private.ProfileRowOnEnter(button:GetParentElement()) +end + +function private.ResetProfileOnLeave(button) + Tooltip.Hide() + private.ProfileRowOnLeave(button:GetParentElement()) +end + +function private.DeleteProfileOnClick(button) + local profileName = button:GetParentElement():GetContext() + local desc = format(L["This will permanently delete the '%s' profile."], profileName) + button:GetBaseElement():ShowConfirmationDialog(L["Delete Profile?"], desc, private.DeleteProfileConfirmed, button, profileName) +end + +function private.DeleteProfileConfirmed(button, profileName) + TSM.db:DeleteProfile(profileName) + button:GetBaseElement():GetElement("content.settings.contentFrame.content"):ReloadContent() +end + +function private.DeleteProfileOnEnter(button) + button:ShowTooltip(L["Delete the profile"]) + private.ProfileRowOnEnter(button:GetParentElement()) +end + +function private.DeleteProfileOnLeave(button) + Tooltip.Hide() + private.ProfileRowOnLeave(button:GetParentElement()) +end + +function private.NewProfileInputOnEnterPressed(input) + local profileName = input:GetValue() + if not TSM.db:IsValidProfileName(profileName) then + Log.PrintUser(L["This is not a valid profile name. Profile names must be at least one character long and may not contain '@' characters."]) + return + elseif TSM.db:ProfileExists(profileName) then + Log.PrintUser(L["A profile with this name already exists."]) + return + end + TSM.db:SetProfile(profileName) + input:GetBaseElement():GetElement("content.settings.contentFrame.content"):ReloadContent() +end + +function private.AccountSyncRowOnEnter(frame) + local account = frame:GetContext() + if account then + frame:GetElement("sendProfileBtn"):Show() + frame:GetElement("removeBtn"):Show() + end + frame:SetBackgroundColor("ACTIVE_BG", true) + frame:Draw() +end + +function private.AccountSyncRowOnLeave(frame) + frame:SetBackgroundColor("PRIMARY_BG_ALT", true) + frame:GetElement("sendProfileBtn"):Hide() + frame:GetElement("removeBtn"):Hide() + frame:Draw() +end + +function private.AccountSyncTextOnEnter(text) + local account = text:GetParentElement():GetContext() + local tooltipLines = TempTable.Acquire() + if account then + tinsert(tooltipLines, Theme.GetColor("INDICATOR"):ColorText(L["Sync Status"])) + local mirrorConnected, mirrorSynced = Sync.GetMirrorStatus(account) + local mirrorStatus = nil + if not mirrorConnected then + mirrorStatus = Theme.GetFeedbackColor("RED"):ColorText(L["Not Connected"]) + elseif not mirrorSynced then + mirrorStatus = Theme.GetFeedbackColor("YELLOW"):ColorText(L["Updating"]) + else + mirrorStatus = Theme.GetFeedbackColor("GREEN"):ColorText(L["Up to date"]) + end + tinsert(tooltipLines, L["Inventory / Gold Graph"]..TSM.CONST.TOOLTIP_SEP..mirrorStatus) + tinsert(tooltipLines, L["Profession Info"]..TSM.CONST.TOOLTIP_SEP..TSM.Crafting.Sync.GetStatus(account)) + tinsert(tooltipLines, L["Purchase / Sale Info"]..TSM.CONST.TOOLTIP_SEP..TSM.Accounting.Sync.GetStatus(account)) + else + tinsert(tooltipLines, L["Establishing connection..."]) + end + text:ShowTooltip(table.concat(tooltipLines, "\n"), nil, 52) + TempTable.Release(tooltipLines) + private.AccountSyncRowOnEnter(text:GetParentElement()) +end + +function private.AccountSyncTextOnLeave(text) + Tooltip.Hide() + private.AccountSyncRowOnLeave(text:GetParentElement()) +end + +function private.SendProfileOnClick(button) + local player = Sync.GetConnectedCharacterByAccount(button:GetParentElement():GetContext()) + if not player then + return + end + TSM.Groups.Sync.SendCurrentProfile(player) +end + +function private.SendProfileOnEnter(button) + button:ShowTooltip(L["Send your active profile to this synced account"]) + private.AccountSyncRowOnEnter(button:GetParentElement()) +end + +function private.SendProfileOnLeave(button) + Tooltip.Hide() + private.AccountSyncRowOnLeave(button:GetParentElement()) +end + +function private.RemoveAccountSyncOnClick(button) + Sync.RemoveAccount(button:GetParentElement():GetContext()) + button:GetBaseElement():GetElement("content.settings.contentFrame.content"):ReloadContent() + Tooltip.Hide() + Log.PrintUser(L["Account sync removed. Please delete the account sync from the other account as well."]) +end + +function private.RemoveAccountOnEnter(button) + button:ShowTooltip(L["Remove this account sync and all synced data from this account"]) + private.AccountSyncRowOnEnter(button:GetParentElement()) +end + +function private.RemoveAccountOnLeave(button) + Tooltip.Hide() + private.AccountSyncRowOnLeave(button:GetParentElement()) +end + +function private.NewAccountSyncInputOnEnterPressed(input) + local character = Ambiguate(input:GetValue(), "none") + if Sync.EstablishConnection(character) then + Log.PrintfUser(L["Establishing connection to %s. Make sure that you've entered this character's name on the other account."], character) + private.SyncConnectionChangedCallback() + else + input:SetValue("") + input:Draw() + end +end diff --git a/Core/UI/MainUI/Settings/Macros.lua b/Core/UI/MainUI/Settings/Macros.lua new file mode 100644 index 0000000..df906cf --- /dev/null +++ b/Core/UI/MainUI/Settings/Macros.lua @@ -0,0 +1,270 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Macros = TSM.MainUI.Settings:NewPackage("Macros") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Vararg = TSM.Include("Util.Vararg") +local Log = TSM.Include("Util.Log") +local Theme = TSM.Include("Util.Theme") +local UIElements = TSM.Include("UI.UIElements") +local private = {} +local MACRO_NAME = "TSMMacro" +local MACRO_ICON = TSM.IsWowClassic() and "INV_Misc_Flower_01" or "Achievement_Faction_GoldenLotus" +local BINDING_NAME = "MACRO "..MACRO_NAME +local BUTTON_MAPPING = { + ["row1.myauctionsCheckbox"] = "TSMCancelAuctionBtn", + ["row1.auctioningCheckbox"] = "TSMAuctioningBtn", + ["row2.shoppingCheckbox"] = "TSMShoppingBuyoutBtn", + ["row2.bidBuyConfirmBtn"] = "TSMBidBuyConfirmBtn", + ["row3.sniperCheckbox"] = "TSMSniperBtn", + ["row3.craftingCheckbox"] = "TSMCraftingBtn", + ["row4.destroyingCheckbox"] = "TSMDestroyBtn", + ["row4.vendoringCheckbox"] = "TSMVendoringSellAllButton", +} +local CHARACTER_BINDING_SET = 2 +local MAX_MACRO_LENGTH = 255 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Macros.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Macros"], "middle", private.GetMacrosSettingsFrame) +end + + + +-- ============================================================================ +-- Macros Settings UI +-- ============================================================================ + +function private.GetMacrosSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "macros") + local body = GetMacroBody(MACRO_NAME) or "" + local upEnabled, downEnabled, altEnabled, ctrlEnabled, shiftEnabled = false, false, false, false, false + for _, binding in Vararg.Iterator(GetBindingKey(BINDING_NAME)) do + upEnabled = upEnabled or (strfind(binding, "MOUSEWHEELUP") and true) + downEnabled = upEnabled or (strfind(binding, "MOUSEWHEELDOWN") and true) + altEnabled = altEnabled or (strfind(binding, "ALT-") and true) + ctrlEnabled = ctrlEnabled or (strfind(binding, "CTRL-") and true) + shiftEnabled = shiftEnabled or (strfind(binding, "SHIFT-") and true) + end + + local frame = UIElements.New("ScrollFrame", "macroSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Macro", "setup", L["Macro Setup"], L["Many commonly-used actions in TSM can be added to a macro and bound to your scroll wheel. Use the options below to setup this macro and scroll wheel binding."], 40) + :AddChild(UIElements.New("Text", "actionsSubHeading") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY2_BOLD") + :SetText(L["Bound Actions"]) + ) + :AddChild(UIElements.New("Frame", "row1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "myauctionsCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["My Auctions %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Cancel"]))) + ) + :AddChild(UIElements.New("Checkbox", "auctioningCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Auctioning %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Post / Cancel"]))) + ) + ) + :AddChild(UIElements.New("Frame", "row2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "shoppingCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Shopping %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Buyout"]))) + ) + :AddChild(UIElements.New("Checkbox", "bidBuyConfirmBtn") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Confirmation %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Bid / Buyout"]))) + ) + ) + :AddChild(UIElements.New("Frame", "row3") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "sniperCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Sniper %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Buyout"]))) + ) + :AddChild(UIElements.New("Checkbox", "craftingCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Crafting %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Craft Next"]))) + ) + ) + :AddChild(UIElements.New("Frame", "row4") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Checkbox", "destroyingCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Destroying %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Destroy Next"]))) + ) + :AddChild(UIElements.New("Checkbox", "vendoringCheckbox") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format(L["Vendoring %s button"], Theme.GetColor("INDICATOR"):ColorText(L["Sell All"]))) + ) + ) + :AddChild(UIElements.New("Text", "scrollWheelSubHeading") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY2_BOLD") + :SetText(L["Scroll Wheel Options"]) + ) + :AddChild(UIElements.New("Frame", "direction") + :SetLayout("VERTICAL") + :SetMargin(0, 0, 0, 14) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 6) + :SetFont("BODY_BODY2") + :SetText(L["Scroll wheel direction"]) + ) + :AddChild(UIElements.New("Frame", "check") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "up") + :SetWidth("AUTO") + :SetMargin(0, 16, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["Up"]) + :SetChecked(upEnabled) + ) + :AddChild(UIElements.New("Checkbox", "down") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["Down"]) + :SetChecked(downEnabled) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(UIElements.New("Frame", "modifiers") + :SetLayout("VERTICAL") + :SetMargin(0, 0, 0, 18) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 6) + :SetFont("BODY_BODY2") + :SetText(L["Modifiers"]) + ) + :AddChild(UIElements.New("Frame", "check") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "alt") + :SetWidth("AUTO") + :SetMargin(0, 16, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["ALT"]) + :SetChecked(altEnabled) + ) + :AddChild(UIElements.New("Checkbox", "ctrl") + :SetWidth("AUTO") + :SetMargin(0, 16, 0, 0) + :SetFont("BODY_BODY2") + :SetText(L["CTRL"]) + :SetChecked(ctrlEnabled) + ) + :AddChild(UIElements.New("Checkbox", "shift") + :SetWidth("AUTO") + :SetFont("BODY_BODY2") + :SetText(L["SHIFT"]) + :SetChecked(shiftEnabled) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(UIElements.New("ActionButton", "createBtn") + :SetHeight(24) + :SetText(GetMacroInfo(MACRO_NAME) and L["Update existing macro"] or L["Create macro"]) + :SetScript("OnClick", private.CreateButtonOnClick) + ) + ) + + for path, buttonName in pairs(BUTTON_MAPPING) do + frame:GetElement("setup.content."..path) + :SetChecked(strfind(body, buttonName) and true or false) + end + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.CreateButtonOnClick(button) + -- remove the old bindings and macros + for _, binding in Vararg.Iterator(GetBindingKey(BINDING_NAME)) do + SetBinding(binding) + end + DeleteMacro(MACRO_NAME) + + if GetNumMacros() >= MAX_ACCOUNT_MACROS then + Log.PrintUser(L["Could not create macro as you already have too many. Delete one of your existing macros and try again."]) + return + end + + -- create the new macro + local scrollFrame = button:GetParentElement():GetParentElement():GetParentElement() + local lines = TempTable.Acquire() + for elementPath, buttonName in pairs(BUTTON_MAPPING) do + if scrollFrame:GetElement("setup.content."..elementPath):IsChecked() then + tinsert(lines, "/click "..buttonName) + end + end + local macroText = table.concat(lines, "\n") + CreateMacro(MACRO_NAME, MACRO_ICON, macroText) + TempTable.Release(lines) + + -- create the binding + local modifierStr = "" + if scrollFrame:GetElement("setup.content.modifiers.check.ctrl"):IsChecked() then + modifierStr = modifierStr.."CTRL-" + end + if scrollFrame:GetElement("setup.content.modifiers.check.alt"):IsChecked() then + modifierStr = modifierStr.."ALT-" + end + if scrollFrame:GetElement("setup.content.modifiers.check.shift"):IsChecked() then + modifierStr = modifierStr.."SHIFT-" + end + -- we want to save these bindings to be per-character, so the mode should be 1 / 2 if we're currently on + -- per-character bindings or not respectively + local bindingMode = (GetCurrentBindingSet() == CHARACTER_BINDING_SET) and 1 or 2 + if scrollFrame:GetElement("setup.content.direction.check.up") then + SetBinding(modifierStr.."MOUSEWHEELUP", nil, bindingMode) + SetBinding(modifierStr.."MOUSEWHEELUP", BINDING_NAME, bindingMode) + end + if scrollFrame:GetElement("setup.content.direction.check.down") then + SetBinding(modifierStr.."MOUSEWHEELDOWN", nil, bindingMode) + SetBinding(modifierStr.."MOUSEWHEELDOWN", BINDING_NAME, bindingMode) + end + + if TSM.IsWowClassic() then + AttemptToSaveBindings(CHARACTER_BINDING_SET) + else + SaveBindings(CHARACTER_BINDING_SET) + end + + button:SetText(GetMacroInfo(MACRO_NAME) and L["Update existing macro"] or L["Create macro"]) + :Draw() + + Log.PrintUser(L["Macro created and scroll wheel bound!"]) + if #macroText > MAX_MACRO_LENGTH then + Log.PrintUser(L["WARNING: The macro was too long, so was truncated to fit by WoW."]) + end +end diff --git a/Core/UI/MainUI/Settings/Mailing.lua b/Core/UI/MainUI/Settings/Mailing.lua new file mode 100644 index 0000000..6211f09 --- /dev/null +++ b/Core/UI/MainUI/Settings/Mailing.lua @@ -0,0 +1,167 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Mailing = TSM.MainUI.Settings:NewPackage("Mailing") +local L = TSM.Include("Locale").GetTable() +local Sound = TSM.Include("Util.Sound") +local Math = TSM.Include("Util.Math") +local UIElements = TSM.Include("UI.UIElements") +local private = { + sounds = {}, + soundkeys = {}, +} +local ITEM_QUALITY_DESCS = { ITEM_QUALITY2_DESC, ITEM_QUALITY3_DESC, ITEM_QUALITY4_DESC } +local ITEM_QUALITY_KEYS = { 2, 3, 4 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Mailing.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Mailing"], "middle", private.GetMailingSettingsFrame) + for key, name in pairs(Sound.GetSounds()) do + tinsert(private.sounds, name) + tinsert(private.soundkeys, key) + end +end + + + +-- ============================================================================ +-- Mailing Settings UI +-- ============================================================================ + +function private.GetMailingSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "mailing") + return UIElements.New("ScrollFrame", "mailingSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Mailing", "inbox", L["Inbox Settings"], "") + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "inboxMessagesCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Enable inbox chat messages"]) + :SetSettingInfo(TSM.db.global.mailingOptions, "inboxMessages") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Text", "label") + :SetHeight(18) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Amount of bag space to keep free"]) + ) + :AddChild(UIElements.New("Frame", "freeSpaceFrame") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 8) + :AddChild(UIElements.New("Input", "keepMailInput") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "0:20") + :SetSettingInfo(TSM.db.global.mailingOptions, "keepMailSpace") + ) + :AddChild(UIElements.New("Text", "label") + :SetSize("AUTO", 16) + :SetFont("BODY_BODY3") + :SetText(L["Min 0 - Max 20"]) + ) + ) + :AddChild(UIElements.New("Text", "label") + :SetHeight(18) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Open mail complete sound"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "soundDropdown") + :SetHeight(24) + :SetItems(private.sounds, private.soundkeys) + :SetSettingInfo(TSM.db.global.mailingOptions, "openMailSound") + :SetScript("OnSelectionChanged", private.SoundOnSelectionChanged) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Mailing", "send", L["Sending Settings"], "") + :AddChild(UIElements.New("Frame", "check1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "sendMessagesCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Enable sending chat messages"]) + :SetSettingInfo(TSM.db.global.mailingOptions, "sendMessages") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "check2") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 22) + :AddChild(UIElements.New("Checkbox", "sendItemsCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Send grouped items individually"]) + :SetSettingInfo(TSM.db.global.mailingOptions, "sendItemsIndividually") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Restart delay (minutes)"]) + ) + :AddChild(UIElements.New("Frame", "restartDelayFrame") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "restartDelay") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetValidateFunc("NUMBER", "0.5:10") + :SetValue(TSM.db.global.mailingOptions.resendDelay) + :SetScript("OnValueChanged", private.RestartDelayOnValueChanged) + ) + :AddChild(UIElements.New("Text", "label") + :SetSize("AUTO", 16) + :SetFont("BODY_BODY3") + :SetText(L["Min 0.5 - Max 10"]) + ) + ) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Mail disenchantables max quality"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "mailPageDropdown") + :SetHeight(26) + :SetItems(ITEM_QUALITY_DESCS, ITEM_QUALITY_KEYS) + :SetSettingInfo(TSM.db.global.mailingOptions, "deMaxQuality") + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.SoundOnSelectionChanged(self, selection) + Sound.PlaySound(TSM.db.global.mailingOptions.openMailSound) +end + +function private.RestartDelayOnValueChanged(input) + local value = Math.Round(tonumber(input:GetValue()), 0.5) + TSM.db.global.mailingOptions.resendDelay = value +end diff --git a/Core/UI/MainUI/Settings/Shopping.lua b/Core/UI/MainUI/Settings/Shopping.lua new file mode 100644 index 0000000..ab18297 --- /dev/null +++ b/Core/UI/MainUI/Settings/Shopping.lua @@ -0,0 +1,168 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Shopping = TSM.MainUI.Settings:NewPackage("Shopping") +local L = TSM.Include("Locale").GetTable() +local Sound = TSM.Include("Util.Sound") +local UIElements = TSM.Include("UI.UIElements") +local private = { + sounds = {}, + soundkeys = {}, +} +local MAX_ITEM_LEVEL = 500 + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Shopping.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Browse / Sniper"], "middle", private.GetShoppingSettingsFrame) + for key, name in pairs(Sound.GetSounds()) do + tinsert(private.sounds, name) + tinsert(private.soundkeys, key) + end +end + + + +-- ============================================================================ +-- Shopping Settings UI +-- ============================================================================ + +function private.GetShoppingSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "shopping") + return UIElements.New("ScrollFrame", "shoppingSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Shopping", "general", L["General Options"], L["Some general Browse/Sniper options are below."]) + :AddChild(UIElements.New("Frame", "focusFrame") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Auto-focus browse search input"]) + :SetSettingInfo(TSM.db.global.shoppingOptions, "searchAutoFocus") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(TSM.MainUI.Settings.CreateInputWithReset("marketValueSourceField", L["Market value price source"], "global.shoppingOptions.pctSource") + :SetMargin(0, 0, 0, 12) + ) + :AddChild(TSM.MainUI.Settings.CreateInputWithReset("buyoutConfirmationAlert", L["Buyout confirmation alert"], "global.shoppingOptions.buyoutAlertSource") + :SetMargin(0, 0, 0, 12) + ) + :AddChild(UIElements.New("Frame", "showConfirmFrame") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Checkbox", "showConfirm") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetCheckboxPosition("LEFT") + :SetSettingInfo(TSM.db.global.shoppingOptions, "buyoutConfirm") + :SetText(L["Show confirmation alert if buyout is above the alert price"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Shopping", "disenchant", L["Disenchant Search Options"], L["Some options for the Disenchant Search are below."]) + :AddChild(UIElements.New("Text", "minLevelLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Minimum disenchant level"]) + ) + :AddChild(UIElements.New("Frame", "minLevelInput") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetFont("BODY_BODY2_MEDIUM") + :SetValidateFunc("NUMBER", "0:"..MAX_ITEM_LEVEL) + :SetSettingInfo(TSM.db.global.shoppingOptions, "minDeSearchLvl") + ) + :AddChild(UIElements.New("Text", "rangeText") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetText(format(L["(minimum 0 - maximum %d)"], MAX_ITEM_LEVEL)) + ) + ) + :AddChild(UIElements.New("Text", "maxLevelLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Maximum disenchant level"]) + ) + :AddChild(UIElements.New("Frame", "maxLevelInput") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetFont("BODY_BODY2_MEDIUM") + :SetValidateFunc("NUMBER", "0:"..MAX_ITEM_LEVEL) + :SetSettingInfo(TSM.db.global.shoppingOptions, "maxDeSearchLvl") + ) + :AddChild(UIElements.New("Text", "rangeText") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetText(format(L["(minimum 0 - maximum %d)"], MAX_ITEM_LEVEL)) + ) + ) + :AddChild(UIElements.New("Text", "pctLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Maximum disenchant search percent"]) + ) + :AddChild(UIElements.New("Frame", "pctInput") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetMargin(0, 8, 0, 0) + :SetBackgroundColor("ACTIVE_BG") + :SetFont("BODY_BODY2_MEDIUM") + :SetValidateFunc("NUMBER", "0:100") + :SetSettingInfo(TSM.db.global.shoppingOptions, "maxDeSearchPercent") + ) + :AddChild(UIElements.New("Text", "rangeText") + :SetWidth("AUTO") + :SetFont("BODY_BODY3") + :SetText(format(L["(minimum 0 - maximum %d)"], 100)) + ) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Shopping", "sniper", L["Sniper Options"], L["Options specific to Sniper are below."]) + :AddChild(UIElements.New("Text", "soundLabel") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Found auction sound"]) + ) + :AddChild(UIElements.New("SelectionDropdown", "soundDrodown") + :SetHeight(24) + :SetItems(private.sounds, private.soundkeys) + :SetSettingInfo(TSM.db.global.sniperOptions, "sniperSound") + :SetScript("OnSelectionChanged", private.SoundOnSelectionChanged) + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.SoundOnSelectionChanged(self) + Sound.PlaySound(self:GetSelectedItemKey()) +end diff --git a/Core/UI/MainUI/Settings/Tooltip.lua b/Core/UI/MainUI/Settings/Tooltip.lua new file mode 100644 index 0000000..22a0a95 --- /dev/null +++ b/Core/UI/MainUI/Settings/Tooltip.lua @@ -0,0 +1,395 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Tooltip = TSM.MainUI.Settings:NewPackage("Tooltip") +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local Table = TSM.Include("Util.Table") +local TempTable = TSM.Include("Util.TempTable") +local CustomPrice = TSM.Include("Service.CustomPrice") +local UIElements = TSM.Include("UI.UIElements") +local private = { + operationModules = {}, + operationModuleNames = {}, + destroySources = {}, + destroySourceKeys = {}, +} +local INVALID_DESTROY_PRICE_SOURCES = { + crafting = true, + vendorbuy = true, + vendorsell = true, + destroy = true, + itemquality = true, + itemlevel = true, + requiredlevel = true, + numinventory = true, + numexpires = true, + salerate = true, + dbregionsalerate = true, + dbregionsoldperday = true, + auctioningopmin = true, + auctioningopmax = true, + auctioningopnormal = true, + shoppingopmax = true, + sniperopmax = true, +} +local GROUPS_OPS_SETTINGS_INFO = { + { label = L["Group name"], settingKey = "groupNameTooltip" }, + { label = L["Auctioning operation"], settingTbl = "operationTooltips", settingKey = "Auctioning" }, + { label = L["Crafting operation"], settingTbl = "operationTooltips", settingKey = "Crafting" }, + { label = L["Mailing operation"], settingTbl = "operationTooltips", settingKey = "Mailing" }, + { label = L["Shopping operation"], settingTbl = "operationTooltips", settingKey = "Shopping" }, + { label = L["Sniper operation"], settingTbl = "operationTooltips", settingKey = "Sniper" }, + { label = L["Vendoring operation"], settingTbl = "operationTooltips", settingKey = "Vendoring" }, + { label = L["Warehousing operation"], settingTbl = "operationTooltips", settingKey = "Warehousing" }, +} +local VALUES_SETTINGS_INFO = { + { label = L["Disenchant value"], settingKey = "deTooltip" }, + { label = L["Mill value"], settingKey = "millTooltip" }, + { label = L["Prospect value"], settingKey = "prospectTooltip" }, + { label = L["Transform value"], settingKey = "transformTooltip" }, + { label = L["Detailed destroy information"], settingKey = "detailedDestroyTooltip" }, + { label = L["Vendor buy price"], settingKey = "vendorBuyTooltip" }, + { label = L["Vendor sell price"], settingKey = "vendorSellTooltip" }, +} +local INVENTORY_SETTINGS_INFO = { + { label = L["Full inventory"], settingKey = "inventoryTooltipFormat", setValue = "full", clearValue = "none" }, + { label = L["Simple inventory"], settingKey = "inventoryTooltipFormat", setValue = "simple", clearValue = "none" }, +} +local ACCOUNTING_SETTINGS_INFO = { + { label = L["Purchase information"], settingModule = "Accounting", settingKey = "purchase" }, + { label = L["Sale information"], settingModule = "Accounting", settingKey = "sale" }, + { label = L["Sale rate"], settingModule = "Accounting", settingKey = "saleRate" }, + { label = L["Expired information"], settingModule = "Accounting", settingKey = "expiredAuctions" }, + { label = L["Canceled information"], settingModule = "Accounting", settingKey = "cancelledAuctions" }, +} +local AUCTIONDB_SETTINGS_INFO = { + { label = L["Min buyout"], settingModule = "AuctionDB", settingKey = "minBuyout" }, + { label = L["Market value"], settingModule = "AuctionDB", settingKey = "marketValue" }, + { label = L["Historical price"], settingModule = "AuctionDB", settingKey = "historical" }, + { label = L["Region min buyout avg"], settingModule = "AuctionDB", settingKey = "regionMinBuyout" }, + { label = L["Region market value"], settingModule = "AuctionDB", settingKey = "regionMarketValue" }, + { label = L["Region historical price"], settingModule = "AuctionDB", settingKey = "regionHistorical" }, + { label = L["Region sale avg"], settingModule = "AuctionDB", settingKey = "regionSale" }, + { label = L["Region sale rate"], settingModule = "AuctionDB", settingKey = "regionSalePercent" }, + { label = L["Region avg daily sold"], settingModule = "AuctionDB", settingKey = "regionSoldPerDay" }, +} +local AUCTIONING_SETTINGS_INFO = { + { label = L["Post Quantity"], settingModule = "Auctioning", settingKey = "postQuantity" }, + { label = L["Min/Normal/Max price"], settingModule = "Auctioning", settingKey = "operationPrices" }, +} +local CRAFTING_SETTINGS_INFO = { + { label = L["Crafting cost"], settingModule = "Crafting", settingKey = "craftingCost" }, + { label = L["Detailed crafting cost"], settingModule = "Crafting", settingKey = "detailedMats" }, + { label = L["Mat cost"], settingModule = "Crafting", settingKey = "matPrice" }, +} +local SHOPPING_SETTINGS_INFO = { + { label = L["Max shopping price"], settingModule = "Shopping", settingKey = "maxPrice" }, +} +local SNIPER_SETTINGS_INFO = { + { label = L["Max sniper price"], settingModule = "Sniper", settingKey = "belowPrice" }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Tooltip.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Tooltip Settings"], "top", private.GetTooltipSettingsFrame) +end + + + +-- ============================================================================ +-- Tooltip Settings UI +-- ============================================================================ + +function private.GetTooltipSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "tooltips", "main") + wipe(private.operationModules) + wipe(private.operationModuleNames) + for _, moduleName in TSM.Operations.ModuleIterator() do + tinsert(private.operationModules, moduleName) + tinsert(private.operationModuleNames, TSM.Operations.GetLocalizedName(moduleName)) + end + wipe(private.destroySources) + wipe(private.destroySourceKeys) + local foundCurrentSetting = false + for key, _, label in CustomPrice.Iterator() do + key = strlower(key) + if not INVALID_DESTROY_PRICE_SOURCES[key] then + tinsert(private.destroySources, label) + tinsert(private.destroySourceKeys, key) + if TSM.db.global.coreOptions.destroyValueSource == key then + foundCurrentSetting = true + end + end + end + if not foundCurrentSetting then + -- the current setting isn't in the list, so reset it to the default + TSM.db.global.coreOptions.destroyValueSource = strlower(TSM.db:GetDefaultReadOnly("global", "coreOptions", "destroyValueSource")) + end + return UIElements.New("ScrollFrame", "tooltipSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Tooltip", "general", L["General Options"], L["Some general options for the TSM tooltip information are below."]) + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Enable TSM tooltips"]) + ) + :AddChild(UIElements.New("ToggleOnOff", "enableToggle") + :SetHeight(24) + :SetMargin(0, 0, 0, 12) + :SetSettingInfo(TSM.db.global.tooltipOptions, "enabled") + ) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "embedCheckbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Embed TSM tooltip"]) + :SetSettingInfo(TSM.db.global.tooltipOptions, "embeddedTooltip") + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(UIElements.New("Frame", "labelRow1") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 4) + :AddChild(UIElements.New("Text", "priceFormatLabel") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Tooltip price format"]) + ) + :AddChild(UIElements.New("Text", "modifierLabel") + :SetMargin(0, 8, 0, 0) + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Show on modifier"]) + ) + :AddChild(UIElements.New("Text", "destroyLabel") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Destroy value source"]) + ) + ) + :AddChild(UIElements.New("Frame", "dropdownRow1") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("SelectionDropdown", "priceFormatDropdown") + :SetMargin(0, 8, 0, 0) + :AddItem(format(L["Coins (%s)"], Money.ToString(3451267, nil, "OPT_ICON")), "icon") + :AddItem(format(L["Text (%s)"], Money.ToString(3451267)), "text") + :SetSettingInfo(TSM.db.global.tooltipOptions, "tooltipPriceFormat") + :SetScript("OnSelectionChanged", private.OnSettingChange) + ) + :AddChild(UIElements.New("SelectionDropdown", "modifierDropdown") + :SetMargin(0, 8, 0, 0) + :AddItem(L["None (Always Show)"], "none") + :AddItem(ALT_KEY, "alt") + :AddItem(CTRL_KEY, "ctrl") + :SetSettingInfo(TSM.db.global.tooltipOptions, "tooltipShowModifier") + ) + :AddChild(UIElements.New("SelectionDropdown", "dropdown") + :SetItems(private.destroySources, private.destroySourceKeys) + :SetSettingInfo(TSM.db.global.coreOptions, "destroyValueSource") + :SetScript("OnSelectionChanged", private.OnSettingChange) + ) + ) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Tooltip", "options", L["Tooltip Options"], L["Use the settings below to control which lines are shown in tooltips."]) + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(private.GetSettingsHeight()) + :AddChild(UIElements.New("Frame", "settings") + :SetLayout("VERTICAL") + :SetMargin(0, 8, 0, -12) + :AddChildrenWithFunction(private.AddTooltipSettings) + ) + :AddChild(UIElements.New("Frame", "example") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Text", "label") + :SetHeight(20) + :SetMargin(0, 0, 0, 6) + :SetFont("BODY_BODY2_BOLD") + :SetText(L["Example Tooltip"]) + ) + :AddChild(UIElements.New("Frame", "tooltip") + :SetLayout("VERTICAL") + :SetPadding(4) + :SetBackgroundColor("PRIMARY_BG") + :AddChildrenWithFunction(private.AddExampleTooltip) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + ) + ) +end + +function private.GetSettingsHeight() + -- calculate the height of the settings and use that for the content height since the settings will always be larger than the tooltip + local height = 0 + height = height + 32 + #GROUPS_OPS_SETTINGS_INFO * 32 + height = height + 32 + #VALUES_SETTINGS_INFO * 32 + Table.Count(TSM.db.global.userData.customPriceSources) * 32 + height = height + 32 + #INVENTORY_SETTINGS_INFO * 32 + height = height + 32 + #ACCOUNTING_SETTINGS_INFO * 32 + height = height + 32 + #AUCTIONDB_SETTINGS_INFO * 32 + height = height + 32 + #AUCTIONING_SETTINGS_INFO * 32 + height = height + 32 + #CRAFTING_SETTINGS_INFO * 32 + height = height + 32 + #SHOPPING_SETTINGS_INFO * 32 + height = height + 32 + #SNIPER_SETTINGS_INFO * 32 + height = height - 12 + return height +end + +function private.AddTooltipSettings(frame) + private.AddSettingHeading(frame, "groupsOpsHeading", L["Groups & Operations"]) + private.AddSettingsFromInfoTable(frame, GROUPS_OPS_SETTINGS_INFO) + + private.AddSettingHeading(frame, "valuesHeading", L["Values"]) + private.AddSettingsFromInfoTable(frame, VALUES_SETTINGS_INFO) + + local customPriceSources = TempTable.Acquire() + for name in pairs(TSM.db.global.userData.customPriceSources) do + tinsert(customPriceSources, name) + end + sort(customPriceSources) + for _, name in ipairs(customPriceSources) do + frame:AddChild(UIElements.New("Checkbox", "checkbox_"..name) + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY2") + :SetCheckboxPosition("LEFT") + :SetText(format(L["Custom source (%s)"], name)) + :SetSettingInfo(TSM.db.global.tooltipOptions.customPriceTooltips, name) + :SetScript("OnValueChanged", private.CustomPriceSourceOnValueChanged) + ) + end + TempTable.Release(customPriceSources) + + private.AddSettingHeading(frame, "inventoryHeading", L["Inventory"]) + private.AddSettingsFromInfoTable(frame, INVENTORY_SETTINGS_INFO) + + private.AddSettingHeading(frame, "accountingHeading", L["Accounting"]) + private.AddSettingsFromInfoTable(frame, ACCOUNTING_SETTINGS_INFO) + + private.AddSettingHeading(frame, "auctiondbHeading", L["AuctionDB"]) + private.AddSettingsFromInfoTable(frame, AUCTIONDB_SETTINGS_INFO) + + private.AddSettingHeading(frame, "auctioningHeading", L["Auctioning"]) + private.AddSettingsFromInfoTable(frame, AUCTIONING_SETTINGS_INFO) + + private.AddSettingHeading(frame, "craftingHeading", L["Crafting"]) + private.AddSettingsFromInfoTable(frame, CRAFTING_SETTINGS_INFO) + + private.AddSettingHeading(frame, "shoppingHeading", L["Shopping"]) + private.AddSettingsFromInfoTable(frame, SHOPPING_SETTINGS_INFO) + + private.AddSettingHeading(frame, "sniperHeading", L["Sniper"]) + private.AddSettingsFromInfoTable(frame, SNIPER_SETTINGS_INFO) +end + +function private.AddSettingHeading(frame, id, heading) + frame:AddChild(UIElements.New("Text", id) + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY2_BOLD") + :SetText(heading) + ) +end + +function private.GetSettingTableFromInfo(info) + local settingTbl = TSM.db.global.tooltipOptions + if info.settingModule then + settingTbl = settingTbl.moduleTooltips[info.settingModule] + end + if info.settingTbl then + settingTbl = settingTbl[info.settingTbl] + end + return settingTbl +end + +function private.AddSettingsFromInfoTable(frame, infoTbl) + for i, info in ipairs(infoTbl) do + local settingTbl = private.GetSettingTableFromInfo(info) + frame:AddChild(UIElements.New("Checkbox", "checkbox_"..i) + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :SetFont("BODY_BODY2") + :SetCheckboxPosition("LEFT") + :SetContext(info) + :SetText(info.label) + :SetChecked(settingTbl[info.settingKey] == (info.setValue or true)) + :SetScript("OnValueChanged", private.ContentCheckboxOnValueChanged) + ) + end +end + +function private.AddExampleTooltip(frame) + for i, left, right, lineColor in TSM.Tooltip.SettingsLineIterator() do + frame:AddChild(UIElements.New("Frame", "row_"..i) + :SetLayout("HORIZONTAL") + :SetHeight(20) + :AddChild(UIElements.New("Text", "left_"..i) + :SetWidth("AUTO") + :SetFont("ITEM_BODY3") + :SetTextColor(lineColor) + :SetText(left) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChildIf(right, UIElements.New("Text", "right_"..i) + :SetWidth("AUTO") + :SetFont("ITEM_BODY3") + :SetTextColor(lineColor) + :SetJustifyH("RIGHT") + :SetText(right or "") + ) + ) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.OnSettingChange(checkbox) + private.RebuildExampleTooltip(checkbox:GetElement("__parent.__parent.__parent.__parent.options.content.content.example.tooltip")) +end + +function private.ContentCheckboxOnValueChanged(checkbox) + local info = checkbox:GetContext() + local settingTbl = private.GetSettingTableFromInfo(info) + if checkbox:IsChecked() then + settingTbl[info.settingKey] = info.setValue or true + else + settingTbl[info.settingKey] = info.clearValue or false + end + local frame = checkbox:GetParentElement() + for _, child in frame:LayoutChildrenIterator() do + local childContext = child:GetContext() + if child ~= checkbox and childContext and childContext.settingTbl == info.settingTbl and childContext.settingKey == info.settingKey then + child:SetChecked(settingTbl[childContext.settingKey] == (childContext.setValue or true), true) + :Draw() + end + end + private.RebuildExampleTooltip(checkbox:GetElement("__parent.__parent.example.tooltip")) +end + +function private.CustomPriceSourceOnValueChanged(checkbox) + private.RebuildExampleTooltip(checkbox:GetElement("__parent.__parent.example.tooltip")) +end + +function private.RebuildExampleTooltip(tooltipFrame) + tooltipFrame:ReleaseAllChildren() + tooltipFrame:AddChildrenWithFunction(private.AddExampleTooltip) + tooltipFrame:Draw() +end diff --git a/Core/UI/MainUI/Settings/Vendoring.lua b/Core/UI/MainUI/Settings/Vendoring.lua new file mode 100644 index 0000000..a8ea8e3 --- /dev/null +++ b/Core/UI/MainUI/Settings/Vendoring.lua @@ -0,0 +1,83 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Vendoring = TSM.MainUI.Settings:NewPackage("Vendoring") +local L = TSM.Include("Locale").GetTable() +local ItemInfo = TSM.Include("Service.ItemInfo") +local UIElements = TSM.Include("UI.UIElements") +local private = {} + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Vendoring.OnInitialize() + TSM.MainUI.Settings.RegisterSettingPage(L["Vendoring"], "middle", private.GetVendoringSettingsFrame) +end + + + +-- ============================================================================ +-- Vendoring Settings UI +-- ============================================================================ + +function private.GetVendoringSettingsFrame() + TSM.UI.AnalyticsRecordPathChange("main", "settings", "vendoring") + return UIElements.New("ScrollFrame", "vendoringSettings") + :SetPadding(8, 8, 8, 0) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Vendoring", "general", L["General Options"], "") + :AddChild(UIElements.New("Frame", "content") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 12) + :AddChild(UIElements.New("Checkbox", "checkbox") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetSettingInfo(TSM.db.global.vendoringOptions, "displayMoneyCollected") + :SetText(L["Display total money received in chat"]) + ) + :AddChild(UIElements.New("Spacer", "spacer")) + ) + :AddChild(TSM.MainUI.Settings.CreateInputWithReset("qsMarketValueSourceField", L["Market Value Price Source"], "global.vendoringOptions.qsMarketValue")) + ) + :AddChild(TSM.MainUI.Settings.CreateExpandableSection("Vendoring", "ignore", L["Ignored Items"], "Use this list to manage what items you'd like TSM to ignore from vendoring.") + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetHeight(326) + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetIconSize(12) + :SetTextInfo("itemString", TSM.UI.GetColoredItemName) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :DisableHiding() + :Commit() + :Commit() + :SetQuery(TSM.Vendoring.Sell.CreateIgnoreQuery()) + :SetAutoReleaseQuery(true) + :SetSelectionDisabled(true) + :SetScript("OnRowClick", private.IgnoredItemsOnRowClick) + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.IgnoredItemsOnRowClick(_, row, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + TSM.Vendoring.Sell.ForgetIgnoreItemPermanent(row:GetField("itemString")) +end diff --git a/Core/UI/Scrollbar.lua b/Core/UI/Scrollbar.lua new file mode 100644 index 0000000..1ca5e47 --- /dev/null +++ b/Core/UI/Scrollbar.lua @@ -0,0 +1,126 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- Scrollbar Functions +-- @module Scrollbar + +local _, TSM = ... +local Scrollbar = TSM.UI:NewPackage("Scrollbar") +local Math = TSM.Include("Util.Math") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local private = { + scrollbars = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Scrollbar.OnInitialize() + Theme.RegisterChangeCallback(private.OnThemeChange) +end + +--- Creates a scrollbar. +-- @return The newly-created scrollbar +function Scrollbar.Create(parent, isHorizontal) + local scrollbar = CreateFrame("Slider", nil, parent, nil) + scrollbar:ClearAllPoints() + if isHorizontal then + scrollbar:SetOrientation("HORIZONTAL") + scrollbar:SetPoint("BOTTOMLEFT", 4, 0) + scrollbar:SetPoint("BOTTOMRIGHT", -4, 0) + scrollbar:SetHitRectInsets(-4, -4, -6, -10) + scrollbar:SetHeight(Theme.GetScrollbarWidth()) + scrollbar:SetPoint("BOTTOMLEFT", Theme.GetScrollbarMargin(), Theme.GetScrollbarMargin()) + scrollbar:SetPoint("BOTTOMRIGHT", -Theme.GetScrollbarMargin(), Theme.GetScrollbarMargin()) + else + scrollbar:SetOrientation("VERTICAL") + scrollbar:SetHitRectInsets(-6, -10, -4, -4) + scrollbar:SetWidth(Theme.GetScrollbarWidth()) + scrollbar:SetPoint("TOPRIGHT", -Theme.GetScrollbarMargin(), -Theme.GetScrollbarMargin()) + scrollbar:SetPoint("BOTTOMRIGHT", -Theme.GetScrollbarMargin(), Theme.GetScrollbarMargin()) + end + scrollbar:SetValueStep(1) + scrollbar:SetObeyStepOnDrag(true) + ScriptWrapper.Set(scrollbar, "OnShow", private.ScrollbarOnLeave) + ScriptWrapper.Set(scrollbar, "OnHide", private.ScrollbarOnMouseUp) + ScriptWrapper.Set(scrollbar, "OnUpdate", private.ScrollbarOnUpdate) + ScriptWrapper.Set(scrollbar, "OnEnter", private.ScrollbarOnEnter) + ScriptWrapper.Set(scrollbar, "OnLeave", private.ScrollbarOnLeave) + ScriptWrapper.Set(scrollbar, "OnMouseDown", private.ScrollbarOnMouseDown) + ScriptWrapper.Set(scrollbar, "OnMouseUp", private.ScrollbarOnMouseUp) + + scrollbar:SetThumbTexture(scrollbar:CreateTexture()) + scrollbar.thumb = scrollbar:GetThumbTexture() + scrollbar.thumb:SetPoint("CENTER") + scrollbar.thumb:SetColorTexture(Theme.GetColor("ACTIVE_BG_ALT"):GetFractionalRGBA()) + if isHorizontal then + scrollbar.thumb:SetHeight(Theme.GetScrollbarWidth()) + else + scrollbar.thumb:SetWidth(Theme.GetScrollbarWidth()) + end + tinsert(private.scrollbars, scrollbar) + + return scrollbar +end + +function Scrollbar.GetLength(contentLength, visibleLength) + -- arbitrary minimum length + local minLength = 25 + -- the maximum length of the scrollbar is half the total visible length + local maxLength = visibleLength / 2 + if minLength >= maxLength or visibleLength >= contentLength then + return maxLength + end + + -- calculate the ratio of our total content length to the visible length (capped at 10) + local ratio = min(contentLength / visibleLength, 10) + assert(ratio >= 1) + + -- calculate the appropriate scroll bar length based on the ratio (which is between 1 and 10) + return Math.Scale(ratio, 1, 10, maxLength, minLength) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.ScrollbarOnUpdate(scrollbar) + scrollbar:SetFrameLevel(scrollbar:GetParent():GetFrameLevel() + 5) +end + +function private.ScrollbarOnEnter(scrollbar) + scrollbar.thumb:SetColorTexture(Theme.GetColor("ACTIVE_BG_ALT+SELECTED_HOVER"):GetFractionalRGBA()) +end + +function private.ScrollbarOnLeave(scrollbar) + scrollbar.thumb:SetColorTexture(Theme.GetColor("ACTIVE_BG_ALT"):GetFractionalRGBA()) +end + +function private.ScrollbarOnMouseDown(scrollbar) + scrollbar.dragging = true +end + +function private.ScrollbarOnMouseUp(scrollbar) + scrollbar.dragging = nil +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.OnThemeChange() + for _, scrollbar in ipairs(private.scrollbars) do + scrollbar.thumb:SetColorTexture(Theme.GetColor("ACTIVE_BG_ALT"):GetFractionalRGBA()) + end +end diff --git a/Core/UI/Support/TexturePacks.lua b/Core/UI/Support/TexturePacks.lua new file mode 100644 index 0000000..27dea60 --- /dev/null +++ b/Core/UI/Support/TexturePacks.lua @@ -0,0 +1,534 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local TexturePacks = TSM.UI:NewPackage("TexturePacks") +local NineSlice = TSM.Include("Util.NineSlice") +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local private = { + colorLookup = {}, +} +local TEXTURE_FILE_INFO = { + uiFrames = { + path = "Interface\\Addons\\TradeSkillMaster\\Media\\UIFrames.tga", + scale = 1, + width = 256, + height = 256, + coord = { + ["AuctionCounterTexture"] = { 166, 189, 214, 225 }, + ["DividerHandle"] = { 3, 11, 3, 85 }, + ["GlobalEdgeBottomEdge"] = { 68, 78, 217, 227 }, + ["GlobalEdgeBottomLeftCorner"] = { 83, 93, 217, 227 }, + ["GlobalEdgeBottomRightCorner"] = { 194, 204, 3, 13 }, + ["GlobalEdgeLeftEdge"] = { 194, 204, 18, 28 }, + ["GlobalEdgeRightEdge"] = { 243, 253, 3, 13 }, + ["GlobalEdgeTopEdge"] = { 243, 253, 18, 28 }, + ["GlobalEdgeTopLeftCorner"] = { 194, 204, 33, 43 }, + ["GlobalEdgeTopRightCorner"] = { 243, 253, 33, 43 }, + ["HighlightDot"] = { 97, 105, 48, 56 }, + ["InnerFrameBottomEdge"] = { 110, 120, 87, 97 }, + ["InnerFrameBottomLeftCorner"] = { 110, 120, 102, 112 }, + ["InnerFrameBottomRightCorner"] = { 118, 128, 66, 76 }, + ["InnerFrameLeftEdge"] = { 133, 143, 3, 13 }, + ["InnerFrameRightEdge"] = { 148, 158, 3, 13 }, + ["InnerFrameTopEdge"] = { 133, 143, 18, 28 }, + ["InnerFrameTopLeftCorner"] = { 148, 158, 18, 28 }, + ["InnerFrameTopRightCorner"] = { 67, 77, 33, 43 }, + ["LargeActiveButtonLeft"] = { 14, 26, 90, 114 }, + ["LargeActiveButtonMiddle"] = { 14, 26, 119, 143 }, + ["LargeActiveButtonRight"] = { 16, 28, 3, 27 }, + ["LargeClickedButtonLeft"] = { 16, 28, 32, 56 }, + ["LargeClickedButtonMiddle"] = { 16, 28, 61, 85 }, + ["LargeClickedButtonRight"] = { 31, 43, 90, 114 }, + ["LargeHoverButtonLeft"] = { 31, 43, 119, 143 }, + ["LargeHoverButtonMiddle"] = { 33, 45, 3, 27 }, + ["LargeHoverButtonRight"] = { 33, 45, 32, 56 }, + ["LargeInactiveButtonLeft"] = { 33, 45, 61, 85 }, + ["LargeInactiveButtonMiddle"] = { 48, 60, 90, 114 }, + ["LargeInactiveButtonRight"] = { 48, 60, 119, 143 }, + ["LoadingBarLeft"] = { 50, 62, 3, 27 }, + ["LoadingBarMiddle"] = { 50, 62, 32, 56 }, + ["LoadingBarRight"] = { 50, 62, 61, 85 }, + ["MediumActiveButtonLeft"] = { 3, 15, 232, 252 }, + ["MediumActiveButtonMiddle"] = { 20, 32, 232, 252 }, + ["MediumActiveButtonRight"] = { 37, 49, 232, 252 }, + ["MediumClickedButtonLeft"] = { 54, 66, 232, 252 }, + ["MediumClickedButtonMiddle"] = { 65, 77, 117, 137 }, + ["MediumClickedButtonRight"] = { 65, 77, 142, 162 }, + ["MediumHoverButtonLeft"] = { 68, 80, 167, 187 }, + ["MediumHoverButtonMiddle"] = { 68, 80, 192, 212 }, + ["MediumHoverButtonRight"] = { 71, 83, 232, 252 }, + ["MediumInactiveButtonLeft"] = { 88, 100, 232, 252 }, + ["MediumInactiveButtonMiddle"] = { 82, 94, 117, 137 }, + ["MediumInactiveButtonRight"] = { 82, 94, 142, 162 }, + ["OuterFrameBottomEdge"] = { 67, 77, 48, 58 }, + ["OuterFrameBottomLeftCorner"] = { 67, 77, 3, 13 }, + ["OuterFrameBottomRightCorner"] = { 67, 77, 18, 28 }, + ["OuterFrameLeftEdge"] = { 82, 92, 33, 43 }, + ["OuterFrameRightEdge"] = { 82, 92, 48, 58 }, + ["OuterFrameTopEdge"] = { 82, 92, 3, 13 }, + ["OuterFrameTopLeftCorner"] = { 82, 92, 18, 28 }, + ["OuterFrameTopRightCorner"] = { 97, 107, 33, 43 }, + ["PopupBottomEdge"] = { 14, 26, 148, 160 }, + ["PopupBottomLeftCorner"] = { 31, 43, 148, 160 }, + ["PopupBottomRightCorner"] = { 48, 60, 148, 160 }, + ["PopupLeftEdge"] = { 98, 110, 214, 226 }, + ["PopupRightEdge"] = { 115, 127, 214, 226 }, + ["PopupTopEdge"] = { 132, 144, 214, 226 }, + ["PopupTopLeftCorner"] = { 149, 161, 214, 226 }, + ["PopupTopRightCorner"] = { 65, 105, 90, 112 }, + ["RoundDarkBottom"] = { 110, 118, 48, 56 }, + ["RoundDarkBottomLeft"] = { 112, 120, 33, 41 }, + ["RoundDarkBottomRight"] = { 123, 131, 46, 54 }, + ["RoundDarkCenter"] = { 125, 133, 33, 41 }, + ["RoundDarkLeft"] = { 149, 156, 55, 62 }, + ["RoundDarkRight"] = { 138, 146, 33, 41 }, + ["RoundDarkTop"] = { 133, 140, 59, 66 }, + ["RoundDarkTopLeft"] = { 209, 216, 45, 52 }, + ["RoundDarkTopRight"] = { 151, 159, 33, 41 }, + ["RoundedBottomCenter"] = { 183, 187, 3, 11 }, + ["RoundedBottomLeft"] = { 97, 105, 3, 11 }, + ["RoundedBottomRight"] = { 97, 105, 16, 24 }, + ["RoundedCenter"] = { 110, 118, 3, 11 }, + ["RoundedLeft"] = { 149, 157, 46, 50 }, + ["RoundedRight"] = { 149, 157, 46, 50 }, + ["RoundedTop"] = { 183, 187, 3, 11 }, + ["RoundedTopLeft"] = { 110, 118, 16, 24 }, + ["RoundedTopRight"] = { 136, 144, 46, 54 }, + ["SettingsNavShadow"] = { 3, 9, 90, 162 }, + ["SmallActiveButtonLeft"] = { 99, 111, 117, 133 }, + ["SmallActiveButtonMiddle"] = { 99, 111, 138, 154 }, + ["SmallActiveButtonRight"] = { 209, 221, 3, 19 }, + ["SmallClickedButtonLeft"] = { 166, 178, 3, 19 }, + ["SmallClickedButtonMiddle"] = { 166, 178, 24, 40 }, + ["SmallClickedButtonRight"] = { 226, 238, 3, 19 }, + ["SmallHoverButtonLeft"] = { 209, 221, 24, 40 }, + ["SmallHoverButtonMiddle"] = { 226, 238, 24, 40 }, + ["SmallHoverButtonRight"] = { 166, 178, 45, 61 }, + ["SmallInactiveButtonLeft"] = { 67, 79, 66, 82 }, + ["SmallInactiveButtonMiddle"] = { 84, 96, 66, 82 }, + ["SmallInactiveButtonRight"] = { 101, 113, 66, 82 }, + ["SmallLogo"] = { 85, 161, 167, 186 }, + ["TSMLogo"] = { 3, 63, 167, 227 }, + ["ToggleDisabledOff"] = { 85, 132, 191, 209 }, + ["ToggleDisabledOn"] = { 105, 152, 231, 249 }, + ["ToggleOff"] = { 137, 184, 191, 209 }, + ["ToggleOn"] = { 157, 204, 231, 249 }, + }, + }, + iconPack = { + path = "Interface\\Addons\\TradeSkillMaster\\Media\\IconPack.tga", + scale = 1, + width = 256, + height = 256, + coord = { + ["12x12/Add/Circle"] = { 236, 248, 88, 100 }, + ["12x12/Add/Default"] = { 92, 104, 92, 104 }, + ["12x12/Attention"] = { 106, 118, 92, 104 }, + ["12x12/Bid"] = { 120, 132, 92, 104 }, + ["12x12/Caret/Down"] = { 134, 146, 92, 104 }, + ["12x12/Caret/Right"] = { 148, 160, 92, 104 }, + ["12x12/Checkmark/Circle"] = { 162, 174, 92, 104 }, + ["12x12/Checkmark/Default"] = { 176, 188, 92, 104 }, + ["12x12/Chevron/Down"] = { 190, 202, 92, 104 }, + ["12x12/Chevron/Right"] = { 204, 216, 92, 104 }, + ["12x12/Chevron/Up"] = { 218, 230, 92, 104 }, + ["12x12/Circle"] = { 232, 244, 102, 114 }, + ["12x12/Clock"] = { 88, 100, 116, 128 }, + ["12x12/Close/Circle"] = { 88, 100, 130, 142 }, + ["12x12/Close/Default"] = { 88, 100, 144, 156 }, + ["12x12/Configure"] = { 88, 100, 158, 170 }, + ["12x12/Delete"] = { 88, 100, 172, 184 }, + ["12x12/DragHandle"] = { 88, 100, 186, 198 }, + ["12x12/Duplicate"] = { 88, 100, 200, 212 }, + ["12x12/Edit"] = { 88, 100, 214, 226 }, + ["12x12/Expand All"] = { 88, 100, 228, 240 }, + ["12x12/Export"] = { 88, 100, 242, 254 }, + ["12x12/Filter"] = { 102, 114, 106, 118 }, + ["12x12/Folder"] = { 116, 128, 106, 118 }, + ["12x12/Grip"] = { 130, 142, 106, 118 }, + ["12x12/Groups"] = { 144, 156, 106, 118 }, + ["12x12/Hide"] = { 158, 170, 106, 118 }, + ["12x12/Import"] = { 172, 184, 106, 118 }, + ["12x12/Link"] = { 186, 198, 106, 118 }, + ["12x12/Mailing"] = { 200, 212, 106, 118 }, + ["12x12/More/Horizontal"] = { 214, 226, 106, 118 }, + ["12x12/More/Vertical"] = { 228, 240, 116, 128 }, + ["12x12/Operation"] = { 242, 254, 116, 128 }, + ["12x12/PlayPause"] = { 102, 114, 120, 132 }, + ["12x12/Popout"] = { 116, 128, 120, 132 }, + ["12x12/Post"] = { 130, 142, 120, 132 }, + ["12x12/Queue"] = { 144, 156, 120, 132 }, + ["12x12/Reset"] = { 158, 170, 120, 132 }, + ["12x12/Resize"] = { 172, 184, 120, 132 }, + ["12x12/Running"] = { 186, 198, 120, 132 }, + ["12x12/SaleRate"] = { 200, 212, 120, 132 }, + ["12x12/Search"] = { 214, 226, 120, 132 }, + ["12x12/Select All"] = { 228, 240, 130, 142 }, + ["12x12/Shopping"] = { 242, 254, 130, 142 }, + ["12x12/SkillUp"] = { 102, 114, 144, 156 }, + ["12x12/Star/Filled"] = { 102, 114, 158, 170 }, + ["12x12/Star/Unfilled"] = { 102, 114, 172, 184 }, + ["12x12/Subtract/Circle"] = { 102, 114, 186, 198 }, + ["12x12/Subtract/Default"] = { 102, 114, 200, 212 }, + ["12x12/Visible"] = { 102, 114, 214, 226 }, + ["12x12/WoW"] = { 102, 114, 228, 240 }, + ["14x14/Add/Circle"] = { 240, 254, 56, 70 }, + ["14x14/Add/Default"] = { 40, 54, 112, 126 }, + ["14x14/Attention"] = { 40, 54, 128, 142 }, + ["14x14/Bid"] = { 40, 54, 144, 158 }, + ["14x14/Caret/Down"] = { 40, 54, 160, 174 }, + ["14x14/Caret/Right"] = { 40, 54, 176, 190 }, + ["14x14/Checkmark/Circle"] = { 40, 54, 192, 206 }, + ["14x14/Checkmark/Default"] = { 40, 54, 208, 222 }, + ["14x14/Chevron/Down"] = { 40, 54, 224, 238 }, + ["14x14/Chevron/Right"] = { 40, 54, 240, 254 }, + ["14x14/Chevron/Up"] = { 56, 70, 112, 126 }, + ["14x14/Circle"] = { 60, 74, 72, 86 }, + ["14x14/Clock"] = { 60, 74, 88, 102 }, + ["14x14/Close/Circle"] = { 56, 70, 128, 142 }, + ["14x14/Close/Default"] = { 56, 70, 144, 158 }, + ["14x14/Configure"] = { 56, 70, 160, 174 }, + ["14x14/Delete"] = { 56, 70, 176, 190 }, + ["14x14/DragHandle"] = { 56, 70, 192, 206 }, + ["14x14/Duplicate"] = { 56, 70, 208, 222 }, + ["14x14/Edit"] = { 56, 70, 224, 238 }, + ["14x14/Expand All"] = { 56, 70, 240, 254 }, + ["14x14/Export"] = { 72, 86, 104, 118 }, + ["14x14/Filter"] = { 76, 90, 72, 86 }, + ["14x14/Folder"] = { 76, 90, 88, 102 }, + ["14x14/Grip"] = { 72, 86, 120, 134 }, + ["14x14/Groups"] = { 72, 86, 136, 150 }, + ["14x14/Hide"] = { 72, 86, 152, 166 }, + ["14x14/Import"] = { 72, 86, 168, 182 }, + ["14x14/Link"] = { 72, 86, 184, 198 }, + ["14x14/Mailing"] = { 72, 86, 200, 214 }, + ["14x14/More/Horizontal"] = { 72, 86, 216, 230 }, + ["14x14/More/Vertical"] = { 72, 86, 232, 246 }, + ["14x14/Operation"] = { 92, 106, 60, 74 }, + ["14x14/PlayPause"] = { 108, 122, 60, 74 }, + ["14x14/Popout"] = { 124, 138, 60, 74 }, + ["14x14/Post"] = { 140, 154, 60, 74 }, + ["14x14/Queue"] = { 156, 170, 60, 74 }, + ["14x14/Reset"] = { 172, 186, 60, 74 }, + ["14x14/Resize"] = { 188, 202, 60, 74 }, + ["14x14/Running"] = { 204, 218, 60, 74 }, + ["14x14/SaleRate"] = { 220, 234, 60, 74 }, + ["14x14/Search"] = { 236, 250, 72, 86 }, + ["14x14/Select All"] = { 92, 106, 76, 90 }, + ["14x14/Shopping"] = { 108, 122, 76, 90 }, + ["14x14/SkillUp"] = { 124, 138, 76, 90 }, + ["14x14/Star/Filled"] = { 140, 154, 76, 90 }, + ["14x14/Star/Unfilled"] = { 156, 170, 76, 90 }, + ["14x14/Subtract/Circle"] = { 172, 186, 76, 90 }, + ["14x14/Subtract/Default"] = { 188, 202, 76, 90 }, + ["14x14/Visible"] = { 204, 218, 76, 90 }, + ["14x14/WoW"] = { 220, 234, 76, 90 }, + ["18x18/Add/Circle"] = { 0, 18, 26, 44 }, + ["18x18/Add/Default"] = { 0, 18, 46, 64 }, + ["18x18/Attention"] = { 0, 18, 66, 84 }, + ["18x18/Bid"] = { 0, 18, 86, 104 }, + ["18x18/Caret/Down"] = { 0, 18, 106, 124 }, + ["18x18/Caret/Right"] = { 0, 18, 126, 144 }, + ["18x18/Checkmark/Circle"] = { 0, 18, 146, 164 }, + ["18x18/Checkmark/Default"] = { 0, 18, 166, 184 }, + ["18x18/Chevron/Down"] = { 0, 18, 186, 204 }, + ["18x18/Chevron/Right"] = { 0, 18, 206, 224 }, + ["18x18/Chevron/Up"] = { 0, 18, 226, 244 }, + ["18x18/Circle"] = { 20, 38, 26, 44 }, + ["18x18/Clock"] = { 26, 44, 0, 18 }, + ["18x18/Close/Circle"] = { 20, 38, 46, 64 }, + ["18x18/Close/Default"] = { 20, 38, 66, 84 }, + ["18x18/Configure"] = { 20, 38, 86, 104 }, + ["18x18/Delete"] = { 20, 38, 106, 124 }, + ["18x18/DragHandle"] = { 20, 38, 126, 144 }, + ["18x18/Duplicate"] = { 20, 38, 146, 164 }, + ["18x18/Edit"] = { 20, 38, 166, 184 }, + ["18x18/Expand All"] = { 20, 38, 186, 204 }, + ["18x18/Export"] = { 20, 38, 206, 224 }, + ["18x18/Filter"] = { 20, 38, 226, 244 }, + ["18x18/Folder"] = { 46, 64, 0, 18 }, + ["18x18/Grip"] = { 66, 84, 0, 18 }, + ["18x18/Groups"] = { 86, 104, 0, 18 }, + ["18x18/Hide"] = { 106, 124, 0, 18 }, + ["18x18/Import"] = { 126, 144, 0, 18 }, + ["18x18/Link"] = { 146, 164, 0, 18 }, + ["18x18/Mailing"] = { 166, 184, 0, 18 }, + ["18x18/More/Horizontal"] = { 186, 204, 0, 18 }, + ["18x18/More/Vertical"] = { 206, 224, 0, 18 }, + ["18x18/Operation"] = { 226, 244, 0, 18 }, + ["18x18/PlayPause"] = { 40, 58, 20, 38 }, + ["18x18/Popout"] = { 60, 78, 20, 38 }, + ["18x18/Post"] = { 80, 98, 20, 38 }, + ["18x18/Queue"] = { 100, 118, 20, 38 }, + ["18x18/Reset"] = { 120, 138, 20, 38 }, + ["18x18/Resize"] = { 140, 158, 20, 38 }, + ["18x18/Running"] = { 160, 178, 20, 38 }, + ["18x18/SaleRate"] = { 180, 198, 20, 38 }, + ["18x18/Search"] = { 200, 218, 20, 38 }, + ["18x18/Select All"] = { 220, 238, 20, 38 }, + ["18x18/Shopping"] = { 40, 58, 40, 58 }, + ["18x18/SkillUp"] = { 60, 78, 40, 58 }, + ["18x18/Star/Filled"] = { 80, 98, 40, 58 }, + ["18x18/Star/Unfilled"] = { 100, 118, 40, 58 }, + ["18x18/Subtract/Circle"] = { 120, 138, 40, 58 }, + ["18x18/Subtract/Default"] = { 140, 158, 40, 58 }, + ["18x18/Visible"] = { 160, 178, 40, 58 }, + ["18x18/WoW"] = { 180, 198, 40, 58 }, + ["24x24/Close/Default"] = { 0, 24, 0, 24 }, + ["Misc/Checkbox/Checked"] = { 200, 218, 40, 58 }, + ["Misc/Checkbox/Unchecked"] = { 220, 238, 40, 58 }, + ["Misc/Crafting"] = { 240, 256, 20, 36 }, + ["Misc/Normal Search"] = { 240, 256, 38, 54 }, + ["Misc/Radio/Checked"] = { 40, 58, 72, 90 }, + ["Misc/Radio/Unchecked"] = { 40, 58, 92, 110 }, + }, + }, +} +local NINE_SLICE_STYLES = { + rounded = { + topLeft = "uiFrames.RoundedTopLeft", + bottomLeft = "uiFrames.RoundedBottomLeft", + topRight = "uiFrames.RoundedTopRight", + bottomRight = "uiFrames.RoundedBottomRight", + left = "uiFrames.RoundedLeft", + right = "uiFrames.RoundedRight", + top = "uiFrames.RoundedTop", + bottom = "uiFrames.RoundedBottomCenter", + center = "uiFrames.RoundedCenter", + }, + global = { + topLeft = "uiFrames.GlobalEdgeTopLeftCorner", + bottomLeft = "uiFrames.GlobalEdgeBottomLeftCorner", + topRight = "uiFrames.GlobalEdgeTopRightCorner", + bottomRight = "uiFrames.GlobalEdgeBottomRightCorner", + left = "uiFrames.GlobalEdgeLeftEdge", + right = "uiFrames.GlobalEdgeRightEdge", + top = "uiFrames.GlobalEdgeTopEdge", + bottom = "uiFrames.GlobalEdgeBottomEdge", + center = nil, + }, + outerFrame = { + topLeft = "uiFrames.OuterFrameTopLeftCorner", + bottomLeft = "uiFrames.OuterFrameBottomLeftCorner", + topRight = "uiFrames.OuterFrameTopRightCorner", + bottomRight = "uiFrames.OuterFrameBottomRightCorner", + left = "uiFrames.OuterFrameLeftEdge", + right = "uiFrames.OuterFrameRightEdge", + top = "uiFrames.OuterFrameTopEdge", + bottom = "uiFrames.OuterFrameBottomEdge", + center = "__WHITE", + }, + popup = { + topLeft = "uiFrames.PopupTopLeftCorner", + bottomLeft = "uiFrames.PopupBottomLeftCorner", + topRight = "uiFrames.PopupTopRightCorner", + bottomRight = "uiFrames.PopupBottomRightCorner", + left = "uiFrames.PopupLeftEdge", + right = "uiFrames.PopupRightEdge", + top = "uiFrames.PopupTopEdge", + bottom = "uiFrames.PopupBottomEdge", + center = "__WHITE", + }, + solid = { + topLeft = "__WHITE", + bottomLeft = "__WHITE", + topRight = "__WHITE", + bottomRight = "__WHITE", + left = "__WHITE", + right = "__WHITE", + top = "__WHITE", + bottom = "__WHITE", + center = "__WHITE", + } +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function TexturePacks.OnInitialize() + for _, info in pairs(NINE_SLICE_STYLES) do + -- extract the texture info + for part, texturePack in pairs(info) do + if texturePack == "__WHITE" then + info[part] = { + texture = "Interface\\Buttons\\WHITE8X8", + coord = { 0, 1, 0, 1 }, + width = 8, + height = 8, + } + else + local width, height = TexturePacks.GetSize(texturePack) + local fileInfo, coord, color, angle = private.SplitTexturePath(texturePack) + assert(not color and not angle) + info[part] = { + texture = fileInfo.path, + coord = { private.GetTexCoord(fileInfo, coord) }, + width = width, + height = height, + } + end + end + end + + -- apply an offset to the topRight part of the popup style so it shows correctly + NINE_SLICE_STYLES.popup.topRight.offset = { + { 0, 10 }, + } + + for key, info in pairs(NINE_SLICE_STYLES) do + NineSlice.RegisterStyle(key, info) + end +end + +function TexturePacks.IsValid(key) + local fileInfo, coord = private.SplitTexturePath(key) + return fileInfo and coord and true or false +end + +function TexturePacks.GetSize(key) + local fileInfo, coord = private.SplitTexturePath(key) + assert(fileInfo and coord) + local minX, maxX, minY, maxY = unpack(coord) + local width = (maxX - minX) / fileInfo.scale + local height = (maxY - minY) / fileInfo.scale + return width, height +end + +function TexturePacks.GetWidth(key) + local width = TexturePacks.GetSize(key) + return width +end + +function TexturePacks.GetHeight(key) + local _, height = TexturePacks.GetSize(key) + return height +end + +function TexturePacks.SetTexture(texture, key) + local fileInfo, coord, color, angle = private.SplitTexturePath(key) + texture:SetTexture(fileInfo.path) + if angle then + texture:SetTexCoord(private.GetTexCoordRotated(fileInfo, coord, angle)) + else + texture:SetTexCoord(private.GetTexCoord(fileInfo, coord)) + end + if color then + texture:SetVertexColor((private.colorLookup[color] or Theme.GetColor(color)):GetFractionalRGBA()) + else + texture:SetVertexColor(1, 1, 1, 1) + end +end + +function TexturePacks.SetSize(texture, key) + local width, height = TexturePacks.GetSize(key) + texture:SetWidth(width) + texture:SetHeight(height) +end + +function TexturePacks.SetWidth(texture, key) + texture:SetWidth(TexturePacks.GetWidth(key)) +end + +function TexturePacks.SetHeight(texture, key) + texture:SetHeight(TexturePacks.GetHeight(key)) +end + +function TexturePacks.SetTextureAndWidth(texture, key) + TexturePacks.SetTexture(texture, key) + TexturePacks.SetWidth(texture, key) +end + +function TexturePacks.SetTextureAndHeight(texture, key) + TexturePacks.SetTexture(texture, key) + TexturePacks.SetHeight(texture, key) +end + +function TexturePacks.SetTextureAndSize(texture, key) + TexturePacks.SetTexture(texture, key) + TexturePacks.SetSize(texture, key) +end + +function TexturePacks.GetTextureLink(key) + local width, height = TexturePacks.GetSize(key) + local fileInfo, coord, color = private.SplitTexturePath(key) + assert(fileInfo and coord) + local minX, maxX, minY, maxY = unpack(coord) + local r, g, b, a = 255, 255, 255, 255 + if color then + r, g, b, a = (private.colorLookup[color] or Theme.GetColor(color)):GetRGBA() + end + assert(a == 255) + return "|T"..strjoin(":", fileInfo.path, width, height, 0, 0, fileInfo.width, fileInfo.height, minX, maxX, minY, maxY, r, g, b).."|t" +end + +function TexturePacks.GetColoredKey(key, color) + local fileInfo, _, existingColor = private.SplitTexturePath(key) + assert(fileInfo and not existingColor) + if type(color) == "string" then + -- this is a theme color key, so just add it on + return key.."#"..color + elseif color:Equals(Color.GetFullWhite()) then + return key + end + assert(not color:Equals(Color.GetTransparent())) + local hex = color:GetHex() + private.colorLookup[hex] = color + return key..hex +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.SplitTexturePath(key) + local file, entry, color, angle, color2 = strmatch(key, "^([^%.]+)%.([^#@]+)(#?[0-9a-fA-Z_]*)@?([0-9]*)(#?[0-9a-fA-Z_]*)$") + color = (color ~= "" and color) or (color2 ~= "" and color2) or nil + angle = angle ~= "" and tonumber(angle) or nil + local fileInfo = file and TEXTURE_FILE_INFO[file] + if color and not strmatch(color, "^#[0-9a-fA-F]+$") then + -- remove the leading '#' from theme color keys + color = strsub(color, 2) + end + return fileInfo, fileInfo and fileInfo.coord[entry], color, angle +end + +function private.GetTexCoord(fileInfo, coord) + local minX, maxX, minY, maxY = unpack(coord) + minX = minX / fileInfo.width + maxX = maxX / fileInfo.width + minY = minY / fileInfo.height + maxY = maxY / fileInfo.height + return minX, maxX, minY, maxY +end + +function private.GetTexCoordRotated(fileInfo, coord, angle) + local minX, maxX, minY, maxY = private.GetTexCoord(fileInfo, coord) + local aspect = fileInfo.width / fileInfo.height + local centerX = (minX + maxX) / 2 + local centerY = (minY + maxY) / 2 + local ULx, ULy = private.RotateCoordPair(minX, minY, centerX, centerY, angle, aspect) + local LLx, LLy = private.RotateCoordPair(minX, maxY, centerX, centerY, angle, aspect) + local URx, URy = private.RotateCoordPair(maxX, minY, centerX, centerY, angle, aspect) + local LRx, LRy = private.RotateCoordPair(maxX, maxY, centerX, centerY, angle, aspect) + return ULx, ULy, LLx, LLy, URx, URy, LRx, LRy +end + +function private.RotateCoordPair(x, y, originX, originY, angle, aspect) + local cosResult = cos(angle) + local sinResult = sin(angle) + y = y / aspect + originY = originY / aspect + local resultX = originX + (x - originX) * cosResult - (y - originY) * sinResult + local resultY = (originY + (y - originY) * cosResult + (x - originX) * sinResult) * aspect + return resultX, resultY +end diff --git a/Core/UI/TaskListUI/Core.lua b/Core/UI/TaskListUI/Core.lua new file mode 100644 index 0000000..538a1d9 --- /dev/null +++ b/Core/UI/TaskListUI/Core.lua @@ -0,0 +1,342 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local TaskListUI = TSM.UI:NewPackage("TaskListUI") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local Log = TSM.Include("Util.Log") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + frame = nil, + categoryCollapsed = {}, + taskCollapsed = {}, + didAutoShow = false, + updateCallbacks = {}, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function TaskListUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "taskListUIContext", "frame") + :AddKey("global", "taskListUIContext", "isOpen") + TSM.TaskList.SetUpdateCallback(private.OnTaskListUpdate) + if not private.settings.isOpen then + private.didAutoShow = true + end +end + +function TaskListUI.OnDisable() + if private.frame then + -- hide the frame + private.frame:Hide() + assert(not private.frame) + end +end + +function TaskListUI.Toggle() + if private.frame then + private.frame:Hide() + assert(not private.frame) + else + if TSM.TaskList.GetNumTasks() == 0 then + Log.PrintUser(L["Your task list is currently empty."]) + return + end + private.settings.isOpen = true + private.frame = private.CreateMainFrame() + TaskListUI.UpdateFrame() + private.frame:Show() + end + for _, callback in ipairs(private.updateCallbacks) do + callback() + end +end + +function TaskListUI.IsVisible() + return private.frame and true or false +end + +function TaskListUI.RegisterUpdateCallback(callback) + tinsert(private.updateCallbacks, callback) +end + +function TaskListUI.UpdateFrame() + local mouseOver = private.frame:_GetBaseFrame():IsMouseOver() and true or false + private.frame:SetBackgroundColor((mouseOver or TSM.db.global.appearanceOptions.taskListBackgroundLock) and "FRAME_BG%50" or nil, true) + private.frame:SetBorderColor((mouseOver or TSM.db.global.appearanceOptions.taskListBackgroundLock) and "ACTIVE_BG%50" or nil, 2) + private.frame:Draw() +end + + + +-- ============================================================================ +-- Task List UI +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("task_list") + local frame = UIElements.New("OverlayApplicationFrame", "base") + :SetParent(UIParent) + :SetWidth(307) + :SetStrata("HIGH") + :SetSettingsContext(private.settings, "frame") + :SetTitle(L["TSM TASK LIST"]) + :SetScript("OnHide", private.BaseFrameOnHide) + :SetContentFrame(UIElements.New("Frame", "content") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Texture", "hline") + :SetHeight(2) + :SetTexture("ACTIVE_BG_ALT") + ) + :AddChildrenWithFunction(private.CreateTaskListElements) + ) + :SetScript("OnEnter", TaskListUI.UpdateFrame) + :SetScript("OnLeave", TaskListUI.UpdateFrame) + frame:GetElement("closeBtn"):SetScript("OnClick", private.CloseBtnOnClick) + return frame +end + +function private.CreateTaskListElements(frame) + -- get all the category counts + local categoryCount = TempTable.Acquire() + for _, task in TSM.TaskList.Iterator() do + local category = task:GetCategory() + categoryCount[category] = (categoryCount[category] or 0) + 1 + end + + local currentCategoryFrame, currentTaskFrame = nil, nil + local lastCategory = nil + for _, task in TSM.TaskList.Iterator() do + local category = task:GetCategory() + local taskDesc = task:GetTaskDesc() + local buttonEnabled, buttonText = task:GetButtonState() + -- draw a category row if this is the first task for a category + local isNewCategory = category ~= lastCategory + if isNewCategory then + private.CreateCategoryLine(frame, category, categoryCount[category]) + local categoryFrame = UIElements.New("Frame", "categoryChildren_"..category) + :SetLayout("VERTICAL") + frame:AddChild(categoryFrame) + if private.categoryCollapsed[category] then + categoryFrame:Hide() + else + categoryFrame:Show() + end + currentCategoryFrame = categoryFrame + end + lastCategory = category + + private.CreateTaskHeaderLine(currentCategoryFrame, taskDesc, buttonText, buttonEnabled, task) + if task:HasSubTasks() then + local taskFrame = UIElements.New("Frame", "taskChildren_"..taskDesc) + :SetLayout("VERTICAL") + currentCategoryFrame:AddChild(taskFrame) + if private.taskCollapsed[taskDesc] then + taskFrame:Hide() + else + taskFrame:Show() + end + currentTaskFrame = taskFrame + else + currentTaskFrame = nil + end + + if task:HasSubTasks() then + -- draw the subtask rows + for index, subTaskDesc in task:SubTaskIterator() do + private.CreateSubTaskLine(currentTaskFrame, subTaskDesc, task, index) + end + end + end + + TempTable.Release(categoryCount) +end + +function private.CreateCategoryLine(frame, category, count) + frame:AddChild(UIElements.New("Frame", "category_"..category) + :SetLayout("HORIZONTAL") + :SetHeight(28) + :SetMargin(4, 4, 0, 2) + :AddChild(UIElements.New("Button", "expanderBtn") + :SetBackgroundAndSize(private.categoryCollapsed[category] and "iconPack.18x18/Caret/Right" or "iconPack.18x18/Caret/Down") + :SetContext(category) + :SetScript("OnClick", private.CategoryExpanderOnClick) + ) + :AddChild(UIElements.New("Text", "desc") + :SetWidth("AUTO") + :SetMargin(2, 4, 0, 0) + :SetFont("BODY_BODY1_BOLD") + :SetTextColor("INDICATOR_ALT") + :SetText(category) + ) + :AddChild(UIElements.New("Text", "count") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(format("(%d)", count)) + ) + ) +end + +function private.CreateTaskHeaderLine(frame, taskText, buttonText, buttonEnabled, task) + local button = UIElements.New(task:IsSecureMacro() and "SecureMacroActionButton" or "ActionButton", "button") + :SetSize(80, 15) + :SetFont("BODY_BODY3_MEDIUM") + :SetContext(task) + :SetDisabled(not buttonEnabled) + :SetText(buttonText) + if task:IsSecureMacro() then + button:SetMacroText(task:GetSecureMacroText()) + else + button:SetScript("OnMouseDown", private.OnTaskButtonMouseDown) + button:SetScript("OnClick", private.OnTaskButtonClicked) + end + frame:AddChild(UIElements.New("Frame", "task_"..taskText) + :SetLayout("HORIZONTAL") + :SetHeight(26) + :SetPadding(18, 8, 0, 0) + :AddChild(UIElements.New("Button", "expanderBtn") + :SetBackgroundAndSize(private.taskCollapsed[taskText] and "iconPack.18x18/Caret/Right" or "iconPack.18x18/Caret/Down") + :SetContext(taskText) + :SetScript("OnClick", private.TaskExpanderOnClick) + ) + :AddChild(UIElements.New("Text", "desc") + :SetMargin(2, 4, 0, 0) + :SetFont("ITEM_BODY1") + :SetTextColor("INDICATOR") + :SetText(taskText) + ) + :AddChild(button) + ) + if not task:HasSubTasks() then + frame:GetElement("task_"..taskText..".expanderBtn"):Hide() + end +end + +function private.CreateSubTaskLine(frame, subTask, task, index) + if task:CanHideSubTasks() then + frame:AddChild(UIElements.New("Frame", "subTask") + :SetHeight(20) + :SetMargin(18, 8, 0, 2) + :SetLayout("HORIZONTAL") + :SetContext(task) + :AddChild(UIElements.New("Button", "hideBtn") + :SetBackgroundAndSize("iconPack.18x18/Visible") + :SetContext(index) + :SetScript("OnClick", private.HideBtnOnClick) + ) + :AddChild(UIElements.New("Text", "text") + :SetMargin(2, 0, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetText(subTask) + ) + ) + else + frame:AddChild(UIElements.New("Text", "text") + :SetHeight(20) + :SetMargin(38, 8, 0, 2) + :SetFont("BODY_BODY2_BOLD") + :SetText(subTask) + ) + end +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide(frame) + assert(frame == private.frame) + frame:Release() + private.frame = nil + TSM.UI.AnalyticsRecordClose("task_list") +end + +function private.CloseBtnOnClick(button) + Log.PrintUser(L["Hiding the TSM Task List UI. Type '/tsm tasklist' to reopen it."]) + private.settings.isOpen = false + TaskListUI.Toggle() +end + +function private.CategoryExpanderOnClick(button) + local contentFrame = button:GetParentElement():GetParentElement() + local category = button:GetContext() + private.categoryCollapsed[category] = not private.categoryCollapsed[category] + if private.categoryCollapsed[category] then + button:SetBackgroundAndSize("iconPack.18x18/Caret/Right") + contentFrame:GetElement("categoryChildren_"..category):Hide() + else + button:SetBackgroundAndSize("iconPack.18x18/Caret/Down") + contentFrame:GetElement("categoryChildren_"..category):Show() + end + contentFrame:GetBaseElement():Draw() +end + +function private.TaskExpanderOnClick(button) + local contentFrame = button:GetParentElement():GetParentElement() + local taskText = button:GetContext() + private.taskCollapsed[taskText] = not private.taskCollapsed[taskText] + if private.taskCollapsed[taskText] then + button:SetBackgroundAndSize("iconPack.18x18/Caret/Right") + contentFrame:GetElement("taskChildren_"..taskText):Hide() + else + button:SetBackgroundAndSize("iconPack.18x18/Caret/Down") + contentFrame:GetElement("taskChildren_"..taskText):Show() + end + contentFrame:GetBaseElement():Draw() +end + +function private.OnTaskButtonMouseDown(button) + local task = button:GetContext() + task:OnMouseDown() +end + +function private.OnTaskButtonClicked(button) + local task = button:GetContext() + task:OnButtonClick() +end + +function private.HideBtnOnClick(button) + local task = button:GetParentElement():GetContext() + task:HideSubTask(button:GetContext()) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.OnTaskListUpdate() + if private.frame then + local numTasks = TSM.TaskList.GetNumTasks() + if numTasks == 0 then + private.didAutoShow = false + TaskListUI.Toggle() + return + end + private.frame:SetTitle(L["TSM TASK LIST"].." ("..numTasks..")") + local contentFrame = private.frame:GetElement("content") + contentFrame:ReleaseAllChildren() + contentFrame:AddChild(UIElements.New("Texture", "hline") + :SetHeight(2) + :SetTexture("ACTIVE_BG_ALT") + ) + contentFrame:AddChildrenWithFunction(private.CreateTaskListElements) + contentFrame:GetParentElement():Draw() + elseif not private.didAutoShow and TSM.TaskList.GetNumTasks() > 0 then + TaskListUI.Toggle() + private.didAutoShow = true + end +end diff --git a/Core/UI/UI.lua b/Core/UI/UI.lua new file mode 100644 index 0000000..3e025b4 --- /dev/null +++ b/Core/UI/UI.lua @@ -0,0 +1,98 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- UI Functions +-- @module UI + +local _, TSM = ... +local UI = TSM:NewPackage("UI") +local Analytics = TSM.Include("Util.Analytics") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local private = { + analyticsPath = { + auction = "", + banking = "", + crafting = "", + destroying = "", + mailing = "", + main = "", + task_list = "", + vendoring = "", + }, +} +local TIME_LEFT_STRINGS = {} +do + -- generate the TIME_LEFT_STRINGS values + local colors = { + Theme.GetFeedbackColor("RED"), + Theme.GetFeedbackColor("RED"), + Theme.GetFeedbackColor("YELLOW"), + Theme.GetFeedbackColor("GREEN"), + } + local strs = TSM.IsWowClassic() and { "30m", "2h", "8h", "24h" } or { "1h", "2h", "24h", "48h" } + assert(#colors == #strs) + for i = 1, #colors do + TIME_LEFT_STRINGS[i] = colors[i]:ColorText(strs[i]) + end +end + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +--- Colors an item name based on its quality. +-- @tparam string item The item to retrieve the name and quality of +-- @tparam[opt=0] number tintPct The tintPct to apply to the quality color +-- @treturn string The colored name +function UI.GetColoredItemName(item, tintPct) + local name = ItemInfo.GetName(item) + local quality = ItemInfo.GetQuality(item) + return UI.GetQualityColoredText(name, quality, tintPct) +end + +--- Colors an item name based on its quality. +-- @tparam string name The name of the item +-- @tparam number quality The quality of the item +-- @tparam[opt=0] number tintPct The tintPct to apply to the quality color +-- @treturn string The colored name +function UI.GetQualityColoredText(name, quality, tintPct) + if not name or not quality then + return + end + local color = Theme.GetItemQualityColor(quality) + return color:GetTint(tintPct or 0):ColorText(name) +end + +--- Gets the string representation of an auction time left. +-- @tparam number timeLeft The time left index (i.e. from WoW APIs) +-- @treturn string The localized string representation +function UI.GetTimeLeftString(timeLeft) + local str = TIME_LEFT_STRINGS[timeLeft] + assert(str, "Invalid timeLeft: "..tostring(timeLeft)) + return str +end + +function UI.AnalyticsRecordPathChange(uiName, ...) + assert(private.analyticsPath[uiName]) + local path = strjoin("/", uiName, ...) + if path == private.analyticsPath[uiName] then + return + end + Analytics.Action("UI_NAVIGATION", private.analyticsPath[uiName], path) + private.analyticsPath[uiName] = path +end + +function UI.AnalyticsRecordClose(uiName) + assert(private.analyticsPath[uiName]) + if private.analyticsPath[uiName] == "" then + return + end + Analytics.Action("UI_NAVIGATION", private.analyticsPath[uiName], "") + private.analyticsPath[uiName] = "" +end diff --git a/Core/UI/Util/Contacts.lua b/Core/UI/Util/Contacts.lua new file mode 100644 index 0000000..614ff68 --- /dev/null +++ b/Core/UI/Util/Contacts.lua @@ -0,0 +1,163 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Contacts = TSM.UI.Util:NewPackage("Contacts") +local L = TSM.Include("Locale").GetTable() +local String = TSM.Include("Util.String") +local UIElements = TSM.Include("UI.UIElements") +local private = { + listElements = {}, +} +local PLAYER_NAME = UnitName("player") +local PLAYER_NAME_REALM = gsub(PLAYER_NAME.."-"..GetRealmName(), "%s+", "") +local LIST_ENTRIES = {L["Recent"], L["Alts"], L["Friends"], L["Guild"]} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Contacts.ShowDialog(button, input, callback) + input:SetFocused(false) + :Draw() + button:GetBaseElement():ShowDialogFrame(UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(152, 90) + :AddAnchor("TOP", button:_GetBaseFrame(), "BOTTOM", 0, -6) + :SetBackgroundColor("FRAME_BG", true) + :SetBorderColor("ACTIVE_BG") + :SetContext(input) + :AddChild(UIElements.New("ViewContainer", "contacts") + :SetNavCallback(private.GetContactsContentFrame) + :SetContext(callback) + :AddPath("menu", true) + :AddPath("list") + ) + ) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.GetContactsContentFrame(viewContainer, path) + if path == "menu" then + return private.GetContactsMenuFrame() + elseif path == "list" then + return private.GetContactListFrame() + else + error("Unexpected path: "..tostring(path)) + end +end + +function private.GetContactsMenuFrame() + return UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetPadding(1, 1, 5, 5) + :AddChild(UIElements.New("SelectionList", "list") + :SetBackgroundColor("FRAME_BG") + :SetEntries(LIST_ENTRIES) + :SetScript("OnEntrySelected", private.MenuOnEntrySelected) + ) +end + +function private.GetContactListFrame() + return UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetPadding(1, 1, 5, 5) + :AddChild(UIElements.New("Button", "back") + :SetHeight(20) + :SetMargin(8, 0, 0, 0) + :SetFont("BODY_BODY2_BOLD") + :SetJustifyH("LEFT") + :SetText(L["Back"]) + :SetScript("OnClick", private.ListBackButtonOnClick) + ) + :AddChild(UIElements.New("SelectionList", "list") + :SetBackgroundColor("FRAME_BG") + :SetEntries(private.listElements) + :SetScript("OnEntrySelected", private.ListOnEntrySelected) + ) +end + +function private.MenuOnEntrySelected(list, name) + private.GenerateListElements(name) + + list:GetElement("__parent.__parent.__parent") + :SetHeight(min((#private.listElements + 1) * 20 + 10, 130)) + :Draw() + + list:GetElement("__parent.__parent") + :SetPath("list", true) +end + +function private.ListBackButtonOnClick(button) + button:GetElement("__parent.__parent.__parent") + :SetHeight(90) + :Draw() + + button:GetElement("__parent.__parent") + :SetPath("menu", true) +end + +function private.ListOnEntrySelected(list, name) + local callback = list:GetElement("__parent.__parent"):GetContext() + local input = list:GetElement("__parent.__parent.__parent"):GetContext() + input:SetValue(name) + :Draw() + + if callback then + callback(input) + end + + list:GetBaseElement():HideDialog() +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.GenerateListElements(category) + wipe(private.listElements) + if category == L["Recent"] then + for k in pairs(TSM.db.global.mailingOptions.recentlyMailedList) do + local character = Ambiguate(k, "none") + tinsert(private.listElements, character) + end + elseif category == L["Alts"] then + for factionrealm in TSM.db:GetConnectedRealmIterator("realm") do + for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do + character = Ambiguate(gsub(strmatch(character, "(.*) "..String.Escape("-")).."-"..gsub(factionrealm, String.Escape("-"), ""), " ", ""), "none") + if character ~= UnitName("player") then + tinsert(private.listElements, character) + end + end + end + elseif category == L["Friends"] then + for i = 1, C_FriendList.GetNumFriends() do + local info = C_FriendList.GetFriendInfoByIndex(i) + if info.name ~= PLAYER_NAME_REALM then + local character = Ambiguate(info.name, "none") + tinsert(private.listElements, character) + end + end + elseif category == L["Guild"] then + for i = 1, GetNumGuildMembers() do + local name = GetGuildRosterInfo(i) + if name ~= PLAYER_NAME_REALM then + local character = Ambiguate(name, "none") + tinsert(private.listElements, character) + end + end + end + sort(private.listElements) +end diff --git a/Core/UI/Util/Core.lua b/Core/UI/Util/Core.lua new file mode 100644 index 0000000..cfb4faf --- /dev/null +++ b/Core/UI/Util/Core.lua @@ -0,0 +1,128 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Util = TSM.UI:NewPackage("Util") +local L = TSM.Include("Locale").GetTable() +local Color = TSM.Include("Util.Color") +local Theme = TSM.Include("Util.Theme") +local private = {} +local THEME_COLOR_SETS = { + { + key = "midnight", + name = L["Midnight"], + colors = { + PRIMARY_BG = Color.NewFromHex("#000000"), + PRIMARY_BG_ALT = Color.NewFromHex("#121212"), + FRAME_BG = Color.NewFromHex("#232323"), + ACTIVE_BG = Color.NewFromHex("#404046"), + ACTIVE_BG_ALT = Color.NewFromHex("#a0a0a0"), + }, + }, + { + key = "duskwood", + name = L["Duskwood"], + colors = { + PRIMARY_BG = Color.NewFromHex("#000000"), + PRIMARY_BG_ALT = Color.NewFromHex("#2e2e2e"), + FRAME_BG = Color.NewFromHex("#404040"), + ACTIVE_BG = Color.NewFromHex("#585858"), + ACTIVE_BG_ALT = Color.NewFromHex("#9d9d9d"), + }, + }, + { + key = "dalaran", + name = L["Dalaran"], + colors = { + PRIMARY_BG = Color.NewFromHex("#15141f"), + PRIMARY_BG_ALT = Color.NewFromHex("#262537"), + FRAME_BG = Color.NewFromHex("#35334d"), + ACTIVE_BG = Color.NewFromHex("#4a476c"), + ACTIVE_BG_ALT = Color.NewFromHex("#958fd9"), + }, + }, + { + key = "swampOfSorrows", + name = L["Swamp of Sorrows"], + colors = { + PRIMARY_BG = Color.NewFromHex("#151e1b"), + PRIMARY_BG_ALT = Color.NewFromHex("#273430"), + FRAME_BG = Color.NewFromHex("#364942"), + ACTIVE_BG = Color.NewFromHex("#567551"), + ACTIVE_BG_ALT = Color.NewFromHex("#B5B28C"), + }, + }, + { + key = "orgrimmar", + name = L["Orgrimmar"], + colors = { + PRIMARY_BG = Color.NewFromHex("#120908"), + PRIMARY_BG_ALT = Color.NewFromHex("#40221b"), + FRAME_BG = Color.NewFromHex("#6F3A2F"), + ACTIVE_BG = Color.NewFromHex("#A25B3E"), + ACTIVE_BG_ALT = Color.NewFromHex("#E1D4C4"), + }, + }, + { + key = "stormwind", + name = L["Stormwind"], + colors = { + PRIMARY_BG = Color.NewFromHex("#191a1a"), + PRIMARY_BG_ALT = Color.NewFromHex("#2b3131"), + FRAME_BG = Color.NewFromHex("#4C585C"), + ACTIVE_BG = Color.NewFromHex("#6B7673"), + ACTIVE_BG_ALT = Color.NewFromHex("#D9DCD3"), + }, + }, + { + key = "winamp", + name = L["Winamp"], + colors = { + PRIMARY_BG = Color.NewFromHex("#000000"), + PRIMARY_BG_ALT = Color.NewFromHex("#1B1B2A"), + FRAME_BG = Color.NewFromHex("#383858"), + ACTIVE_BG = Color.NewFromHex("#6a6a7a"), + ACTIVE_BG_ALT = Color.NewFromHex("#bdced6"), + }, + }, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Util.OnInitialize() + -- register themes + local foundCurrentColorSet = false + for _, info in ipairs(THEME_COLOR_SETS) do + Theme.RegisterColorSet(info.key, info.name, info.colors) + foundCurrentColorSet = foundCurrentColorSet or info.key == TSM.db.global.appearanceOptions.colorSet + end + if not foundCurrentColorSet then + TSM.db.global.appearanceOptions.colorSet = TSM.db:GetDefaultReadOnly("global", "appearanceOptions", "colorSet") + end + Theme.SetActiveColorSet(TSM.db.global.appearanceOptions.colorSet) +end + +function Util.ColorSetIterator() + return private.ColorSetIterator, THEME_COLOR_SETS, 0 +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.ColorSetIterator(tbl, index) + index = index + 1 + if not tbl[index] then + return + end + return index, tbl[index].key, tbl[index].name +end diff --git a/Core/UI/Util/QueryScrollingTableInfo.lua b/Core/UI/Util/QueryScrollingTableInfo.lua new file mode 100644 index 0000000..f78b015 --- /dev/null +++ b/Core/UI/Util/QueryScrollingTableInfo.lua @@ -0,0 +1,146 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ObjectPool = TSM.Include("Util.ObjectPool") +local QueryScrollingTableInfo = TSM.Include("LibTSMClass").DefineClass("QueryScrollingTableInfo", TSM.UI.Util.ScrollingTableInfo) +local QueryScrollingTableColumnInfo = TSM.Include("LibTSMClass").DefineClass("QueryScrollingTableColumnInfo", TSM.UI.Util.ScrollingTableColumnInfo) +TSM.UI.Util.QueryScrollingTableInfo = QueryScrollingTableInfo +QueryScrollingTableInfo._COL_INFO_POOL = ObjectPool.New("QUERY_SCROLLING_TABLE_COL_INFO", QueryScrollingTableColumnInfo, 1) + + + +-- ============================================================================ +-- QueryScrollingTableColumnInfo Class +-- ============================================================================ + +function QueryScrollingTableColumnInfo.__init(self) + self.__super:__init() + self._textField = nil + self._iconField = nil + self._tooltipField = nil + self._sortField = nil +end + +function QueryScrollingTableColumnInfo._Release(self) + self.__super:_Release() + self._textField = nil + self._iconField = nil + self._tooltipField = nil + self._sortField = nil +end + +function QueryScrollingTableColumnInfo.SetTextFunction(self) + error("This method is not allowed for QueryScrollingTableColumnInfo") +end + +function QueryScrollingTableColumnInfo.SetIconFunction(self, func) + error("This method is not allowed for QueryScrollingTableColumnInfo") +end + +function QueryScrollingTableColumnInfo.SetTooltipFunction(self, func) + error("This method is not allowed for QueryScrollingTableColumnInfo") +end + +function QueryScrollingTableColumnInfo.SetTextInfo(self, field, func) + self._textField = field + self._textFunc = func + return self +end + +function QueryScrollingTableColumnInfo.SetIconInfo(self, field, func) + self._iconField = field + self._iconFunc = func + return self +end + +function QueryScrollingTableColumnInfo.SetTooltipInfo(self, field, func) + self._tooltipField = field + self._tooltipFunc = func + return self +end + +function QueryScrollingTableColumnInfo.SetSortInfo(self, field) + self._sortField = field + return self +end + +function QueryScrollingTableColumnInfo._GetSortField(self) + return self._sortField +end + +function QueryScrollingTableColumnInfo._HasText(self) + return (self._textField or self._textFunc) and true or false +end + +function QueryScrollingTableColumnInfo._HasTooltip(self) + return (self._tooltipField or self._tooltipFunc) and true or false +end + +function QueryScrollingTableColumnInfo._GetValueHelper(self, dataType, context, ...) + local value = self._element._query:GetResultRowByUUID(context) + local field, func = nil, nil + if dataType == "text" then + field = self._textField + func = self._textFunc + elseif dataType == "icon" then + field = self._iconField + func = self._iconFunc + elseif dataType == "tooltip" then + field = self._tooltipField + func = self._tooltipFunc + elseif dataType == "expanderState" then + return self._expanderStateFunc(self._element, value) + elseif dataType == "badgeState" then + return self._badgeStateFunc(self._element, value) + elseif dataType == "actionIcon" then + local index, isMouseOver = ... + return self._actionIconFunc(self._element, context, index, isMouseOver) + else + error("Unknown dataType: "..tostring(dataType)) + end + + if field then + value = value:GetField(field) + end + if func then + value = func(value, ...) + end + return value +end + + + +-- ============================================================================ +-- QueryScrollingTableInfo Class +-- ============================================================================ + +function QueryScrollingTableInfo._IsSortEnabled(self) + for _, col in ipairs(self._cols) do + if col:_GetSortField() then + return true + end + end + return false +end + +function QueryScrollingTableInfo._GetSortFieldById(self, id) + for _, col in ipairs(self._cols) do + if col:_GetId() == id then + return col:_GetSortField() + end + end + error("Unknown id: "..tostring(id)) +end + +function QueryScrollingTableInfo._GetIdBySortField(self, field) + for _, col in ipairs(self._cols) do + if col:_GetSortField() == field then + return col:_GetId() + end + end + error("Unknown field: "..tostring(field)) +end diff --git a/Core/UI/Util/ScrollingTableInfo.lua b/Core/UI/Util/ScrollingTableInfo.lua new file mode 100644 index 0000000..4359af1 --- /dev/null +++ b/Core/UI/Util/ScrollingTableInfo.lua @@ -0,0 +1,522 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local ObjectPool = TSM.Include("Util.ObjectPool") +local Theme = TSM.Include("Util.Theme") +local ScrollingTableInfo = TSM.Include("LibTSMClass").DefineClass("ScrollingTableInfo") +local ScrollingTableColumnInfo = TSM.Include("LibTSMClass").DefineClass("ScrollingTableColumnInfo") +TSM.UI.Util.ScrollingTableInfo = ScrollingTableInfo +TSM.UI.Util.ScrollingTableColumnInfo = ScrollingTableColumnInfo +ScrollingTableInfo._COL_INFO_POOL = ObjectPool.New("SCROLLING_TABLE_COL_INFO", ScrollingTableColumnInfo, 1) + + + +-- ============================================================================ +-- ScrollingTableColumnInfo Class +-- ============================================================================ + +function ScrollingTableColumnInfo.__init(self) + -- general + self._tableInfo = nil + self._id = nil + self._element = nil + self._tooltipLinkingDisabled = false + self._expanderStateFunc = nil + self._flagStateFunc = nil + self._checkStateFunc = nil + self._badgeStateFunc = nil + self._iconHoverEnabled = false + self._iconClickHandler = nil + self._numActionIcons = 0 + self._actionIconSize = nil + self._actionIconFunc = nil + self._actionIconShowOnHover = false + self._actionIconClickHandler = nil + self._hidden = false + self._hidingDisabled = false + -- style + self._width = nil + self._justifyH = nil + self._iconSize = nil + self._font = nil + self._headerIndent = nil + -- header + self._title = nil + self._titleIcon = nil + self._headerTooltip = nil + -- content functions + self._textFunc = nil + self._iconFunc = nil + self._tooltipFunc = nil +end + +function ScrollingTableColumnInfo._Acquire(self, tableInfo, id, element) + self._tableInfo = tableInfo + self._id = id + self._element = element +end + +function ScrollingTableColumnInfo._Release(self) + self._tableInfo = nil + self._id = nil + self._element = nil + self._tooltipLinkingDisabled = false + self._expanderStateFunc = nil + self._flagStateFunc = nil + self._checkStateFunc = nil + self._badgeStateFunc = nil + self._iconHoverEnabled = false + self._iconClickHandler = nil + self._numActionIcons = 0 + self._actionIconSize = nil + self._actionIconFunc = nil + self._actionIconShowOnHover = false + self._actionIconClickHandler = nil + self._hidden = false + self._hidingDisabled = false + self._width = nil + self._justifyH = nil + self._iconSize = nil + self._font = nil + self._headerIndent = nil + self._title = nil + self._titleIcon = nil + self._headerTooltip = nil + self._textFunc = nil + self._iconFunc = nil + self._tooltipFunc = nil +end + +function ScrollingTableColumnInfo.SetTitle(self, title) + self._title = title + return self +end + +function ScrollingTableColumnInfo.SetTitleIcon(self, icon) + self._titleIcon = icon + return self +end + +function ScrollingTableColumnInfo.SetWidth(self, width) + assert(type(width) == "number") + self._width = width + return self +end + +function ScrollingTableColumnInfo.SetAutoWidth(self) + self._width = true + return self +end + +function ScrollingTableColumnInfo.SetJustifyH(self, justifyH) + self._justifyH = justifyH + return self +end + +function ScrollingTableColumnInfo.SetIconSize(self, iconSize) + self._iconSize = iconSize + return self +end + +function ScrollingTableColumnInfo.SetIconHoverEnabled(self, enabled) + self._iconHoverEnabled = enabled + return self +end + +function ScrollingTableColumnInfo.SetIconClickHandler(self, handler) + self._iconClickHandler = handler + return self +end + +function ScrollingTableColumnInfo.SetFont(self, font) + self._font = font + return self +end + +function ScrollingTableColumnInfo.SetHeaderIndent(self, headerIndent) + self._headerIndent = headerIndent + return self +end + +function ScrollingTableColumnInfo.SetTextFunction(self, func) + self._textFunc = func + return self +end + +function ScrollingTableColumnInfo.SetIconFunction(self, func) + self._iconFunc = func + return self +end + +function ScrollingTableColumnInfo.SetHeaderTooltip(self, tooltip) + self._headerTooltip = tooltip + return self +end + +function ScrollingTableColumnInfo.SetTooltipFunction(self, func) + self._tooltipFunc = func + return self +end + +function ScrollingTableColumnInfo.SetTooltipLinkingDisabled(self, disabled) + self._tooltipLinkingDisabled = disabled + return self +end + +function ScrollingTableColumnInfo.SetExpanderStateFunction(self, func) + self._expanderStateFunc = func + return self +end + +function ScrollingTableColumnInfo.SetFlagStateFunction(self, func) + self._flagStateFunc = func + return self +end + +function ScrollingTableColumnInfo.SetCheckStateFunction(self, func) + self._checkStateFunc = func + return self +end + +function ScrollingTableColumnInfo.SetBadgeStateFunction(self, func) + self._badgeStateFunc = func + return self +end + +function ScrollingTableColumnInfo.SetActionIconInfo(self, numIcons, iconSize, func, showOnHover) + self._numActionIcons = numIcons + self._actionIconSize = iconSize + self._actionIconFunc = func + self._actionIconShowOnHover = showOnHover + return self +end + +function ScrollingTableColumnInfo.SetActionIconClickHandler(self, handler) + self._actionIconClickHandler = handler + return self +end + +function ScrollingTableColumnInfo.DisableHiding(self) + assert(not self._hidden) + self._hidingDisabled = true + return self +end + +function ScrollingTableColumnInfo.Commit(self) + return self._tableInfo +end + +function ScrollingTableColumnInfo._GetId(self) + return self._id +end + +function ScrollingTableColumnInfo._IsHidden(self) + return self._hidden +end + +function ScrollingTableColumnInfo._CanHide(self) + return not self._hidingDisabled +end + +function ScrollingTableColumnInfo._GetTitle(self) + return self._title +end + +function ScrollingTableColumnInfo._GetTitleIcon(self) + return self._titleIcon +end + +function ScrollingTableColumnInfo._GetWidth(self) + return self._width +end + +function ScrollingTableColumnInfo._GetJustifyH(self) + return self._justifyH +end + +function ScrollingTableColumnInfo._GetIconSize(self) + return self._iconSize +end + +function ScrollingTableColumnInfo._IsIconHoverEnabled(self) + return self._iconHoverEnabled +end + +function ScrollingTableColumnInfo._OnIconClick(self, context, mouseButton) + self._iconClickHandler(self._element, context, mouseButton) +end + +function ScrollingTableColumnInfo._GetWowFont(self) + return Theme.GetFont(self._font):GetWowFont() +end + +function ScrollingTableColumnInfo._GetHeaderIndent(self) + return self._headerIndent +end + +function ScrollingTableColumnInfo._HasText(self) + return self._textFunc and true or false +end + +function ScrollingTableColumnInfo._GetText(self, context) + return self:_GetValueHelper("text", context) +end + +function ScrollingTableColumnInfo._GetIcon(self, context, isMouseOver) + return self:_GetValueHelper("icon", context, isMouseOver) +end + +function ScrollingTableColumnInfo._GetHeaderTooltip(self) + return self._headerTooltip +end + +function ScrollingTableColumnInfo._HasTooltip(self) + return self._tooltipFunc and true or false +end + +function ScrollingTableColumnInfo._GetTooltip(self, context) + return self:_GetValueHelper("tooltip", context) +end + +function ScrollingTableColumnInfo._GetTooltipLinkingDisabled(self) + return self._tooltipLinkingDisabled +end + +function ScrollingTableColumnInfo._HasExpander(self) + return self._expanderStateFunc and true or false +end + +function ScrollingTableColumnInfo._GetExpanderState(self, context) + if not self._expanderStateFunc then + return + end + return self:_GetValueHelper("expanderState", context) +end + +function ScrollingTableColumnInfo._HasFlag(self) + return self._flagStateFunc and true or false +end + +function ScrollingTableColumnInfo._GetFlagState(self, context, isMouseOverRow) + if not self._flagStateFunc then + return + end + return self:_GetValueHelper("flagState", context, isMouseOverRow) +end + +function ScrollingTableColumnInfo._HasCheck(self) + return self._checkStateFunc and true or false +end + +function ScrollingTableColumnInfo._GetCheckState(self, context) + if not self._checkStateFunc then + return + end + return self:_GetValueHelper("checkState", context) +end + +function ScrollingTableColumnInfo._HasBadge(self) + return self._badgeStateFunc and true or false +end + +function ScrollingTableColumnInfo._GetBadgeState(self, context) + if not self._badgeStateFunc then + return + end + return self:_GetValueHelper("badgeState", context) +end + +function ScrollingTableColumnInfo._GetActionIconInfo(self) + return self._numActionIcons, self._actionIconSize, self._actionIconShowOnHover +end + +function ScrollingTableColumnInfo._GetActionIcon(self, context, index, isMouseOver) + if not self._actionIconFunc then + return + end + return self:_GetValueHelper("actionIcon", context, index, isMouseOver) +end + +function ScrollingTableColumnInfo._OnActionButtonClick(self, context, index, mouseButton) + if self._actionIconClickHandler then + self._actionIconClickHandler(self._element, context, index, mouseButton) + end +end + +function ScrollingTableColumnInfo._GetValueHelper(self, dataType, context, ...) + if dataType == "text" then + return self._textFunc and self._textFunc(self._element, context) or "" + elseif dataType == "icon" then + local isMouseOver = ... + return self._iconFunc(self._element, context, isMouseOver) + elseif dataType == "tooltip" then + if not self._tooltipFunc then + return nil + end + return self._tooltipFunc(self._element, context) + elseif dataType == "expanderState" then + return self._expanderStateFunc(self._element, context) + elseif dataType == "flagState" then + local isMouseOverRow = ... + return self._flagStateFunc(self._element, context, isMouseOverRow) + elseif dataType == "checkState" then + return self._checkStateFunc(self._element, context) + elseif dataType == "badgeState" then + return self._badgeStateFunc(self._element, context) + elseif dataType == "actionIcon" then + local index, isMouseOver = ... + return self._actionIconFunc(self._element, context, index, isMouseOver) + else + error("Unknown dataType: "..tostring(dataType)) + end +end + +function ScrollingTableColumnInfo._SetHidden(self, hidden) + assert(not self._hidingDisabled) + self._hidden = hidden + self._tableInfo:_UpdateHiddenCols() +end + + + +-- ============================================================================ +-- ScrollingTableInfo Class +-- ============================================================================ + +function ScrollingTableInfo.__init(self) + self._cols = {} + self._visibleCols = {} + self._hiddenCols = {} + self._element = nil + self._cursor = nil + self._menuIterator = nil + self._menuClickHandler = nil +end + +function ScrollingTableInfo._Acquire(self, element) + self._element = element +end + +function ScrollingTableInfo._Release(self) + for _, col in ipairs(self._cols) do + col:_Release() + self._COL_INFO_POOL:Recycle(col) + end + wipe(self._cols) + wipe(self._visibleCols) + wipe(self._hiddenCols) + self._element = nil + self._cursor = nil + self._menuIterator = nil + self._menuClickHandler = nil +end + +function ScrollingTableInfo.NewColumn(self, id, prepend) + local col = self._COL_INFO_POOL:Get() + col:_Acquire(self, id, self._element) + if prepend then + tinsert(self._cols, 1, col) + else + tinsert(self._cols, col) + end + return col +end + +function ScrollingTableInfo.RemoveColumn(self, id) + local index = nil + for i, col in ipairs(self._cols) do + if col:_GetId() == id then + assert(not index) + index = i + end + end + assert(index) + local col = tremove(self._cols, index) + col:_Release() + self._COL_INFO_POOL:Recycle(col) + self:_UpdateHiddenCols() + return self +end + +function ScrollingTableInfo.SetCursor(self, cursor) + self._cursor = cursor + return self +end + +function ScrollingTableInfo.SetMenuInfo(self, iterator, clickHandler) + if not iterator and not clickHandler then + self._menuIterator = nil + self._menuClickHandler = nil + return self + end + assert(type(iterator) == "function" and type(clickHandler) == "function") + self._menuIterator = iterator + self._menuClickHandler = clickHandler + return self +end + +function ScrollingTableInfo.Commit(self) + self:_UpdateHiddenCols() + return self._element:CommitTableInfo() +end + +function ScrollingTableInfo.GetColById(self, id) + for _, col in ipairs(self._cols) do + if col:_GetId() == id then + return col + end + end + error("Unknown id: "..tostring(id)) +end + +function ScrollingTableInfo._GetCols(self) + return self._cols +end + +function ScrollingTableInfo._ColIterator(self) + return ipairs(self._cols) +end + +function ScrollingTableInfo._VisibleColIterator(self) + return ipairs(self._visibleCols) +end + +function ScrollingTableInfo._HiddenColIterator(self) + return ipairs(self._hiddenCols) +end + +function ScrollingTableInfo._GetVisibleCols(self) + return self._visibleCols +end + +function ScrollingTableInfo._GetCursor(self) + return self._cursor +end + +function ScrollingTableInfo._UpdateHiddenCols(self) + wipe(self._visibleCols) + wipe(self._hiddenCols) + for _, col in ipairs(self._cols) do + if col:_IsHidden() then + tinsert(self._hiddenCols, col) + else + tinsert(self._visibleCols, col) + end + end +end + +function ScrollingTableInfo._MenuDialogIterator(self, prevIndex) + if not self._menuIterator then + return + end + return self._menuIterator(self._element, prevIndex) +end + +function ScrollingTableInfo._HandleMenuButtonClick(self, index1, index2) + assert(self._menuClickHandler) + self._menuClickHandler(self._element, index1, index2) +end diff --git a/Core/UI/Util/TableRow.lua b/Core/UI/Util/TableRow.lua new file mode 100644 index 0000000..552b9f6 --- /dev/null +++ b/Core/UI/Util/TableRow.lua @@ -0,0 +1,1476 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local L = TSM.Include("Locale").GetTable() +local TableRow = TSM.Include("LibTSMClass").DefineClass("TableRow") +local Table = TSM.Include("Util.Table") +local Math = TSM.Include("Util.Math") +local Wow = TSM.Include("Util.Wow") +local Theme = TSM.Include("Util.Theme") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Tooltip = TSM.Include("UI.Tooltip") +TSM.UI.Util.TableRow = TableRow +local private = { + rowFrameLookup = {}, + layoutTemp = {}, + colIdsTemp = {}, +} +local FLAG_WIDTH = 6 +local FLAG_SPACING = 2 +local ICON_SPACING = 4 +local MIN_TEXT_WIDTH = 12 +local RESIZER_WIDTH = 4 +local INDENT_WIDTH = 8 +local HEADER_LINE_WIDTH = 2 +local MORE_COL_WIDTH = 8 +local FULL_TEXT_TOOLTIP_DELAY_S = 1 + + + +-- ============================================================================ +-- TableRow - Public Class Methods +-- ============================================================================ + +function TableRow.__init(self) + self._scrollingTable = nil + self._tableInfo = nil + self._rowData = nil + self._texts = {} + self._icons = {} + self._rotatingIcons = {} + self._buttons = {} + self._recycled = { + buttons = {}, + texts = {}, + icons = {}, + rotatingIcons = {}, + } + self._sortId = nil + self._sortAscending = true + self._state = nil + + local frame = CreateFrame("Button", nil, nil, nil) + frame:RegisterForClicks("LeftButtonUp", "RightButtonUp") + self._frame = frame + private.rowFrameLookup[frame] = self + + frame.background = frame:CreateTexture(nil, "BACKGROUND") + frame.background:SetAllPoints() + + frame.highlight = frame:CreateTexture(nil, "ARTWORK", -1) + frame.highlight:SetAllPoints() + frame.highlight:Hide() + + frame.sortBackground = frame:CreateTexture(nil, "ARTWORK", -2) + frame.sortBackground:Hide() + + frame.sortFlag = frame:CreateTexture(nil, "ARTWORK", -1) + frame.sortFlag:SetHeight(3) + frame.sortFlag:Hide() +end + +function TableRow.Acquire(self, scrollingTable, isHeader) + self._scrollingTable = scrollingTable + self._tableInfo = self._scrollingTable._tableInfo + + self._frame:SetParent(isHeader and self._scrollingTable._hContent or self._scrollingTable._content) + self._frame:SetHitRectInsets(0, 0, 0, 0) + self._frame:Show() + self._frame.highlight:Hide() + self._frame.sortBackground:Hide() + self._frame.sortFlag:Hide() + + if isHeader then + self:_CreateHeaderRowCols() + self._frame:SetPoint("TOPLEFT", 0, -HEADER_LINE_WIDTH) + self._frame:SetPoint("TOPRIGHT", 0, -HEADER_LINE_WIDTH) + self:_LayoutHeaderRow() + else + self:_CreateDataRowCols() + ScriptWrapper.Set(self._frame, "OnEnter", private.RowOnEnter, self) + ScriptWrapper.Set(self._frame, "OnLeave", private.RowOnLeave, self) + ScriptWrapper.Set(self._frame, "OnClick", private.RowOnClick, self) + if scrollingTable._rightClickToggle then + ScriptWrapper.Set(self._frame, "OnMouseDown", private.RowOnMouseDown, self) + else + ScriptWrapper.Clear(self._frame, "OnMouseDown") + end + self:_LayoutDataRow() + end +end + +function TableRow.Release(self) + self._frame:Hide() + for _, text in pairs(self._texts) do + text:Hide() + text:ClearAllPoints() + text:SetWidth(0) + text:SetHeight(0) + text:SetTextColor(1, 1, 1, 1) + tinsert(self._recycled.texts, text) + end + wipe(self._texts) + for _, icon in pairs(self._icons) do + icon:Hide() + icon:SetDrawLayer("ARTWORK", 0) + icon:SetTexture(nil) + icon:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1) + icon:SetColorTexture(0, 0, 0, 0) + icon:SetVertexColor(1, 1, 1, 1) + icon:ClearAllPoints() + icon:SetWidth(0) + icon:SetHeight(0) + tinsert(self._recycled.icons, icon) + end + wipe(self._icons) + for _, icon in pairs(self._rotatingIcons) do + icon.ag:Stop() + icon:Hide() + icon:SetDrawLayer("ARTWORK", 0) + icon:SetTexture(nil) + icon:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1) + icon:SetColorTexture(0, 0, 0, 0) + icon:SetVertexColor(1, 1, 1, 1) + icon:ClearAllPoints() + icon:SetWidth(0) + icon:SetHeight(0) + tinsert(self._recycled.rotatingIcons, icon) + end + wipe(self._rotatingIcons) + for _, button in pairs(self._buttons) do + if button.isShowingTooltip then + Tooltip.Hide() + button.isShowingTooltip = nil + end + button:Hide() + button:SetMouseClickEnabled(true) + button:RegisterForDrag(nil) + button:SetResizable(false) + button:SetMovable(false) + ScriptWrapper.Clear(button, "OnEnter") + ScriptWrapper.Clear(button, "OnLeave") + ScriptWrapper.Clear(button, "OnClick") + ScriptWrapper.Clear(button, "OnMouseDown") + ScriptWrapper.Clear(button, "OnMouseUp") + ScriptWrapper.Clear(button, "OnUpdate") + button:SetParent(nil) + button:ClearAllPoints() + button:SetWidth(0) + button:SetHeight(0) + tinsert(self._recycled.buttons, button) + end + wipe(self._buttons) + + self._scrollingTable = nil + self._tableInfo = nil + self._rowData = nil + self._state = nil + self._frame:SetParent(nil) + self._frame:ClearAllPoints() + ScriptWrapper.Clear(self._frame, "OnEnter") + ScriptWrapper.Clear(self._frame, "OnLeave") + ScriptWrapper.Clear(self._frame, "OnClick") +end + +function TableRow.SetData(self, data) + for _, col in self._tableInfo:_ColIterator() do + local id = col:_GetId() + self._texts[id]:SetText(col:_GetText(data)) + if col:_GetIconSize() then + local button = self._buttons["_icon_"..id] + local texture = col:_GetIcon(data, button and button:IsMouseOver()) + if type(texture) == "string" and TSM.UI.TexturePacks.IsValid(texture) then + TSM.UI.TexturePacks.SetTexture(self._icons[id], texture) + else + self._icons[id]:SetTexture(texture) + end + end + end + self._rowData = data + self:_LayoutDataRow() +end + +function TableRow.SetHeaderData(self) + if self._scrollingTable._headerHidden then + return + end + for _, col in self._tableInfo:_ColIterator() do + if not col:_GetTitleIcon() then + self._texts[col:_GetId()]:SetText(col:_GetTitle()) + end + end +end + +function TableRow.GetData(self) + return self._rowData +end + +function TableRow.ClearData(self) + self._rowData = nil +end + +function TableRow.SetHeight(self, height) + for _, text in pairs(self._texts) do + text:SetHeight(height) + end + for _, btn in pairs(self._buttons) do + btn:SetHeight(height) + end + for _, col in ipairs(self._tableInfo:_GetCols()) do + local id = col:_GetId() + local flag = self._icons["_flag_"..id] + if flag then + flag:SetHeight(height - FLAG_SPACING * 2) + end + end + self._frame:SetHeight(height) +end + +function TableRow.SetBackgroundColor(self, color) + self._frame.background:SetColorTexture(color:GetFractionalRGBA()) +end + +function TableRow.SetTextColor(self, color) + for _, col in ipairs(self._tableInfo:_GetCols()) do + local id = col:_GetId() + local text = self._texts[id] + if text then + text:SetTextColor(color:GetFractionalRGBA()) + end + end +end + +function TableRow.SetVisible(self, visible) + if visible == self._frame:IsVisible() then + return + end + if visible then + self._frame:Show() + self._frame.highlight:Hide() + else + self._frame:Hide() + end +end + +function TableRow.IsVisible(self) + return self._frame:IsVisible() +end + +function TableRow.SetHighlightState(self, state, noLayout) + self._state = state + local highlightTint, shouldLayoutActionIcons = nil, false + if state == "selectedHover" then + highlightTint = "+SELECTED_HOVER" + elseif state == "selected" then + highlightTint = "+SELECTED" + elseif state == "hover" then + highlightTint = self._scrollingTable._rowHoverEnabled and "+HOVER" or nil + shouldLayoutActionIcons = true + elseif state == nil then + shouldLayoutActionIcons = true + else + error("Invalid state: "..state) + end + if highlightTint then + local backgroundColor = self._scrollingTable._backgroundColor + self._frame.highlight:SetColorTexture(Theme.GetColor(backgroundColor):GetTint(highlightTint):GetFractionalRGBA()) + self._frame.highlight:Show() + else + self._frame.highlight:Hide() + end + if not noLayout then + local shouldLayout = false + for _, col in self._tableInfo:_VisibleColIterator() do + if col:_HasFlag() or (shouldLayoutActionIcons and select(3, col:_GetActionIconInfo())) then + shouldLayout = true + break + end + end + if shouldLayout then + self:_LayoutDataRow() + end + end +end + +function TableRow.IsMouseOver(self) + return self._frame:IsMouseOver() +end + +function TableRow.SetHitRectInsets(self, left, right, top, bottom) + for _, tooltipFrame in pairs(self._buttons) do + tooltipFrame:SetHitRectInsets(left, right, top, bottom) + end + self._frame:SetHitRectInsets(left, right, top, bottom) +end + +function TableRow.SetSort(self, sortId, sortAscending) + if sortId == self._sortId and sortAscending == self._sortAscending then + return + end + self._sortId = sortId + self._sortAscending = sortAscending + self:_LayoutHeaderRow() +end + + + +-- ============================================================================ +-- TableRow - Private Class Methods +-- ============================================================================ + +function TableRow._GetFontString(self) + local fontString = tremove(self._recycled.texts) + if not fontString then + fontString = self._frame:CreateFontString() + fontString:SetShadowColor(0, 0, 0, 0) + fontString:SetWordWrap(false) + end + fontString:Show() + return fontString +end + +function TableRow._GetTexture(self) + local texture = tremove(self._recycled.icons) + if not texture then + texture = self._frame:CreateTexture() + end + texture:Show() + return texture +end + +function TableRow._GetRotatingTexture(self) + local texture = tremove(self._recycled.rotatingIcons) + if not texture then + texture = self._frame:CreateTexture() + texture.ag = texture:CreateAnimationGroup() + local spin = texture.ag:CreateAnimation("Rotation") + spin:SetDuration(2) + spin:SetDegrees(360) + texture.ag:SetLooping("REPEAT") + end + texture:Show() + return texture +end + +function TableRow._GetButton(self) + local button = tremove(self._recycled.buttons) + if not button then + button = CreateFrame("Button", nil, self._frame, nil) + end + button:SetParent(self._frame) + button:SetHitRectInsets(0, 0, 0, 0) + button:RegisterForClicks("LeftButtonUp", "RightButtonUp") + button:Show() + return button +end + +function TableRow._GetSepTexture(self) + local sepTexture = self:_GetTexture() + sepTexture:SetDrawLayer("BORDER") + sepTexture:SetWidth(HEADER_LINE_WIDTH) + return sepTexture +end + +function TableRow._CreateHeaderRowCols(self) + if self._scrollingTable._headerHidden then + return + end + if self._scrollingTable:_CanResizeCols() then + -- craete the "more" column + local button = self:_GetButton() + ScriptWrapper.Set(button, "OnClick", private.MoreColOnClick, self) + self._buttons._more = button + local moreIcon = self:_GetTexture() + moreIcon:SetDrawLayer("ARTWORK") + TSM.UI.TexturePacks.SetTextureAndSize(moreIcon, "iconPack.12x12/More/Vertical") + self._icons._more = moreIcon + self._icons._sep__more = self:_GetSepTexture() + end + + for _, col in self._tableInfo:_ColIterator() do + local id = col:_GetId() + local button = self:_GetButton() + ScriptWrapper.Set(button, "OnClick", private.HeaderColOnClick) + ScriptWrapper.Set(button, "OnEnter", private.HeaderColOnEnter) + ScriptWrapper.Set(button, "OnLeave", private.HeaderColOnLeave) + button:SetResizable(true) + self._buttons[id] = button + if self._scrollingTable:_CanResizeCols() then + local resizerButton = self:_GetButton() + resizerButton:SetMovable(true) + ScriptWrapper.Set(resizerButton, "OnEnter", private.ResizerOnEnter) + ScriptWrapper.Set(resizerButton, "OnLeave", private.ResizerOnLeave) + ScriptWrapper.Set(resizerButton, "OnMouseDown", private.ResizerOnMouseDown) + ScriptWrapper.Set(resizerButton, "OnMouseUp", private.ResizerOnMouseUp) + ScriptWrapper.Set(resizerButton, "OnClick", private.ResizerOnClick) + self._buttons["_resizer_"..id] = resizerButton + local resizerHighlight = self:_GetTexture() + resizerHighlight:SetDrawLayer("ARTWORK") + resizerHighlight:SetPoint("TOPLEFT", resizerButton) + resizerHighlight:SetPoint("BOTTOMRIGHT", resizerButton) + resizerHighlight:SetColorTexture(Theme.GetColor("ACTIVE_BG+HOVER"):GetFractionalRGBA()) + resizerHighlight:Hide() + self._icons["_resizer_"..id] = resizerHighlight + end + self._icons["_sep_"..id] = self:_GetSepTexture() + local iconTexture = col:_GetTitleIcon() + if iconTexture then + local icon = self:_GetTexture() + icon:SetDrawLayer("ARTWORK", 1) + TSM.UI.TexturePacks.SetTextureAndSize(icon, iconTexture) + self._icons[id] = icon + else + local text = self:_GetFontString() + text:SetFont(Theme.GetFont("BODY_BODY3_MEDIUM"):GetWowFont()) + text:SetJustifyH("LEFT") + text:SetText(col:_GetTitle()) + self._texts[id] = text + end + end +end + +function TableRow._CreateDataRowCols(self) + for _, col in self._tableInfo:_ColIterator() do + local id = col:_GetId() + local iconSize = col:_GetIconSize() + if iconSize then + local icon = self:_GetTexture() + icon:SetDrawLayer("ARTWORK", 1) + icon:SetWidth(iconSize) + icon:SetHeight(iconSize) + self._icons[id] = icon + + if col:_IsIconHoverEnabled() then + local iconBtn = self:_GetButton() + iconBtn:SetAllPoints(icon) + ScriptWrapper.Set(iconBtn, "OnEnter", private.IconButtonOnEnter) + ScriptWrapper.Set(iconBtn, "OnLeave", private.IconButtonOnLeave) + ScriptWrapper.Set(iconBtn, "OnClick", private.IconButtonOnClick) + self._buttons["_icon_"..id] = iconBtn + end + end + -- need to create a text element even if there's no text, as it's used for the layout + local text = self:_GetFontString() + if col:_HasText() then + text:SetFont(col:_GetWowFont()) + text:SetJustifyH(col:_GetJustifyH()) + else + -- use an arbitrary default font since the text will be an empty string + text:SetFontObject(GameFontNormal) + end + self._texts[id] = text + if col:_HasTooltip() then + local tooltipFrame = self:_GetButton() + ScriptWrapper.Set(tooltipFrame, "OnEnter", private.TooltipFrameOnEnter) + ScriptWrapper.Set(tooltipFrame, "OnLeave", private.TooltipFrameOnLeave) + ScriptWrapper.Set(tooltipFrame, "OnClick", private.TooltipFrameOnClick) + if self._scrollingTable._rightClickToggle then + ScriptWrapper.Set(tooltipFrame, "OnMouseDown", private.RowOnMouseDown, self) + end + self._buttons[id] = tooltipFrame + end + if col:_HasFlag() then + -- add the flag texture + local flag = self:_GetTexture() + flag:SetDrawLayer("ARTWORK", 1) + flag:SetWidth(FLAG_WIDTH) + self._icons["_flag_"..id] = flag + end + if col:_HasExpander() then + -- add the expander texture + local expander = self:_GetTexture() + expander:SetDrawLayer("ARTWORK", 1) + self._icons["_expander_"..id] = expander + + local expanderBtn = self:_GetButton() + expanderBtn:SetAllPoints(expander) + ScriptWrapper.SetPropagate(expanderBtn, "OnEnter") + ScriptWrapper.Set(expanderBtn, "OnLeave", private.ExpanderOnLeave, self) + ScriptWrapper.Set(expanderBtn, "OnClick", private.ExpanderOnClick, self) + self._buttons["_expander_"..id] = expanderBtn + end + if col:_HasCheck() then + -- add the check texture + local expander = self:_GetTexture() + expander:SetDrawLayer("ARTWORK", 1) + TSM.UI.TexturePacks.SetTextureAndSize(expander, "iconPack.14x14/Checkmark/Default") + self._icons["_check_"..id] = expander + end + if col:_HasBadge() then + local badgeText = self:_GetFontString() + badgeText:SetFont(Theme.GetFont("TABLE_TABLE1"):GetWowFont()) + badgeText:SetTextColor(Theme.GetColor("INDICATOR"):GetFractionalRGBA()) + badgeText:SetJustifyH("RIGHT") + badgeText:SetJustifyV("MIDDLE") + self._texts["_badge_"..id] = badgeText + end + -- add the action icons + local numActionIcons, actionIconSize = col:_GetActionIconInfo() + for i = 1, numActionIcons do + local icon = self:_GetRotatingTexture() + icon:SetDrawLayer("ARTWORK", 1) + icon:SetWidth(actionIconSize) + icon:SetHeight(actionIconSize) + self._rotatingIcons["_action"..i.."_"..id] = icon + + local iconBtn = self:_GetButton() + iconBtn:SetAllPoints(icon) + ScriptWrapper.Set(iconBtn, "OnEnter", private.ActionIconButtonOnEnter) + ScriptWrapper.Set(iconBtn, "OnLeave", private.ActionIconButtonOnLeave) + ScriptWrapper.Set(iconBtn, "OnClick", private.ActionIconButtonOnClick) + self._buttons["_action"..i.."_"..id] = iconBtn + end + end +end + +function TableRow._LayoutHeaderRow(self) + if self._scrollingTable._headerHidden then + return + end + + -- hide any hidden cols + for _, col in self._tableInfo:_HiddenColIterator() do + local id = col:_GetId() + local button = self._buttons[id] + button:Hide() + local sepIcon = self._icons["_sep_"..id] + sepIcon:Hide() + + if self._scrollingTable:_CanResizeCols() then + local resizerButton = self._buttons["_resizer_"..id] + resizerButton:Hide() + end + + local iconTexture = col:_GetTitleIcon() + if iconTexture then + self._icons[id]:Hide() + else + self._texts[id]:Hide() + end + end + + local cols = self._tableInfo:_GetVisibleCols() + + if self._scrollingTable:_CanResizeCols() then + -- layout the "more" column + local button = self._buttons._more + button:SetPoint("LEFT", Theme.GetColSpacing() / 2, 0) + button:SetWidth(MORE_COL_WIDTH) + local icon = self._icons._more + icon:SetPoint("CENTER", button) + local sepIcon = self._icons._sep__more + sepIcon:SetPoint("TOP", button, "TOPRIGHT", Theme.GetColSpacing() / 2, 0) + sepIcon:SetPoint("BOTTOM", button, "BOTTOMRIGHT", Theme.GetColSpacing() / 2, 0) + sepIcon:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + end + + -- build buttons from the left until we get to the col without a width, then switch to building from the right + for i, col, isAscending in Table.InwardIterator(cols) do + local id = col:_GetId() + local button = self._buttons[id] + local leftButton = i > 1 and self._buttons[cols[i - 1]:_GetId()] or (self._scrollingTable:_CanResizeCols() and self._buttons._more) or nil + local rightButton = i < #cols and self._buttons[cols[i + 1]:_GetId()] or nil + button:Show() + button:ClearAllPoints() + + if self._scrollingTable:_CanResizeCols() then + local resizerBtn = self._buttons["_resizer_"..id] + if self._scrollingTable:_IsColWidthLocked() then + resizerBtn:Disable() + resizerBtn:SetMovable(false) + resizerBtn:SetMouseClickEnabled(false) + else + resizerBtn:Enable() + resizerBtn:SetMovable(true) + resizerBtn:SetMouseClickEnabled(true) + end + end + + local sepIcon = self._icons["_sep_"..id] + sepIcon:Show() + sepIcon:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + sepIcon:SetPoint("TOP", button, "TOPRIGHT", Theme.GetColSpacing() / 2, 0) + sepIcon:SetPoint("BOTTOM", button, "BOTTOMRIGHT", Theme.GetColSpacing() / 2, 0) + + -- layout the button + if isAscending then + if leftButton then + button:SetPoint("LEFT", leftButton, "RIGHT", Theme.GetColSpacing(), 0) + else + button:SetPoint("LEFT", Theme.GetColSpacing() / 2, 0) + end + else + if rightButton then + button:SetPoint("RIGHT", rightButton, "LEFT", -Theme.GetColSpacing(), 0) + else + button:SetPoint("RIGHT", -Theme.GetColSpacing() / 2, 0) + end + end + + if self._scrollingTable:_CanResizeCols() then + local iconSize = col:_GetIconSize() + local iconTexture = col:_GetTitleIcon() + button:SetWidth(self._scrollingTable:_GetColWidth(id)) + + -- the minimum width of the content is our minimum text width plus the size of the content icon + local minContentWidth = 0 + if col:_HasText() then + minContentWidth = minContentWidth + MIN_TEXT_WIDTH + end + if iconSize then + minContentWidth = minContentWidth + iconSize + ICON_SPACING + end + if col:_HasFlag() then + minContentWidth = minContentWidth + FLAG_WIDTH + end + if col:_HasExpander() then + minContentWidth = minContentWidth + TSM.UI.TexturePacks.GetWidth("iconPack.12x12/Caret/Right") + ICON_SPACING + end + if col:_HasCheck() then + minContentWidth = minContentWidth + TSM.UI.TexturePacks.GetWidth("iconPack.14x14/Checkmark/Default") + ICON_SPACING + end + if col:_HasBadge() then + minContentWidth = minContentWidth + TSM.UI.TexturePacks.GetWidth("uiFrames.AuctionCounterTexture") + ICON_SPACING + end + -- the minimum header width is either our header icon width or the minimum text width + local minHeaderWidth = iconTexture and TSM.UI.TexturePacks.GetWidth(iconTexture) or MIN_TEXT_WIDTH + button:SetMinResize(max(minContentWidth, minHeaderWidth), 0) + + -- layout the resizer button + local resizerButton = self._buttons["_resizer_"..id] + resizerButton:Show() + resizerButton:SetPoint("LEFT", button, "RIGHT", (Theme.GetColSpacing() - RESIZER_WIDTH) / 2, 0) + resizerButton:SetWidth(RESIZER_WIDTH) + resizerButton:SetHitRectInsets(-RESIZER_WIDTH / 2, -RESIZER_WIDTH / 2, 0, 0) + else + local width = col:_GetWidth() + if width then + -- don't currently support auto-width with a header + assert(width ~= true) + button:SetWidth(width) + else + -- we found the button which will expand to fill the extra width, so reverse the iterator direction (only once) + assert(isAscending) + Table.InwardIteratorReverse(cols) + if rightButton then + -- anchor the right of this button to the left of the next one + button:SetPoint("RIGHT", rightButton, "LEFT", -Theme.GetColSpacing(), 0) + else + -- this button is the last one, so anchor it to the right of our frame + button:SetPoint("RIGHT", -Theme.GetColSpacing() / 2, 0) + end + end + end + end + + -- update the text, icons, and sort icons + local sortBackground = self._frame.sortBackground + local sortFlag = self._frame.sortFlag + sortBackground:Hide() + sortBackground:ClearAllPoints() + sortFlag:Hide() + sortFlag:ClearAllPoints() + for _, col in ipairs(cols) do + local id = col:_GetId() + local button = self._buttons[id] + if self._sortId == id then + sortBackground:SetPoint("TOPLEFT", button, -Theme.GetColSpacing() / 2, 0) + sortBackground:SetPoint("BOTTOMRIGHT", button, Theme.GetColSpacing() / 2, 0) + sortBackground:SetColorTexture(Theme.GetColor("ACTIVE_BG"):GetFractionalRGBA()) + sortBackground:Show() + sortFlag:SetPoint("TOPLEFT", button, -Theme.GetColSpacing() / 2, 2) + sortFlag:SetPoint("TOPRIGHT", button, Theme.GetColSpacing() / 2, 2) + sortFlag:SetColorTexture(Theme.GetColor(self._sortAscending and "INDICATOR" or "INDICATOR_ALT"):GetFractionalRGBA()) + sortFlag:Show() + end + local iconTexture = col:_GetTitleIcon() + if iconTexture then + local icon = self._icons[id] + icon:Show() + icon:ClearAllPoints() + icon:SetPoint(col:_GetJustifyH(), button) + else + local text = self._texts[id] + text:Show() + text:ClearAllPoints() + text:SetAllPoints(button) + end + end +end + +function TableRow._LayoutDataRow(self) + -- hide any hidden cols + for _, col in self._tableInfo:_HiddenColIterator() do + local id = col:_GetId() + local icon = self._icons[id] + + local flag = self._icons["_flag_"..id] + if flag then + flag:Hide() + end + + local expander = self._icons["_expander_"..id] + if expander then + expander:Hide() + local expanderButton = self._buttons["_expander_"..id] + expanderButton:Hide() + end + + local check = self._icons["_check_"..id] + if check then + check:Hide() + end + + if icon then + icon:Hide() + end + + if col:_HasText() then + local text = self._texts[id] + text:Hide() + end + + local badge = self._texts["_badge_"..id] + if badge then + badge:Hide() + end + + local numActionIcons = col:_GetActionIconInfo() + for i = 1, numActionIcons do + local actionIconKey = "_action"..i.."_"..id + local actionIcon = self._rotatingIcons[actionIconKey] + local actionIconButton = self._buttons[actionIconKey] + actionIcon:Hide() + actionIconButton:Hide() + end + + if col:_HasTooltip() then + local tooltipFrame = self._buttons[id] + tooltipFrame:Hide() + end + end + + local cols = self._tableInfo:_GetVisibleCols() + local canResize = self._scrollingTable:_CanResizeCols() + local data = self:GetData() + + -- cache a list of the col ids + wipe(private.colIdsTemp) + for i, col in ipairs(cols) do + private.colIdsTemp[i] = col:_GetId() + end + + -- build from the left until we get to the col without a width, then switch to building from the right + for i, col, isAscending in Table.InwardIterator(cols) do + local id = private.colIdsTemp[i] + local text = self._texts[id] + local flag = self._icons["_flag_"..id] + local expander = self._icons["_expander_"..id] + local check = self._icons["_check_"..id] + local badge = self._texts["_badge_"..id] + local icon = self._icons[id] + local width = canResize and self._scrollingTable:_GetColWidth(id) or col:_GetWidth() + local hasText = col:_HasText() + local textIndex = nil + local numActionIcons, actionIconSize, actionIconShowOnHover = col:_GetActionIconInfo() + local leftText = self:_GetDataLayoutRelativeFrame(i > 1 and private.colIdsTemp[i - 1], i > 1 and cols[i - 1], data, "LEFT") + local rightText = self:_GetDataLayoutRelativeFrame(i < #cols and private.colIdsTemp[i + 1], i < #cols and cols[i + 1], data, "RIGHT") + wipe(private.layoutTemp) + local extraWidth = 0 + if not leftText and canResize then + extraWidth = MORE_COL_WIDTH + Theme.GetColSpacing() + private.LayoutTempInsertSpacing(MORE_COL_WIDTH + Theme.GetColSpacing()) + end + + if flag then + local visible, color = false, nil + if data then + visible, color = col:_GetFlagState(data, self:IsMouseOver()) + end + if visible then + flag:Show() + flag:SetHeight(self._frame:GetHeight() - FLAG_SPACING * 2) + flag:SetColorTexture(color:GetFractionalRGBA()) + private.LayoutTempInsertSpacing(-FLAG_SPACING) + private.LayoutTempInsertElementWithSpacing(flag, FLAG_SPACING) + else + flag:Hide() + end + end + + if expander then + expander:Show() + expander:ClearAllPoints() + local visible, expanded, indentLevel, indentWidth, expanderSpacing, largeCaretIcons = false, false, 0, nil, nil, false + if data then + visible, expanded, indentLevel, indentWidth, expanderSpacing, largeCaretIcons = col:_GetExpanderState(data) + end + expanderSpacing = expanderSpacing or ICON_SPACING + local expanderButton = self._buttons["_expander_"..id] + if indentWidth or indentLevel < 0 then + -- indent the expander itself + private.LayoutTempInsertSpacing(indentWidth or (abs(indentLevel) * INDENT_WIDTH)) + end + local texture = expanded and (largeCaretIcons and "iconPack.14x14/Caret/Down" or "iconPack.12x12/Caret/Down") or (largeCaretIcons and "iconPack.14x14/Caret/Right" or "iconPack.12x12/Caret/Right") + local expanderWidth = TSM.UI.TexturePacks.GetWidth(texture) + -- check if there is only spacing before the expander and the expander can fit within the spacing + local firstLayoutElement = #private.layoutTemp == 1 and not leftText and private.layoutTemp[1] or nil + if type(firstLayoutElement) == "number" and Math.Round(expanderWidth + expanderSpacing) <= Math.Round(firstLayoutElement) then + private.layoutTemp[1] = private.layoutTemp[1] - expanderWidth - expanderSpacing + end + if visible then + TSM.UI.TexturePacks.SetTextureAndSize(expander, texture) + expander:Show() + expanderButton:Show() + private.LayoutTempInsertElementWithSpacing(expander, expanderSpacing) + else + expander:Hide() + ScriptWrapper.Clear(expanderButton, "OnLeave") + expanderButton:Hide() + ScriptWrapper.Set(expanderButton, "OnLeave", private.ExpanderOnLeave, self) + private.LayoutTempInsertSpacing(expanderWidth + expanderSpacing) + end + if not indentWidth and indentLevel > 0 then + -- indent the other elements after the expander + private.LayoutTempInsertSpacing(indentLevel * INDENT_WIDTH) + end + end + + if check then + check:Show() + check:ClearAllPoints() + local visible = false + if data then + visible = col:_GetCheckState(data) + end + if visible then + local checkWidth = check:GetWidth() + -- check if there is only spacing before the check and the check can fit within the spacing + local firstLayoutElement = #private.layoutTemp == 1 and not leftText and private.layoutTemp[1] or nil + if type(firstLayoutElement) == "number" and Math.Round(checkWidth + ICON_SPACING) <= Math.Round(firstLayoutElement) then + private.layoutTemp[1] = private.layoutTemp[1] - checkWidth - ICON_SPACING + end + check:Show() + private.LayoutTempInsertElementWithSpacing(check, ICON_SPACING) + else + check:Hide() + end + end + + local iconTexture = icon and icon:GetTexture() + if iconTexture then + icon:Show() + icon:ClearAllPoints() + textIndex = #private.layoutTemp + 1 + private.LayoutTempInsertElementWithSpacing(icon, ICON_SPACING) + end + + if hasText then + text:Show() + text:ClearAllPoints() + textIndex = textIndex or #private.layoutTemp + 1 + private.LayoutTempInsertElementWithSpacing(text, ICON_SPACING) + end + + if badge then + badge:ClearAllPoints() + private.LayoutTempInsertElementWithSpacing(badge, ICON_SPACING) + local visible, value = false, nil + if data then + visible, value = col:_GetBadgeState(data) + end + if visible then + badge:SetText(value) + badge:Show() + else + badge:Hide() + end + end + + local hadVisibleRightActionIcon = false + for j = 1, numActionIcons do + local actionIconKey = "_action"..j.."_"..id + local actionIcon = self._rotatingIcons[actionIconKey] + local actionIconButton = self._buttons[actionIconKey] + actionIcon:ClearAllPoints() + actionIconButton:Show() + local visible, texture, isLeft = false, nil, false + if data and (not actionIconShowOnHover or self._state) then + local _, shouldRotate = nil, nil + visible, texture, isLeft, _, shouldRotate = col:_GetActionIcon(data, j, actionIconButton:IsMouseOver()) + if shouldRotate then + actionIcon.ag:Play() + else + actionIcon.ag:Stop() + end + end + if visible then + hadVisibleRightActionIcon = hadVisibleRightActionIcon or not isLeft + if isLeft and textIndex then + -- check if there is only padding before the text, and if this icon can fit within that padding + local firstLayoutElement = textIndex == 2 and not leftText and private.layoutTemp[1] or nil + if textIndex == 2 and type(firstLayoutElement) == "number" and Math.Round(actionIconSize + ICON_SPACING) <= Math.Round(firstLayoutElement) then + private.layoutTemp[1] = private.layoutTemp[1] - actionIconSize - ICON_SPACING + end + private.LayoutTempInsertElementWithSpacing(actionIcon, ICON_SPACING, textIndex) + textIndex = textIndex + 2 + else + private.LayoutTempInsertElementWithSpacing(actionIcon, ICON_SPACING) + end + actionIcon:Show() + actionIconButton:SetMouseClickEnabled(true) + if type(texture) == "string" and TSM.UI.TexturePacks.IsValid(texture) then + TSM.UI.TexturePacks.SetTexture(actionIcon, texture) + else + actionIcon:SetTexture(texture) + end + else + actionIcon:Hide() + actionIconButton:SetMouseClickEnabled(false) + end + end + + local usedWidth = 0 + local lastElement = text + if hasText then + local didText = false + local prevElement = nil + local currentSpacing = 0 + local isInverted = false + local primaryText = (isAscending and leftText) or (not isAscending and rightText) or nil + local primarySide = isAscending and "LEFT" or "RIGHT" + local secondarySide = isAscending and "RIGHT" or "LEFT" + local spacingModifier = isAscending and 1 or -1 + for j = (isAscending and 1 or #private.layoutTemp), (isAscending and #private.layoutTemp or 1), (isAscending and 1 or -1) do + local element = private.layoutTemp[j] + if type(element) == "number" then + currentSpacing = currentSpacing + element + else + if prevElement then + isInverted = isInverted or (prevElement == text and not width and numActionIcons > 0) + if isInverted then + assert(isAscending) + -- this is an icon to the right of the text and the text will need to expand, so invert the anchoring + prevElement:SetPoint("RIGHT", element, "LEFT", -currentSpacing, 0) + else + element:SetPoint(primarySide, prevElement, secondarySide, currentSpacing * spacingModifier, 0) + end + elseif primaryText then + element:SetPoint(primarySide, primaryText, secondarySide, (currentSpacing + Theme.GetColSpacing()) * spacingModifier, 0) + else + if element == text and not canResize then + currentSpacing = currentSpacing + Theme.GetColSpacing() / 2 + end + element:SetPoint(primarySide, (currentSpacing + Theme.GetColSpacing() / 2) * spacingModifier, 0) + end + -- don't use up width for elements to the right of the text which aren't visible + if isAscending and (not didText or element:IsVisible()) then + -- the text will resize to fill any remaining width, so don't count it in the used width + if element ~= text then + usedWidth = usedWidth + element:GetWidth() + end + usedWidth = usedWidth + currentSpacing + end + prevElement = element + currentSpacing = 0 + didText = didText or element == text + end + end + lastElement = prevElement + else + -- center the icon in the middle + -- TODO: support other combinations + assert(isAscending and icon and not badge and not expander) + if leftText then + text:SetPoint("LEFT", leftText, "RIGHT", Theme.GetColSpacing(), 0) + else + text:SetPoint("LEFT", Theme.GetColSpacing() / 2, 0) + end + icon:SetPoint("CENTER", text, extraWidth / 2, 0) + end + + if col:_HasTooltip() then + local tooltipFrame = self._buttons[id] + tooltipFrame:Show() + tooltipFrame:SetPoint("LEFT", iconTexture and icon or text) + tooltipFrame:SetPoint("RIGHT", text) + end + + if width then + if hasText then + if width == true then + -- auto-width + text:SetWidth(10000) + width = text:GetStringWidth() + elseif not rightText then + -- leave an extra COL_SPACING / 2 of margin on the right of the last col + extraWidth = extraWidth - Theme.GetColSpacing() / 2 + end + text:SetWidth(width - usedWidth + extraWidth) + else + assert(width ~= true) -- auto-width requires the cell to have text + text:SetWidth(width + extraWidth) + end + else + -- we found the text which will expand to fill the extra width, so reverse the iterator direction (only once) + assert(isAscending and not canResize) + Table.InwardIteratorReverse(cols) + if rightText then + -- anchor the right of this the last element for this col to the left of the next one + lastElement:SetPoint("RIGHT", rightText, "LEFT", -Theme.GetColSpacing(), 0) + else + -- this col is the last one, so anchor it to the right of our frame + local spacing = Theme.GetColSpacing() + if hadVisibleRightActionIcon and not rightText then + -- a bit of extra spacing to the right of the last action icon + spacing = spacing + ICON_SPACING + end + lastElement:SetPoint("RIGHT", -spacing, 0) + end + end + end +end + +function TableRow._GetDataLayoutRelativeFrame(self, id, col, data, side) + if not id then + return nil + end + assert(side == "LEFT" or side == "RIGHT") + local numActionIcons, _, actionIconShowOnHover = col:_GetActionIconInfo() + if data and (not actionIconShowOnHover or self._state) then + for i = (side == "RIGHT" and 1 or numActionIcons), (side == "RIGHT" and numActionIcons or 1), (side == "RIGHT" and 1 or -1) do + local actionIconKey = "_action"..i.."_"..id + local actionIcon = self._rotatingIcons[actionIconKey] + local actionIconButton = self._buttons[actionIconKey] + local visible, _, isLeft = col:_GetActionIcon(data, i, actionIconButton:IsMouseOver()) + if visible and ((isLeft and side == "RIGHT") or (not isLeft and side == "LEFT")) then + return actionIcon + end + end + end + local badge = self._texts["_badge_"..id] + if side == "LEFT" and badge and badge:IsVisible() then + return badge + else + return self._texts[id] + end +end + +function TableRow._LookupIconByButton(self, button) + local key = Table.GetDistinctKey(self._buttons, button) + local id = strmatch(key, "^_icon_(.+)$") + local col = self._tableInfo:GetColById(id) + return col, self._icons[id] +end + +function TableRow._LookupActionIconByButton(self, button) + local key = Table.GetDistinctKey(self._buttons, button) + local iconIndex, id = strmatch(key, "^_action(%d+)_(.+)$") + iconIndex = tonumber(iconIndex) + assert(iconIndex and id) + local col = self._tableInfo:GetColById(id) + return col, iconIndex, self._rotatingIcons[key] +end + +function TableRow._LookupTooltipByFrame(self, frame) + local col = self._tableInfo:GetColById(Table.GetDistinctKey(self._buttons, frame)) + if not col:_HasTooltip() then + return nil + end + local tooltip, noWrap, anchorFrame = col:_GetTooltip(self:GetData()) + local linkingDisabled = col:_GetTooltipLinkingDisabled() + return tooltip, noWrap, anchorFrame, linkingDisabled +end + + + +-- ============================================================================ +-- TableRow - Local Script Handlers +-- ============================================================================ + +function private.MenuDialogIterator(self, prevIndex) + if prevIndex == nil then + return "HIDE", L["Hide Columns"], private.MenuDialogHideColIterator + elseif prevIndex == "HIDE" then + return "LOCK", self._scrollingTable:_IsColWidthLocked() and L["Unlock Column Width"] or L["Lock Column Width"] + elseif prevIndex == "LOCK" then + return "RESET", L["Reset Table"] + else + return self._tableInfo:_MenuDialogIterator(prevIndex) + end +end + +function private.GetMoreDialogColRowContent(col) + local color = Theme.GetColor(col:_IsHidden() and "TEXT+DISABLED" or "TEXT") + local titleText = col:_GetTitle() + if titleText then + return color:ColorText(titleText) + end + local titleIcon = col:_GetTitleIcon() + if titleIcon then + local textureKey = TSM.UI.TexturePacks.GetColoredKey(titleIcon, color) + return TSM.UI.TexturePacks.GetTextureLink(textureKey) + end + error("Unknown title") +end + +function private.MenuDialogHideColIterator(self, prevIndex) + local foundPrevIndex = prevIndex == nil + for _, col in ipairs(self._tableInfo:_GetCols()) do + if foundPrevIndex and col:_CanHide() then + return col:_GetId(), private.GetMoreDialogColRowContent(col) + elseif col:_GetId() == prevIndex then + foundPrevIndex = true + end + end +end + +function private.MoreMenuDialogButtonOnClick(button, self, index1, index2, extra) + assert(not extra and index1) + if index1 == "HIDE" then + assert(index2) + local col = self._tableInfo:GetColById(index2) + assert(col:_CanHide()) + self._scrollingTable:_ToggleColHide(index2) + -- update the button text + button:SetText(private.GetMoreDialogColRowContent(col)) + :Draw() + elseif index1 == "LOCK" then + assert(not index2) + self._scrollingTable:GetBaseElement():HideDialog() + self._scrollingTable:_ToogleColWidthLocked() + elseif index1 == "RESET" then + assert(not index2) + self._scrollingTable:GetBaseElement():HideDialog() + self._scrollingTable:_ResetContext() + else + self._tableInfo:_HandleMenuButtonClick(index1, index2) + end +end + +function private.MoreColOnClick(self, mouseButton) + self._scrollingTable:GetBaseElement():ShowMenuDialog(self._frame, private.MenuDialogIterator, self, private.MoreMenuDialogButtonOnClick) +end + +function private.HeaderColOnClick(button, mouseButton) + local self = private.rowFrameLookup[button:GetParent()] + if mouseButton == "LeftButton" then + self._scrollingTable:_ToggleSort(Table.GetDistinctKey(self._buttons, button)) + end +end + +function private.HeaderColOnEnter(button) + local self = private.rowFrameLookup[button:GetParent()] + local col = self._tableInfo:GetColById(Table.GetDistinctKey(self._buttons, button)) + local tooltip = col:_GetHeaderTooltip() + if tooltip then + Tooltip.Show(button, tooltip) + end +end + +function private.HeaderColOnLeave(button) + local self = private.rowFrameLookup[button:GetParent()] + local col = self._tableInfo:GetColById(Table.GetDistinctKey(self._buttons, button)) + if col:_GetHeaderTooltip() then + Tooltip.Hide() + end +end + +function private.RowOnMouseDown(self, button) + if button ~= "RightButton" then + return + end + private.RowOnClick(self, "LeftButton") +end + +function private.RowOnClick(self, mouseButton) + if mouseButton == "LeftButton" and not self._scrollingTable._selectionDisabled then + self._scrollingTable:SetSelection(self:GetData()) + end + if self._scrollingTable:_IsSelected(self:GetData()) then + self:SetHighlightState(self._scrollingTable._selectionDisabled and "selected" or "selectedHover") + else + self:SetHighlightState("hover") + end + self._scrollingTable:_HandleRowClick(self:GetData(), mouseButton) +end + +function private.RowOnEnter(self) + if self._scrollingTable:_IsSelected(self:GetData()) then + self:SetHighlightState(self._scrollingTable._selectionDisabled and "selected" or "selectedHover") + else + self:SetHighlightState("hover") + end + if self._scrollingTable._rightClickToggle and IsMouseButtonDown("RightButton") then + private.RowOnClick(self, "LeftButton") + end + local cursor = self._tableInfo:_GetCursor() + if cursor then + SetCursor(cursor) + end + ScriptWrapper.Set(self._frame, "OnUpdate", private.RowOnUpdate, self) +end + +function private.RowOnLeave(self) + for _, text in pairs(self._texts) do + if text._hoverStart then + Tooltip.Hide() + text._hoverStart = nil + end + end + ScriptWrapper.Clear(self._frame, "OnUpdate") + if self:IsVisible() then + if self._scrollingTable:_IsSelected(self:GetData()) then + self:SetHighlightState("selected") + else + self:SetHighlightState(nil) + end + end + if self._tableInfo:_GetCursor() then + ResetCursor() + end +end + +function private.RowOnUpdate(self) + if not self._frame:IsMouseOver() then + private.RowOnLeave(self) + return + end + local tooltipText = nil + for _, tooltipFrame in pairs(self._buttons) do + if tooltipFrame:IsVisible() and tooltipFrame:IsMouseOver() then + return + end + end + for _, text in pairs(self._texts) do + if text:IsMouseOver() and text:GetWidth() + 0.5 < text:GetUnboundedStringWidth() then + text._hoverStart = text._hoverStart or GetTime() + tooltipText = text + else + text._hoverStart = nil + end + end + if not tooltipText or tooltipText:GetText() == "" or GetTime() - tooltipText._hoverStart < FULL_TEXT_TOOLTIP_DELAY_S then + Tooltip.Hide() + return + end + Tooltip.Show(tooltipText, tooltipText:GetText(), true) +end + +function private.IconButtonOnEnter(button) + local self = private.rowFrameLookup[button:GetParent()] + self._frame:GetScript("OnEnter")(self._frame) + local col, icon = self:_LookupIconByButton(button) + local texture, tooltip = col:_GetIcon(self:GetData(), true) + if type(texture) == "string" and TSM.UI.TexturePacks.IsValid(texture) then + TSM.UI.TexturePacks.SetTexture(icon, texture) + else + icon:SetTexture(texture) + end + if tooltip then + Tooltip.Show(button, tooltip) + end +end + +function private.IconButtonOnLeave(button) + local self = private.rowFrameLookup[button:GetParent()] + self._frame:GetScript("OnLeave")(self._frame) + local col, icon = self:_LookupIconByButton(button) + local texture, tooltip = col:_GetIcon(self:GetData(), false) + if type(texture) == "string" and TSM.UI.TexturePacks.IsValid(texture) then + TSM.UI.TexturePacks.SetTexture(icon, texture) + else + icon:SetTexture(texture) + end + if tooltip then + Tooltip.Hide() + end +end + +function private.IconButtonOnClick(button, mouseButton) + local self = private.rowFrameLookup[button:GetParent()] + local col = self:_LookupIconByButton(button) + col:_OnIconClick(self:GetData(), mouseButton) +end + +function private.ActionIconButtonOnEnter(button) + local self = private.rowFrameLookup[button:GetParent()] + self._frame:GetScript("OnEnter")(self._frame) + local col, iconIndex, icon = self:_LookupActionIconByButton(button) + local visible, texture, _, tooltip = col:_GetActionIcon(self:GetData(), iconIndex, true) + if not visible then + return + end + if tooltip then + Tooltip.Show(button, tooltip) + end + if type(texture) == "string" and TSM.UI.TexturePacks.IsValid(texture) then + TSM.UI.TexturePacks.SetTexture(icon, texture) + else + icon:SetTexture(texture) + end +end + +function private.ActionIconButtonOnLeave(button) + if not button:IsVisible() then + -- ignore OnLeave if we just hid this button + Tooltip.Hide() + return + end + local self = private.rowFrameLookup[button:GetParent()] + self._frame:GetScript("OnLeave")(self._frame) + local col, iconIndex, icon, tooltip = self:_LookupActionIconByButton(button) + local visible, texture = col:_GetActionIcon(self:GetData(), iconIndex, false) + if tooltip then + Tooltip.Hide() + end + if not visible then + return + end + if type(texture) == "string" and TSM.UI.TexturePacks.IsValid(texture) then + TSM.UI.TexturePacks.SetTexture(icon, texture) + else + icon:SetTexture(texture) + end +end + +function private.ActionIconButtonOnClick(button, mouseButton) + local self = private.rowFrameLookup[button:GetParent()] + local col, iconIndex = self:_LookupActionIconByButton(button) + col:_OnActionButtonClick(self:GetData(), iconIndex, mouseButton) +end + +function private.TooltipFrameOnEnter(frame) + frame.isShowingTooltip = true + local self = private.rowFrameLookup[frame:GetParent()] + self._frame:GetScript("OnEnter")(self._frame) + local tooltip, noWrap, anchorFrame = self:_LookupTooltipByFrame(frame) + Tooltip.Show(anchorFrame and self._frame or frame, tooltip, noWrap) +end + +function private.TooltipFrameOnLeave(frame) + frame.isShowingTooltip = nil + local self = private.rowFrameLookup[frame:GetParent()] + self._frame:GetScript("OnLeave")(self._frame) + Tooltip.Hide() +end + +function private.TooltipFrameOnClick(frame, ...) + local self = private.rowFrameLookup[frame:GetParent()] + local tooltip, _, _, linkingDisabled = self:_LookupTooltipByFrame(frame) + if not linkingDisabled and (IsShiftKeyDown() or IsControlKeyDown()) then + local link = tooltip and ItemInfo.GetLink(tooltip) + if link then + if IsShiftKeyDown() then + Wow.SafeItemRef(link) + elseif IsControlKeyDown() then + DressUpItemLink(link) + end + return + end + end + self._frame:GetScript("OnClick")(self._frame, ...) +end + +function private.ResizerOnEnter(button) + local self = private.rowFrameLookup[button:GetParent()] + local id = Table.GetDistinctKey(self._buttons, button) + self._icons[id]:Show() +end + +function private.ResizerOnLeave(button) + local self = private.rowFrameLookup[button:GetParent()] + local id = Table.GetDistinctKey(self._buttons, button) + self._icons[id]:Hide() +end + +function private.ResizerOnMouseDown(button, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + ScriptWrapper.Set(button, "OnUpdate", private.ResizerOnUpdate) + local self = private.rowFrameLookup[button:GetParent()] + local id = Table.GetDistinctKey(self._buttons, button) + local parentId = strmatch(id, "^_resizer_(.+)$") + self._buttons[parentId]:StartSizing("RIGHT") + self._icons[id]:SetPoint("TOPLEFT", self._buttons[parentId], "TOPLEFT", -Theme.GetColSpacing() / 2, 0) +end + +function private.ResizerOnMouseUp(button, mouseButton) + if mouseButton ~= "LeftButton" then + return + end + ScriptWrapper.Clear(button, "OnUpdate") + local self = private.rowFrameLookup[button:GetParent()] + local id = Table.GetDistinctKey(self._buttons, button) + local parentId = strmatch(id, "^_resizer_(.+)$") + local parentButton = self._buttons[parentId] + parentButton:StopMovingOrSizing() + self:_LayoutHeaderRow() + self._scrollingTable:_SetColWidth(parentId, Math.Round(parentButton:GetWidth()), true) + self._icons[id]:SetPoint("TOPLEFT", button) +end + +function private.ResizerOnClick(button, mouseButton) + if mouseButton ~= "RightButton" then + return + end + local self = private.rowFrameLookup[button:GetParent()] + local id = Table.GetDistinctKey(self._buttons, button) + local parentId = strmatch(id, "^_resizer_(.+)$") + self._scrollingTable:_ResetColWidth(parentId) +end + +function private.ResizerOnUpdate(button) + local self = private.rowFrameLookup[button:GetParent()] + local id = Table.GetDistinctKey(self._buttons, button) + local parentId = strmatch(id, "^_resizer_(.+)$") + local parentButton = self._buttons[parentId] + self._scrollingTable:_SetColWidth(parentId, Math.Round(parentButton:GetWidth())) +end + +function private.ExpanderOnLeave(self) + self._frame:GetScript("OnEnter")(self._frame) +end + +function private.ExpanderOnClick(self, mouseButton, ...) + if mouseButton ~= "LeftButton" then + return + end + self._frame:GetScript("OnDoubleClick")(self._frame, mouseButton, ...) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.LayoutTempInsertSpacing(spacing) + assert(type(spacing) == "number") + local length = #private.layoutTemp + if type(private.layoutTemp[length]) == "number" then + -- add to the previous spacing + private.layoutTemp[length] = private.layoutTemp[length] + spacing + else + -- insert a new entry + private.layoutTemp[length + 1] = spacing + end +end + +function private.LayoutTempInsertElementWithSpacing(element, spacing, index) + assert(type(element) == "table" and type(spacing) == "number") + if index then + tinsert(private.layoutTemp, index, element) + tinsert(private.layoutTemp, index + 1, spacing) + else + local length = #private.layoutTemp + private.layoutTemp[length + 1] = element + private.layoutTemp[length + 2] = spacing + end +end diff --git a/Core/UI/VendoringUI/Buy.lua b/Core/UI/VendoringUI/Buy.lua new file mode 100644 index 0000000..8b3347d --- /dev/null +++ b/Core/UI/VendoringUI/Buy.lua @@ -0,0 +1,526 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Buy = TSM.UI.VendoringUI:NewPackage("Buy") +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local String = TSM.Include("Util.String") +local Log = TSM.Include("Util.Log") +local TempTable = TSM.Include("Util.TempTable") +local Theme = TSM.Include("Util.Theme") +local ItemString = TSM.Include("Util.ItemString") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + query = nil, + filterText = "", +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Buy.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "vendoringUIContext", "buyScrollingTable") + TSM.UI.VendoringUI.RegisterTopLevelPage(L["Buy"], private.GetFrame) +end + +function Buy.UpdateCurrency(frame) + if not GetMerchantCurrencies() or frame:GetSelectedNavButton() ~= L["Buy"] then + return + end + frame:GetElement("content.buy.footer.altCost") + :SetText(private.GetCurrencyText()) + frame:GetElement("content.buy.footer") + :Draw() +end + + + +-- ============================================================================ +-- Buy UI +-- ============================================================================ + +function private.GetFrame() + TSM.UI.AnalyticsRecordPathChange("vendoring", "buy") + private.filterText = "" + if not private.query then + private.query = TSM.Vendoring.Buy.CreateMerchantQuery() + :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") + end + private.query:ResetFilters() + private.query:NotEqual("numAvailable", 0) + private.query:ResetOrderBy() + private.query:OrderBy("name", true) + + local altCost = not TSM.IsWowClassic() and GetMerchantCurrencies() + local frame = UIElements.New("Frame", "buy") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "filters") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Input", "searchInput") + :SetMargin(0, 8, 0, 0) + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert() + :SetHintText(L["Search Vendor"]) + :SetScript("OnValueChanged", private.InputOnValueChanged) + ) + :AddChild(UIElements.New("Button", "filterBtn") + :SetWidth("AUTO") + :SetMargin(0, 4, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(FILTERS) + -- TODO + -- :SetScript("OnClick", private.FilterButtonOnClick) + ) + :AddChild(UIElements.New("Button", "filterBtnIcon") + :SetBackgroundAndSize("iconPack.14x14/Filter") + -- TODO + -- :SetScript("OnClick", private.FilterButtonOnClick) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetSettingsContext(private.settings, "buyScrollingTable") + :GetScrollingTableInfo() + :NewColumn("qty") + :SetTitle(L["Qty"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("stackSize") + :SetSortInfo("stackSize") + :Commit() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo(nil, private.GetItemText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :SetTooltipLinkingDisabled(true) + :DisableHiding() + :Commit() + :NewColumn("ilvl") + :SetTitle(L["ilvl"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("itemString", ItemInfo.GetItemLevel) + :Commit() + :NewColumn("cost") + :SetTitle(L["Cost"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo(nil, private.GetCostText) + :SetTooltipInfo("firstCostItemString", private.GetCostTooltip) + :SetSortInfo("price") + :SetTooltipLinkingDisabled(true) + :Commit() + :SetCursor("BUY_CURSOR") + :Commit() + :SetQuery(private.query) + :SetScript("OnRowClick", private.RowOnClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "gold") + :SetLayout("HORIZONTAL") + :SetWidth(166) + :SetMargin(0, 8, 0, 0) + :SetPadding(4) + :AddChild(UIElements.New("PlayerGoldText", "text")) + ) + :AddChild(UIElements.New("Texture", "line") + :SetSize(2, 22) + :SetMargin(0, 8, 0, 0) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Button", "altCost") + :SetWidth("AUTO") + :SetMargin(0, 16, 0, 0) + :SetFont("TABLE_TABLE1") + :SetText(private.GetCurrencyText()) + :SetTooltip(altCost and "currency:"..altCost or nil) + ) + :AddChild(UIElements.New("ActionButton", "repairBtn") + :SetDisabled(not TSM.Vendoring.Buy.NeedsRepair()) + :SetText(L["Repair"]) + :SetModifierText(L["Repair from Guild Bank"], "ALT") + :SetTooltip(private.GetRepairTooltip) + :SetScript("OnClick", private.RepairOnClick) + ) + ) + if not altCost then + frame:GetElement("footer.line") + :Hide() + frame:GetElement("footer.altCost") + :Hide() + end + return frame +end + +function private.GetItemText(row) + local itemString, numAvailable = row:GetFields("itemString", "numAvailable") + local itemName = TSM.UI.GetColoredItemName(itemString) or "?" + if numAvailable == -1 then + return itemName + elseif numAvailable > 0 then + return itemName..Theme.GetFeedbackColor("RED"):ColorText(" ("..numAvailable..")") + else + error("Invalid numAvailable: "..numAvailable) + end +end + +function private.GetCostText(row) + local index, costItemsText, price, stackSize = row:GetFields("index", "costItemsText", "price", "stackSize") + local color = TSM.Vendoring.Buy.GetMaxCanAfford(index) < stackSize and Theme.GetFeedbackColor("RED"):GetTextColorPrefix() + if costItemsText == "" then + -- just a price + return Money.ToString(price, color) + elseif price == 0 then + -- just an extended cost string + return costItemsText + else + -- both + return Money.ToString(price, color).." "..costItemsText + end +end + +function private.GetCostTooltip(itemString) + return itemString ~= "" and itemString or nil +end + +function private.GetRepairTooltip() + local tooltipLines = TempTable.Acquire() + local repairAllCost, canRepair = GetRepairAllCost() + if canRepair and repairAllCost > 0 then + tinsert(tooltipLines, REPAIR_ALL_ITEMS) + if IsAltKeyDown() then + local amount = GetGuildBankWithdrawMoney() + local guildBankMoney = GetGuildBankMoney() + if amount == -1 then + amount = guildBankMoney + else + amount = min(amount, guildBankMoney) + end + tinsert(tooltipLines, GUILDBANK_REPAIR) + tinsert(tooltipLines, Money.ToString(amount)) + if repairAllCost > amount then + local personalAmount = repairAllCost - amount + local personalMoney = GetMoney() + if personalMoney >= personalAmount then + tinsert(tooltipLines, GUILDBANK_REPAIR_PERSONAL) + tinsert(tooltipLines, Money.ToString(personalAmount)) + else + tinsert(tooltipLines, Theme.GetFeedbackColor("RED"):ColorText(GUILDBANK_REPAIR_INSUFFICIENT_FUNDS)) + end + end + else + tinsert(tooltipLines, Money.ToString(repairAllCost)) + local personalMoney = GetMoney() + if repairAllCost > personalMoney then + tinsert(tooltipLines, Theme.GetFeedbackColor("RED"):ColorText(GUILDBANK_REPAIR_INSUFFICIENT_FUNDS)) + end + tinsert(tooltipLines, L["Hold ALT to repair from the guild bank."]) + end + end + return strjoin("\n", TempTable.UnpackAndRelease(tooltipLines)) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.InputOnValueChanged(input) + local text = input:GetValue() + if text == private.filterText then + return + end + private.filterText = text + + private.query:ResetFilters() + private.query:NotEqual("numAvailable", 0) + if text ~= "" then + private.query:Matches("name", String.Escape(text)) + end + input:GetElement("__parent.__parent.items"):UpdateData(true) +end + +function private.RowOnClick(scrollingTable, row, mouseButton) + if IsShiftKeyDown() then + local itemString = row:GetField("itemString") + local dialogFrame = UIElements.New("Frame", "frame") + :SetLayout("VERTICAL") + :SetSize(328, 214) + :SetPadding(12) + :AddAnchor("CENTER") + :SetContext(row) + :SetMouseEnabled(true) + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, -4, 10) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(20) + ) + :AddChild(UIElements.New("Text", "title") + :SetJustifyH("CENTER") + :SetFont("BODY_BODY1_BOLD") + :SetText(L["Purchase Item"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.PurchaseCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "item") + :SetLayout("HORIZONTAL") + :SetPadding(6) + :SetMargin(0, 0, 0, 16) + :SetBackgroundColor("PRIMARY_BG_ALT", true) + :AddChild(UIElements.New("Button", "icon") + :SetSize(36, 36) + :SetMargin(0, 8, 0, 0) + :SetBackground(ItemInfo.GetTexture(itemString)) + :SetTooltip(itemString) + ) + :AddChild(UIElements.New("Text", "name") + :SetHeight(36) + :SetFont("ITEM_BODY1") + :SetText(TSM.UI.GetColoredItemName(itemString)) + ) + ) + :AddChild(UIElements.New("Frame", "qty") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Text", "text") + :SetWidth("AUTO") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Quantity"]..":") + ) + :AddChild(UIElements.New("Input", "input") + :SetWidth(156) + :SetMargin(12, 8, 0, 0) + :SetBackgroundColor("PRIMARY_BG_ALT") + :SetJustifyH("CENTER") + :SetSubAddEnabled(true) + :SetValidateFunc("NUMBER", "1:99999") + :SetValue(1) + :SetScript("OnValidationChanged", private.InputQtyOnValidationChanged) + :SetScript("OnValueChanged", private.InputQtyOnValueChanged) + :SetScript("OnEnterPressed", private.InputQtyOnEnterPressed) + ) + :AddChild(UIElements.New("ActionButton", "max") + :SetText(L["Max"]) + :SetScript("OnClick", private.MaxBtnOnClick) + ) + ) + :AddChild(UIElements.New("Frame", "cost") + :SetLayout("HORIZONTAL") + :SetHeight(20) + :SetMargin(0, 0, 0, 16) + :AddChild(UIElements.New("Spacer", "spacer")) + :AddChild(UIElements.New("Text", "label") + :SetWidth("AUTO") + :SetMargin(0, 8, 0, 0) + :SetJustifyH("RIGHT") + :SetFont("BODY_BODY2_MEDIUM") + :SetText(L["Current Price"]..":") + ) + :AddChild(UIElements.New("Button", "text") + :SetWidth("AUTO") + :SetJustifyH("RIGHT") + :SetFont("TABLE_TABLE1") + :SetText(private.GetAltCostText(row, 1)) + :SetTooltip(private.GetAltCostTooltip(row)) + ) + ) + :AddChild(UIElements.New("ActionButton", "purchaseBtn") + :SetHeight(24) + :SetText(L["Purchase"]) + :SetDisabled(TSM.Vendoring.Buy.GetMaxCanAfford(row:GetField("index")) < 1) + :SetScript("OnClick", private.PurchaseBtnOnClick) + ) + scrollingTable:GetBaseElement():ShowDialogFrame(dialogFrame) + dialogFrame:GetElement("qty.input"):SetFocused(true) + elseif mouseButton == "RightButton" then + TSM.Vendoring.Buy.BuyItemIndex(row:GetFields("index", "stackSize")) + end +end + +function private.PurchaseCloseBtnOnClick(button) + button:GetBaseElement():HideDialog() +end + +function private.InputQtyOnValidationChanged(input) + local row = input:GetElement("__parent.__parent"):GetContext() + if input:IsValid() and tonumber(input:GetValue()) <= TSM.Vendoring.Buy.GetMaxCanAfford(row:GetField("index")) then + input:GetElement("__parent.__parent.purchaseBtn") + :SetDisabled(false) + :Draw() + else + input:GetElement("__parent.__parent.purchaseBtn") + :SetDisabled(true) + :Draw() + end +end + +function private.InputQtyOnValueChanged(input) + local row = input:GetElement("__parent.__parent"):GetContext() + local value = tonumber(input:GetValue()) + input:GetElement("__parent.__parent.cost.text") + :SetText(private.GetAltCostText(row, value)) + input:GetElement("__parent.__parent.cost") + :Draw() + if input:IsValid() and value <= TSM.Vendoring.Buy.GetMaxCanAfford(row:GetField("index")) then + input:GetElement("__parent.__parent.purchaseBtn") + :SetDisabled(false) + :Draw() + else + input:GetElement("__parent.__parent.purchaseBtn") + :SetDisabled(true) + :Draw() + end +end + +function private.InputQtyOnEnterPressed(input) + input:GetElement("__parent.__parent.purchaseBtn"):Click() +end + +function private.MaxBtnOnClick(button) + local row = button:GetElement("__parent.__parent"):GetContext() + local value = max(1, min(TSM.Vendoring.Buy.GetMaxCanAfford(row:GetField("index")), ItemInfo.GetMaxStack(row:GetField("itemString")) * CalculateTotalNumberOfFreeBagSlots())) + button:GetElement("__parent.input") + :SetValue(value) + :Draw() + private.InputQtyOnValidationChanged(button:GetElement("__parent.input")) + button:GetElement("__parent.__parent.cost.text") + :SetText(private.GetAltCostText(row, tonumber(value))) + button:GetElement("__parent.__parent.cost") + :Draw() +end + +function private.GetAltCostText(row, quantity) + local index, costItemsText, price, stackSize = row:GetFields("index", "costItemsText", "price", "stackSize") + local color = TSM.Vendoring.Buy.GetMaxCanAfford(index) < quantity and Theme.GetFeedbackColor("RED"):GetTextColorPrefix() + price = price * quantity / stackSize + if costItemsText == "" then + -- just a price + return Money.ToString(price, color) + elseif price == 0 then + -- just an extended cost string + return private.GetItemAltCostText(row, quantity) + else + -- both + return Money.ToString(price, color).." "..private.GetItemAltCostText(row, quantity) + end +end + +function private.GetAltCostTooltip(row) + return row:GetField("firstCostItemString") or nil +end + +function private.GetItemAltCostText(row, quantity) + local index = row:GetField("index") + local _, _, _, stackSize, _, _, _, extendedCost = GetMerchantItemInfo(index) + local numAltCurrencies = GetMerchantItemCostInfo(index) + -- bug with big keech vendor returning extendedCost = true for gold only items + if numAltCurrencies == 0 then + extendedCost = false + end + local costItemsText = "" + if extendedCost then + assert(numAltCurrencies > 0) + local costItems = TempTable.Acquire() + for j = 1, numAltCurrencies do + local _, costNum, costItemLink = GetMerchantItemCostItem(index, j) + costNum = costNum * quantity / stackSize + local costItemString = ItemString.Get(costItemLink) + local texture = nil + if costItemString then + texture = ItemInfo.GetTexture(costItemString) + elseif not TSM.IsWowClassic() and strmatch(costItemLink, "currency:") then + if TSM.IsShadowlands() then + texture = C_CurrencyInfo.GetCurrencyInfoFromLink(costItemLink).iconFileID + else + _, _, texture = GetCurrencyInfo(costItemLink) + end + else + error(format("Unknown item cost (%d, %d, %s)", index, costNum, tostring(costItemLink))) + end + if TSM.Vendoring.Buy.GetMaxCanAfford(index) < quantity then + costNum = Theme.GetFeedbackColor("RED"):ColorText(costNum) + end + tinsert(costItems, costNum.." |T"..(texture or "")..":12|t") + end + costItemsText = table.concat(costItems, " ") + TempTable.Release(costItems) + end + return costItemsText +end + +function private.PurchaseBtnOnClick(button) + local row = button:GetElement("__parent"):GetContext() + TSM.Vendoring.Buy.BuyItemIndex(row:GetField("index"), button:GetElement("__parent.qty.input"):GetValue()) + button:GetBaseElement():HideDialog() +end + +function private.GetCurrencyText() + local name, amount, texturePath = "", nil, nil + if TSM.IsShadowlands() then + local firstCurrency = GetMerchantCurrencies() + if firstCurrency then + local info = C_CurrencyInfo.GetCurrencyInfo(firstCurrency) + name = info.name + amount = info.quantity + texturePath = info.iconFileID + end + elseif not TSM.IsWowClassic() then + name, amount, texturePath = GetCurrencyInfo(GetMerchantCurrencies() or "") + end + local text = "" + if name ~= "" and amount and texturePath then + text = amount.." |T"..texturePath..":12|t" + end + return text +end + +function private.RepairOnClick(button) + PlaySound(SOUNDKIT.ITEM_REPAIR) + button:SetDisabled(true) + + if IsAltKeyDown() then + if not TSM.Vendoring.Buy.CanGuildRepair() then + Log.PrintfUser(L["Cannot repair from the guild bank!"]) + return + end + TSM.Vendoring.Buy.DoGuildRepair() + else + TSM.Vendoring.Buy.DoRepair() + end +end diff --git a/Core/UI/VendoringUI/Buyback.lua b/Core/UI/VendoringUI/Buyback.lua new file mode 100644 index 0000000..c261d2a --- /dev/null +++ b/Core/UI/VendoringUI/Buyback.lua @@ -0,0 +1,123 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Buyback = TSM.UI.VendoringUI:NewPackage("Buyback") +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + query = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Buyback.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "vendoringUIContext", "buybackScrollingTable") + TSM.UI.VendoringUI.RegisterTopLevelPage(BUYBACK, private.GetFrame) +end + + + +-- ============================================================================ +-- Buy UI +-- ============================================================================ + +function private.GetFrame() + TSM.UI.AnalyticsRecordPathChange("vendoring", "buyback") + private.query = private.query or TSM.Vendoring.Buyback.CreateQuery() + private.query:ResetOrderBy() + private.query:OrderBy("name", true) + + return UIElements.New("Frame", "buy") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetMargin(0, 0, -2, 0) + :SetSettingsContext(private.settings, "buybackScrollingTable") + :GetScrollingTableInfo() + :NewColumn("qty") + :SetTitle(L["Qty"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("quantity") + :SetSortInfo("quantity") + :Commit() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", private.GetItemText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :SetTooltipLinkingDisabled(true) + :DisableHiding() + :Commit() + :NewColumn("cost") + :SetTitle(L["Cost"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("price", Money.ToString) + :SetSortInfo("price") + :Commit() + :SetCursor("BUY_CURSOR") + :Commit() + :SetQuery(private.query) + :SetScript("OnRowClick", private.RowOnClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "gold") + :SetLayout("HORIZONTAL") + :SetWidth(166) + :SetMargin(0, 8, 0, 0) + :SetPadding(4) + :AddChild(UIElements.New("PlayerGoldText", "text")) + ) + :AddChild(UIElements.New("ActionButton", "buybackAllBtn") + :SetText(L["Buyback All"]) + :SetScript("OnClick", private.BuybackAllBtnOnClick) + ) + ) +end + +function private.GetItemText(itemString) + return TSM.UI.GetColoredItemName(itemString) or "?" +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.RowOnClick(_, row, mouseButton) + if mouseButton == "RightButton" then + TSM.Vendoring.Buyback.BuybackItem(row:GetField("index")) + end +end + +function private.BuybackAllBtnOnClick(button) + for _, row in private.query:Iterator() do + TSM.Vendoring.Buyback.BuybackItem(row:GetField("index")) + end +end diff --git a/Core/UI/VendoringUI/Core.lua b/Core/UI/VendoringUI/Core.lua new file mode 100644 index 0000000..9f7678f --- /dev/null +++ b/Core/UI/VendoringUI/Core.lua @@ -0,0 +1,236 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local VendoringUI = TSM.UI:NewPackage("VendoringUI") +local L = TSM.Include("Locale").GetTable() +local Delay = TSM.Include("Util.Delay") +local FSM = TSM.Include("Util.FSM") +local Event = TSM.Include("Util.Event") +local ScriptWrapper = TSM.Include("Util.ScriptWrapper") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + topLevelPages = {}, + fsm = nil, + defaultUISwitchBtn = nil, + isVisible = false, +} +local MIN_FRAME_SIZE = { width = 560, height = 500 } + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function VendoringUI.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "vendoringUIContext", "showDefault") + :AddKey("global", "vendoringUIContext", "frame") + private.FSMCreate() +end + +function VendoringUI.OnDisable() + -- hide the frame + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function VendoringUI.RegisterTopLevelPage(name, callback) + tinsert(private.topLevelPages, { name = name, callback = callback }) +end + +function VendoringUI.IsVisible() + return private.isVisible +end + + + +-- ============================================================================ +-- Main Frame +-- ============================================================================ + +function private.CreateMainFrame() + TSM.UI.AnalyticsRecordPathChange("vendoring") + local frame = UIElements.New("LargeApplicationFrame", "base") + :SetParent(UIParent) + :SetSettingsContext(private.settings, "frame") + :SetMinResize(MIN_FRAME_SIZE.width, MIN_FRAME_SIZE.height) + :SetStrata("HIGH") + :AddSwitchButton(private.SwitchBtnOnClick) + :SetScript("OnHide", private.BaseFrameOnHide) + + for _, info in ipairs(private.topLevelPages) do + frame:AddNavButton(info.name, info.callback) + end + + return frame +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.BaseFrameOnHide() + TSM.UI.AnalyticsRecordClose("vendoring") + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.SwitchBtnOnClick(button) + private.settings.showDefault = button ~= private.defaultUISwitchBtn + private.fsm:ProcessEvent("EV_SWITCH_BTN_CLICKED") +end + +function private.SwitchButtonOnEnter(button) + button:SetTextColor("TEXT") + :Draw() +end + +function private.SwitchButtonOnLeave(button) + button:SetTextColor("TEXT_ALT") + :Draw() +end + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local function MerchantShowDelayed() + private.fsm:ProcessEvent("EV_MERCHANT_SHOW") + end + local function CurrencyUpdate() + private.fsm:ProcessEvent("EV_CURRENCY_UPDATE") + end + Event.Register("MERCHANT_SHOW", function() + Delay.AfterFrame("MERCHANT_SHOW_DELAYED", 0, MerchantShowDelayed) + end) + Event.Register("MERCHANT_CLOSED", function() + private.fsm:ProcessEvent("EV_MERCHANT_CLOSED") + end) + MerchantFrame:UnregisterEvent("MERCHANT_SHOW") + + local fsmContext = { + frame = nil, + defaultPoint = nil, + } + local function DefaultFrameOnHide() + private.fsm:ProcessEvent("EV_FRAME_HIDE") + end + private.fsm = FSM.New("MERCHANT_UI") + :AddState(FSM.NewState("ST_CLOSED") + :AddTransition("ST_DEFAULT_OPEN") + :AddTransition("ST_FRAME_OPEN") + :AddEvent("EV_FRAME_TOGGLE", function(context) + assert(not private.settings.showDefault) + return "ST_FRAME_OPEN" + end) + :AddEvent("EV_MERCHANT_SHOW", function(context) + if private.settings.showDefault then + return "ST_DEFAULT_OPEN" + else + return "ST_FRAME_OPEN" + end + end) + ) + :AddState(FSM.NewState("ST_DEFAULT_OPEN") + :SetOnEnter(function(context, isIgnored) + MerchantFrame_OnEvent(MerchantFrame, "MERCHANT_SHOW") + if not private.defaultUISwitchBtn then + private.defaultUISwitchBtn = UIElements.New("ActionButton", "switchBtn") + :SetSize(60, TSM.IsWowClassic() and 16 or 15) + :AddAnchor("TOPRIGHT", TSM.IsWowClassic() and -26 or -27, TSM.IsWowClassic() and -3 or -4) + :SetFont("BODY_BODY3_MEDIUM") + :DisableClickCooldown() + :SetText(L["TSM4"]) + :SetScript("OnClick", private.SwitchBtnOnClick) + :SetScript("OnEnter", private.SwitchButtonOnEnter) + :SetScript("OnLeave", private.SwitchButtonOnLeave) + private.defaultUISwitchBtn:_GetBaseFrame():SetParent(MerchantFrame) + end + if isIgnored then + private.defaultUISwitchBtn:Hide() + else + private.defaultUISwitchBtn:Show() + private.defaultUISwitchBtn:Draw() + end + ScriptWrapper.Set(MerchantFrame, "OnHide", DefaultFrameOnHide) + end) + :SetOnExit(function(context) + ScriptWrapper.Clear(MerchantFrame, "OnHide") + HideUIPanel(MerchantFrame) + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_FRAME_OPEN") + :AddEvent("EV_FRAME_HIDE", function(context) + CloseMerchant() + return "ST_CLOSED" + end) + :AddEvent("EV_MERCHANT_SHOW", MerchantFrame_Update) + :AddEventTransition("EV_MERCHANT_CLOSED", "ST_CLOSED") + :AddEvent("EV_SWITCH_BTN_CLICKED", function() + return "ST_FRAME_OPEN" + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPEN") + :SetOnEnter(function(context) + assert(not context.frame) + MerchantFrame_OnEvent(MerchantFrame, "MERCHANT_SHOW") + if not context.defaultPoint then + context.defaultPoint = { MerchantFrame:GetPoint(1) } + end + MerchantFrame:SetClampedToScreen(false) + MerchantFrame:ClearAllPoints() + MerchantFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 100000, 100000) + OpenAllBags() + context.frame = private.CreateMainFrame() + context.frame:Show() + context.frame:GetElement("titleFrame.switchBtn"):Show() + context.frame:Draw() + if not TSM.IsWowClassic() then + Event.Register("CURRENCY_DISPLAY_UPDATE", CurrencyUpdate) + end + private.isVisible = true + end) + :SetOnExit(function(context) + CloseAllBags() + MerchantFrame:ClearAllPoints() + local point, region, relativePoint, x, y = unpack(context.defaultPoint) + if point and region and relativePoint and x and y then + MerchantFrame:SetPoint(point, region, relativePoint, x, y) + else + MerchantFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 16, -116) + end + if not TSM.IsWowClassic() then + Event.Unregister("CURRENCY_DISPLAY_UPDATE", CurrencyUpdate) + end + private.isVisible = false + context.frame:Hide() + context.frame:Release() + context.frame = nil + end) + :AddTransition("ST_CLOSED") + :AddTransition("ST_DEFAULT_OPEN") + :AddEvent("EV_CURRENCY_UPDATE", function(context) + TSM.UI.VendoringUI.Buy.UpdateCurrency(context.frame) + end) + :AddEvent("EV_FRAME_HIDE", function(context) + CloseMerchant() + return "ST_CLOSED" + end) + :AddEvent("EV_MERCHANT_CLOSED", function(context) + return "ST_CLOSED" + end) + :AddEvent("EV_SWITCH_BTN_CLICKED", function() + return "ST_DEFAULT_OPEN" + end) + ) + :Init("ST_CLOSED", fsmContext) +end diff --git a/Core/UI/VendoringUI/Groups.lua b/Core/UI/VendoringUI/Groups.lua new file mode 100644 index 0000000..26174ba --- /dev/null +++ b/Core/UI/VendoringUI/Groups.lua @@ -0,0 +1,263 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Groups = TSM.UI.VendoringUI:NewPackage("Groups") +local L = TSM.Include("Locale").GetTable() +local TempTable = TSM.Include("Util.TempTable") +local FSM = TSM.Include("Util.FSM") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + groupSearch = "", + fsm = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Groups.OnInitialize() + private.settings = Settings.NewView() + :AddKey("char", "vendoringUIContext", "groupTree") + private.FSMCreate() + TSM.UI.VendoringUI.RegisterTopLevelPage(L["Groups"], private.GetFrame) +end + + + +-- ============================================================================ +-- Groups UI +-- ============================================================================ + +function private.GetFrame() + TSM.UI.AnalyticsRecordPathChange("vendoring", "groups") + return UIElements.New("Frame", "buy") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "container") + :SetLayout("VERTICAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "groupsText") + :SetHeight(18) + :SetMargin(0, 0, 0, 8) + :SetFont("BODY_BODY3") + :SetFormattedText(L["%d |4Group:Groups; Selected (%d |4Item:Items;)"], 0, 0) + ) + :AddChild(UIElements.New("Frame", "search") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "input") + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert(true) + :SetValue(private.groupSearch) + :SetHintText(L["Search Groups"]) + :SetScript("OnValueChanged", private.GroupSearchOnValueChanged) + ) + :AddChild(UIElements.New("Button", "expandAllBtn") + :SetSize(24, 24) + :SetMargin(8, 4, 0, 0) + :SetBackground("iconPack.18x18/Expand All") + :SetScript("OnClick", private.ExpandAllGroupsOnClick) + :SetTooltip(L["Expand / Collapse All Groups"]) + ) + :AddChild(UIElements.New("Button", "selectAllBtn") + :SetSize(24, 24) + :SetBackground("iconPack.18x18/Select All") + :SetScript("OnClick", private.SelectAllGroupsOnClick) + :SetTooltip(L["Select / Deselect All Groups"]) + ) + ) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("ApplicationGroupTree", "groupTree") + :SetSettingsContext(private.settings, "groupTree") + :SetQuery(TSM.Groups.CreateQuery(), "Vendoring") + :SetSearchString(private.groupSearch) + :SetScript("OnGroupSelectionChanged", private.GroupTreeOnGroupSelectionChanged) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Frame", "gold") + :SetLayout("HORIZONTAL") + :SetWidth(166) + :SetMargin(0, 8, 0, 0) + :SetPadding(4) + :AddChild(UIElements.New("PlayerGoldText", "text")) + ) + :AddChild(UIElements.New("ActionButton", "buyBtn") + :SetMargin(0, 8, 0, 0) + :SetText(L["Buy Groups"]) + :SetScript("OnClick", private.BuyBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "sellBtn") + :SetText(L["Sell Groups"]) + :SetScript("OnClick", private.SellBtnOnClick) + ) + ) + :SetScript("OnUpdate", private.FrameOnUpdate) + :SetScript("OnHide", private.FrameOnHide) +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.FrameOnUpdate(frame) + frame:SetScript("OnUpdate", nil) + private.GroupTreeOnGroupSelectionChanged(frame:GetElement("groupTree")) + private.fsm:ProcessEvent("EV_FRAME_SHOW", frame) +end + +function private.FrameOnHide(frame) + private.fsm:ProcessEvent("EV_FRAME_HIDE") +end + +function private.GroupSearchOnValueChanged(input) + local text = strlower(input:GetValue()) + if text == private.groupSearch then + return + end + private.groupSearch = text + + input:GetElement("__parent.__parent.__parent.groupTree") + :SetSearchString(private.groupSearch) + :Draw() +end + +function private.ExpandAllGroupsOnClick(button) + button:GetElement("__parent.__parent.__parent.groupTree") + :ToggleExpandAll() +end + +function private.SelectAllGroupsOnClick(button) + button:GetElement("__parent.__parent.__parent.groupTree") + :ToggleSelectAll() +end + +function private.GroupTreeOnGroupSelectionChanged(groupTree) + local footerFrame = groupTree:GetElement("__parent.footer") + footerFrame:GetElement("sellBtn") + :SetDisabled(groupTree:IsSelectionCleared()) + footerFrame:GetElement("buyBtn") + :SetDisabled(groupTree:IsSelectionCleared()) + footerFrame:Draw() + + local numGroups, numItems = 0, 0 + for _, groupPath in groupTree:SelectedGroupsIterator() do + numGroups = numGroups + 1 + if groupPath == TSM.CONST.ROOT_GROUP_PATH then + -- TODO + else + for _ in TSM.Groups.ItemIterator(groupPath) do + numItems = numItems + 1 + end + end + end + groupTree:GetElement("__parent.container.groupsText") + :SetFormattedText(L["%d |4Group:Groups; Selected (%d |4Item:Items;)"], numGroups, numItems) + :Draw() +end + +function private.BuyBtnOnClick(button) + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", "BUY") +end + +function private.SellBtnOnClick(button) + private.fsm:ProcessEvent("EV_BUTTON_CLICKED", "SELL") +end + + + +-- ============================================================================ +-- FSM +-- ============================================================================ + +function private.FSMCreate() + local fsmContext = { + frame = nil, + currentOperation = nil, + } + local function UpdateFrame(context) + local footer = context.frame:GetElement("footer") + footer:GetElement("buyBtn") + :SetPressed(context.currentOperation == "BUY") + :SetDisabled(context.currentOperation) + footer:GetElement("sellBtn") + :SetPressed(context.currentOperation == "SELL") + :SetDisabled(context.currentOperation) + footer:Draw() + end + private.fsm = FSM.New("VENDORING_GROUPS") + :AddState(FSM.NewState("ST_FRAME_CLOSED") + :SetOnEnter(function(context) + context.frame = nil + assert(not context.currentOperation) + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_FRAME_CLOSED") + :AddEvent("EV_FRAME_SHOW", function(context, frame) + context.frame = frame + return "ST_FRAME_OPEN" + end) + ) + :AddState(FSM.NewState("ST_FRAME_OPEN") + :SetOnEnter(function(context) + UpdateFrame(context) + end) + :AddTransition("ST_BUSY") + :AddTransition("ST_FRAME_CLOSED") + :AddEventTransition("EV_BUTTON_CLICKED", "ST_BUSY") + ) + :AddState(FSM.NewState("ST_BUSY") + :SetOnEnter(function(context, operation) + assert(not context.currentOperation) + context.currentOperation = operation + local groups = TempTable.Acquire() + for _, groupPath in context.frame:GetElement("groupTree"):SelectedGroupsIterator() do + tinsert(groups, groupPath) + end + if operation == "BUY" then + TSM.Vendoring.Groups.BuyGroups(groups, private.FSMSellCallback) + elseif operation == "SELL" then + TSM.Vendoring.Groups.SellGroups(groups, private.FSMSellCallback) + else + error("Unexpected operation: "..tostring(operation)) + end + TempTable.Release(groups) + UpdateFrame(context) + end) + :SetOnExit(function(context) + context.currentOperation = nil + TSM.Vendoring.Groups.StopBuySell() + end) + :AddTransition("ST_FRAME_OPEN") + :AddTransition("ST_FRAME_CLOSED") + :AddEventTransition("EV_SELL_DONE", "ST_FRAME_OPEN") + ) + :AddDefaultEventTransition("EV_FRAME_HIDE", "ST_FRAME_CLOSED") + :Init("ST_FRAME_CLOSED", fsmContext) +end + +function private.FSMSellCallback() + private.fsm:ProcessEvent("EV_SELL_DONE") +end diff --git a/Core/UI/VendoringUI/Sell.lua b/Core/UI/VendoringUI/Sell.lua new file mode 100644 index 0000000..62020e7 --- /dev/null +++ b/Core/UI/VendoringUI/Sell.lua @@ -0,0 +1,215 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +local _, TSM = ... +local Sell = TSM.UI.VendoringUI:NewPackage("Sell") +local L = TSM.Include("Locale").GetTable() +local Money = TSM.Include("Util.Money") +local TempTable = TSM.Include("Util.TempTable") +local String = TSM.Include("Util.String") +local Theme = TSM.Include("Util.Theme") +local ItemInfo = TSM.Include("Service.ItemInfo") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + filterText = "", + query = nil, +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function Sell.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "vendoringUIContext", "sellScrollingTable") + TSM.UI.VendoringUI.RegisterTopLevelPage(L["Sell"], private.GetFrame) +end + + + +-- ============================================================================ +-- Sell UI +-- ============================================================================ + +function private.GetFrame() + TSM.UI.AnalyticsRecordPathChange("vendoring", "sell") + private.filterText = "" + if private.query then + TSM.Vendoring.Sell.ResetBagsQuery(private.query) + else + private.query = TSM.Vendoring.Sell.CreateBagsQuery() + end + + return UIElements.New("Frame", "sell") + :SetLayout("VERTICAL") + :AddChild(UIElements.New("Frame", "header") + :SetLayout("VERTICAL") + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("Text", "ignoreText") + :SetHeight(36) + :SetMargin(0, 0, 0, 8) + :SetFont("BODY_BODY3") + :SetText(format(L["%sLeft-Click|r to ignore an item for this session. Hold %sShift|r to ignore permanently. You can remove items from permanent ignore in the Vendoring settings."], Theme.GetColor("INDICATOR"):GetTextColorPrefix(), Theme.GetColor("INDICATOR"):GetTextColorPrefix())) + ) + :AddChild(UIElements.New("Frame", "filters") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :AddChild(UIElements.New("Input", "searchInput") + :SetIconTexture("iconPack.18x18/Search") + :SetClearButtonEnabled(true) + :AllowItemInsert() + :SetHintText(L["Search Bags"]) + :SetScript("OnValueChanged", private.InputOnValueChanged) + ) + :AddChild(UIElements.New("Button", "filterBtn") + :SetWidth("AUTO") + :SetMargin(8, 8, 0, 0) + :SetFont("BODY_BODY3_MEDIUM") + :SetText(FILTERS) + -- TODO + -- :SetScript("OnClick", private.FilterButtonOnClick) + ) + :AddChild(UIElements.New("Button", "filterBtnIcon") + :SetBackgroundAndSize("iconPack.14x14/Filter") + -- TODO + -- :SetScript("OnClick", private.FilterButtonOnClick) + ) + ) + ) + :AddChild(UIElements.New("QueryScrollingTable", "items") + :SetSettingsContext(private.settings, "sellScrollingTable") + :GetScrollingTableInfo() + :NewColumn("item") + :SetTitle(L["Item"]) + :SetIconSize(12) + :SetFont("ITEM_BODY3") + :SetJustifyH("LEFT") + :SetTextInfo("itemString", private.GetItemText) + :SetIconInfo("itemString", ItemInfo.GetTexture) + :SetTooltipInfo("itemString") + :SetSortInfo("name") + :SetTooltipLinkingDisabled(true) + :DisableHiding() + :Commit() + :NewColumn("vendorSell") + :SetTitle(L["Vendor Sell"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("vendorSell", private.GetVendorSellText) + :SetSortInfo("vendorSell") + :Commit() + :NewColumn("potential") + :SetTitle(L["Potential"]) + :SetFont("TABLE_TABLE1") + :SetJustifyH("RIGHT") + :SetTextInfo("potentialValue", Money.ToString) + :SetSortInfo("potentialValue") + :Commit() + :SetCursor("BUY_CURSOR") + :Commit() + :SetQuery(private.query) + :SetScript("OnRowClick", private.RowOnClick) + ) + :AddChild(UIElements.New("Texture", "line") + :SetHeight(2) + :SetTexture("ACTIVE_BG") + ) + :AddChild(UIElements.New("Frame", "footer") + :SetLayout("HORIZONTAL") + :SetHeight(40) + :SetPadding(8) + :SetBackgroundColor("PRIMARY_BG_ALT") + :AddChild(UIElements.New("ActionButton", "sellTrashBtn") + :SetWidth(128) + :SetMargin(0, 8, 0, 0) + :SetText(L["Sell Trash"]) + :SetScript("OnClick", private.SellTrashBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "sellBOEBtn") + :SetWidth(128) + :SetMargin(0, 8, 0, 0) + :SetText(L["Sell BoEs"]) + :SetScript("OnClick", private.SellBOEBtnOnClick) + ) + :AddChild(UIElements.New("ActionButton", "sellAllBtn") + :SetText(L["Sell All"]) + :SetScript("OnClick", private.SellAllBtnOnClick) + ) + ) +end + +function private.GetItemText(itemString) + return TSM.UI.GetColoredItemName(itemString) or "?" +end + +function private.GetVendorSellText(vendorSell) + return vendorSell > 0 and Money.ToString(vendorSell) or "" +end + + + +-- ============================================================================ +-- Local Script Handlers +-- ============================================================================ + +function private.InputOnValueChanged(input) + local text = input:GetValue() + if text == private.filterText then + return + end + private.filterText = text + + TSM.Vendoring.Sell.ResetBagsQuery(private.query) + if text ~= "" then + private.query:Matches("name", String.Escape(text)) + end + input:GetElement("__parent.__parent.__parent.items"):UpdateData(true) +end + +function private.RowOnClick(_, row, mouseButton) + local itemString = row:GetField("itemString") + if mouseButton == "RightButton" then + TSM.Vendoring.Sell.SellItem(itemString) + elseif IsShiftKeyDown() then + TSM.Vendoring.Sell.IgnoreItemPermanent(itemString) + else + TSM.Vendoring.Sell.IgnoreItemSession(itemString) + end +end + +function private.SellTrashBtnOnClick(button) + for _, row in private.query:Iterator() do + local itemString, quality = row:GetFields("itemString", "quality") + if quality == (TSM.IsShadowlands() and Enum.ItemQuality.Poor or LE_ITEM_QUALITY_POOR) then + TSM.Vendoring.Sell.SellItem(itemString) + end + end +end + +function private.SellBOEBtnOnClick(button) + -- checking if an item is disenchantable might cause our query to change since it depends on the ItemInfo DB, so cache the list of items first + local items = TempTable.Acquire() + for _, row in private.query:Iterator() do + tinsert(items, row:GetField("itemString")) + end + for _, itemString in ipairs(items) do + if ItemInfo.IsDisenchantable(itemString) then + TSM.Vendoring.Sell.SellItem(itemString) + end + end + TempTable.Release(items) +end + +function private.SellAllBtnOnClick(button) + for _, row in private.query:Iterator() do + TSM.Vendoring.Sell.SellItem(row:GetField("itemString")) + end +end diff --git a/Core/UI/WhatsNew.lua b/Core/UI/WhatsNew.lua new file mode 100644 index 0000000..a5be41d --- /dev/null +++ b/Core/UI/WhatsNew.lua @@ -0,0 +1,95 @@ +-- ------------------------------------------------------------------------------ -- +-- TradeSkillMaster -- +-- https://tradeskillmaster.com -- +-- All Rights Reserved - Detailed license information included with addon. -- +-- ------------------------------------------------------------------------------ -- + +--- "What's New" Dialog +-- @module WhatsNew + +local _, TSM = ... +local WhatsNew = TSM.UI:NewPackage("WhatsNew") +local L = TSM.Include("Locale").GetTable() +local Theme = TSM.Include("Util.Theme") +local Analytics = TSM.Include("Util.Analytics") +local Settings = TSM.Include("Service.Settings") +local UIElements = TSM.Include("UI.UIElements") +local private = { + settings = nil, + showTime = nil, +} +local WHATS_NEW_VERSION = 1 +local CONTENT_LINES = { + Theme.GetColor("INDICATOR"):ColorText(L["A brand new and improved user interface."]).." "..L["The entirety of the addon has been redesigned from the ground up. Highlights include: a more modern UI that maximizes on available space, new appearances that allow you to change the theme, updated Dashboard, more powerful tables and much, much more."], + Theme.GetColor("INDICATOR"):ColorText(L["New price sources."]).." "..L["We've added new price sources to give you even more flexibility in how you use TSM to manage your gold making. You can now reference NumInventory, SaleRate, and much more throughout the addon. SmartAvgBuy has also been moved from an option to its own separate price source."], + Theme.GetColor("INDICATOR"):ColorText(L["Improved Import / Export."]).." "..L["Now embedded within the Groups tab of the main TSM window with dedicated buttons to help with both importing and exporting."], + Theme.GetColor("INDICATOR"):ColorText(L["New Base Group search."]).." "..L["Trouble making groups? You can now search any item in the game from within the base group and easily add them to existing groups or simply create a new group for them."], + Theme.GetColor("INDICATOR"):ColorText(L["Per-Character group selections."]).." "..L["To make it easier to use TSM across different characters, the groups you have selected in various UIs will now be persistent on a per-character basis and selected by default."], +} + + + +-- ============================================================================ +-- Module Functions +-- ============================================================================ + +function WhatsNew.OnInitialize() + private.settings = Settings.NewView() + :AddKey("global", "internalData", "whatsNewVersion") +end + +function WhatsNew.GetDialog() + if private.settings.whatsNewVersion == WHATS_NEW_VERSION then + return + end + private.showTime = GetTime() + return UIElements.New("Frame") + :SetLayout("VERTICAL") + :SetSize(650, 390) + :SetPadding(12, 12, 0, 12) + :AddAnchor("CENTER") + :SetBackgroundColor("FRAME_BG", true) + :AddChild(UIElements.New("Frame", "header") + :SetLayout("HORIZONTAL") + :SetHeight(24) + :SetMargin(0, 0, 8, 16) + :AddChild(UIElements.New("Spacer", "spacer") + :SetWidth(20) + ) + :AddChild(UIElements.New("Text", "title") + :SetJustifyH("CENTER") + :SetFont("BODY_BODY1_BOLD") + :SetText(L["TSM 4.10: What's new"]) + ) + :AddChild(UIElements.New("Button", "closeBtn") + :SetMargin(0, -4, 0, 0) + :SetBackgroundAndSize("iconPack.24x24/Close/Default") + :SetScript("OnClick", private.DialogCloseBtnOnClick) + ) + ) + :AddChild(UIElements.New("ScrollFrame", "body") + :AddChild(UIElements.New("Text", "content1") + :SetHeight(400) + :SetFont("BODY_BODY2") + :SetText(table.concat(CONTENT_LINES, "\n\n")) + ) + ) + :AddChild(UIElements.New("Text", "footer1") + :SetHeight(20) + :SetMargin(0, 0, 12, 0) + :SetFont("BODY_BODY3") + :SetText(format(L["For more info, visit %s. For help, join us in Discord: %s."], Theme.GetColor("INDICATOR_ALT"):ColorText("blog.tradeskillmaster.com"), Theme.GetColor("INDICATOR_ALT"):ColorText("discord.gg/woweconomy"))) + ) +end + + + +-- ============================================================================ +-- Private Helper Functions +-- ============================================================================ + +function private.DialogCloseBtnOnClick(button) + private.settings.whatsNewVersion = WHATS_NEW_VERSION + button:GetBaseElement():HideDialog() + Analytics.Action("WHATS_NEW_TIME", floor((GetTime() - private.showTime) * 1000), WHATS_NEW_VERSION) +end diff --git a/External/AccurateTime/!AccurateTime.toc b/External/AccurateTime/!AccurateTime.toc new file mode 100644 index 0000000..691f548 --- /dev/null +++ b/External/AccurateTime/!AccurateTime.toc @@ -0,0 +1,8 @@ +## Interface: 50400 +## Title: AccurateTime +## Notes: Provides accurate, millisecond-level timing. +## Author: Sapu94 +## Version: @project-version@ + + +AccurateTime.lua \ No newline at end of file diff --git a/External/AccurateTime/AccurateTime.lua b/External/AccurateTime/AccurateTime.lua new file mode 100644 index 0000000..b80b2cf --- /dev/null +++ b/External/AccurateTime/AccurateTime.lua @@ -0,0 +1,142 @@ +--[[ + +This library is intended to fix the shortfalls of using debugprofilestop() for +getting accurate sub-second timing in addons. Specifically, this library aims +to prevent any conflicts that may arrise with multiple addons using +debugprofilestart and debugprofilestop. While perfect accuracy is not +guarenteed due to the potential for an addon to load before this library and +use the original debugprofilestart/debugprofilestop functions, this library +provides a best-effort means of correcting any issues if this is the case. +The best solution is for addons to NOT use debugprofilestart() and to NOT store +a local reference to debugprofilestop(), even if they aren't using this library +directly. + +------------------------------------------------------------------------------- + +AccurateTime is hereby placed in the Public Domain +See the wowace page for usage and documentation. +Author: Sapu94 (sapu94@gmail.com) +Website: http://www.wowace.com/addons/accuratetime/ +--]] + +local _G = _G +local AT_VERSION = 7 + + +-- Check if we're already loaded +-- If this is a newer version, remove the old hooks and we'll re-hook +if _G.AccurateTime then + if _G.AccurateTime.version > AT_VERSION then + -- newer (or same) version already loaded - abort + return + end + + -- undo hook so we can re-hook + debugprofilestart = _G.AccurateTime._debugprofilestart + debugprofilestop = _G.AccurateTime._debugprofilestop +end + + +-- setup global library reference +_G.AccurateTime = {} +AccurateTime = _G.AccurateTime +AccurateTime.version = AT_VERSION + +-- Store original functions. +-- debugprofilestart should never be called, but we'll store it just in case. +AccurateTime._debugprofilestop = debugprofilestop +AccurateTime._debugprofilestart = debugprofilestart +AccurateTime._currentDebugprofilestop = debugprofilestop + +-- other internal variables +AccurateTime._errorTime = AccurateTime._errorTime or 0 +AccurateTime._timers = AccurateTime._timers or {} + + +-- Gets the current time in milliseconds. Will be directly from the original +-- debugprofilestop() with any error we've detected added in. This error would +-- come solely from an addon calling the unhooked debugprofilestart(). +function AccurateTime:GetAbsTime() + return AccurateTime._debugprofilestop() + AccurateTime._errorTime +end + +-- It is up to the caller to ensure the key they are using is unique. +-- Using table reference or description strings is preferable. +-- If no key is specified, a unique key will be created and returned. +-- If the timer is already running, restart it. +-- Usage: local key = AccurateTime:GetTimer([key]) +function AccurateTime:StartTimer(key) + key = key or {} + AccurateTime._timers[key] = AccurateTime._timers[key] or AccurateTime:GetAbsTime() + return key +end + +-- gets the current value of a timer +-- Usage: local value = AccurateTime:GetTimer(key[, silent]) +function AccurateTime:GetTimer(key, silent) + assert(key, "No key specified.") + if silent and not AccurateTime._timers[key] then return end + assert(AccurateTime._timers[key], "No timer currently running for the given key.") + return AccurateTime:GetAbsTime() - AccurateTime._timers[key] +end + +-- Removes a timer and returns its current value. +-- Usage: local value = AccurateTime:StopTimer(key) +function AccurateTime:StopTimer(key) + local value = AccurateTime:GetTimer(key) + AccurateTime._timers[key] = nil + return value +end + + +-- apply hooks +debugprofilestart = function() error("You should never use debugprofilestart()!", 2) end +debugprofilestop = function() return AccurateTime._currentDebugprofilestop() end + + +-- Create an OnUpdate script to detect and attempt to correct other addons +-- which use the original (non-hooked) debugprofilestart(). This should in +-- theory never happen, but we'll do a best-effort correction if it does. +local function OnUpdate(self) + local absTime = AccurateTime:GetAbsTime() + if absTime < self.lastUpdateAbsTime then + -- debugprofilestart() was called and the back-end timer was reset + -- Estimate what the absolute time should be using GetTime() (converted + -- to ms) and add it to AccurateTime._errorTime. + local realAbsTime = self.lastUpdateAbsTime + (GetTime() - self.lastUpdateTime) * 1000 + AccurateTime._errorTime = AccurateTime._errorTime + (realAbsTime - absTime) + if AccurateTime._errorTime > 0 then + -- update AccurateTime._currentDebugprofilestop() to use our version of the function now that there is some error time + AccurateTime._currentDebugprofilestop = function() return AccurateTime:GetAbsTime() end + end + end + self.lastUpdateAbsTime = absTime + self.lastUpdateTime = GetTime() +end +if not AccurateTime._frame then + -- create frame just once + AccurateTime._frame = CreateFrame("Frame") + AccurateTime._frame.lastUpdateTime = GetTime() + AccurateTime._frame.lastUpdateAbsTime = 0 +end +-- upgrade the frame +AccurateTime._frame:SetScript("OnUpdate", OnUpdate) + +--[[ +function AccurateTimeTest() + local start = debugprofilestop() + for i=1, 10000000 do + end + print("loop", debugprofilestop()-start) + start = debugprofilestop() + for i=1, 10000000 do + debugprofilestop() + end + print("overriden", debugprofilestop()-start) + start = debugprofilestop() + for i=1, 10000000 do + AccurateTime._debugprofilestop() + end + print("raw", debugprofilestop()-start) +end +--]] diff --git a/External/EmbeddedLibs/AceComm-3.0/AceComm-3.0.lua b/External/EmbeddedLibs/AceComm-3.0/AceComm-3.0.lua new file mode 100644 index 0000000..242d92c --- /dev/null +++ b/External/EmbeddedLibs/AceComm-3.0/AceComm-3.0.lua @@ -0,0 +1,305 @@ +--- **AceComm-3.0** allows you to send messages of unlimited length over the addon comm channels. +-- It'll automatically split the messages into multiple parts and rebuild them on the receiving end.\\ +-- **ChatThrottleLib** is of course being used to avoid being disconnected by the server. +-- +-- **AceComm-3.0** can be embeded into your addon, either explicitly by calling AceComm:Embed(MyAddon) or by +-- specifying it as an embeded library in your AceAddon. All functions will be available on your addon object +-- and can be accessed directly, without having to explicitly call AceComm itself.\\ +-- It is recommended to embed AceComm, otherwise you'll have to specify a custom `self` on all calls you +-- make into AceComm. +-- @class file +-- @name AceComm-3.0 +-- @release $Id: AceComm-3.0.lua 1202 2019-05-15 23:11:22Z nevcairiel $ + +--[[ AceComm-3.0 + +TODO: Time out old data rotting around from dead senders? Not a HUGE deal since the number of possible sender names is somewhat limited. + +]] + +local CallbackHandler = LibStub("CallbackHandler-1.0") +local CTL = assert(ChatThrottleLib, "AceComm-3.0 requires ChatThrottleLib") + +local MAJOR, MINOR = "AceComm-3.0", 12 +local AceComm,oldminor = LibStub:NewLibrary(MAJOR, MINOR) + +if not AceComm then return end + +-- Lua APIs +local type, next, pairs, tostring = type, next, pairs, tostring +local strsub, strfind = string.sub, string.find +local match = string.match +local tinsert, tconcat = table.insert, table.concat +local error, assert = error, assert + +-- WoW APIs +local Ambiguate = Ambiguate + +-- Global vars/functions that we don't upvalue since they might get hooked, or upgraded +-- List them here for Mikk's FindGlobals script +-- GLOBALS: LibStub, DEFAULT_CHAT_FRAME, geterrorhandler, RegisterAddonMessagePrefix + +AceComm.embeds = AceComm.embeds or {} + +-- for my sanity and yours, let's give the message type bytes some names +local MSG_MULTI_FIRST = "\001" +local MSG_MULTI_NEXT = "\002" +local MSG_MULTI_LAST = "\003" +local MSG_ESCAPE = "\004" + +-- remove old structures (pre WoW 4.0) +AceComm.multipart_origprefixes = nil +AceComm.multipart_reassemblers = nil + +-- the multipart message spool: indexed by a combination of sender+distribution+ +AceComm.multipart_spool = AceComm.multipart_spool or {} + +--- Register for Addon Traffic on a specified prefix +-- @param prefix A printable character (\032-\255) classification of the message (typically AddonName or AddonNameEvent), max 16 characters +-- @param method Callback to call on message reception: Function reference, or method name (string) to call on self. Defaults to "OnCommReceived" +function AceComm:RegisterComm(prefix, method) + if method == nil then + method = "OnCommReceived" + end + + if #prefix > 16 then -- TODO: 15? + error("AceComm:RegisterComm(prefix,method): prefix length is limited to 16 characters") + end + if C_ChatInfo then + C_ChatInfo.RegisterAddonMessagePrefix(prefix) + else + RegisterAddonMessagePrefix(prefix) + end + + return AceComm._RegisterComm(self, prefix, method) -- created by CallbackHandler +end + +local warnedPrefix=false + +--- Send a message over the Addon Channel +-- @param prefix A printable character (\032-\255) classification of the message (typically AddonName or AddonNameEvent) +-- @param text Data to send, nils (\000) not allowed. Any length. +-- @param distribution Addon channel, e.g. "RAID", "GUILD", etc; see SendAddonMessage API +-- @param target Destination for some distributions; see SendAddonMessage API +-- @param prio OPTIONAL: ChatThrottleLib priority, "BULK", "NORMAL" or "ALERT". Defaults to "NORMAL". +-- @param callbackFn OPTIONAL: callback function to be called as each chunk is sent. receives 3 args: the user supplied arg (see next), the number of bytes sent so far, and the number of bytes total to send. +-- @param callbackArg: OPTIONAL: first arg to the callback function. nil will be passed if not specified. +function AceComm:SendCommMessage(prefix, text, distribution, target, prio, callbackFn, callbackArg) + prio = prio or "NORMAL" -- pasta's reference implementation had different prio for singlepart and multipart, but that's a very bad idea since that can easily lead to out-of-sequence delivery! + if not( type(prefix)=="string" and + type(text)=="string" and + type(distribution)=="string" and + (target==nil or type(target)=="string" or type(target)=="number") and + (prio=="BULK" or prio=="NORMAL" or prio=="ALERT") + ) then + error('Usage: SendCommMessage(addon, "prefix", "text", "distribution"[, "target"[, "prio"[, callbackFn, callbackarg]]])', 2) + end + + local textlen = #text + local maxtextlen = 255 -- Yes, the max is 255 even if the dev post said 256. I tested. Char 256+ get silently truncated. /Mikk, 20110327 + local queueName = prefix..distribution..(target or "") + + local ctlCallback = nil + if callbackFn then + ctlCallback = function(sent) + return callbackFn(callbackArg, sent, textlen) + end + end + + local forceMultipart + if match(text, "^[\001-\009]") then -- 4.1+: see if the first character is a control character + -- we need to escape the first character with a \004 + if textlen+1 > maxtextlen then -- would we go over the size limit? + forceMultipart = true -- just make it multipart, no escape problems then + else + text = "\004" .. text + end + end + + if not forceMultipart and textlen <= maxtextlen then + -- fits all in one message + CTL:SendAddonMessage(prio, prefix, text, distribution, target, queueName, ctlCallback, textlen) + else + maxtextlen = maxtextlen - 1 -- 1 extra byte for part indicator in prefix(4.0)/start of message(4.1) + + -- first part + local chunk = strsub(text, 1, maxtextlen) + CTL:SendAddonMessage(prio, prefix, MSG_MULTI_FIRST..chunk, distribution, target, queueName, ctlCallback, maxtextlen) + + -- continuation + local pos = 1+maxtextlen + + while pos+maxtextlen <= textlen do + chunk = strsub(text, pos, pos+maxtextlen-1) + CTL:SendAddonMessage(prio, prefix, MSG_MULTI_NEXT..chunk, distribution, target, queueName, ctlCallback, pos+maxtextlen-1) + pos = pos + maxtextlen + end + + -- final part + chunk = strsub(text, pos) + CTL:SendAddonMessage(prio, prefix, MSG_MULTI_LAST..chunk, distribution, target, queueName, ctlCallback, textlen) + end +end + + +---------------------------------------- +-- Message receiving +---------------------------------------- + +do + local compost = setmetatable({}, {__mode = "k"}) + local function new() + local t = next(compost) + if t then + compost[t]=nil + for i=#t,3,-1 do -- faster than pairs loop. don't even nil out 1/2 since they'll be overwritten + t[i]=nil + end + return t + end + + return {} + end + + local function lostdatawarning(prefix,sender,where) + DEFAULT_CHAT_FRAME:AddMessage(MAJOR..": Warning: lost network data regarding '"..tostring(prefix).."' from '"..tostring(sender).."' (in "..where..")") + end + + function AceComm:OnReceiveMultipartFirst(prefix, message, distribution, sender) + local key = prefix.."\t"..distribution.."\t"..sender -- a unique stream is defined by the prefix + distribution + sender + local spool = AceComm.multipart_spool + + --[[ + if spool[key] then + lostdatawarning(prefix,sender,"First") + -- continue and overwrite + end + --]] + + spool[key] = message -- plain string for now + end + + function AceComm:OnReceiveMultipartNext(prefix, message, distribution, sender) + local key = prefix.."\t"..distribution.."\t"..sender -- a unique stream is defined by the prefix + distribution + sender + local spool = AceComm.multipart_spool + local olddata = spool[key] + + if not olddata then + --lostdatawarning(prefix,sender,"Next") + return + end + + if type(olddata)~="table" then + -- ... but what we have is not a table. So make it one. (Pull a composted one if available) + local t = new() + t[1] = olddata -- add old data as first string + t[2] = message -- and new message as second string + spool[key] = t -- and put the table in the spool instead of the old string + else + tinsert(olddata, message) + end + end + + function AceComm:OnReceiveMultipartLast(prefix, message, distribution, sender) + local key = prefix.."\t"..distribution.."\t"..sender -- a unique stream is defined by the prefix + distribution + sender + local spool = AceComm.multipart_spool + local olddata = spool[key] + + if not olddata then + --lostdatawarning(prefix,sender,"End") + return + end + + spool[key] = nil + + if type(olddata) == "table" then + -- if we've received a "next", the spooled data will be a table for rapid & garbage-free tconcat + tinsert(olddata, message) + AceComm.callbacks:Fire(prefix, tconcat(olddata, ""), distribution, sender) + compost[olddata] = true + else + -- if we've only received a "first", the spooled data will still only be a string + AceComm.callbacks:Fire(prefix, olddata..message, distribution, sender) + end + end +end + + + + + + +---------------------------------------- +-- Embed CallbackHandler +---------------------------------------- + +if not AceComm.callbacks then + AceComm.callbacks = CallbackHandler:New(AceComm, + "_RegisterComm", + "UnregisterComm", + "UnregisterAllComm") +end + +AceComm.callbacks.OnUsed = nil +AceComm.callbacks.OnUnused = nil + +local function OnEvent(self, event, prefix, message, distribution, sender) + if event == "CHAT_MSG_ADDON" then + sender = Ambiguate(sender, "none") + local control, rest = match(message, "^([\001-\009])(.*)") + if control then + if control==MSG_MULTI_FIRST then + AceComm:OnReceiveMultipartFirst(prefix, rest, distribution, sender) + elseif control==MSG_MULTI_NEXT then + AceComm:OnReceiveMultipartNext(prefix, rest, distribution, sender) + elseif control==MSG_MULTI_LAST then + AceComm:OnReceiveMultipartLast(prefix, rest, distribution, sender) + elseif control==MSG_ESCAPE then + AceComm.callbacks:Fire(prefix, rest, distribution, sender) + else + -- unknown control character, ignore SILENTLY (dont warn unnecessarily about future extensions!) + end + else + -- single part: fire it off immediately and let CallbackHandler decide if it's registered or not + AceComm.callbacks:Fire(prefix, message, distribution, sender) + end + else + assert(false, "Received "..tostring(event).." event?!") + end +end + +AceComm.frame = AceComm.frame or CreateFrame("Frame", "AceComm30Frame") +AceComm.frame:SetScript("OnEvent", OnEvent) +AceComm.frame:UnregisterAllEvents() +AceComm.frame:RegisterEvent("CHAT_MSG_ADDON") + + +---------------------------------------- +-- Base library stuff +---------------------------------------- + +local mixins = { + "RegisterComm", + "UnregisterComm", + "UnregisterAllComm", + "SendCommMessage", +} + +-- Embeds AceComm-3.0 into the target object making the functions from the mixins list available on target:.. +-- @param target target object to embed AceComm-3.0 in +function AceComm:Embed(target) + for k, v in pairs(mixins) do + target[v] = self[v] + end + self.embeds[target] = true + return target +end + +function AceComm:OnEmbedDisable(target) + target:UnregisterAllComm() +end + +-- Update embeds +for target, v in pairs(AceComm.embeds) do + AceComm:Embed(target) +end diff --git a/External/EmbeddedLibs/AceComm-3.0/AceComm-3.0.xml b/External/EmbeddedLibs/AceComm-3.0/AceComm-3.0.xml new file mode 100644 index 0000000..09e8d87 --- /dev/null +++ b/External/EmbeddedLibs/AceComm-3.0/AceComm-3.0.xml @@ -0,0 +1,5 @@ + +