enUS Localization Update Announcement / localization-update-announcement (push) Canceled after 0s
1234 lines
41 KiB
Lua
1234 lines
41 KiB
Lua
-- Bagshui Utility Functions
|
|
-- Exposes: BsUtil (and Bagshui.components.Util)
|
|
|
|
Bagshui:LoadComponent(function()
|
|
local Util = {}
|
|
Bagshui.environment.BsUtil = Util
|
|
Bagshui.components.Util = Util
|
|
|
|
--#region Strings
|
|
|
|
-- In Lua, "." won't match multi-byte UTF8 characters. This pattern will.
|
|
local ANY_CHAR_INCL_UTF8 = "[%z\1-\127\194-\244][\128-\191]*"
|
|
|
|
--- Remove whitespace (or desired characters) from beginning and end of a string.
|
|
---@param str string
|
|
---@param trim string? Characters to trim instead of whitespace.
|
|
---@return string
|
|
function Util.Trim(str, trim)
|
|
trim = trim or "[%s\n]"
|
|
return (string.gsub(str, "^" .. trim .. "*(.-)" .. trim .. "*$", "%1"))
|
|
end
|
|
|
|
-- Reusable table for Util.Split().
|
|
local split_ReturnTable = {}
|
|
|
|
--- Split a string by the provided delimiter (whitespace by default).
|
|
--- Credit: https://stackoverflow.com/a/76989560
|
|
---@param str string
|
|
---@param delimiter string Character(s) or pattern by which to split the string.
|
|
---@param delimiterIsPattern boolean? Don't escape pattern characters in `delimiter`.
|
|
---@param includeEmpty boolean? Include empty strings in the results.
|
|
---@return string[] # `str` split into multiple strings.
|
|
function Util.Split(str, delimiter, delimiterIsPattern, includeEmpty)
|
|
assert(
|
|
type(str) == "string",
|
|
"Util.Split() - str must be a string (was " .. type(str) .. ": " .. tostring(str) .. ")"
|
|
)
|
|
|
|
Util.TableClear(split_ReturnTable)
|
|
|
|
if not delimiter then
|
|
delimiter = "%s+"
|
|
elseif not delimiterIsPattern then
|
|
delimiter = Util.EscapeMagicCharacters(delimiter)
|
|
end
|
|
|
|
str = string.gsub(str, delimiter, "\1")
|
|
|
|
for match in string.gfind(str, "([^\1]" .. (includeEmpty and "*" or "+") .. ")") do
|
|
table.insert(split_ReturnTable, match)
|
|
end
|
|
|
|
return split_ReturnTable
|
|
end
|
|
|
|
--- Escape the magic characters used in Lua patterns so they are interpreted literally.
|
|
---@param str any
|
|
---@return string
|
|
function Util.EscapeMagicCharacters(str)
|
|
return (string.gsub(str, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
|
|
end
|
|
|
|
-- Clean all WoW UI escape sequences from the given string.
|
|
-- Useful for matching text against tooltips which may contain formatting.
|
|
---@param str string
|
|
---@param extractHyperlinkContentsInsteadOfText boolean Get item links instead of the text displayed in the UI. ("|Hitem:1234:0:0:0|h[Item Name]|h" becomes "item:1234:0:0:0" instead of "[Item Name]")
|
|
---@return string
|
|
function Util.RemoveUiEscapes(str, extractHyperlinkContentsInsteadOfText)
|
|
-- Font colors.
|
|
str = string.gsub(str, "|c%w%w%w%w%w%w%w%w(.+)|r", "%1")
|
|
-- Links.
|
|
if extractHyperlinkContentsInsteadOfText then
|
|
str = string.gsub(str, "|H(.+)|h.+|h", "%1")
|
|
else
|
|
str = string.gsub(str, "|H.+|h(.+)|h", "%1")
|
|
end
|
|
|
|
return str
|
|
end
|
|
|
|
--- Turn "This is a string" into "string a is This"
|
|
---@param str string
|
|
---@return string
|
|
function Util.ReverseStringByWords(str)
|
|
local reversed = ""
|
|
local nextWord = nil
|
|
local remainingString = str
|
|
|
|
repeat
|
|
nextWord, remainingString = Util.SplitOnFirstSpace(remainingString)
|
|
if reversed == "" then
|
|
reversed = nextWord
|
|
else
|
|
reversed = nextWord .. " " .. reversed
|
|
end
|
|
until remainingString == ""
|
|
|
|
return reversed
|
|
end
|
|
|
|
-- Split a string at the first instance of a space.
|
|
-- Used by ReverseStringByWords
|
|
---@param str string
|
|
---@return string nextWord
|
|
---@return string remainingString
|
|
function Util.SplitOnFirstSpace(str)
|
|
if str then
|
|
local firstWord = str
|
|
local leftoverString = ""
|
|
local nextSpacePosition = string.find(str, " ")
|
|
|
|
if nextSpacePosition then
|
|
firstWord = string.sub(str, 1, nextSpacePosition - 1)
|
|
leftoverString = string.sub(str, nextSpacePosition + 1)
|
|
end
|
|
|
|
return firstWord, leftoverString
|
|
else
|
|
return "", ""
|
|
end
|
|
end
|
|
|
|
-- Truncate a string to the specified length.
|
|
---@param str string
|
|
---@param len number
|
|
---@param noEllipsis boolean? Don't add an ellipsis to the end of the string when it's truncated.
|
|
---@return string
|
|
function Util.TruncateString(str, len, noEllipsis)
|
|
local ret = Util.Utf8Sub(str, 1, len)
|
|
-- Add ellipsis if needed.
|
|
if not noEllipsis and Util.Utf8Len(ret) < Util.Utf8Len(str) then
|
|
ret = ret .. "…"
|
|
end
|
|
return ret
|
|
end
|
|
|
|
--- Lowercase the first letter of a string.
|
|
---@param str string
|
|
---@return string
|
|
function Util.LowercaseFirstLetter(str)
|
|
if type(str) ~= "string" then
|
|
return ""
|
|
end
|
|
return (string.gsub(str, "^%w", string.lower))
|
|
end
|
|
|
|
--- Capitalize a string (uppercase first letter, lowercase the rest).
|
|
---@param str string
|
|
---@return string
|
|
function Util.Capitalize(str)
|
|
if type(str) ~= "string" then
|
|
return ""
|
|
end
|
|
return (string.gsub(string.lower(str), "^%w", string.upper))
|
|
end
|
|
|
|
-- Return a substring, taking UTF8 characters into account (UTF8 version of `string.sub()`).
|
|
---@param str string
|
|
---@param first number
|
|
---@param last number
|
|
---@return string
|
|
function Util.Utf8Sub(str, first, last)
|
|
-- We'll need he original string length repeatedly.
|
|
local len = Util.Utf8Len(str)
|
|
|
|
if len == 0 then
|
|
return ""
|
|
end
|
|
|
|
-- Go through the end if we didn't get a final position.
|
|
if not last then
|
|
last = len
|
|
end
|
|
|
|
-- Handle negatives.
|
|
if first < 0 then
|
|
first = len + first + 1
|
|
end
|
|
if last < 0 then
|
|
last = len + last + 1
|
|
end
|
|
|
|
-- Prevent out of bounds.
|
|
first = math.max(first, 1)
|
|
last = math.min(last, len)
|
|
|
|
-- Nothing to do if positions are inverted.
|
|
if last < first then
|
|
return ""
|
|
end
|
|
|
|
local desiredReturnLen = last - first + 1
|
|
local ret = ""
|
|
local i, firstChar, lastChar, charsFound, newLen
|
|
|
|
-- Update i to be the REAL start (accounting for UTF8ness) point by iterating character-by-character.
|
|
if first > 1 then
|
|
charsFound = 0
|
|
lastChar = 0
|
|
repeat
|
|
i = lastChar + 1
|
|
_, lastChar = string.find(str, ANY_CHAR_INCL_UTF8, i) -- Step forward by the next single UTF character, which can be one or more ASCII character points.
|
|
charsFound = charsFound + 1
|
|
until charsFound == first
|
|
end
|
|
|
|
-- Build up the return string
|
|
repeat
|
|
-- Find the ASCII character positions of the next single UTF character.
|
|
firstChar, lastChar = string.find(str, ANY_CHAR_INCL_UTF8, i)
|
|
-- Add to return string.
|
|
ret = ret .. string.sub(str, firstChar, lastChar)
|
|
-- Increment start point for next iteration.
|
|
i = lastChar + 1
|
|
-- Calculate true length of return string.
|
|
newLen = Util.Utf8Len(ret)
|
|
until newLen >= desiredReturnLen or newLen >= len -- (This is written with >= just to be safe).
|
|
|
|
return ret
|
|
end
|
|
|
|
-- String length, taking into account UTF8 characters (UTF8 version of `string.len()`).
|
|
---@param str any
|
|
---@return integer
|
|
function Util.Utf8Len(str)
|
|
if not str or type(str) == "table" then
|
|
return 0
|
|
end
|
|
local _, len = string.gsub(tostring(str), ANY_CHAR_INCL_UTF8, "")
|
|
return len
|
|
end
|
|
|
|
--- Return true when `str` matches either `strToMatch` or its localized equivalent.
|
|
--- Case-insensitive.
|
|
---@param str string String to test.
|
|
---@param strToMatch string String to match.
|
|
---@return boolean
|
|
function Util.MatchLocalizedOrNon(str, strToMatch)
|
|
if not str then
|
|
return false
|
|
end
|
|
return string.lower(Util.Trim(str)) == string.lower(strToMatch)
|
|
or string.lower(Util.Trim(str)) == string.lower(L[strToMatch])
|
|
end
|
|
|
|
function Util.ExtractErrorMessage(str)
|
|
return string.gsub(str, "^.-:.-:%s*", "")
|
|
end
|
|
|
|
--#endregion Strings
|
|
|
|
--#region Numbers
|
|
|
|
--- Get the first number from a string.
|
|
---@param str any
|
|
---@return number?
|
|
function Util.ExtractNumber(str)
|
|
local _, _, num = string.find(tostring(str), "(%d+)")
|
|
return tonumber(num)
|
|
end
|
|
|
|
--- Scale a number from one range to another.
|
|
---@param num number Number to scale.
|
|
---@param originalMin number Source range minimum value.
|
|
---@param originalMax number Source range maximum value.
|
|
---@param scaledMin number Target range minimum value.
|
|
---@param scaledMax number Target range maximum value.
|
|
---@return number
|
|
function Util.ScaleNumber(num, originalMin, originalMax, scaledMin, scaledMax)
|
|
return scaledMin + ((num - originalMin) * (scaledMax - scaledMin)) / (originalMax - originalMin)
|
|
end
|
|
|
|
--- Lua doesn't have native rounding.
|
|
---@param num number Number to round.
|
|
---@param decimals number? Precision (default: 2).
|
|
---@return number?
|
|
function Util.Round(num, decimals)
|
|
if not num then
|
|
return
|
|
end
|
|
return tonumber(string.format("%." .. (decimals or 2) .. "f", num))
|
|
end
|
|
|
|
--#endregion Numbers
|
|
|
|
--#region Tables
|
|
|
|
--- Deep table copy, optionally using a template table to filter keys and values.
|
|
--- Copy will be performed in-place (i.e. `dest` will be overwritten and will *not*
|
|
--- be returned). The only time a value is returned is when `dest` is not provided,
|
|
--- since a new table is created in this scenario.
|
|
---
|
|
--- #### `template` notes:
|
|
--- The general idea is that template keys define what keys will be copied from `source`,
|
|
--- and template values define the allowed data types for `source`'s values.
|
|
--- There are a few special cases:
|
|
--- * An associative key-value table as a template key will define a set of multiple
|
|
--- allowed keys. Each value associated with these keys in `source` will be expected
|
|
--- to follow the data types allowed by this template key's value.
|
|
--- See `BS_CATEGORY_SKELETON` for an example.
|
|
--- * A key with a single-value array-type table as its value will allow an array-type
|
|
--- table in the corresponding `source` location, with the data type of the single
|
|
--- value in the template's array. BS_CATEGORY_SKELETON also has an example of this.
|
|
--- * Providing a `tableWildcard` will whitelist any table found in `source` at a
|
|
--- template location that has the pointer to `tableWildcard` as its value.
|
|
--- See the profileSkeleton setup in Components\Profiles.lua for an example.
|
|
---@param source table Table to copy from.
|
|
---@param dest table? Table to copy into, or `nil` to copy into a new table and return that.
|
|
---@param forceKeyValueCopy boolean? Use key-value copy even when `table.getn(source) > 0`.
|
|
---@param template table? Table that defines the allowable structure of `source`. Anything not found in the template won't be copied.
|
|
---@param tableWildcard table? When the pointer to this table appears in the template, anything in the corresponding `source` location will be copied over.
|
|
---@param parentHadTableWildcard boolean? Will be `true` when a recursive call is made and the `tableWildcard` was found as the value.
|
|
---@return table? newDest Newly created copy if `dest` was not provided.
|
|
function Util.TableCopy(source, dest, forceKeyValueCopy, template, tableWildcard, parentHadTableWildcard)
|
|
assert(type(source) == "table", "TableCopy(): type(source) = " .. type(source) .. " instead of table")
|
|
assert(
|
|
dest == nil or type(dest) == "table",
|
|
"TableCopy:() type(dest) = " .. type(dest) .. " instead of table or nil"
|
|
)
|
|
|
|
-- Use an empty table if we don't have a destination (or if destination is the same as source).
|
|
local target = (dest and dest ~= source) and dest or {}
|
|
|
|
-- Ensure the destination table is empty.
|
|
Util.TableClear(target)
|
|
|
|
-- Assume that table.getn() > 0 is an array -- pass forceKeyValueCopy to override this.
|
|
if table.getn(source) > 0 and not forceKeyValueCopy then
|
|
for i = 1, table.getn(source) do
|
|
if type(source[i]) == "table" then
|
|
table.insert(target, Util.TableCopy(source[i]))
|
|
else
|
|
table.insert(target, source[i])
|
|
end
|
|
end
|
|
else
|
|
-- Only process the template when the parent element in the template wasn't a wildcard.
|
|
if template and not parentHadTableWildcard then
|
|
-- Templates are handled by looping through the template instead of
|
|
-- the source table, since anything that isn't in the template should
|
|
-- be omitted.
|
|
for key, templateVal in pairs(template) do
|
|
if type(key) == "table" then
|
|
-- If a table is found as a template key, use that to define
|
|
-- a set of multiple allowed keys.
|
|
for refKey, refVal in pairs(key) do
|
|
if source[refKey] then
|
|
if type(templateVal) == "table" then
|
|
target[refKey] = Util.TableCopy(source[refKey], nil, nil, templateVal)
|
|
else
|
|
target[refKey] = source[refKey]
|
|
end
|
|
end
|
|
end
|
|
elseif
|
|
type(templateVal) == "table"
|
|
and table.getn(templateVal) == 1
|
|
and source[key]
|
|
and templateVal ~= tableWildcard
|
|
then
|
|
-- When the template has an array-type table with a single value,
|
|
-- use that to determine what type is allowed in the target array.
|
|
target[key] = {}
|
|
for _, sourceVal in ipairs(source[key]) do
|
|
if type(sourceVal) == type(templateVal[1]) then
|
|
if type(templateVal[1]) == "table" then
|
|
table.insert(target[key], Util.TableCopy(sourceVal, nil, nil, templateVal[1]))
|
|
else
|
|
table.insert(target[key], sourceVal)
|
|
end
|
|
end
|
|
end
|
|
elseif source[key] ~= nil and type(source[key]) == type(templateVal) then
|
|
-- Normal behavior - only copy if type matches.
|
|
if type(templateVal) == "table" then
|
|
-- Recurse for tables.
|
|
target[key] = Util.TableCopy(
|
|
source[key],
|
|
nil, -- No dest since we need to create a new table.
|
|
nil,
|
|
templateVal,
|
|
tableWildcard,
|
|
templateVal == tableWildcard -- parentHadTableWildcard.
|
|
)
|
|
else
|
|
target[key] = source[key]
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- No template -- just copy everything.
|
|
for key, val in pairs(source) do
|
|
if type(val) == "table" then
|
|
target[key] = Util.TableCopy(val)
|
|
else
|
|
target[key] = val
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Only return if dest was nil.
|
|
if not dest then
|
|
return target
|
|
end
|
|
end
|
|
|
|
--- Non-recursive table copy; just loop through the first level.
|
|
--- Useful when it's important to preserve an array of table pointers.
|
|
--- `TableCopy()` could be refactored to handle this too, but it's not worth it.
|
|
---@param source table Table to copy from.
|
|
---@param dest table? Table to copy into, or `nil` to copy into a new table and return that.
|
|
---@param forceKeyValueCopy boolean? Use key-value copy even when `table.getn(source) > 0`.
|
|
---@return table? newDest Newly created copy if `dest` was not provided.
|
|
function Util.TableCopyFlat(source, dest, forceKeyValueCopy)
|
|
assert(type(source) == "table", "TableCopy(): type(source) = " .. type(source) .. " instead of table")
|
|
assert(
|
|
dest == nil or type(dest) == "table",
|
|
"TableCopy:() type(dest) = " .. type(dest) .. " instead of table or nil"
|
|
)
|
|
|
|
local target = (dest and dest ~= source) and dest or {}
|
|
Util.TableClear(target)
|
|
|
|
if table.getn(source) > 0 and not forceKeyValueCopy then
|
|
for _, value in ipairs(source) do
|
|
table.insert(target, value)
|
|
end
|
|
else
|
|
for key, value in pairs(source) do
|
|
target[key] = value
|
|
end
|
|
end
|
|
|
|
if not dest then
|
|
return target
|
|
end
|
|
end
|
|
|
|
--- Wipe a table. Using this is easier on the garbage collector than creating a new table.
|
|
---@param tbl table
|
|
function Util.TableClear(tbl)
|
|
if type(tbl) ~= "table" then
|
|
return
|
|
end
|
|
|
|
-- Clear array-type tables first so table.insert will start over at 1.
|
|
for i = table.getn(tbl), 1, -1 do
|
|
table.remove(tbl, i)
|
|
end
|
|
|
|
-- Remove any remaining associative table elements.
|
|
-- Credit: https://stackoverflow.com/a/27287723
|
|
for k in next, tbl do
|
|
rawset(tbl, k, nil)
|
|
end
|
|
end
|
|
|
|
--- Get the actual table size regardless of whether it's a numeric or associative array.
|
|
---@param tbl table
|
|
---@return integer # Count of all elements.
|
|
function Util.TrueTableSize(tbl)
|
|
if type(tbl) ~= "table" then
|
|
return 0
|
|
end
|
|
local size = 0
|
|
for k, v in pairs(tbl) do
|
|
size = size + 1
|
|
end
|
|
return size
|
|
end
|
|
|
|
--- Find the key (index) of the given element in a table.
|
|
---@param table table
|
|
---@param element any
|
|
---@return any? # Key/index of element, or nil if not found.
|
|
function Util.TableContainsValue(table, element)
|
|
if type(table) ~= "table" then
|
|
return nil
|
|
end
|
|
for key, value in pairs(table) do
|
|
if value == element then
|
|
return key
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- `table.insert()`, but only if `val` isn't `nil`.
|
|
---@param tbl table Table into which `val` will be inserted.
|
|
---@param val any Value to insert.
|
|
---@param emptyStringAsNil boolean? Treat empty string as nil.
|
|
---@return boolean # Whether insert was performed.
|
|
function Util.TableInsertNonNil(tbl, val, emptyStringAsNil)
|
|
if type(tbl) ~= "table" or val == nil or (emptyStringAsNil and type(val) == "string" and string.len(val) == 0) then
|
|
return false
|
|
end
|
|
table.insert(tbl, val)
|
|
return true
|
|
end
|
|
|
|
--- Remove an item from an array-type table.
|
|
---@param tbl table
|
|
---@param item any Value of array entry to remove.
|
|
function Util.TableRemoveArrayItem(tbl, item)
|
|
if type(tbl) ~= "table" then
|
|
return
|
|
end
|
|
|
|
for i = table.getn(tbl), 1, -1 do
|
|
if tbl[i] == item then
|
|
table.remove(tbl, i)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Add an item to an array-type table only if it isn't already present.
|
|
---@param tbl table
|
|
---@param item any Value to add.
|
|
function Util.TableInsertArrayItemUnique(tbl, item)
|
|
if type(tbl) ~= "table" then
|
|
return
|
|
end
|
|
if not Util.TableContainsValue(tbl, item) then
|
|
table.insert(tbl, item)
|
|
end
|
|
end
|
|
|
|
--#endregion Tables
|
|
|
|
--#region Classes
|
|
|
|
--- Instantiate a new class with multiple inheritance.
|
|
--- https://www.lua.org/pil/16.3.html
|
|
---@param ... table Parent classes, in order of priority (i.e. array index #1 is the highest priority superclass).
|
|
---@return table newClass
|
|
function Util.NewClass(...)
|
|
local newClass = {}
|
|
local metatable = {}
|
|
|
|
-- newClass will search for each property it doesn't have in
|
|
-- its superclasses (arg is the list of superclasses).
|
|
setmetatable(metatable, {
|
|
__index = function(_, prop)
|
|
return Util.FindSuperclassProperty(prop, arg)
|
|
end,
|
|
})
|
|
metatable.__index = metatable
|
|
|
|
-- Create and return the new class.
|
|
setmetatable(newClass, metatable)
|
|
return newClass
|
|
end
|
|
|
|
--- Helper function to implement multiple inheritance.
|
|
--- Iterates the list of superclasses and return the value of `prop` from the first one that has it.
|
|
---@param prop any
|
|
---@param parents table[]
|
|
---@return any?
|
|
function Util.FindSuperclassProperty(prop, parents)
|
|
for i = 1, table.getn(parents) do
|
|
local value = parents[i][prop]
|
|
if value then
|
|
return value
|
|
end
|
|
end
|
|
end
|
|
|
|
--#endregion Classes
|
|
|
|
--#region WoW UI
|
|
|
|
-- Convert "LEFT" to "RIGHT" and "TOP" to "BOTTOM" (and vice versa).
|
|
---@param point string
|
|
---@return string
|
|
function Util.FlipAnchorPoint(point)
|
|
if point == "LEFT" or point == "RIGHT" then
|
|
return (point == "LEFT") and "RIGHT" or "LEFT"
|
|
elseif point == "TOP" or point == "BOTTOM" then
|
|
return (point == "TOP") and "BOTTOM" or "TOP"
|
|
end
|
|
return point
|
|
end
|
|
|
|
--- Flip only one part of a multi-component anchor.
|
|
---```
|
|
--- Util.FlipAnchorPointComponent("TOPLEFT", 1) -> "BOTTOMLEFT"
|
|
--- Util.FlipAnchorPointComponent("TOPLEFT", 2) -> "TOPRIGHT"
|
|
---```
|
|
---@param point string
|
|
---@param componentNum number? 1 for the first half, 2 for the second half (defaults to 1).
|
|
---@return string
|
|
function Util.FlipAnchorPointComponent(point, componentNum)
|
|
-- "^%s" for componentNum 1 or "%s$" for componentNum 2.
|
|
-- This allows matching "^TOP"/"^BOTTOM" at the start of the string for
|
|
-- `componentNum` 1, but "^LEFT"/"^RIGHT" will never match (and the same
|
|
-- for `componentNum` 2 with "LEFT$"/"RIGHT$").
|
|
local searchAnchor = ((componentNum == 1 or not componentNum) and "^" or "")
|
|
.. "%s"
|
|
.. (componentNum == 2 and "$" or "")
|
|
|
|
if string.find(point, string.format(searchAnchor, "LEFT")) then
|
|
return (string.gsub(point, string.format(searchAnchor, "LEFT"), "RIGHT"))
|
|
end
|
|
|
|
if string.find(point, string.format(searchAnchor, "RIGHT")) then
|
|
return (string.gsub(point, string.format(searchAnchor, "RIGHT"), "LEFT"))
|
|
end
|
|
|
|
if string.find(point, string.format(searchAnchor, "BOTTOM")) then
|
|
return (string.gsub(point, string.format(searchAnchor, "BOTTOM"), "TOP"))
|
|
end
|
|
|
|
if string.find(point, string.format(searchAnchor, "TOP")) then
|
|
return (string.gsub(point, string.format(searchAnchor, "TOP"), "BOTTOM"))
|
|
end
|
|
|
|
return point
|
|
end
|
|
|
|
-- Return "LEFT", "RIGHT", or "" (i.e. center) depending on the provided anchor.
|
|
---@param point string
|
|
---@return string
|
|
function Util.GetAnchorLeftRight(point)
|
|
return (
|
|
(point ~= "BOTTOM" and point ~= "TOP") and (point and (string.find(point, "RIGHT$") and "RIGHT" or "LEFT"))
|
|
or ""
|
|
)
|
|
end
|
|
|
|
--- Take a RGB percent set (0.0-1.0) and convert it to a hex string.
|
|
--- Credit: https://warcraft.wiki.gg/wiki/RGBPercToHex
|
|
---@param r number Red
|
|
---@param g number Blue
|
|
---@param b number Green
|
|
---@return string hexColor
|
|
function Util.RGBPercentToHex(r, g, b)
|
|
r = r <= 1 and r >= 0 and r or 0
|
|
g = g <= 1 and g >= 0 and g or 0
|
|
b = b <= 1 and b >= 0 and b or 0
|
|
return string.format("%02x%02x%02x", r * 255, g * 255, b * 255)
|
|
end
|
|
|
|
--- Get a texture with its full path.
|
|
--- If the path has more than 2 backslashes, the path will be returned as-is.
|
|
--- Otherwise, the Bagshui addon path will be prefixed.
|
|
---@param path string
|
|
---@return string?
|
|
function Util.GetFullTexturePath(path)
|
|
if not path then
|
|
return
|
|
end
|
|
local _, slashCount = string.gsub(path, "\\", "")
|
|
return slashCount > 1 and path or ("Interface\\AddOns\\Bagshui\\Images\\" .. path)
|
|
end
|
|
|
|
--- Helper function to remove some redundant code for determining when a menu frame
|
|
--- is offscreen and how much adjustment needs to be done to bring it back on.
|
|
--- This uses `UIParent:GetLeft/Right/Top/Bottom()` in lieu of `GetScreenWidth/Height()`,
|
|
--- which seems to work much better.
|
|
--- Written primarily for `Bagshui:ToggleDropDownMenu()` but also used elsewhere.
|
|
---@param frame table Menu frame.
|
|
---@param xy "x"|"y" Horizontal or vertical adjustment.
|
|
---@return number adjustment How much the frame needs to be moved to bring it back onscreen.
|
|
function Util.GetFrameOffscreenAmount(frame, xy)
|
|
assert(
|
|
frame and frame.GetScale,
|
|
"Util.GetFrameOffscreenAmount(): frame does not appear to be a valid WoW UI frame."
|
|
)
|
|
xy = xy or "x"
|
|
|
|
local lesserDirection = "Left"
|
|
local greaterDirection = "Right"
|
|
if string.lower(xy) == "y" then
|
|
lesserDirection = "Bottom"
|
|
greaterDirection = "Top"
|
|
end
|
|
|
|
local frameScale = frame:GetScale()
|
|
|
|
-- Only apply UI scale to non-menu frames. For some reason I'm not going to bother figuring out,
|
|
-- menu frame positions are only accurately calculated if they are NOT scaled by UIParent's scale,
|
|
-- but other frames' positions need to be scaled. *shrug*
|
|
if not string.find(frame:GetName(), "^DropDownList%d$") then
|
|
frameScale = frameScale * _G.UIParent:GetScale()
|
|
end
|
|
|
|
local lesserDimension = frame["Get" .. lesserDirection] and frame["Get" .. lesserDirection](frame) or nil
|
|
lesserDimension = lesserDimension and lesserDimension * frameScale
|
|
local greaterDimension = frame["Get" .. greaterDirection] and frame["Get" .. greaterDirection](frame) or nil
|
|
greaterDimension = greaterDimension and greaterDimension * frameScale
|
|
|
|
local uiParentLesserDimension = _G.UIParent["Get" .. lesserDirection](_G.UIParent) * _G.UIParent:GetScale()
|
|
local uiParentGreaterDimension = _G.UIParent["Get" .. greaterDirection](_G.UIParent) * _G.UIParent:GetScale()
|
|
-- Bagshui:PrintDebug(xy .. " UIParent Lesser: " .. uiParentLesserDimension)
|
|
-- Bagshui:PrintDebug(xy .. " Frame Lesser: " .. tostring(lesserDimension))
|
|
-- Bagshui:PrintDebug(xy .. " UIParent Greater: " .. uiParentGreaterDimension)
|
|
-- Bagshui:PrintDebug(xy .. " Frame Greater: " .. tostring(greaterDimension))
|
|
|
|
if lesserDimension and lesserDimension < uiParentLesserDimension then
|
|
-- Left/Bottom will be less than the corresponding UIParent coordinate if offscreen.
|
|
return (uiParentLesserDimension - lesserDimension) / frameScale
|
|
elseif greaterDimension and greaterDimension > uiParentGreaterDimension then
|
|
-- Right/Top will be greater than the corresponding UIParent coordinate if offscreen.
|
|
return (uiParentGreaterDimension - greaterDimension) / frameScale
|
|
end
|
|
|
|
return 0
|
|
end
|
|
|
|
--- StaticPopupDialogs substitute for the `enterClicksFirstButton` property since
|
|
--- that doesn't exist in Vanilla. Assign this to the `EditBoxOnEnterPressed` property.
|
|
function Util.StaticPopupDialogs_EnterClicksFirstButton()
|
|
_G.StaticPopup_OnClick(_G.this:GetParent(), 1)
|
|
end
|
|
|
|
--- Dialogs with a text box should clear their text when hidden because some built-in
|
|
--- ones (like `DELETE_GOOD_ITEM`) don't do it OnShow.
|
|
function Util.StaticPopupDialogs_ClearTextOnHide()
|
|
_G[_G.this:GetName() .. "EditBox"]:SetText("")
|
|
end
|
|
|
|
--- Pause code execution until a dialog is dismissed.
|
|
---@param dialogName string Static popup name to wait for.
|
|
---@param timeoutSec number? Maximum wait time in seconds.
|
|
---@param callback function? Called once waiting is completed. Will receive a boolean indicating whether the dialog closed within timeout. *Technically* optional but without it this function is kind of meaningless.
|
|
---@param checkCount number? Number of checks performed. Don't pass this in during the initial call.
|
|
---@return boolean? dialogWasOpen `true` if popup was on screen during the *initial* call.
|
|
function Util.WaitForStaticPopupClose(dialogName, timeoutSec, callback, checkCount)
|
|
timeoutSec = timeoutSec or 30
|
|
if _G.StaticPopup_FindVisible(dialogName) then
|
|
if ((checkCount or 0) * 0.1) >= timeoutSec then
|
|
-- Dialog did NOT close within timeout (failure).
|
|
if type(callback) == "function" then
|
|
callback(true)
|
|
end
|
|
end
|
|
Bagshui:QueueEvent(
|
|
Util.WaitForStaticPopupClose,
|
|
0.1,
|
|
nil,
|
|
dialogName,
|
|
timeoutSec,
|
|
callback,
|
|
(checkCount or 0) + 1
|
|
)
|
|
return true
|
|
end
|
|
|
|
-- Dialog closed (success).
|
|
if type(callback) == "function" then
|
|
callback(true)
|
|
end
|
|
end
|
|
|
|
--#endregion WoW UI
|
|
|
|
--#region Import/Export
|
|
|
|
--- Prepare a table for export by serializing, compressing, and encoding.
|
|
---@param tbl table Object to export.
|
|
---@param humanReadable boolean? `true` to only pretty-print and NOT compress/encode.
|
|
---@return string?
|
|
function Util.Export(tbl, humanReadable)
|
|
local export = Util.Serialize(tbl)
|
|
if not humanReadable then
|
|
export = Util.Encode(Util.Compress(export))
|
|
end
|
|
return export
|
|
end
|
|
|
|
--- Import a table previously prepared by `Util.Export()`.
|
|
---@param str string
|
|
---@return table?
|
|
function Util.Import(str)
|
|
local import = Util.Trim(str)
|
|
if not string.find(import, "^{") then
|
|
import = Util.Trim(Util.Decompress(Util.Decode(import)) or "")
|
|
end
|
|
if not string.find(import, "^{") then
|
|
return nil
|
|
end
|
|
return Util.Deserialize(import)
|
|
end
|
|
|
|
--- Serialize a table to a string.
|
|
--- Credit: pfUi serialize() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
|
|
---@param tbl table Object to serialize.
|
|
---@param comp table? Anything in this table that is the same as `tbl` will be excluded.
|
|
---@param key string? Key name.
|
|
---@param spacing string? Indentation.
|
|
---@return string?
|
|
function Util.Serialize(tbl, comp, key, spacing)
|
|
if type(tbl) ~= "table" then
|
|
return tostring(tbl)
|
|
end
|
|
|
|
spacing = spacing or ""
|
|
local match = nil
|
|
local str = (spacing ~= "" and key) and (spacing .. "[" .. Util.ToPrintableString(key) .. "] = {\n") or "{\n"
|
|
|
|
for k, v in pairs(tbl) do
|
|
if not comp or not comp[k] or comp[k] ~= tbl[k] then
|
|
if type(v) == "table" then
|
|
local result = Util.Serialize(tbl[k], comp and comp[k], k, spacing .. " ")
|
|
if result then
|
|
match = true
|
|
str = str .. result
|
|
end
|
|
elseif type(v) == "string" or type(v) == "number" or type(v) == "boolean" then
|
|
match = true
|
|
str = str .. spacing .. " [" .. Util.ToPrintableString(k) .. "] = " .. Util.ToPrintableString(v) .. ",\n"
|
|
end
|
|
end
|
|
end
|
|
|
|
str = str .. spacing .. "}" .. (spacing == "" and "" or ",") .. BS_NEWLINE
|
|
return match and str or nil
|
|
end
|
|
|
|
--- Transform the given value into a string that can be printed or used in serialization.
|
|
--- Strings will be surrounded by double quotes (and double quotes within the string escaped)
|
|
---@param v any
|
|
---@param noStringEscapes boolean? Don't escape quotes and backslashes in strings.
|
|
---@return string
|
|
function Util.ToPrintableString(v, noStringEscapes)
|
|
if type(v) == "string" or type(v) == "userdata" then
|
|
if noStringEscapes then
|
|
return '"' .. v .. '"'
|
|
else
|
|
return '"' .. string.gsub(string.gsub(v, "\\", "\\\\"), '"', '\\"') .. '"'
|
|
end
|
|
end
|
|
return tostring(v)
|
|
end
|
|
|
|
--- Turn a previously-serialized table in string form back into a table.
|
|
---@param str string? Serialized table.
|
|
---@return table?
|
|
function Util.Deserialize(str)
|
|
if type(str) ~= "string" or string.len(str) < 1 then
|
|
return nil
|
|
end
|
|
local deserialize = assert(loadstring("return " .. str))
|
|
if type(deserialize) == "function" then
|
|
-- Add some sort of protection against code injection.
|
|
setfenv(deserialize, {})
|
|
return deserialize()
|
|
end
|
|
end
|
|
|
|
--- LZW-compress a string.
|
|
--- Credit: pfUi compress() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
|
|
---@param input any
|
|
---@return nil
|
|
function Util.Compress(input)
|
|
-- based on Rochet2's lzw compression
|
|
if type(input) ~= "string" then
|
|
return nil
|
|
end
|
|
local len = string.len(input)
|
|
if len <= 1 then
|
|
return "u" .. input
|
|
end
|
|
|
|
local dict = {}
|
|
for i = 0, 255 do
|
|
local ic, iic = string.char(i), string.char(i, 0)
|
|
dict[ic] = iic
|
|
end
|
|
local a, b = 0, 1
|
|
|
|
local result = { "c" }
|
|
local resultLen = 1
|
|
local n = 2
|
|
local word = ""
|
|
for i = 1, len do
|
|
local c = string.sub(input, i, i)
|
|
local wc = word .. c
|
|
if not dict[wc] then
|
|
local write = dict[word]
|
|
if not write then
|
|
return nil
|
|
end
|
|
result[n] = write
|
|
resultLen = resultLen + string.len(write)
|
|
n = n + 1
|
|
if len <= resultLen then
|
|
return "u" .. input
|
|
end
|
|
local str = wc
|
|
if a >= 256 then
|
|
a, b = 0, b + 1
|
|
if b >= 256 then
|
|
dict = {}
|
|
b = 1
|
|
end
|
|
end
|
|
dict[str] = string.char(a, b)
|
|
a = a + 1
|
|
word = c
|
|
else
|
|
word = wc
|
|
end
|
|
end
|
|
result[n] = dict[word]
|
|
resultLen = resultLen + string.len(result[n])
|
|
n = n + 1
|
|
if len <= resultLen then
|
|
return "u" .. input
|
|
end
|
|
return table.concat(result)
|
|
end
|
|
|
|
--- Decompress a previously LZW-compressed string.
|
|
--- Credit: pfUi decompress() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
|
|
---@param input any
|
|
---@return nil
|
|
function Util.Decompress(input)
|
|
-- based on Rochet2's lzw compression
|
|
if type(input) ~= "string" or string.len(input) < 1 then
|
|
return nil
|
|
end
|
|
|
|
local control = string.sub(input, 1, 1)
|
|
if control == "u" then
|
|
return string.sub(input, 2)
|
|
elseif control ~= "c" then
|
|
return nil
|
|
end
|
|
input = string.sub(input, 2)
|
|
local len = string.len(input)
|
|
|
|
if len < 2 then
|
|
return nil
|
|
end
|
|
|
|
local dict = {}
|
|
for i = 0, 255 do
|
|
local ic, iic = string.char(i), string.char(i, 0)
|
|
dict[iic] = ic
|
|
end
|
|
|
|
local a, b = 0, 1
|
|
|
|
local result = {}
|
|
local n = 1
|
|
local last = string.sub(input, 1, 2)
|
|
result[n] = dict[last]
|
|
n = n + 1
|
|
for i = 3, len, 2 do
|
|
local code = string.sub(input, i, i + 1)
|
|
local lastStr = dict[last]
|
|
if not lastStr then
|
|
return nil
|
|
end
|
|
local toAdd = dict[code]
|
|
if toAdd then
|
|
result[n] = toAdd
|
|
n = n + 1
|
|
local str = lastStr .. string.sub(toAdd, 1, 1)
|
|
if a >= 256 then
|
|
a, b = 0, b + 1
|
|
if b >= 256 then
|
|
dict = {}
|
|
b = 1
|
|
end
|
|
end
|
|
dict[string.char(a, b)] = str
|
|
a = a + 1
|
|
else
|
|
local str = lastStr .. string.sub(lastStr, 1, 1)
|
|
result[n] = str
|
|
n = n + 1
|
|
if a >= 256 then
|
|
a, b = 0, b + 1
|
|
if b >= 256 then
|
|
dict = {}
|
|
b = 1
|
|
end
|
|
end
|
|
dict[string.char(a, b)] = str
|
|
a = a + 1
|
|
end
|
|
last = code
|
|
end
|
|
return table.concat(result)
|
|
end
|
|
|
|
--- Base64-encode a string.
|
|
--- Credit: pfUi enc() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
|
|
---@param to_encode string?
|
|
---@return string
|
|
function Util.Encode(to_encode)
|
|
if type(to_encode) ~= "string" or string.len(to_encode) < 1 then
|
|
return ""
|
|
end
|
|
|
|
local index_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
local bit_pattern = ""
|
|
local encoded = ""
|
|
local trailing = ""
|
|
|
|
for i = 1, string.len(to_encode) do
|
|
local remaining = tonumber(string.byte(string.sub(to_encode, i, i)))
|
|
local bin_bits = ""
|
|
for j = 7, 0, -1 do
|
|
local current_power = math.pow(2, j)
|
|
if remaining >= current_power then
|
|
bin_bits = bin_bits .. "1"
|
|
remaining = remaining - current_power
|
|
else
|
|
bin_bits = bin_bits .. "0"
|
|
end
|
|
end
|
|
bit_pattern = bit_pattern .. bin_bits
|
|
end
|
|
|
|
if mod(string.len(bit_pattern), 3) == 2 then
|
|
trailing = "=="
|
|
bit_pattern = bit_pattern .. "0000000000000000"
|
|
elseif mod(string.len(bit_pattern), 3) == 1 then
|
|
trailing = "="
|
|
bit_pattern = bit_pattern .. "00000000"
|
|
end
|
|
|
|
local count = 0
|
|
for i = 1, string.len(bit_pattern), 6 do
|
|
local byte = string.sub(bit_pattern, i, i + 5)
|
|
local offset = tonumber(tonumber(byte, 2))
|
|
encoded = encoded .. string.sub(index_table, offset + 1, offset + 1)
|
|
count = count + 1
|
|
if count >= 92 then
|
|
encoded = encoded .. BS_NEWLINE
|
|
count = 0
|
|
end
|
|
end
|
|
|
|
return string.sub(encoded, 1, -1 - string.len(trailing)) .. trailing
|
|
end
|
|
|
|
--- Decode a Base64-encoded string.
|
|
--- Credit: pfUi dec() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
|
|
---@param to_decode any
|
|
---@return string?
|
|
function Util.Decode(to_decode)
|
|
if type(to_decode) ~= "string" or string.len(to_decode) < 1 then
|
|
return ""
|
|
end
|
|
|
|
local index_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
local padded = string.gsub(to_decode, "%s", "")
|
|
local unpadded = string.gsub(padded, "=", "")
|
|
local bit_pattern = ""
|
|
local decoded = ""
|
|
|
|
to_decode = string.gsub(to_decode, BS_NEWLINE, "")
|
|
to_decode = string.gsub(to_decode, " ", "")
|
|
|
|
for i = 1, string.len(unpadded) do
|
|
local char = string.sub(to_decode, i, i)
|
|
local offset, _ = string.find(index_table, char)
|
|
if offset == nil then
|
|
return nil
|
|
end
|
|
|
|
local remaining = tonumber(offset - 1)
|
|
local bin_bits = ""
|
|
for i = 7, 0, -1 do
|
|
local current_power = math.pow(2, i)
|
|
if remaining >= current_power then
|
|
bin_bits = bin_bits .. "1"
|
|
remaining = remaining - current_power
|
|
else
|
|
bin_bits = bin_bits .. "0"
|
|
end
|
|
end
|
|
|
|
bit_pattern = bit_pattern .. string.sub(bin_bits, 3)
|
|
end
|
|
|
|
for i = 1, string.len(bit_pattern), 8 do
|
|
local byte = string.sub(bit_pattern, i, i + 7)
|
|
decoded = decoded .. string.char(tonumber(byte, 2))
|
|
end
|
|
|
|
local padding_length = string.len(padded) - string.len(unpadded)
|
|
|
|
if padding_length == 1 or padding_length == 2 then
|
|
decoded = string.sub(decoded, 1, -2)
|
|
end
|
|
|
|
return decoded
|
|
end
|
|
|
|
--#endregion Import/Export
|
|
|
|
--#region Misc
|
|
|
|
--- Compare two objects.
|
|
--- Based on: https://gist.github.com/sapphyrus/fd9aeb871e3ce966cc4b0b969f62f539?permalink_comment_id=4563041#gistcomment-4563041
|
|
---@param obj1 any
|
|
---@param obj2 any
|
|
---@param ignoreKeys table<string, true>? Table keys listed here (expressed in level1.level2.level3 notation) will not affect the outcome of the comparison.
|
|
---@param keyPath string? Recursion parameter for keeping track of the key path.
|
|
---@param maxDepth number? Don't recurse tables any further than this (anything lower than 1 will just return `obj1 == obj2` immediately and is basically pointless).
|
|
---@return boolean equal
|
|
function Util.ObjectsEqual(obj1, obj2, ignoreKeys, keyPath, maxDepth)
|
|
keyPath = keyPath or ""
|
|
|
|
-- Stop once maximum depth reached.
|
|
if type(maxDepth) == "number" and maxDepth <= 0 then
|
|
return obj1 == obj2
|
|
end
|
|
|
|
-- Same object.
|
|
if obj1 == obj2 then
|
|
return true
|
|
end
|
|
|
|
-- Different type.
|
|
if type(obj1) ~= type(obj2) then
|
|
return false
|
|
end
|
|
|
|
-- Same type but not table: we already know they're different
|
|
-- because of the very first test.
|
|
if type(obj1) ~= "table" then
|
|
return false
|
|
end
|
|
|
|
-- Build table key path separated by dots.
|
|
local keyPrefix = string.len(keyPath) > 0 and (keyPath .. ".") or ""
|
|
|
|
-- Iterate over obj1 and make sure every value is found in obj2.
|
|
for key1, value1 in pairs(obj1) do
|
|
local value2 = obj2[key1]
|
|
if
|
|
not (ignoreKeys and ignoreKeys[keyPrefix .. key1])
|
|
and (
|
|
value2 == nil
|
|
or Util.ObjectsEqual(
|
|
value1,
|
|
value2,
|
|
ignoreKeys,
|
|
keyPrefix .. key1,
|
|
(type(maxDepth) == "number" and maxDepth - 1 or nil)
|
|
)
|
|
== false
|
|
)
|
|
then
|
|
return false
|
|
end
|
|
end
|
|
|
|
--- Check for keys that exist in obj2 but not obj1.
|
|
for key2, _ in pairs(obj2) do
|
|
if not (ignoreKeys and ignoreKeys[keyPrefix .. key2]) and obj1[key2] == nil then
|
|
return false
|
|
end
|
|
end
|
|
|
|
-- All table values are equal.
|
|
return true
|
|
end
|
|
|
|
--- Use built-in formatting strings to nicely display remaining time expressed
|
|
--- in the largest unit `timeRemaining` contains.
|
|
---@param timeRemaining number Time in seconds.
|
|
---@return string
|
|
function Util.FormatTimeRemainingString(timeRemaining)
|
|
-- 60 secs in a minute.
|
|
-- 3600 secs in an hour.
|
|
-- 86400 secs in a day.
|
|
local days, hours, minutes, seconds
|
|
days = math.floor(timeRemaining / 86400)
|
|
timeRemaining = timeRemaining - 86400 * days
|
|
hours = math.floor(timeRemaining / 3600)
|
|
timeRemaining = timeRemaining - 3600 * hours
|
|
minutes = math.floor(timeRemaining / 60)
|
|
seconds = math.floor(timeRemaining - 60 * minutes)
|
|
if days > 0 then
|
|
return string.format(_G.INT_SPELL_DURATION_DAYS, days + 1)
|
|
elseif hours > 0 then
|
|
return string.format(_G.INT_SPELL_DURATION_HOURS_P1, hours + 1)
|
|
elseif minutes > 0 then
|
|
return string.format(_G.INT_SPELL_DURATION_MIN, minutes + 1)
|
|
else
|
|
return string.format(_G.INT_SPELL_DURATION_SEC, seconds)
|
|
end
|
|
end
|
|
|
|
--- Transform a money amount into a nicely formatted string.
|
|
--- Credit: `pfUI.api.CreateGoldString()` from [pfUI](https://github.com/shagu/pfUI/) (api.lua) by Shagu.
|
|
---@param money number Amount of money in copper (i.e. `GetMoney()` result).
|
|
---@return string # Colorized gold, silver, copper string (XgYsZc).
|
|
function Util.FormatMoneyString(money)
|
|
if type(money) ~= "number" then
|
|
return "-"
|
|
end
|
|
|
|
local gold = math.floor(money / 100 / 100)
|
|
local silver = math.floor(mod((money / 100), 100))
|
|
local copper = math.floor(mod(money, 100))
|
|
|
|
local string = ""
|
|
if gold > 0 then
|
|
string = string .. "|cffffffff" .. gold .. "|cffffd700g"
|
|
end
|
|
if silver > 0 or gold > 0 then
|
|
string = string .. "|cffffffff " .. silver .. "|cffc7c7cfs"
|
|
end
|
|
string = string .. "|cffffffff " .. copper .. "|cffeda55fc" .. FONT_COLOR_CODE_CLOSE
|
|
|
|
return string
|
|
end
|
|
|
|
--- Always returns `true`.
|
|
--- Used to temporarily override IsAltKeyDown/IsControlKeyDown/IsShiftKeyDown.
|
|
---@return boolean # false
|
|
function Util.ReturnFalse()
|
|
return false
|
|
end
|
|
|
|
--- Always returns `false`.
|
|
--- Used to temporarily override IsAltKeyDown/IsControlKeyDown/IsShiftKeyDown.
|
|
---@return boolean # true
|
|
function Util.ReturnTrue()
|
|
return true
|
|
end
|
|
|
|
--#endregion Misc
|
|
end)
|