Files
github-actions[bot] fc92506470 WeakestAuras 0.18.1
Fewer freezes

Fixes
- Fixed frequent freezes on characters with auras for another class or for items
  not carried: those auras no longer make every aura recompile whenever spell or
  item data arrives.
- Trigger State Updater auras no longer lose their clones when an unrelated aura
  names a spell or item the client cannot look up.

Performance
- Much less garbage during play from dynamic groups, health, power and cast bars
  in raids, spell cooldowns, progress bars and looping animations.

- /wa prof report shows memory allocated per entry, WeakestAuras' share of the
  client's memory churn, and garbage collections with how long their frame took.
  /wa prof report kb sorts by memory.
2026-10-31 18:45:49 +00:00

4937 lines
207 KiB
Lua

-- WeakestAuras -- region types (the visual side of a display). Each type
-- registers defaults, options, create/modify methods, and a properties registry
-- through WeakestAuras.RegisterRegionType. Mirrors WA2's region types (§6).
-- Upstream section refs (§N) point at design/architecture/weakauras2-reference.md
--
-- modify defines setter methods and applies saved config through them, so
-- conditions use the same path to override values. The region consumes a state
-- through region:Update(), which reads region.state and drives the shared
-- progress resolver. Groups are containers holding other auras through
-- controlledChildren; dynamic groups additionally arrange visible children.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
-- Group border thickness. A WoW SetBackdrop edge is painted *inward* from the
-- frame boundary, so a border drawn on the exact content box would sit entirely
-- inside the children. GROUP_BORDER_PAD (half the edge) pushes the border frame
-- outward so the edge straddles the content boundary instead -- the usual
-- wraps-the-content look. The cached blx/bly/trx/try stay the tight content box.
local GROUP_BORDER_EDGE = 12
local GROUP_BORDER_PAD = GROUP_BORDER_EDGE / 2
-- One child's bounding box, in coordinates measured from its group's anchor and
-- computed purely from data -- xOffset/yOffset is the child's anchor position,
-- selfPoint says which of the child's own corners sits there (WA2's Group.lua
-- getRect). No live frame coords, so it's stable regardless of layout timing.
--
-- A child group has no width/height of its own; its extent is its own box,
-- offset by where it sits. Upstream reaches the same leaves by walking the whole
-- subtree flat (Group.lua's TraverseLeafs), which loses each nested group's own
-- offset on the way down -- recursing keeps it.
local childRect
-- A group's own bounding box, in coordinates measured from its own anchor, or
-- nil when it encloses nothing. A static group's is the union of its children's
-- rects and stays data-pure; a dynamic group's depends on which clones are up,
-- so the box its last layout cached is the only thing that can answer, and an
-- unrendered one contributes nothing.
local function groupRect(gdata)
if gdata.regionType == "dynamicgroup" then
local region = WA.PeekRegion and WA.PeekRegion(gdata.id, "")
if region and region.blx then return region.blx, region.bly, region.trx, region.try end
return nil
end
local blx, bly, trx, try
local kids = gdata.controlledChildren or {}
for i = 1, #kids do
local cd = WeakestAurasDB.displays[kids[i]]
if cd and WA.regionPrototype.IsGroupAnchored(cd) then
local a, b, c, d = childRect(cd)
if not blx or a < blx then blx = a end
if not bly or b < bly then bly = b end
if not trx or c > trx then trx = c end
if not try or d > try then try = d end
end
end
return blx, bly, trx, try
end
function childRect(cdata)
local blx = cdata.xOffset or 0
local bly = cdata.yOffset or 0
if WA.IsGroup(cdata) then
local a, b, c, d = groupRect(cdata)
if not a then return blx, bly, blx, bly end
return blx + a, bly + b, blx + c, bly + d
end
local w, h = cdata.width, cdata.height
if not w or not h then return blx, bly, blx, bly end
local sp = cdata.selfPoint or "CENTER"
local trx, try
if string.find(sp, "LEFT") then trx = blx + w
elseif string.find(sp, "RIGHT") then trx = blx; blx = blx - w
else blx = blx - w / 2; trx = blx + w end
if string.find(sp, "BOTTOM") then try = bly + h
elseif string.find(sp, "TOP") then try = bly; bly = bly - h
else bly = bly - h / 2; try = bly + h end
return blx, bly, trx, try
end
-- True if any leaf under this group is currently shown (a live clone with
-- toShow). The group frame is always shown (a transparent container children
-- parent to), so its *border* is what tracks child visibility -- otherwise an
-- empty/muted group would paint a box around nothing (WA2's UpdateBorder
-- childVisible check).
local function groupHasVisibleChild(data)
local kids = data.controlledChildren or {}
for i = 1, #kids do
local id = kids[i]
local shown = false
WA.ForEachClone(id, function(frame)
if frame.toShow and not frame.limited then shown = true end
end)
if shown then return true end
local cd = WeakestAurasDB.displays[id]
if cd and WA.IsGroup(cd) and groupHasVisibleChild(cd) then return true end
end
return false
end
-- Fits the group's frame to the box its caller computed (blx/bly/trx/try, in
-- coordinates measured from the group's anchor) and draws the border round it.
-- Shared by the static and dynamic paths, which each fill the box differently
-- before calling this.
--
-- The box is rarely centred on the anchor -- a DOWN grow's hangs entirely below
-- it -- so sizing the frame is only half of it: the box slot then slides the
-- frame onto the box while the anchor goes on pinning the origin. That is what
-- makes the frame's rectangle the content rectangle, which in turn is what lets
-- the border, the mover and magnetism all read the frame instead of each
-- re-deriving the box from the cached corners.
--
-- `hasBox` is whether the corners describe anything at all. A static group can
-- hold nothing the box is measured from -- every child anchored to a nameplate
-- or a unit frame -- and the degenerate 8px minimum below then reads as a real
-- box, which is the same stray patch at the group's own point that
-- useAnchorPerUnit produces. The dynamic path always has one, its grower having
-- placed every child it iterated whatever that child's own anchor says.
local function drawGroupBox(region, data, blx, bly, trx, try, hasBox)
local changed = region.blx ~= blx or region.bly ~= bly
or region.trx ~= trx or region.try ~= try
region.blx, region.bly, region.trx, region.try = blx, bly, trx, try
region:SetOffsetBox((blx + trx) / 2, (bly + try) / 2)
region:SetWidth(math.max(trx - blx, 8))
region:SetHeight(math.max(try - bly, 8))
local border = region.border
WA.regionPrototype.AnchorArea(border, region, GROUP_BORDER_PAD)
local bc = data.borderColor or { 0, 0, 0, 1 }
border:SetBackdropBorderColor(bc[1], bc[2], bc[3], bc[4])
-- Anchoring per unit puts every child on a frame of its own, so there is no
-- box left to wrap and the border would be a stray patch at the group's own
-- point (upstream's Resize suppresses it on the same test).
if data.border and hasBox and not data.useAnchorPerUnit and groupHasVisibleChild(data) then
border:Show()
else
border:Hide()
end
-- A group's box is its parent's geometry too, and only this call knows it
-- moved. Strictly upward through `parent`, so it terminates; guarded on an
-- actual change so a dynamic group relaying out on every clone update doesn't
-- walk its ancestors each time.
if changed and data.parent then WA.RelayoutGroup(data.parent) end
end
-- Static group: the box is the union of children's data-defined rects (each
-- child keeps its own anchor/offset, so this is stable without live coords).
--
-- The box wraps the children and nothing else. Upstream seeds its union at the
-- origin, which drags the box back to the group's own point whenever the pack
-- sits off to one side of it; a border is supposed to say where the children
-- are.
local function applyGroupBounds(region, data)
local blx, bly, trx, try = groupRect(data)
drawGroupBox(region, data, blx or 0, bly or 0, trx or 0, try or 0, blx ~= nil)
-- Fitting the frame to the box moved it under the children, so their offsets
-- into it moved by the same amount. Only the children the box was measured
-- from: one anchored elsewhere never moved with the frame and owes nothing.
local kids = data.controlledChildren or {}
for i = 1, #kids do
local cd = WeakestAurasDB.displays[kids[i]]
local frames = cd and WA.regionPrototype.IsGroupAnchored(cd) and WA.CloneFrames(cd.id)
if frames then
local x, y = WA.regionPrototype.GroupChildOffset(region, cd)
for _, frame in pairs(frames) do frame:SetOffset(x, y) end
end
end
end
-- Deterministic clone ordering: the comparator algebra a dynamicgroup's `sort`
-- and a `customSort` are built from (WA2 DynamicGroup.lua's sort helpers,
-- ported onto WA under upstream's exact names so a user's customSort composes
-- the way upstream's own documentation shows). A comparator takes two values
-- and answers true/false/**nil** -- nil means "no opinion". That is what lets
-- WA.ComposeSorts fall through to the next comparator, and what stops
-- WA.InvertSort turning "no opinion" into an opinion. Every comparator here is
-- called with exactly two values, so `function(a, b)` replaces upstream's
-- `function(...)`.
function WA.InvertSort(sortFunc)
if type(sortFunc) ~= "function" then
error("InvertSort requires a function to invert.")
end
return function(a, b)
local result = sortFunc(a, b)
if result == nil then return nil end
return not result
end
end
-- Fixed parameters, not a vararg: this client has no `...` expression. Six is
-- the cap because nothing in this addon composes more than that; nil slots
-- are skipped without assuming they're contiguous.
function WA.ComposeSorts(f1, f2, f3, f4, f5, f6)
local sorts = {}
if type(f1) == "function" then table.insert(sorts, f1) end
if type(f2) == "function" then table.insert(sorts, f2) end
if type(f3) == "function" then table.insert(sorts, f3) end
if type(f4) == "function" then table.insert(sorts, f4) end
if type(f5) == "function" then table.insert(sorts, f5) end
if type(f6) == "function" then table.insert(sorts, f6) end
return function(a, b)
for i = 1, #sorts do
local result = sorts[i](a, b)
if result ~= nil then return result end
end
return nil
end
end
function WA.SortNilLast(a, b)
if a == nil and b == nil then
return false
elseif a == nil then
return false
elseif b == nil then
return true
else
return nil
end
end
local sortNilFirst = WA.InvertSort(WA.SortNilLast)
function WA.SortNilFirst(a, b)
if a == nil and b == nil then
return false
else
return sortNilFirst(a, b)
end
end
function WA.SortGreaterLast(a, b)
if a == b then return nil end
if type(a) ~= type(b) then return type(a) > type(b) end
if type(a) == "number" then
if math.abs(b - a) < 0.001 then return nil end
return a < b
elseif type(a) == "string" then
return a < b
else
return nil
end
end
WA.SortGreaterFirst = WA.InvertSort(WA.SortGreaterLast)
function WA.SortRegionData(path, sortFunc)
if type(path) ~= "table" then path = {} end
if type(sortFunc) ~= "function" then sortFunc = WA.SortGreaterLast end
return function(a, b)
local aValue, bValue = a, b
for i = 1, #path do
local key = path[i]
if type(aValue) ~= "table" then return nil end
if type(bValue) ~= "table" then return nil end
aValue, bValue = aValue[key], bValue[key]
end
return sortFunc(aValue, bValue)
end
end
function WA.SortAscending(path)
return WA.SortRegionData(path, WA.ComposeSorts(WA.SortNilFirst, WA.SortGreaterLast))
end
-- Composed rather than upstream's `InvertSort(SortAscending(path))`, and this is
-- the one place the port deliberately parts company with it. Both nil-partition
-- helpers answer *false* for two nils on purpose -- "no swap", which is what
-- keeps equal entries stable -- but inverting that turns it into *true*, so
-- upstream's descending swaps two entries with no sort key on every pass and
-- never reaches the tiebreaker behind it. Ordering is identical for every other
-- pair; only the degenerate one is fixed.
function WA.SortDescending(path)
return WA.SortRegionData(path, WA.ComposeSorts(WA.SortNilLast, WA.SortGreaterFirst))
end
-- Not table.sort: a comparator here may answer nil (no opinion), and
-- customSort is user code that can answer inconsistently -- 5.0's table.sort
-- raises "invalid order function for sorting" on either, where an insertion
-- pass just leaves the pair where it found it. That's the point: a pair the
-- comparator has no opinion about keeps the order the list arrived in.
local function stableSort(list, cmp)
if not cmp then return end
for i = 2, #list do
local item = list[i]
local j = i
while j > 1 and cmp(item, list[j - 1]) do
list[j] = list[j - 1]
j = j - 1
end
list[j] = item
end
end
-- The currently-shown child region frames of a dynamicgroup, in controlledChildren
-- order, one entry per visible clone. Each carries its size (from data, falling
-- back to the live frame) plus the fields WA2's regionData carries (id, cloneId,
-- dataIndex, region, data, dimensions, parent) so a customSort or a customGrow
-- can read them. The one field of upstream's shape deliberately absent is
-- `controlPoint`: it names a frame this addon does not have, and it appears
-- nowhere in upstream outside its own DynamicGroup.lua, so no code written
-- against the documented contract reaches for it.
--
-- WA.ForEachClone walks entry.byClone with pairs(), a hash whose iteration order
-- can differ between two passes over the same set -- without this stableSort, a
-- clone the eventual sort key ties on would take whatever position the hash gave
-- it and drift between layouts. (dataIndex, cloneSeq) is the deterministic base
-- order every sort mode composes on top of: dataIndex groups by
-- controlledChildren order, cloneSeq (stamped on the frame at acquisition)
-- breaks ties within one display, base clone first.
--
-- Built once: this runs on every relayout, and each SortAscending is six
-- closures and two tables.
local baseOrder = WA.ComposeSorts(WA.SortAscending({ "dataIndex" }), WA.SortAscending({ "cloneSeq" }))
-- The list is the group's own and each entry is its child frame's own, both
-- refilled per layout rather than rebuilt: a relayout follows every state
-- change of every child, and the tables were most of what one cost. Upstream
-- keeps its regionData per child across layouts too, so code written against
-- it does not expect a fresh one.
local function activeChildren(region, data)
local list = region.layoutList
if not list then
list = {}
region.layoutList = list
end
for i = #list, 1, -1 do list[i] = nil end
local parent = WA.PeekRegion(data.id, "")
local kids = data.controlledChildren or {}
for i = 1, #kids do
local childId = kids[i]
local cd = WeakestAurasDB.displays[childId]
local clones = cd and WA.CloneFrames(childId)
if clones then
for cloneId, frame in pairs(clones) do
if frame.toShow or frame.animatingFinish then
local entry = frame.layoutEntry
if not entry then
entry = {}
-- WA2's regionData.dimensions is whichever table carries the
-- child's size; ours already is one, so pointing the field at
-- the entry gives an imported customGrow its
-- `regionData.dimensions.width` without a second table.
entry.dimensions = entry
frame.layoutEntry = entry
end
entry.region, entry.data, entry.id, entry.cloneId = frame, cd, childId, cloneId
entry.dataIndex, entry.cloneSeq = i, frame.cloneSeq or 0
entry.width = cd.width or (frame:GetWidth() or 0)
entry.height = cd.height or (frame:GetHeight() or 0)
entry.parent = parent
list[#list + 1] = entry
end
end
end
end
stableSort(list, baseOrder)
return list
end
-- data.sort's comparator, one per mode. Each entry returns a comparator (or
-- nil for "no opinion", i.e. leave activeChildren's deterministic base order).
local sorters = {
none = function(data)
return WA.ComposeSorts(
WA.SortAscending({ "dataIndex" }),
WA.SortAscending({ "region", "state", "index" })
)
end,
ascending = function(data)
return WA.ComposeSorts(
WA.SortAscending({ "region", "state", "expirationTime" }),
WA.SortAscending({ "dataIndex" })
)
end,
descending = function(data)
return WA.ComposeSorts(
WA.SortDescending({ "region", "state", "expirationTime" }),
WA.SortAscending({ "dataIndex" })
)
end,
hybrid = function(data)
local sortHybridTable = data.sortHybridTable or {}
local hybridSortAscending = data.hybridSortMode == "ascending"
local hybridFirst = data.hybridPosition == "hybridFirst"
local function sortHybridStatus(a, b)
if not b then return true end
if not a then return false end
local aIsHybrid = sortHybridTable[a.id]
local bIsHybrid = sortHybridTable[b.id]
if aIsHybrid and not bIsHybrid then
return hybridFirst
elseif bIsHybrid and not aIsHybrid then
return not hybridFirst
else
return nil
end
end
local sortExpirationTime
if hybridSortAscending then
sortExpirationTime = WA.SortAscending({ "region", "state", "expirationTime" })
else
sortExpirationTime = WA.SortDescending({ "region", "state", "expirationTime" })
end
return WA.ComposeSorts(sortHybridStatus, sortExpirationTime, WA.SortAscending({ "dataIndex" }))
end,
custom = function(data)
-- Blank is not broken: an aura arriving through import has `sort =
-- "custom"` and an empty editor, and compiling that would report a
-- failure on every WA.Add. Same whitespace test the generic trigger's
-- optional code fields use.
local source = data.customSort
if not source or not string.find(source, "%S") then return nil end
local errTag = tostring(data.id) .. ": custom sort"
local sortFunc = WA.LoadFunction(source, errTag)
if not sortFunc then return nil end
-- A comparator is the only user-code site this addon calls O(n^2) times
-- per pass, and the pass repeats on every state update -- so one that
-- throws would bury its own first report under thousands of copies and
-- take the chat frame with it. It is consulted until it throws and not
-- again for the rest of that pass: exactly one report, and the pairs it
-- never saw keep the deterministic base order. `WA.safecall` stays
-- honest for every other site rather than growing a dedup nobody else
-- needs.
local broken = false
return function(a, b)
if broken then return nil end
local ok, result = WA.RunAuraFunc(data.id, errTag, sortFunc, a, b)
if ok then return result end
broken = true
return nil
end, function() broken = false end
end,
}
-- Returns the comparator, plus (custom sort only) the per-pass reset that
-- re-arms its one-report budget.
local function createSortFunc(data)
local sorter = sorters[data.sort] or sorters.none
return sorter(data)
end
-- Upstream's staggerCoefficient verbatim: which end of a staggered run sits at
-- offset 0. Upstream's alignment argument is direction-dependent (LEFT/RIGHT
-- name an edge of whichever axis is perpendicular to that grow); reused below
-- against our flat LEFT/CENTER/RIGHT align, which only carries meaning for the
-- vertical grows.
local function staggerCoefficient(align, stagger)
if align == "LEFT" then
if stagger < 0 then return 1 else return 0 end
elseif align == "RIGHT" then
if stagger > 0 then return 1 else return 0 end
end
return 0.5
end
-- Per-unit anchoring (WA2 DynamicGroup.lua's `anchorers`): each clone is placed
-- against the frame showing *its own* unit rather than against the group, so one
-- dynamic group can put an icon on every nameplate or party frame at once. The
-- clone's unit comes from the producer's `state.unit` -- Stage 10c's families
-- write it, and a clone with no unit has nothing to anchor to.
--
-- Upstream anchors a pooled control point per unit; ours anchors the child
-- region itself, which is why there is no control-point pool here. The child
-- stays parented to the group (scale, strata and dragging still cascade) and
-- only its SetPoint target changes -- an anchor tracks a moving nameplate by
-- itself, so a plate sliding across the screen costs nothing per frame.
local function regionUnit(entry)
local state = entry.region and entry.region.state
if not state then return nil, nil end
return state.unit or state.unitId, state.guid
end
local anchorers = {
NAMEPLATE = function(region, data, entry)
local unit, guid = regionUnit(entry)
return unit and WA.GetUnitNameplate and WA.GetUnitNameplate(unit, guid) or nil
end,
UNITFRAME = function(region, data, entry)
local unit = regionUnit(entry)
return unit and WA.GetUnitFrame and WA.GetUnitFrame(unit) or nil
end,
}
-- The custom anchorer keeps upstream's signature -- function(frames,
-- activeRegions), filling frames[frame] with the regionDatas that belong to it
-- -- so an upstream custom anchor runs here unchanged.
local function customAnchorFrames(region, data, list)
local source = data.customAnchorPerUnit
if not source or not string.find(source, "%S") then return nil end
local errTag = tostring(data.id) .. ": custom anchor"
if not region.anchorFuncBuilt then
region.anchorFunc = WA.LoadFunction(source, errTag)
region.anchorFuncBuilt = true
end
if not region.anchorFunc then return nil end
local frames = {}
local ok = WA.RunAuraFunc(data.id, errTag, region.anchorFunc, frames, list)
if not ok then
-- The layout re-runs on every state update of every child, so an anchor
-- that throws would report on every one. It is dropped for the rest of
-- this compile instead: one report, and the next WA.Add -- which any edit
-- to the aura is -- hands it back.
region.anchorFunc = nil
return nil
end
return frames
end
-- Orders a frame-keyed hash of runs so the layout never inherits `pairs` order,
-- and lets the first run to claim an entry keep it. Shared by the custom
-- anchorer and the custom grower, both of which hand back such a hash: `rank`
-- is the entry's place in the ordered list, and a run is ranked by its earliest
-- member. Returns an array of { frame, entries }.
local function rankRuns(runs, list)
local total = #list
local rank = {}
for i = 1, total do rank[list[i]] = i end
local ranked = {}
for frame, entries in pairs(runs) do
local first = total + 1
for i = 1, #entries do
local at = rank[entries[i]]
if at and at < first then first = at end
end
table.insert(ranked, { frame = frame, entries = entries, first = first })
end
stableSort(ranked, WA.SortAscending({ "first" }))
local claimed = {}
for r = 1, #ranked do
local entries = ranked[r].entries
for i = #entries, 1, -1 do
if claimed[entries[i]] then table.remove(entries, i)
else claimed[entries[i]] = true end
end
end
return ranked
end
-- Groups the ordered child list by the frame each clone anchors to. Returns an
-- array of { frame, entries }, with `frame` nil for the run the group itself
-- anchors. Partition order follows the first entry of each run, so the layout
-- does not inherit the iteration order of a hash -- the same reason
-- activeChildren sorts before anything reads it.
local function groupRun(region, list)
local runs = region.layoutRuns
if not runs then
runs = { {} }
region.layoutRuns = runs
end
runs[1].entries = list
return runs
end
local function anchorPartitions(region, data, list)
local total = #list
if not data.useAnchorPerUnit then return groupRun(region, list) end
local buckets, order = {}, {}
local function bucketFor(frame)
local bucket = buckets[frame]
if not bucket then
bucket = { frame = frame, entries = {} }
buckets[frame] = bucket
table.insert(order, bucket)
end
return bucket
end
local mode = data.anchorPerUnit or "NAMEPLATE"
if mode == "CUSTOM" then
local frames = customAnchorFrames(region, data, list)
if not frames then return groupRun(region, list) end
-- The custom anchorer hands back a frame-keyed hash, so the runs come out
-- in whatever order `pairs` feels like. rankRuns is what makes a custom
-- anchor that assigns one clone to two frames lay out the same way twice
-- running instead of flipping between them.
return rankRuns(frames, list)
end
-- A clone whose unit has no frame right now still exists and still holds its
-- state; it parks on the hidden anchor frame rather than being dropped, so it
-- reappears in place the moment the frame does. With the options window open
-- there is no real unit behind a preview clone at all, so the group anchors
-- its own children and the editor shows the layout instead of nothing.
local anchorer = anchorers[mode] or anchorers.NAMEPLATE
local fallback = WA.optionsOpen and nil or (WA.HiddenAnchorFrame and WA.HiddenAnchorFrame())
for i = 1, total do
local entry = list[i]
local frame = anchorer(region, data, entry)
table.insert(bucketFor(frame or fallback).entries, entry)
end
return order
end
local function centerSpan(list, from, to, on)
local low, high
for i = from, to do
local c = list[i]
local half = c[on.dim] / 2
if not low or c[on.axis] - half < low then low = c[on.axis] - half end
if not high or c[on.axis] + half > high then high = c[on.axis] + half end
end
if not low then return end
local shift = (low + high) / 2
for i = from, to do
local c = list[i]
c[on.axis] = c[on.axis] - shift
end
end
local gridX, gridY = { axis = "x", dim = "width" }, { axis = "y", dim = "height" }
-- Two-axis grower: `gridType` is a fill direction then a wrap direction -- "RD"
-- fills rightward and wraps down, "UL" upward then left -- with an H or V in
-- the first letter centering each finished row on the anchor and in the second
-- centering the whole block. Swapping the two descriptors is what lets one loop
-- serve all eighteen combinations.
--
-- Ours is CENTER-origin where upstream's is corner-origin, so each axis converts
-- by half the child's own dimension and the centering passes work on box edges.
-- Upstream's take min/max over corners instead, which leaves a row half its last
-- child's width off center -- identical for same-size icons, wrong for anything
-- else. `align` and `stagger` say nothing about a two-axis layout and are
-- ignored here, as they are upstream.
local function growGrid(data, list, n)
local gridType = data.gridType or "RD"
local perLine = tonumber(data.gridWidth) or 5
if perLine < 1 then perLine = 1 end
gridX.space, gridX.mul = data.columnSpace or 0, string.find(gridType, "L", 1, true) and -1 or 1
gridY.space, gridY.mul = data.rowSpace or 0, string.find(gridType, "D", 1, true) and -1 or 1
local primary, secondary = gridX, gridY
if not string.find(gridType, "^[RLH]") then
primary, secondary = secondary, primary
end
local first, second = string.sub(gridType, 1, 1), string.sub(gridType, 2, 2)
local centerRows = (first == "H" or first == "V")
local centerBlock = (second == "H" or second == "V")
-- Every child of one line sits at the same secondary coordinate, so a line of
-- mixed heights aligns on its leading edge; the line's own tallest child is
-- what the secondary axis then advances by.
local pCursor, sCursor, tallest, lineStart = 0, 0, 0, 1
for i = 1, n do
local c = list[i]
local pDim, sDim = c[primary.dim], c[secondary.dim]
c[primary.axis] = pCursor + primary.mul * pDim / 2
c[secondary.axis] = sCursor + secondary.mul * sDim / 2
if sDim > tallest then tallest = sDim end
if math.fmod(i, perLine) == 0 then
if centerRows then centerSpan(list, lineStart, i, primary) end
pCursor, lineStart = 0, i + 1
sCursor = sCursor + (secondary.space + tallest) * secondary.mul
tallest = 0
else
pCursor = pCursor + (primary.space + pDim) * primary.mul
end
end
if centerRows and lineStart <= n then centerSpan(list, lineStart, n, primary) end
if centerBlock then centerSpan(list, 1, n, secondary) end
end
-- Polar grower: children ride a ring of `radius` from the anchor, starting at
-- `rotation` and stepping by whichever of the three `constantFactor` modes is
-- picked -- RADIUS spreads the arc over the ring, ANGLE steps a fixed
-- stepAngle, SPACING derives the radius from `space` and the count instead.
-- `align` and `stagger` say nothing about a ring and are ignored here, as they
-- are upstream.
--
-- The two names are one grower differing only in the sign of dAngle, and their
-- labels read backwards: CIRCLE advances theta, and against WoW's y-up frame
-- coordinates an increasing theta is counter-clockwise. Upstream's labels are
-- the right way round; it is the internal names that mislead.
local function growCircle(data, list, n)
local constantFactor = data.constantFactor or "RADIUS"
local arc = (data.fullCircle and 360 or data.arcLength or 0) * math.pi / 180
local mul = (data.grow == "COUNTERCIRCLE") and -1 or 1
local r
if constantFactor == "RADIUS" or constantFactor == "ANGLE" then
r = data.radius or 0
elseif n <= 1 then
r = 0
else
r = (n * (data.space or 0)) / (2 * math.pi)
end
-- A full circle divides the arc by n so the last child does not land on top
-- of the first; an arc divides by n - 1 because both of its endpoints are
-- wanted.
local dAngle
if n == 1 then
dAngle = 0
elseif constantFactor == "ANGLE" then
dAngle = mul * (data.stepAngle or 0) * math.pi / 180
elseif data.fullCircle then
dAngle = mul * arc / n
else
dAngle = mul * arc / (n - 1)
end
local theta = (data.rotation or 0) * math.pi / 180
for i = 1, n do
local c = list[i]
c.x, c.y = r * math.cos(theta), r * math.sin(theta)
theta = theta + dAngle
end
end
-- Cross-axis alignment for the vertical grows (UP/DOWN/VERTICAL): line up the
-- children's left/right edges (LEFT/RIGHT) or centers (CENTER) on x = 0.
-- Only affects mixed-width children; identical widths land the same either
-- way. The horizontal grows keep children centered on y (align n/a there).
local function crossX(align, w)
if align == "LEFT" then return w / 2
elseif align == "RIGHT" then return -w / 2
else return 0 end
end
-- Perpendicular stagger offset for child i (1-based) of an n-run. Our
-- grower is CENTER-origin where upstream's is corner-origin, and our align
-- vocabulary is LEFT/CENTER/RIGHT only (upstream's is direction-dependent),
-- so the vertical grows (UP/DOWN/VERTICAL) stagger on x via crossX using
-- staggerCoefficient(align, stagger), while the horizontal grows (LEFT/
-- RIGHT/HORIZONTAL) stagger on y with a flat 0.5 (run centred), since
-- local align says nothing about that axis there.
local function perpOffset(i, n, stagger, staggerCoeff)
return (i - 1) * stagger - (n - 1) * stagger * staggerCoeff
end
-- Axis-aligned grower: assigns each visible child of one run a position relative
-- to that run's anchor, stacking successive children by their own dimension +
-- spacing (WA2's DynamicGroup.lua growers, minus animation). HORIZONTAL/VERTICAL
-- center the run on the anchor; the four cardinals grow from it; GRID wraps
-- (growGrid) and the two circles ring it (growCircle). Shortens the list to the
-- visible ones (parking the rest) and returns the box extents.
local function growRun(data, list)
local total = #list
local visible = total
if data.useLimit then
visible = tonumber(data.limit) or 0
if visible < 0 then visible = 0 end
if visible > total then visible = total end
end
-- A clone past the limit gives up its painted region without giving up its
-- state (region:SetLimited) -- it comes back in place the moment the limit
-- frees up rather than losing its spot in the order.
for i = 1, total do
list[i].region:SetLimited(i > visible)
end
for i = total, visible + 1, -1 do
table.remove(list, i)
end
local space = data.space or 0
local grow = data.grow or "DOWN"
local n = #list
local blx, bly, trx, try
local align = data.align or "CENTER"
local stagger = data.stagger or 0
local vertical = (grow == "UP" or grow == "DOWN" or grow == "VERTICAL")
local staggerCoeff = vertical and staggerCoefficient(align, stagger) or 0.5
if grow == "GRID" then
growGrid(data, list, n)
elseif grow == "CIRCLE" or grow == "COUNTERCIRCLE" then
growCircle(data, list, n)
elseif grow == "HORIZONTAL" or grow == "VERTICAL" then
local runLength = 0
for i = 1, n do
runLength = runLength + (grow == "HORIZONTAL" and list[i].width or list[i].height)
end
if n > 1 then runLength = runLength + space * (n - 1) end
local cursor = -runLength / 2
for i = 1, n do
local c = list[i]
local dim = (grow == "HORIZONTAL") and c.width or c.height
local center = cursor + dim / 2
if grow == "HORIZONTAL" then
c.x, c.y = center, perpOffset(i, n, stagger, staggerCoeff)
else
c.x, c.y = crossX(align, c.width) + perpOffset(i, n, stagger, staggerCoeff), -center
end
cursor = cursor + dim + space
end
else
local cursor = 0
for i = 1, n do
local c = list[i]
local perp = perpOffset(i, n, stagger, staggerCoeff)
if grow == "LEFT" then
c.x = cursor - c.width / 2; c.y = perp; cursor = cursor - c.width - space
elseif grow == "RIGHT" then
c.x = cursor + c.width / 2; c.y = perp; cursor = cursor + c.width + space
elseif grow == "UP" then
c.x = crossX(align, c.width) + perp; c.y = cursor + c.height / 2; cursor = cursor + c.height + space
else -- DOWN
c.x = crossX(align, c.width) + perp; c.y = cursor - c.height / 2; cursor = cursor - c.height - space
end
end
end
-- Seeded from the first child rather than from zero, as upstream's growers
-- are. Every grow here happens to leave one edge of the run on the origin --
-- the cursor starts there, and staggerCoefficient is what pins the
-- perpendicular end -- so the two seeds agree today; the union is the
-- children's either way, and a grower that ever cleared the origin would not
-- have to know about this.
for i = 1, n do
local c = list[i]
local l, r, b, t = c.x - c.width / 2, c.x + c.width / 2,
c.y - c.height / 2, c.y + c.height / 2
if not blx or l < blx then blx = l end
if not trx or r > trx then trx = r end
if not bly or b < bly then bly = b end
if not try or t > try then try = t end
end
return list, blx or 0, bly or 0, trx or 0, try or 0
end
-- Custom grow (WA2 DynamicGroup.lua's growers.CUSTOM): the whole ordered clone
-- list goes to the user's function, which writes positions back rather than
-- having them derived from a direction on `data`.
--
-- Upstream's coordinate contract is the child's `selfPoint` offset from the
-- group's `selfPoint`, carried on a control point anchored selfPoint-to-selfPoint
-- and sized to the child -- so the control point cancels out of the arithmetic
-- and the numbers are the child's own. Picking CUSTOM there forces selfPoint to
-- CENTER (its grow select has no direction to derive one from), which is what
-- every group here anchors by, so an upstream grow function's numbers mean to
-- layoutDynamicGroup exactly what they meant to upstream.
local function customGrowPositions(region, data, list)
local source = data.customGrow
if not source or not string.find(source, "%S") then return nil end
local errTag = tostring(data.id) .. ": custom grow"
if not region.growFuncBuilt then
region.growFunc = WA.LoadFunction(source, errTag)
region.growFuncBuilt = true
end
if not region.growFunc then return nil end
local positions = {}
local ok = WA.RunAuraFunc(data.id, errTag, region.growFunc, positions, list)
if not ok then
-- Dropped for the rest of this compile, for customAnchorFrames' reason:
-- the layout re-runs on every state update of every child, so a grow that
-- throws would report on every one. Upstream wipes the positions table
-- instead, which is the same "nothing moves this pass" outcome without the
-- budget.
region.growFunc = nil
return nil
end
return positions
end
-- One { x, y, show } tuple, read as WA2's DoPositionChildrenPerFrame reads it: a
-- non-number coordinate is 0, and anything but an explicit false shows.
local function readGrowPosition(pos)
local x = type(pos[1]) == "number" and pos[1] or 0
local y = type(pos[2]) == "number" and pos[2] or 0
return x, y, pos[3] ~= false
end
-- Places the clones a custom grow returned. Both of upstream's return shapes are
-- read: an array indexed by the sorted list, which can only mean the run the
-- group itself anchors, or a hash of anchor frame -> (entry -> position) where
-- `""` names the group. Returns the same tuple growRun does per run.
--
-- A clone the grow function does not place gives up its painted region without
-- giving up its state -- the same SetLimited the visible-clone cap uses, and
-- what upstream's un-shown control point amounts to. That covers a `show` of
-- false, a grow that returns nothing, and one that threw.
local function growCustom(region, data, list)
local positions = customGrowPositions(region, data, list)
local total = #list
local placed, byFrame, runs = {}, nil, nil
local blx, bly, trx, try
if positions and #positions > 0 then
local entries, pos = {}, {}
for i = 1, #positions do
if type(positions[i]) == "table" then
local entry = list[i]
if entry then
table.insert(entries, entry)
pos[entry] = positions[i]
elseif WA.ReportForAura then
WA.ReportForAura(data.id, "runtime:custom grow", "error",
tostring(data.id) .. ": custom grow returned a position for slot "
.. i .. ", which holds no aura", true)
end
end
end
byFrame = { [""] = pos }
runs = { { frame = "", entries = entries } }
elseif positions then
byFrame = positions
local frames = {}
for frame, pos in pairs(positions) do
if type(pos) == "table" then
local entries = {}
for i = 1, total do
if type(pos[list[i]]) == "table" then table.insert(entries, list[i]) end
end
frames[frame] = entries
end
end
-- Frame-keyed positions arrive in `pairs` order, and a grow that puts one
-- clone under two frames would otherwise land wherever the hash fell that
-- pass -- the custom anchorer's problem, and its answer.
runs = rankRuns(frames, list)
end
local held = {}
for r = 1, #(runs or {}) do
local run = runs[r]
local pos = byFrame[run.frame]
local frame = run.frame ~= "" and run.frame or nil
for i = 1, #run.entries do
local entry = run.entries[i]
local x, y, show = readGrowPosition(pos[entry])
entry.x, entry.y, entry.anchorFrame = x, y, frame
entry.region:SetLimited(not show)
held[entry] = true
table.insert(placed, entry)
if show and not frame then
local l, r2, b, t = x - entry.width / 2, x + entry.width / 2,
y - entry.height / 2, y + entry.height / 2
if not blx or l < blx then blx = l end
if not trx or r2 > trx then trx = r2 end
if not bly or b < bly then bly = b end
if not try or t > try then try = t end
end
end
end
for i = 1, total do
if not held[list[i]] then list[i].region:SetLimited(true) end
end
return placed, blx or 0, bly or 0, trx or 0, try or 0
end
-- Orders the group's clones, splits them into anchor runs, and grows each run
-- from its own anchor. Returns every placed child (each carrying the frame it
-- anchors to) plus the box extents of the run the group itself holds -- with
-- per-unit anchoring nothing is anchored to the group, so the box collapses to
-- the group's own point, which is what a border around a set of nameplates
-- would have to mean anyway.
local function growChildren(region, data)
local list = activeChildren(region, data)
if not region.sortFuncBuilt then
region.sortFunc, region.sortBeginPass = createSortFunc(data)
region.sortFuncBuilt = true
end
if region.sortBeginPass then region.sortBeginPass() end
stableSort(list, region.sortFunc)
-- A custom grow decides its own runs, so it replaces the anchor partition and
-- the grower both.
if data.grow == "CUSTOM" then return growCustom(region, data, list) end
local placed = region.layoutPlaced
if not placed then
placed = {}
region.layoutPlaced = placed
end
for i = #placed, 1, -1 do placed[i] = nil end
local blx, bly, trx, try = 0, 0, 0, 0
local runs = anchorPartitions(region, data, list)
for r = 1, #runs do
local run = runs[r]
local entries, a, b, c, d = growRun(data, run.entries)
for i = 1, #entries do
entries[i].anchorFrame = run.frame
table.insert(placed, entries[i])
end
if not run.frame then blx, bly, trx, try = a, b, c, d end
end
return placed, blx, bly, trx, try
end
local LAYOUT_SLIDE_DURATION = 0.2
local TWO_PI = math.pi * 2
-- Animated expand and collapse (WA2's `data.animate`): a surviving child slides
-- to its new slot rather than snapping there when one of its siblings comes,
-- goes or re-sorts.
--
-- The group owns this tween outright and never calls WA.Animate. That is not
-- thrift: the animation registry holds one animation per frame, so a child that
-- both pulses and slides would have the two fighting over the same slot --
-- which is the whole reason upstream gives every child a second frame (its
-- control point) to slide instead. Here the fourth offset slot plays that part,
-- and a child's own translate animation composes on top exactly as upstream's
-- does.
--
-- The layout writes the child's *new* position into its config slot as it always
-- has; all that moves is the layout slot, decaying the old-minus-new delta to
-- zero.
local slides, slidesRunning = {}, false
local slideFrame = CreateFrame("Frame")
local slideLast = GetTime()
-- Ends a slide where the child stands rather than where it was headed: dropping
-- the layout slot leaves the config slot, which already holds the position the
-- last layout gave it.
local function stopSlide(region)
if not slides[region] then return end
slides[region] = nil
region:SetOffsetLayout(0, 0)
end
WA.StopLayoutSlide = stopSlide
-- Split from the OnUpdate script so the headless harness can drive it: this
-- client's frames tick against a clock nothing offline has.
local function advanceSlides(elapsed)
local live = false
for region, s in pairs(slides) do
s.elapsed = s.elapsed + elapsed
local t = s.elapsed / LAYOUT_SLIDE_DURATION
if t >= 1 then
slides[region] = nil
region:SetOffsetLayout(0, 0)
else
live = true
if s.radius then
local r = s.radius + t * s.dRadius
local a = s.angle + t * s.dAngle
region:SetOffsetLayout(math.cos(a) * r - s.x, math.sin(a) * r - s.y)
else
region:SetOffsetLayout(s.dx * (1 - t), s.dy * (1 - t))
end
end
end
if not live then
slidesRunning = false
slideFrame:SetScript("OnUpdate", nil)
end
end
WA.AdvanceLayoutSlides = advanceSlides
local function slideTick()
local now = GetTime()
local elapsed = now - slideLast
slideLast = now
advanceSlides(elapsed)
end
-- `x`/`y` are the child's position in the group's own coordinates -- before any
-- per-frame nudge -- because the polar path measures its origin from them, and
-- the offsets cancel out of a delta anyway.
local function startSlide(region, dx, dy, x, y, grow)
if dx * dx + dy * dy < 0.0001 then stopSlide(region) return end
local s = { elapsed = 0, dx = dx, dy = dy }
if grow == "CIRCLE" or grow == "COUNTERCIRCLE" then
-- On a ring, travel along it rather than chording across it: interpolate
-- the two slots' polar coordinates about the group's centre, the short way
-- round. Upstream builds the same curve as a custom translateFunc.
local fromX, fromY = x + dx, y + dy
local r1 = math.sqrt(fromX * fromX + fromY * fromY)
local a1 = math.atan2(fromY, fromX)
local dA = math.atan2(y, x) - a1
while dA > math.pi do dA = dA - TWO_PI end
while dA < -math.pi do dA = dA + TWO_PI end
s.radius, s.dRadius = r1, math.sqrt(x * x + y * y) - r1
s.angle, s.dAngle, s.x, s.y = a1, dA, x, y
end
slides[region] = s
region:SetOffsetLayout(dx, dy)
if not slidesRunning then
slidesRunning = true
slideLast = GetTime()
slideFrame:SetScript("OnUpdate", slideTick)
end
end
-- Dynamic group: run the grower, push each visible child's computed position
-- onto its region (CENTER-to-CENTER + offset, via the region's own offset slot
-- so UpdatePosition stays authoritative), then draw the box around the result.
--
-- A run anchored to a foreign frame takes the group's own anchor tuple as the
-- nudge off that frame -- `anchorPoint` for which of its corners, `xOffset`/
-- `yOffset` for how far -- which is upstream's DoPositionChildrenPerFrame and the
-- only way to sit an icon *above* a nameplate rather than on top of it. The
-- group's own screen position means nothing to such a run, so the tuple is free
-- to say something else.
local function layoutDynamicGroup(region, data)
local list, blx, bly, trx, try = growChildren(region, data)
local anchorPoint = WA.regionPrototype.ResolveAnchorPoint(data.anchorPoint, "CENTER")
local xOff, yOff = data.xOffset or 0, data.yOffset or 0
-- The grower works from the group's anchor, but drawGroupBox will fit the
-- frame to the box the run actually filled -- so a child anchored to the group
-- owes back the difference between the two origins. A run on a foreign frame
-- measures from that frame and owes nothing. The *previous* centre is what the
-- live offsets below are still measured against.
local cx, cy = (blx + trx) / 2, (bly + try) / 2
local wasCx, wasCy = region.xOffsetBox or 0, region.yOffsetBox or 0
for i = 1, #list do
local c = list[i]
local r = c.region
local frame = c.anchorFrame or region
local point = c.anchorFrame and anchorPoint or "CENTER"
local x = c.anchorFrame and (c.x + xOff) or c.x
local y = c.anchorFrame and (c.y + yOff) or c.y
-- Two cases have no delta worth animating, whatever the toggle says. A
-- child this layout has never placed has no old position, only a default
-- zero; and one that changed anchor frame -- a clone handed from one
-- nameplate to another -- has an old position measured in a coordinate
-- space the new one knows nothing about. Both snap.
local slide = data.animate and r.layoutPlaced
and r.anchorFrame == frame and r.anchorPoint == point
local fromX, fromY
if slide then
-- The live visual position, not the last one the layout wrote: a
-- relayout landing mid-slide continues from where the child actually
-- is, so a group churning children glides instead of stuttering.
-- Upstream measures from its last layout position and stutters.
--
-- Read back into the coordinates `x`/`y` are in, so a box centre that
-- moved between the two layouts cancels: the frame carries that shift
-- itself, and counting it again would slide every surviving child by a
-- distance it never travelled.
fromX = (r.xOffset or 0) + (r.xOffsetLayout or 0) + (c.anchorFrame and 0 or wasCx)
fromY = (r.yOffset or 0) + (r.yOffsetLayout or 0) + (c.anchorFrame and 0 or wasCy)
end
r:SetAnchor("CENTER", frame, point)
if c.anchorFrame then r:SetOffset(x, y) else r:SetOffset(x - cx, y - cy) end
r.layoutPlaced = true
if slide then
startSlide(r, fromX - x, fromY - y, c.x, c.y, data.grow)
else
stopSlide(r)
end
end
drawGroupBox(region, data, blx, bly, trx, try, true)
end
-- Every dynamic group anchoring per unit, re-laid-out because the frames it
-- anchors to changed -- a plate appearing or leaving, a roster shuffle moving a
-- unit to another party frame. A plate that merely *moves* needs nothing: the
-- child is anchored to it, so it travels along.
function WA.RelayoutUnitAnchoredGroups()
for id, data in pairs(WeakestAurasDB and WeakestAurasDB.displays or {}) do
if data.regionType == "dynamicgroup" and data.useAnchorPerUnit then
WA.RelayoutGroup(id)
end
end
end
local function relayoutNow(groupId)
local data = WeakestAurasDB.displays[groupId]
if not data or not WA.IsGroup(data) then return end
local region = WA.PeekRegion(groupId, "")
if not region then return end
if data.regionType == "dynamicgroup" then
layoutDynamicGroup(region, data)
else
applyGroupBounds(region, data)
end
end
-- A dispatch that updates several displays at once runs inside a batch, and
-- each group they belong to relays out once when it closes rather than once per
-- child -- upstream's Pause/ResumeAllDynamicGroups around a scan. Closing
-- happens before the dispatch returns, so nothing renders in between.
local relayoutDepth, relayoutPending = 0, {}
-- Refresh a group after a child's position/size/visibility/membership changed:
-- static groups just recompute the box, dynamic groups re-run the whole layout.
-- No-op until the group frame exists. The single relayout entry point the state
-- machine and reparent primitives call.
function WA.RelayoutGroup(groupId)
if relayoutDepth > 0 then
relayoutPending[groupId] = true
return
end
relayoutNow(groupId)
end
-- Runs fn(arg) as one batch. The depth is unwound even when fn throws: a batch
-- left open would defer every later relayout for the rest of the session.
function WA.RunRelayoutBatch(fn, arg)
relayoutDepth = relayoutDepth + 1
local ok, err = pcall(fn, arg)
relayoutDepth = relayoutDepth - 1
if relayoutDepth == 0 then
local id = next(relayoutPending)
while id do
relayoutPending[id] = nil
relayoutNow(id)
id = next(relayoutPending)
end
end
if not ok then error(err, 0) end
end
-- group and dynamicgroup share one container implementation: a frame positioned
-- by its own anchor tuple, scaled by data.scale (children inherit via SetParent
-- in ApplyPosition), with an optional bounding-box border. A static group's
-- children keep their own anchor; a dynamicgroup's are arranged by the grower
-- (layoutDynamicGroup) whenever the visible set changes.
local function groupCreate(parent, data)
local region = CreateFrame("Frame", nil, parent)
local border = CreateFrame("Frame", nil, region)
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
edgeSize = GROUP_BORDER_EDGE,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
region.border = border
WA.regionPrototype.create(region)
function region:Update() end -- groups hold no state
region:Hide()
return region
end
local function groupModify(region, data)
-- Which corner of a group is pinned is not the free choice it is for a leaf:
-- the frame is fitted to a box whose size and offset both move as children
-- come and go, so only the centre -- the point the box slot is measured from,
-- and the one the children's own offsets are measured from -- holds still.
-- Upstream's Group.lua overwrites the field on every modify for the same
-- reason, and a stale saved value heals here rather than displacing the pack.
data.selfPoint = "CENTER"
region:SetScale(data.scale and data.scale > 0 and data.scale or 1)
WA.regionPrototype.ApplyPosition(region, data)
if data.regionType == "dynamicgroup" then
region.sortFunc, region.sortBeginPass = createSortFunc(data)
region.sortFuncBuilt = true
region.anchorFunc, region.anchorFuncBuilt = nil, false
region.growFunc, region.growFuncBuilt = nil, false
layoutDynamicGroup(region, data)
else
applyGroupBounds(region, data)
end
region:Show()
WA.regionPrototype.modifyFinish(region, data)
end
-- List-row preview: data.groupIcon when it's set and loadable, else three
-- stacked coloured bars (WA2's own RegionOptions/Group.lua createDefaultIcon)
-- scaled to whatever box WA.AcquireThumbnail hands it rather than upstream's
-- fixed 24/20px. Both group and dynamicgroup share it, as they share the field.
local function groupCreateThumbnail(parent)
local frame = CreateFrame("Frame", nil, parent)
local t1 = frame:CreateTexture(nil, "ARTWORK")
t1:SetTexture(0.8, 0, 0, 0.5)
frame.t1 = t1
local t2 = frame:CreateTexture(nil, "ARTWORK")
t2:SetTexture(0.2, 0.8, 0.2, 0.5)
frame.t2 = t2
local t3 = frame:CreateTexture(nil, "ARTWORK")
t3:SetTexture(0.1, 0.25, 1, 0.5)
frame.t3 = t3
local icon = frame:CreateTexture(nil, "OVERLAY")
icon:SetAllPoints(frame)
icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
icon:Hide()
frame.icon = icon
return frame
end
local function groupModifyThumbnail(frame, data)
-- WA.DrawableTexture first, and the read-back second: a fileID or an atlas
-- name is not a path at all, and `GetTexture` hands one straight back rather
-- than reporting the failure, so the read-back alone let both through to the
-- engine's missing-texture block. The stored value is left alone -- it is
-- still what the author chose, and it is what the options tab shows.
local path = WA.DrawableTexture(data and data.groupIcon)
if path then
frame.icon:SetTexture(path)
-- SetTexture returns nothing here, so upstream's `if success` has no
-- equivalent: a path the client can't load leaves GetTexture nil, and
-- that read-back is the only signal that it failed. Without it a typo'd
-- path gives a blank thumbnail with no way back to the bars.
if frame.icon:GetTexture() then
frame.t1:Hide()
frame.t2:Hide()
frame.t3:Hide()
frame.icon:Show()
return
end
end
frame.icon:Hide()
local size = frame:GetHeight() or 32
local k = size / 32
local t1, t2, t3 = frame.t1, frame.t2, frame.t3
t1:ClearAllPoints()
t1:SetWidth(24 * k)
t1:SetHeight(8 * k)
t1:SetPoint("TOP", frame, "TOP", 0, -6 * k)
t2:ClearAllPoints()
t2:SetWidth(20 * k)
t2:SetHeight(20 * k)
t2:SetPoint("TOP", t1, "BOTTOM", 0, 5 * k)
t3:ClearAllPoints()
t3:SetWidth(20 * k)
t3:SetHeight(12 * k)
t3:SetPoint("TOP", t2, "BOTTOM", -5 * k, 8 * k)
t1:Show()
t2:Show()
t3:Show()
end
-- The one field group and dynamicgroup add for themselves: purely cosmetic,
-- read only by groupModifyThumbnail. Empty falls back to the default bars.
local function groupIconOption(data)
return {
type = "icon", name = "Group icon", key = "groupIcon",
get = function() return data.groupIcon end,
set = function(v) data.groupIcon = v; WA.Add(data) end,
}
end
WA.RegisterRegionType("group", {
displayName = "Group",
description = "Holds other auras, moving and showing them together.",
isGroup = true,
icon = "Interface\\Icons\\INV_Misc_Bag_08",
defaults = {
controlledChildren = {},
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
border = false,
borderColor = { 0, 0, 0, 1 },
scale = 1,
groupIcon = "",
},
create = groupCreate,
modify = groupModify,
createThumbnail = groupCreateThumbnail,
modifyThumbnail = groupModifyThumbnail,
options = function(data)
local fields = {
{ type = "header", name = "Group" },
groupIconOption(data),
{
type = "toggle", name = "Border", key = "border",
get = function() return data.border end,
set = function(v) data.border = v; WA.Add(data) end,
},
{
type = "range", name = "Scale", key = "scale", min = 0.1, max = 3, step = 0.05,
get = function() return data.scale end,
set = function(v) data.scale = v; WA.Add(data) end,
},
}
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do
table.insert(fields, f)
end
return fields
end,
})
-- Upstream's own eighteen (Types.lua's grid_types), verbatim, so an imported
-- grid keeps its author's value rather than landing on an approximation.
local GRID_TYPES = {
"RD", "RU", "LD", "LU", "DR", "DL", "UR", "UL",
"HD", "HU", "VR", "VL", "DH", "UH", "LV", "RV", "HV", "VH",
}
local GRID_TYPE_LABELS = {
RD = "Right, then Down", RU = "Right, then Up",
LD = "Left, then Down", LU = "Left, then Up",
DR = "Down, then Right", DL = "Down, then Left",
UR = "Up, then Right", UL = "Up, then Left",
HD = "Centered Horizontal, then Down", HU = "Centered Horizontal, then Up",
VR = "Centered Vertical, then Right", VL = "Centered Vertical, then Left",
DH = "Down, then Centered Horizontal", UH = "Up, then Centered Horizontal",
LV = "Left, then Centered Vertical", RV = "Right, then Centered Vertical",
HV = "Centered Horizontal, then Centered Vertical",
VH = "Centered Vertical, then Centered Horizontal",
}
-- Upstream's grow_types. CIRCLE reads as "Counter Clockwise" and COUNTERCIRCLE
-- as "Clockwise" on purpose: the labels describe the direction the grower
-- actually turns, and it is the internal names that are backwards.
local GROW_TYPES = {
"UP", "DOWN", "LEFT", "RIGHT", "HORIZONTAL", "VERTICAL",
"CIRCLE", "COUNTERCIRCLE", "GRID", "CUSTOM",
}
local GROW_TYPE_LABELS = {
UP = "Up", DOWN = "Down", LEFT = "Left", RIGHT = "Right",
HORIZONTAL = "Centered Horizontal", VERTICAL = "Centered Vertical",
CIRCLE = "Counter Clockwise", COUNTERCIRCLE = "Clockwise", GRID = "Grid",
CUSTOM = "Custom",
}
local CONSTANT_FACTOR_LABELS = {
RADIUS = "Radius", ANGLE = "Angle and Radius", SPACING = "Spacing",
}
local function isCircle(data)
return data.grow == "CIRCLE" or data.grow == "COUNTERCIRCLE"
end
WA.RegisterRegionType("dynamicgroup", {
displayName = "Dynamic Group",
description = "Arranges its children itself, closing the gaps as they come and go.",
isGroup = true,
icon = "Interface\\Icons\\INV_Misc_Bag_09",
defaults = {
controlledChildren = {},
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
border = false,
borderColor = { 0, 0, 0, 1 },
scale = 1,
grow = "DOWN",
customGrow = "",
animate = false,
sort = "none",
space = 2,
align = "CENTER",
gridType = "RD",
gridWidth = 5,
rowSpace = 2,
columnSpace = 2,
radius = 200,
rotation = 0,
stepAngle = 15,
fullCircle = true,
arcLength = 360,
constantFactor = "RADIUS",
groupIcon = "",
stagger = 0,
useLimit = false,
limit = 5,
useAnchorPerUnit = false,
anchorPerUnit = "NAMEPLATE",
customAnchorPerUnit = "",
},
create = groupCreate,
modify = groupModify,
createThumbnail = groupCreateThumbnail,
modifyThumbnail = groupModifyThumbnail,
options = function(data)
local fields = {
{ type = "header", name = "Dynamic Group" },
groupIconOption(data),
{
type = "select", name = "Grow direction", key = "grow",
values = GROW_TYPES, labels = GROW_TYPE_LABELS,
get = function() return data.grow end,
set = function(v) data.grow = v; WA.Add(data); WA.RefreshOptions() end,
},
}
local isCustom = data.grow == "CUSTOM"
if isCustom then
table.insert(fields, {
type = "code", name = "Custom Grow", key = "customGrow", height = 160,
get = function() return data.customGrow end,
set = function(v) data.customGrow = v; WA.Add(data) end,
-- Seeded in the array form -- one { x, y } per slot of the sorted
-- list -- because it is the shape with nothing to look up: the
-- frame-keyed form needs the entry tables themselves as keys.
default = "function(positions, activeRegions)\n"
.. " for i = 1, #activeRegions do\n"
.. " positions[i] = { 0, -(i - 1) * 32 }\n"
.. " end\nend",
validate = function(txt)
return WA.Widgets.LuaSyntaxError(WA.WrapFunctionSource(txt), "custom grow")
end,
})
end
table.insert(fields, {
type = "select", name = "Sort", key = "sort",
values = { "none", "ascending", "descending", "hybrid", "custom" },
labels = { none = "None", ascending = "Ascending", descending = "Descending",
hybrid = "Hybrid", custom = "Custom" },
get = function() return data.sort end,
set = function(v) data.sort = v; WA.Add(data); WA.RefreshOptions() end,
})
-- A grid spaces its two axes separately and reads neither `space` nor
-- `align`, so it offers its own pair and withholds both -- upstream hides
-- the same three fields, `stagger` included, for the same reason. A circle
-- withholds the same three, and reads `space` only under SPACING, where it
-- sets the radius rather than a gap. A custom grow reads none of the
-- layout fields at all: it is handed the ordered list and answers with
-- coordinates, so spacing, alignment, the clone cap and the per-unit
-- anchor are all its own business.
if isCustom then -- no layout fields of ours
elseif data.grow == "GRID" then
table.insert(fields, {
type = "select", name = "Grid direction", key = "gridType",
values = GRID_TYPES, labels = GRID_TYPE_LABELS,
get = function() return data.gridType end,
set = function(v) data.gridType = v; WA.Add(data); WA.RefreshOptions() end,
})
table.insert(fields, {
type = "range", key = "gridWidth", min = 1, max = 20, step = 1,
name = string.find(data.gridType or "RD", "^[RLH]") and "Row width"
or "Column height",
get = function() return data.gridWidth end,
set = function(v) data.gridWidth = v; WA.Add(data) end,
})
table.insert(fields, {
type = "range", name = "Row spacing", key = "rowSpace", min = 0, max = 20, step = 1,
get = function() return data.rowSpace end,
set = function(v) data.rowSpace = v; WA.Add(data) end,
})
table.insert(fields, {
type = "range", name = "Column spacing", key = "columnSpace",
min = 0, max = 20, step = 1,
get = function() return data.columnSpace end,
set = function(v) data.columnSpace = v; WA.Add(data) end,
})
elseif isCircle(data) then
local constantFactor = data.constantFactor or "RADIUS"
table.insert(fields, {
type = "select", name = "Constant Factor", key = "constantFactor",
values = { "RADIUS", "ANGLE", "SPACING" }, labels = CONSTANT_FACTOR_LABELS,
get = function() return data.constantFactor end,
set = function(v) data.constantFactor = v; WA.Add(data); WA.RefreshOptions() end,
})
table.insert(fields, {
type = "range", name = "Start Angle", key = "rotation", min = 0, max = 360, step = 3,
get = function() return data.rotation or 0 end,
set = function(v) data.rotation = v; WA.Add(data) end,
})
if constantFactor == "SPACING" then
table.insert(fields, {
type = "range", name = "Spacing", key = "space", min = 0, max = 20, step = 1,
get = function() return data.space end,
set = function(v) data.space = v; WA.Add(data) end,
})
end
if constantFactor ~= "ANGLE" then
table.insert(fields, {
type = "toggle", name = "Full Circle", key = "fullCircle",
get = function() return data.fullCircle end,
set = function(v) data.fullCircle = v; WA.Add(data); WA.RefreshOptions() end,
})
-- Upstream greys this out under a full circle; conditional fields
-- here are expressed by not emitting them at all.
if not data.fullCircle then
table.insert(fields, {
type = "range", name = "Total Angle", key = "arcLength",
min = 0, max = 360, step = 3,
get = function() return data.arcLength end,
set = function(v) data.arcLength = v; WA.Add(data) end,
})
end
end
if constantFactor ~= "SPACING" then
table.insert(fields, {
type = "range", name = "Radius", key = "radius", min = 0, max = 500, step = 1,
get = function() return data.radius end,
set = function(v) data.radius = v; WA.Add(data) end,
})
end
if constantFactor == "ANGLE" then
table.insert(fields, {
type = "range", name = "Angle Between Auras", key = "stepAngle",
min = 0, max = 180, step = 1,
get = function() return data.stepAngle end,
set = function(v) data.stepAngle = v; WA.Add(data) end,
})
end
else
table.insert(fields, {
type = "range", name = "Spacing", key = "space", min = 0, max = 20, step = 1,
get = function() return data.space end,
set = function(v) data.space = v; WA.Add(data) end,
})
table.insert(fields, {
type = "select", name = "Align", key = "align",
values = { "LEFT", "CENTER", "RIGHT" },
get = function() return data.align end,
set = function(v) data.align = v; WA.Add(data) end,
})
end
if data.sort == "hybrid" then
table.insert(fields, {
type = "select", name = "Hybrid Position", key = "hybridPosition",
values = { "hybridFirst", "hybridLast" },
labels = { hybridFirst = "Marked First", hybridLast = "Marked Last" },
get = function() return data.hybridPosition end,
set = function(v) data.hybridPosition = v; WA.Add(data) end,
})
table.insert(fields, {
type = "select", name = "Hybrid Sort Mode", key = "hybridSortMode",
values = { "ascending", "descending" },
get = function() return data.hybridSortMode end,
set = function(v) data.hybridSortMode = v; WA.Add(data) end,
})
local kids = data.controlledChildren or {}
for i = 1, #kids do
local childId = kids[i]
table.insert(fields, {
type = "toggle", name = childId, key = "sortHybrid_" .. childId,
get = function() return data.sortHybridTable and data.sortHybridTable[childId] or false end,
set = function(v)
data.sortHybridTable = data.sortHybridTable or {}
data.sortHybridTable[childId] = v
WA.Add(data)
end,
})
end
elseif data.sort == "custom" then
table.insert(fields, {
type = "code", name = "Custom Sort", key = "customSort", height = 160,
get = function() return data.customSort end,
set = function(v) data.customSort = v; WA.Add(data) end,
-- Seeded nil-safe on purpose: a comparator sees the options
-- preview's states as well as the real ones, and a preview carries
-- only what its own trigger filled in -- a fallback state, none of
-- it. Answering nil is "no opinion", which leaves the pair in the
-- deterministic base order.
default = "function(a, b)\n"
.. " local x = a.region.state and a.region.state.index\n"
.. " local y = b.region.state and b.region.state.index\n"
.. " if x == nil or y == nil then return nil end\n"
.. " return x < y\nend",
validate = function(txt)
return WA.Widgets.LuaSyntaxError(WA.WrapFunctionSource(txt), "custom sort")
end,
})
end
if not isCustom and data.grow ~= "GRID" and not isCircle(data) then
table.insert(fields, {
type = "range", name = "Stagger", key = "stagger", min = -50, max = 50, step = 1,
get = function() return data.stagger end,
set = function(v) data.stagger = v; WA.Add(data) end,
})
end
if not isCustom then
table.insert(fields, {
type = "toggle", name = "Limit visible clones", key = "useLimit",
get = function() return data.useLimit end,
set = function(v) data.useLimit = v; WA.Add(data); WA.RefreshOptions() end,
})
end
if data.useLimit and not isCustom then
table.insert(fields, {
type = "range", name = "Limit", key = "limit", min = 0, max = 20, step = 1,
get = function() return data.limit end,
set = function(v) data.limit = v; WA.Add(data) end,
})
end
table.insert(fields, {
type = "toggle", name = "Animated expand and collapse", key = "animate",
get = function() return data.animate end,
set = function(v) data.animate = v; WA.Add(data) end,
})
if not isCustom then
table.insert(fields, {
type = "toggle", name = "Anchor per unit", key = "useAnchorPerUnit",
get = function() return data.useAnchorPerUnit end,
set = function(v) data.useAnchorPerUnit = v; WA.Add(data); WA.RefreshOptions() end,
})
end
if data.useAnchorPerUnit and not isCustom then
table.insert(fields, {
type = "select", name = "Anchor each clone to", key = "anchorPerUnit",
values = { "NAMEPLATE", "UNITFRAME", "CUSTOM" },
labels = { NAMEPLATE = "Its unit's nameplate", UNITFRAME = "Its unit's frame",
CUSTOM = "Custom" },
get = function() return data.anchorPerUnit end,
set = function(v) data.anchorPerUnit = v; WA.Add(data); WA.RefreshOptions() end,
})
if data.anchorPerUnit == "CUSTOM" then
table.insert(fields, {
type = "code", name = "Custom Anchor", key = "customAnchorPerUnit", height = 160,
get = function() return data.customAnchorPerUnit end,
set = function(v) data.customAnchorPerUnit = v; WA.Add(data) end,
-- Upstream's signature: fill `frames[frame]` with the clones that
-- belong to that frame. Seeded with the nameplate the built-in
-- anchorer would find, since that is the shape most custom
-- anchors start from.
default = "function(frames, regions)\n"
.. " for i = 1, #regions do\n"
.. " local state = regions[i].region.state\n"
.. " local frame = state and state.unit\n"
.. " and WeakestAuras.GetUnitNameplate(state.unit)\n"
.. " if frame then\n"
.. " frames[frame] = frames[frame] or {}\n"
.. " table.insert(frames[frame], regions[i])\n"
.. " end\n"
.. " end\nend",
validate = function(txt)
return WA.Widgets.LuaSyntaxError(WA.WrapFunctionSource(txt), "custom anchor")
end,
})
end
end
table.insert(fields, {
type = "toggle", name = "Border", key = "border",
get = function() return data.border end,
set = function(v) data.border = v; WA.Add(data) end,
})
table.insert(fields, {
type = "range", name = "Scale", key = "scale", min = 0.1, max = 3, step = 0.05,
get = function() return data.scale end,
set = function(v) data.scale = v; WA.Add(data) end,
})
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do
table.insert(fields, f)
end
return fields
end,
})
local TEXTURE_BLEND_MODES = { "BLEND", "ADD" }
local TEXTURE_BLEND_LABELS = {
BLEND = "Opaque", ADD = "Glow",
}
local TEXTURE_DEFAULT = "Interface\\AddOns\\WeakestAuras\\textures\\shapes\\arrows_target.tga"
local TEXTURE_SQRT2 = math.sqrt(2)
local function textureRotationCoords(degrees, mirror)
local angle = math.pi * (135 - (degrees or 0)) / 180
local vx = math.cos(angle) / TEXTURE_SQRT2
local vy = math.sin(angle) / TEXTURE_SQRT2
local ulx, uly = 0.5 + vx, 0.5 - vy
local llx, lly = 0.5 - vy, 0.5 - vx
local urx, ury = 0.5 + vy, 0.5 + vx
local lrx, lry = 0.5 - vx, 0.5 + vy
if mirror then return urx, ury, lrx, lry, ulx, uly, llx, lly end
return ulx, uly, llx, lly, urx, ury, lrx, lry
end
local function applyTextureCoords(texture, mirror, rotation)
texture:SetTexCoord(textureRotationCoords(rotation, mirror))
end
local TEXTURE_SUB_ANCHORS = {
TOPLEFT = { display = "Edge / Top Left", point = true }, TOP = { display = "Edge / Top", point = true }, TOPRIGHT = { display = "Edge / Top Right", point = true },
LEFT = { display = "Edge / Left", point = true }, CENTER = { display = "Center", point = true }, RIGHT = { display = "Edge / Right", point = true },
BOTTOMLEFT = { display = "Edge / Bottom Left", point = true }, BOTTOM = { display = "Edge / Bottom", point = true }, BOTTOMRIGHT = { display = "Edge / Bottom Right", point = true },
}
local function textureCoords(mirror)
if mirror then return 1, 0, 1, 1, 0, 0, 0, 1 end
return 0, 0, 0, 1, 1, 0, 1, 1
end
local function textureThumbnailSize(frame, data)
local size = frame:GetWidth() or 32
local width, height = data.width or 1, data.height or 1
local scale = size / math.max(width, height)
return width * scale, height * scale
end
WA.RegisterRegionType("texture", {
displayName = "Texture",
description = "A custom texture with colour, mirroring and desaturation.",
defaults = {
texture = TEXTURE_DEFAULT,
width = 200,
height = 200,
alpha = 1,
desaturate = false,
color = { 1, 1, 1, 1 },
blendMode = "BLEND",
mirror = false,
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
},
icon = TEXTURE_DEFAULT,
getSubRegionAnchors = function() return TEXTURE_SUB_ANCHORS end,
properties = WA.regionPrototype.AddProperties({
texture = { display = "Texture", setter = "SetTexture", type = "texture" },
color = { display = "Color", setter = "Color", type = "color" },
desaturate = { display = "Desaturate", setter = "SetDesaturated", type = "bool" },
blendMode = { display = "Blend Mode", setter = "SetBlendMode", type = "list", values = TEXTURE_BLEND_LABELS },
width = { display = "Width", setter = "SetRegionWidth", type = "number", min = 1, softMin = 8, softMax = 800, step = 1 },
height = { display = "Height", setter = "SetRegionHeight", type = "number", min = 1, softMin = 8, softMax = 800, step = 1 },
mirror = { display = "Mirror", setter = "SetMirror", type = "bool" },
}),
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
local texture = frame:CreateTexture(nil, "ARTWORK")
frame.texture = texture
return frame
end,
modifyThumbnail = function(frame, data)
local texture = frame.texture
local width, height = textureThumbnailSize(frame, data)
texture:ClearAllPoints()
texture:SetPoint("CENTER", frame, "CENTER")
texture:SetWidth(width)
texture:SetHeight(height)
texture:SetTexture(WA.DrawableTexture(data.texture) or TEXTURE_DEFAULT)
local color = data.color or { 1, 1, 1, 1 }
texture:SetVertexColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1)
texture:SetBlendMode(data.blendMode or "BLEND")
texture:SetDesaturated(data.desaturate and true or false)
applyTextureCoords(texture, data.mirror, data.rotation)
end,
options = function(data)
local fields = {
{ type = "header", name = "Texture" },
{
type = "texture", name = "Texture", key = "texture",
get = function() return data.texture end,
set = function(v) data.texture = v; WA.Add(data, true); WA.RefreshList() end,
},
{
type = "color", name = "Color", key = "color",
half = true,
get = function() return data.color or { 1, 1, 1, 1 } end,
set = function(v) data.color = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Desaturate", key = "desaturate",
half = true,
get = function() return data.desaturate end,
set = function(v) data.desaturate = v; WA.Add(data, true) end,
},
{
type = "range", name = "Alpha", key = "alpha", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.alpha or 1 end,
set = function(v) data.alpha = v; WA.Add(data, true) end,
},
{
type = "select", name = "Blend mode", key = "blendMode", half = true,
values = TEXTURE_BLEND_MODES, labels = TEXTURE_BLEND_LABELS,
get = function() return data.blendMode or "BLEND" end,
set = function(v) data.blendMode = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Mirror", key = "mirror", half = true,
get = function() return data.mirror end,
set = function(v) data.mirror = v; WA.Add(data, true) end,
},
{
type = "range", name = "Rotation", key = "rotation", min = 0, max = 360, step = 1, half = true,
get = function() return data.rotation or 0 end,
set = function(v) data.rotation = v; WA.Add(data, true) end,
},
{ type = "header", name = "Size" },
{
type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.width end,
set = function(v) data.width = v; WA.Add(data, true) end,
},
{
type = "range", name = "Height", key = "height", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.height end,
set = function(v) data.height = v; WA.Add(data, true) end,
},
}
for _, field in ipairs(WA.regionPrototype.PositionOptions(data)) do table.insert(fields, field) end
return fields
end,
create = function(parent)
local region = CreateFrame("Frame", nil, parent)
local texture = region:CreateTexture(nil, "ARTWORK")
texture:SetAllPoints(region)
region.texture = texture
WA.regionPrototype.create(region)
function region:Update()
if self.state and self.state.texture then self:SetTexture(self.state.texture) end
end
region:Hide()
return region
end,
modify = function(region, data)
function region:SetRegionWidth(width) self.regionWidth = width; self:SetWidth(width) end
function region:SetRegionHeight(height) self.regionHeight = height; self:SetHeight(height) end
function region:SetTexture(path) self.texture:SetTexture(WA.DrawableTexture(path) or TEXTURE_DEFAULT) end
function region:Color(r, g, b, a) self.texture:SetVertexColor(r, g, b, a or 1) end
function region:SetDesaturated(value) self.texture:SetDesaturated(value and true or false) end
function region:SetBlendMode(value) self.texture:SetBlendMode(value or "BLEND") end
function region:SetMirror(value) self.mirror = value and true or false; applyTextureCoords(self.texture, self.mirror, self.rotation) end
function region:SetRotation(value) self.rotation = value or 0; applyTextureCoords(self.texture, self.mirror, self.rotation) end
function region:SetAnimRotation(value) self.animRotation = value; applyTextureCoords(self.texture, self.mirror, value or self.rotation) end
function region:GetBaseRotation() return self.rotation or 0 end
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
region:SetRegionAlpha(data.alpha)
region:SetTexture(data.texture)
local color = data.color or { 1, 1, 1, 1 }
region:Color(color[1], color[2], color[3], color[4])
region:SetDesaturated(data.desaturate)
region:SetBlendMode(data.blendMode)
region:SetMirror(data.mirror)
region:SetRotation(data.rotation)
WA.regionPrototype.ApplyPosition(region, data)
WA.regionPrototype.modifyFinish(region, data)
end,
})
-- Model region -- a 3D model in a frame, WA2's Model.lua (§6) rendered through
-- this client's PlayerModel widget. Only the legacy camera exists here
-- (SetPosition + SetFacing); an aura imported with upstream's transform camera
-- has its yaw folded into `rotation` at import and keeps its st_* keys
-- untouched for the trip back.
-- PlayerModel frames are pooled across model regions, acquired on show and
-- released on hide: they cannot be destroyed, and this client drops a hidden
-- model's geometry anyway, so every show re-applies the config regardless --
-- pooling by visibility keeps the frame count at the number of models on
-- screen rather than the number defined.
local modelFramePool = {}
-- Everything a paint needs lives on wa* fields of the model frame itself, so
-- the OnShow and OnEvent handlers (globals `this`/`event`/`arg1` on this
-- client) can re-apply without reaching back into a region or its data.
local function modelApply(model)
if model.waModelKind == "unit" then
pcall(model.SetUnit, model, model.waModelValue)
elseif model.waModelKind == "display" then
-- An id this client's CreatureDisplayInfo lacks leaves the model as it
-- was, and a pooled frame may still hold another aura's.
model:ClearModel()
model:SetDisplayInfo(model.waModelValue)
elseif model.waModelKind == "path" then
pcall(model.SetModel, model, model.waModelValue)
else
pcall(model.ClearModel, model)
return
end
model:SetPortraitZoom(model.waModelZoom and 1 or 0)
pcall(model.SetModelScale, model, model.waModelScale or 1)
pcall(model.SetPosition, model, model.waModelZ or 0, model.waModelX or 0, model.waModelY or 0)
pcall(model.SetFacing, model, math.rad(model.waModelFacing or 0))
-- SetSequence indexes this model's own sequence list; retail's SetAnimation
-- ids do not map onto it, so an imported `sequence` is best-effort.
if model.waModelSequence then pcall(model.SetSequence, model, model.waModelSequence) end
end
local function modelOnShow()
modelApply(this)
end
-- The client projects a model where its frame sat when the model was applied;
-- moving the frame afterwards moves the frame alone, and no event announces the
-- move. A thumbnail is anchored only after AcquireThumbnail has already
-- applied, and dragging the options window carries every thumbnail in it, so
-- each model frame watches its own screen rect and re-applies when the rect
-- settles after a change -- settle, not per move, or a drag would reload
-- geometry every tick.
local function modelOnUpdate()
local left, top = this:GetLeft(), this:GetTop()
if left ~= this.waModelLeft or top ~= this.waModelTop then
this.waModelLeft, this.waModelTop = left, top
this.waModelMoved = left and true or nil
elseif this.waModelMoved then
this.waModelMoved = nil
modelApply(this)
end
end
local function modelOnEvent()
if event == "PLAYER_TARGET_CHANGED"
or (event == "UNIT_MODEL_CHANGED" and arg1 and this.waModelValue
and UnitIsUnit(arg1, this.waModelValue)) then
modelApply(this)
end
end
local function acquireModelFrame()
local model = table.remove(modelFramePool)
if not model then
local ok, frame = pcall(CreateFrame, "PlayerModel", nil, UIParent)
if not ok or not frame then return nil end
model = frame
end
return model
end
local function releaseModelFrame(model)
model:UnregisterAllEvents()
model:SetScript("OnEvent", nil)
model:SetScript("OnShow", nil)
model:SetScript("OnUpdate", nil)
model.waModelLeft, model.waModelTop, model.waModelMoved = nil, nil, nil
pcall(model.ClearModel, model)
model:Hide()
model:ClearAllPoints()
model:SetParent(UIParent)
table.insert(modelFramePool, model)
end
-- A model file's path: a retail fileDataID resolved through WA2ModelIDs.lua,
-- then a literal path (pre-fileID exports, and auras authored here). A literal
-- path is re-spelled `\\` + `.mdx`, the form this client's SetModel provably
-- takes.
local function modelPath(data)
local resolved = WA.ResolveModelFile(data.model_fileId)
if resolved then return resolved end
local literal = data.model_path
if type(literal) ~= "string" or literal == "" then return nil end
literal = string.gsub(literal, "/", "\\")
local low = string.lower(literal)
if string.find(low, "%.m2$") then
literal = string.sub(literal, 1, -4)
elseif string.find(low, "%.md[xl]$") then
literal = string.sub(literal, 1, -5)
end
return literal .. ".mdx"
end
-- The forms a model aura can carry, in upstream's precedence: a unit
-- (modelIsUnit reuses model_fileId for the unit token, upstream's layout), a
-- CreatureDisplayInfo id (modelDisplayInfo, the same reuse), then a model
-- file. A file some display dresses renders through that display: SetModel
-- loads geometry only, and draws a creature untextured white.
local function modelSource(data)
if data.modelIsUnit then
local unit = data.model_fileId
if type(unit) == "string" and unit ~= "" then return "unit", unit end
return nil
end
if data.modelDisplayInfo then
local id = tonumber(data.model_fileId)
if id and id > 0 then return "display", id end
return nil
end
local path = modelPath(data)
if not path then return nil end
local display = WA.ResolveModelDisplay(path)
if display then return "display", display end
return "path", path
end
WA.RegisterRegionType("model", {
displayName = "Model",
description = "A 3D model, from the game's files or a live unit.",
icon = "Interface\\Icons\\INV_Misc_Idol_02",
-- A PlayerModel inside a real ScrollFrame's scroll child renders at the
-- child's untranslated coordinates -- from the screen origin, not the row
-- (gotchas.md) -- so a scrolling pane paints the static icon instead. The
-- aura list's faux scroll is plain repositioned frames and keeps the live
-- miniature.
thumbnailNoScroll = true,
defaults = {
-- A looping ambient effect with its own textures.
model_path = "Spells\\Cyclone_State.mdx",
model_fileId = "",
modelIsUnit = false,
modelDisplayInfo = false,
portraitZoom = false,
advance = false,
sequence = 1,
rotation = 0,
-- Local key, no upstream counterpart: with only the legacy camera,
-- SetModelScale is half of how a model is fitted to its frame.
modelScale = 1,
model_x = 0,
model_y = 0,
model_z = 0,
-- Upstream's transform camera, carried for the round trip and never
-- rendered here (api = SetTransform on a retail client).
api = false,
model_st_tx = 40,
model_st_ty = 0,
model_st_tz = 0,
model_st_rx = 90,
model_st_ry = 0,
model_st_rz = 90,
model_st_us = 40,
width = 200,
height = 200,
alpha = 1,
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
},
getSubRegionAnchors = function() return TEXTURE_SUB_ANCHORS end,
properties = WA.regionPrototype.AddProperties({
width = { display = "Width", setter = "SetRegionWidth", type = "number", min = 1, softMin = 8, softMax = 800, step = 1 },
height = { display = "Height", setter = "SetRegionHeight", type = "number", min = 1, softMin = 8, softMax = 800, step = 1 },
}),
-- Upstream's list thumbnail: a quickslot border with a live miniature of
-- the aura's own model. The model frame is created once with the pooled
-- thumbnail and reconfigured per row; its OnShow re-applies, because a
-- hidden model drops its geometry.
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
local border = frame:CreateTexture(nil, "OVERLAY")
border:SetAllPoints(frame)
border:SetTexture("Interface\\Buttons\\UI-Quickslot2")
border:SetTexCoord(0.2, 0.8, 0.2, 0.8)
local ok, model = pcall(CreateFrame, "PlayerModel", nil, frame)
if ok and model then
model:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2)
model:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
model:SetScript("OnShow", modelOnShow)
model:SetScript("OnUpdate", modelOnUpdate)
frame.model = model
end
return frame
end,
modifyThumbnail = function(frame, data)
local model = frame.model
if not model then return end
local kind, value = modelSource(data)
model.waModelKind, model.waModelValue = kind, value
model.waModelZoom = data.portraitZoom and true or false
model.waModelScale = data.modelScale or 1
model.waModelX = data.model_x or 0
model.waModelY = data.model_y or 0
model.waModelZ = data.model_z or 0
model.waModelFacing = data.rotation or 0
-- A list row holds still and watches no unit events; a stale portrait
-- beats per-row event wiring.
model.waModelSequence = nil
modelApply(model)
end,
options = function(data)
-- model_fileId is the unit token in unit mode, a display id in display
-- mode and a retail file id otherwise; a leftover from another mode
-- would be read in the wrong id space.
local function setSourceMode(isUnit, isDisplay)
data.modelIsUnit = isUnit
data.modelDisplayInfo = isDisplay
data.model_fileId = ""
WA.Add(data, true)
-- Repaints the tab: the field set below follows the mode.
WA.RefreshOptions()
end
local fields = {
{ type = "header", name = "Model" },
{
type = "toggle", name = "Show a unit", key = "modelIsUnit", half = true,
get = function() return data.modelIsUnit end,
set = function(v) setSourceMode(v and true or false, false) end,
},
{
type = "toggle", name = "Use a display ID", key = "modelDisplayInfo", half = true,
get = function() return data.modelDisplayInfo end,
set = function(v) setSourceMode(false, v and true or false) end,
},
}
if data.modelIsUnit then
table.insert(fields, {
type = "input", name = "Unit (player, target, party1 ...)", key = "model_fileId",
get = function() return data.model_fileId end,
set = function(v) data.model_fileId = v; WA.Add(data, true) end,
})
elseif data.modelDisplayInfo then
table.insert(fields, {
type = "input", name = "Creature display ID", key = "model_fileId",
get = function() return tostring(data.model_fileId or "") end,
set = function(v) data.model_fileId = v; WA.Add(data, true) end,
})
else
table.insert(fields, {
type = "input", name = "Model path", key = "model_path",
get = function()
if data.model_path and data.model_path ~= "" then return data.model_path end
return WA.ResolveModelFile(data.model_fileId) or ""
end,
-- A typed path wins over an imported file id from then on.
set = function(v) data.model_path = v; data.model_fileId = ""; WA.Add(data, true) end,
})
table.insert(fields, {
type = "button", name = "Browse models...", key = "modelBrowse",
onClick = function()
WA.Widgets.OpenModelPicker(data.model_path, function(path)
data.model_path = path
data.model_fileId = ""
WA.Add(data, true)
WA.RefreshOptions()
WA.RefreshList()
end)
end,
})
local unresolved = data.model_fileId and data.model_fileId ~= ""
and not WA.ResolveModelFile(data.model_fileId)
and not (data.model_path and data.model_path ~= "")
if unresolved then
table.insert(fields, {
type = "description",
name = "This aura names retail model file " .. tostring(data.model_fileId)
.. ", which this client's files do not contain.",
})
end
end
local extra = {
{
type = "range", name = "Rotation", key = "rotation", min = 0, max = 360, step = 1, half = true,
get = function() return data.rotation or 0 end,
set = function(v) data.rotation = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Play animation", key = "advance", half = true,
get = function() return data.advance end,
set = function(v)
data.advance = v and true or false
WA.Add(data, true)
-- Repaints the tab: the sequence slider follows the toggle.
WA.RefreshOptions()
end,
},
}
if data.advance then
table.insert(extra, {
type = "range", name = "Sequence (varies per model)", key = "sequence", min = 0, max = 150, step = 1, half = true,
get = function() return data.sequence or 0 end,
set = function(v) data.sequence = v; WA.Add(data, true) end,
})
end
for _, f in ipairs(extra) do table.insert(fields, f) end
table.insert(fields, { type = "header", name = "Camera" })
table.insert(fields, {
type = "toggle", name = "Portrait camera", key = "portraitZoom",
get = function() return data.portraitZoom end,
set = function(v) data.portraitZoom = v and true or false; WA.Add(data, true) end,
})
table.insert(fields, {
type = "range", name = "Zoom (camera Z)", key = "model_z", min = -50, max = 50, step = 0.5, half = true,
get = function() return data.model_z or 0 end,
set = function(v) data.model_z = v; WA.Add(data, true) end,
})
table.insert(fields, {
type = "range", name = "Model scale", key = "modelScale", min = 0.1, max = 10, step = 0.1, half = true,
get = function() return data.modelScale or 1 end,
set = function(v) data.modelScale = v; WA.Add(data, true) end,
})
local axes = { { "model_x", "Camera X" }, { "model_y", "Camera Y" } }
for _, axis in ipairs(axes) do
local key = axis[1]
table.insert(fields, {
type = "range", name = axis[2], key = key, min = -50, max = 50, step = 0.5, half = true,
get = function() return data[key] or 0 end,
set = function(v) data[key] = v; WA.Add(data, true) end,
})
end
table.insert(fields, { type = "header", name = "Size" })
table.insert(fields, {
type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.width end,
set = function(v) data.width = v; WA.Add(data, true) end,
})
table.insert(fields, {
type = "range", name = "Height", key = "height", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.height end,
set = function(v) data.height = v; WA.Add(data, true) end,
})
for _, field in ipairs(WA.regionPrototype.PositionOptions(data)) do table.insert(fields, field) end
return fields
end,
create = function(parent)
local region = CreateFrame("Frame", nil, parent)
WA.regionPrototype.create(region)
-- Frame alpha does not reach child frames on this client, so the model
-- frame's is mirrored on every write -- config, conditions and
-- animations all funnel through SetAlpha.
local frameSetAlpha = region.SetAlpha
function region:SetAlpha(a)
frameSetAlpha(self, a)
if self.model then self.model:SetAlpha(a) end
end
function region:Update() end
region:Hide()
return region
end,
modify = function(region, data)
function region:SetRegionWidth(width) self:SetWidth(width) end
function region:SetRegionHeight(height) self:SetHeight(height) end
local function configure()
local model = region.model
if not model then return end
model:SetParent(region)
model:ClearAllPoints()
model:SetPoint("TOPLEFT", region, "TOPLEFT", 0, 0)
model:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", 0, 0)
model:SetFrameLevel(region:GetFrameLevel() + 1)
local kind, value = modelSource(data)
model.waModelKind, model.waModelValue = kind, value
model.waModelZoom = data.portraitZoom and true or false
model.waModelScale = data.modelScale or 1
model.waModelX = data.model_x or 0
model.waModelY = data.model_y or 0
model.waModelZ = data.model_z or 0
model.waModelFacing = region.animRotation or region.rotation or 0
model.waModelSequence = data.advance and (tonumber(data.sequence) or 0) or nil
model:SetScript("OnShow", modelOnShow)
model:SetScript("OnUpdate", modelOnUpdate)
if kind == "unit" then
model:RegisterEvent("UNIT_MODEL_CHANGED")
if value == "target" then model:RegisterEvent("PLAYER_TARGET_CHANGED") end
model:SetScript("OnEvent", modelOnEvent)
else
model:UnregisterAllEvents()
model:SetScript("OnEvent", nil)
end
model:SetAlpha(region:GetAlpha() or 1)
model:Show()
modelApply(model)
end
local function applyFacing()
local model = region.model
if not model then return end
model.waModelFacing = region.animRotation or region.rotation or 0
pcall(model.SetFacing, model, math.rad(model.waModelFacing))
end
function region:SetRotation(degrees) self.rotation = degrees or 0; applyFacing() end
function region:SetAnimRotation(degrees) self.animRotation = degrees; applyFacing() end
function region:GetBaseRotation() return self.rotation or 0 end
region.rotation = data.rotation or 0
region.animRotation = nil
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
region:SetRegionAlpha(data.alpha)
WA.regionPrototype.ApplyPosition(region, data)
WA.regionPrototype.modifyFinish(region, data)
-- After modifyFinish: it clears the subscriber list it is about to
-- rebuild, and these must survive until the next modify.
region.subRegionEvents:AddSubscriber("PreShow", function()
if not region.model then region.model = acquireModelFrame() end
configure()
end)
region.subRegionEvents:AddSubscriber("PreHide", function()
if region.model then
releaseModelFrame(region.model)
region.model = nil
end
end)
-- A repaint while shown (options edits) re-fires no PreShow.
if region.shown then
if not region.model then region.model = acquireModelFrame() end
configure()
end
end,
})
local function regionAnchor(display, point, area)
return { display = display, point = point, area = area }
end
-- Ahead of the registration below, not beside its sibling tables further down: a
-- local declared after the closure that names it is a different variable, so
-- getSubRegionAnchors would read a nil global and the icon would offer no
-- sub-region anchor but the whole region.
local ICON_SUB_ANCHORS = {
TOPLEFT = regionAnchor("Edge / Top Left", true), TOP = regionAnchor("Edge / Top", true), TOPRIGHT = regionAnchor("Edge / Top Right", true),
LEFT = regionAnchor("Edge / Left", true), CENTER = regionAnchor("Center", true), RIGHT = regionAnchor("Edge / Right", true),
BOTTOMLEFT = regionAnchor("Edge / Bottom Left", true), BOTTOM = regionAnchor("Edge / Bottom", true), BOTTOMRIGHT = regionAnchor("Edge / Bottom Right", true),
INNER_TOPLEFT = regionAnchor("Inner / Top Left", true), INNER_TOP = regionAnchor("Inner / Top", true), INNER_TOPRIGHT = regionAnchor("Inner / Top Right", true),
INNER_LEFT = regionAnchor("Inner / Left", true), INNER_CENTER = regionAnchor("Inner / Center", true), INNER_RIGHT = regionAnchor("Inner / Right", true),
INNER_BOTTOMLEFT = regionAnchor("Inner / Bottom Left", true), INNER_BOTTOM = regionAnchor("Inner / Bottom", true), INNER_BOTTOMRIGHT = regionAnchor("Inner / Bottom Right", true),
OUTER_TOPLEFT = regionAnchor("Outer / Top Left", true), OUTER_TOP = regionAnchor("Outer / Top", true), OUTER_TOPRIGHT = regionAnchor("Outer / Top Right", true),
OUTER_LEFT = regionAnchor("Outer / Left", true), OUTER_CENTER = regionAnchor("Outer / Center", true), OUTER_RIGHT = regionAnchor("Outer / Right", true),
OUTER_BOTTOMLEFT = regionAnchor("Outer / Bottom Left", true), OUTER_BOTTOM = regionAnchor("Outer / Bottom", true), OUTER_BOTTOMRIGHT = regionAnchor("Outer / Bottom Right", true),
ALL = regionAnchor("Whole area", nil, true),
}
WA.RegisterRegionType("icon", {
displayName = "Icon",
description = "A spell icon with a cooldown swipe, stacks and timer text.",
defaults = {
width = 32,
height = 32,
alpha = 1,
desaturate = false,
color = { 1, 1, 1, 1 },
-- iconSource: -1 = automatic (the trigger's state.icon), 0 = manual
-- (displayIcon). WA2 also has 1..N for per-trigger icons; we're
-- single-trigger, so those append later without a migration (WA2's
-- Private.IconSources). displayIcon doubles as the automatic-mode
-- fallback for triggers that supply no icon (e.g. Mana/Health).
iconSource = -1,
displayIcon = "",
zoom = 0,
cooldownSwipe = true,
cooldownEdge = false,
-- Local drift: upstream drives the Blizzard Cooldown frame and has
-- neither of these. Offered only on the spinner backend.
swipeColor = { 0, 0, 0, 0.8 },
swipeEdgeColor = { 1, 0.82, 0.35, 1 },
inverse = false,
useAdjustededMin = false,
adjustedMin = "",
useAdjustededMax = false,
adjustedMax = "",
progressSource = -1,
progressSourceManualValue = 0,
progressSourceManualTotal = 100,
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
},
icon = "Interface\\Icons\\INV_Misc_QuestionMark",
getSubRegionAnchors = function() return ICON_SUB_ANCHORS end,
-- List-row preview: the resolved icon (WA.ResolveDisplayIcon, Data.lua) at
-- the saved zoom, same texcoord rule as the runtime region's own SetZoom,
-- under a swipe held part-way so the row shows its colour and direction.
-- The swipe is built with the frame and never per bind: a row is rebound to
-- a different aura on every scroll tick and frames cannot be destroyed here.
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
local tex = frame:CreateTexture(nil, "ARTWORK")
tex:SetAllPoints(frame)
frame.tex = tex
frame.swipe = WA.regionPrototype.CreateSwipe(frame)
return frame
end,
modifyThumbnail = function(frame, data)
frame.tex:SetTexture(WA.ResolveDisplayIcon(data) or "Interface\\Icons\\INV_Misc_QuestionMark")
local inset = 0.07 + (data.zoom or 0) * 0.20
frame.tex:SetTexCoord(inset, 1 - inset, inset, 1 - inset)
local swipe = frame.swipe
-- A row is rebound on every list repaint, and each SetCooldown blanks the
-- sweep for a frame on this client (client/gotchas.md), so the hold is set
-- only when what it shows changes. The colour is not a SetCooldown and
-- re-applies freely.
if not data.cooldownSwipe then
if frame.swipeHold ~= "clear" then
swipe:Clear()
frame.swipeHold = "clear"
end
return
end
local c = data.swipeColor or { 0, 0, 0, 0.8 }
swipe:SetSwipeColor(c[1] or 0, c[2] or 0, c[3] or 0, c[4] == nil and 0.8 or c[4])
-- A wedge about a third of the way round: enough to read as a cooldown,
-- little enough that the icon underneath still identifies the aura. The
-- sweep covers the remaining time; reversed, the elapsed time.
local hold = data.inverse and "reverse" or "forward"
if frame.swipeHold == hold then return end
frame.swipeHold = hold
swipe:SetReverse(data.inverse and true or false)
CooldownFrame_SetDisplayAsPercentage(swipe, data.inverse and 0.35 or 0.65)
end,
-- The overridable-property registry conditions and their editor read
-- Setter names a region method defined in modify below.
properties = WA.regionPrototype.AddProgressProperties(WA.regionPrototype.AddProperties({
width = { display = "Width", setter = "SetRegionWidth", type = "number", min = 1, softMin = 8, softMax = 256, step = 1 },
height = { display = "Height", setter = "SetRegionHeight", type = "number", min = 1, softMin = 8, softMax = 256, step = 1 },
desaturate = { display = "Desaturate", setter = "SetDesaturated", type = "bool" },
color = { display = "Color", setter = "Color", type = "color" },
zoom = { display = "Zoom", setter = "SetZoom", type = "number", min = 0, max = 1, step = 0.05 },
cooldownSwipe = { display = "Cooldown Swipe", setter = "SetCooldownSwipe", type = "bool" },
cooldownEdge = { display = "Cooldown Edge", setter = "SetCooldownEdge", type = "bool" },
swipeColor = { display = "Swipe Color", setter = "SetSwipeColor", type = "color" },
swipeEdgeColor = { display = "Cooldown Edge Color", setter = "SetSwipeEdgeColor", type = "color" },
inverse = { display = "Inverse", setter = "SetInverse", type = "bool" },
iconSource = { display = "Icon Source", setter = "SetIconSource", type = "list", values = { [-1] = "Automatic", [0] = "Manual" }, default = 0 },
displayIcon = { display = "Manual Icon", setter = "SetIcon", type = "icon" },
})),
options = function(data)
local fields = {
{ type = "header", name = "Icon" },
{
type = "select", name = "Icon source", key = "iconSource",
values = { -1, 0 },
labels = { [-1] = "Automatic (trigger)", [0] = "Manual" },
get = function() return data.iconSource end,
set = function(v) data.iconSource = v; WA.Add(data, true); WA.RefreshList() end,
},
{
type = "icon", name = "Manual icon", key = "displayIcon",
get = function() return data.displayIcon end,
set = function(v) data.displayIcon = v; WA.Add(data, true); WA.RefreshList() end,
},
{
type = "toggle", name = "Desaturate", key = "desaturate",
get = function() return data.desaturate end,
set = function(v) data.desaturate = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Cooldown swipe", key = "cooldownSwipe",
get = function() return data.cooldownSwipe end,
set = function(v) data.cooldownSwipe = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Cooldown edge (bright leading line)", key = "cooldownEdge",
get = function() return data.cooldownEdge end,
set = function(v) data.cooldownEdge = v; WA.Add(data, true) end,
},
{
type = "color", name = "Swipe colour", key = "swipeColor",
get = function() return data.swipeColor end,
set = function(v) data.swipeColor = v; WA.Add(data, true) end,
},
{
type = "color", name = "Edge colour", key = "swipeEdgeColor",
get = function() return data.swipeEdgeColor end,
set = function(v) data.swipeEdgeColor = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Inverse (dark wedge grows)", key = "inverse",
get = function() return data.inverse end,
set = function(v) data.inverse = v; WA.Add(data, true) end,
},
{
type = "range", name = "Zoom", key = "zoom", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.zoom end,
set = function(v) data.zoom = v; WA.Add(data, true) end,
},
{
type = "range", name = "Alpha", key = "alpha", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.alpha or 1 end,
set = function(v) data.alpha = v; WA.Add(data, true) end,
},
{ type = "header", name = "Size" },
{
type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 256, step = 1, half = true,
get = function() return data.width end,
set = function(v) data.width = v; WA.Add(data, true) end,
},
{
type = "range", name = "Height", key = "height", min = 1, softMin = 8, softMax = 256, step = 1, half = true,
get = function() return data.height end,
set = function(v) data.height = v; WA.Add(data, true) end,
},
}
for _, f in ipairs(WA.regionPrototype.ProgressOptions(data)) do
table.insert(fields, f)
end
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do
table.insert(fields, f)
end
return fields
end,
-- Frame + icon texture + a cooldown swipe (regionPrototype.CreateSwipe, a
-- native Cooldown frame).
-- Countdown/stacks text rides on %p/%s subtext elements (SubText.lua) on top.
create = function(parent, data)
local region = CreateFrame("Frame", nil, parent)
local iconTex = region:CreateTexture(nil, "ARTWORK")
iconTex:SetAllPoints(region)
iconTex:SetTexCoord(0.07, 0.93, 0.07, 0.93)
region.iconTex = iconTex
local inner = CreateFrame("Frame", nil, region)
local outer = CreateFrame("Frame", nil, region)
region.inner, region.outer = inner, outer
function region:UpdateInnerOuterSize()
local w, h = self.regionWidth or 0, self.regionHeight or 0
inner:ClearAllPoints()
inner:SetPoint("TOPLEFT", self, "TOPLEFT", w * 0.1, -h * 0.1)
inner:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", -w * 0.1, h * 0.1)
outer:ClearAllPoints()
outer:SetPoint("TOPLEFT", self, "TOPLEFT", -w * 0.05, h * 0.05)
outer:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", w * 0.05, -h * 0.05)
end
region.swipe = WA.regionPrototype.CreateSwipe(region)
-- CreateSwipe levels the spiral once, off the level the region had at
-- create time; a subbackground row moves the region afterwards and the
-- spiral has to come with it or the icon paints over its own countdown.
function region:ApplyInternalFrameLevels()
if self.swipe then self.swipe:SetFrameLevel(self:GetFrameLevel() + 1) end
end
WA.regionPrototype.create(region)
function region:GetSubAnchorTarget(key)
if key == "region" or key == "ALL" then return self end
if string.find(key, "^INNER_") then return self.inner end
if string.find(key, "^OUTER_") then return self.outer end
return self.iconTex or self
end
-- Reads region.state (set by the state machine) -- replaces the old
-- updateState(region, state, data). Text (countdown/stacks) rides on
-- subtext elements notified via the subRegionEvents "Update" bus, and the
-- %c refresh goes first so every one of them reads values computed once.
function region:Update()
local state = self.state
if not state then return end
self:RefreshCustomText(true)
self:UpdateIcon()
WA.regionPrototype.UpdateProgress(self)
end
-- The aura's configured inverse and the active state's combine by XOR,
-- matching upstream's effectiveInverse (its Icon.lua SetInverse).
function region:SwipeReverse()
return (self.inverse and true or false) ~= (self.stateInverse and true or false)
end
-- Whether the swipe already shows (mode, a, b, reverse), recording it if
-- not. An unchanged cooldown must not be set again: ClassicAPI's
-- SetCooldown collapses the sweep's geometry and rebuilds it only on the
-- frame's next update, so a call landing between that update and the draw
-- -- any event handler or timer -- renders one frame with no swipe. Every
-- state update re-drives UpdateTime, so without this the swipe flickers.
-- Anything that has to re-arm the frame clears region.swipeMode.
local function swipeShows(region, mode, a, b, reverse)
if region.swipeMode == mode and region.swipeA == a and region.swipeB == b
and region.swipeReverse == reverse then
return true
end
region.swipeMode, region.swipeA, region.swipeB, region.swipeReverse = mode, a, b, reverse
return false
end
-- A held swipe shows `fraction` of the duration remaining. The frame's
-- own sweep covers the remaining time, and reverse covers the elapsed
-- time, which is our drain/grow pair as it is.
local function holdSwipe(region, fraction, reverse)
if swipeShows(region, "hold", fraction, nil, reverse) then return end
region.swipe:SetReverse(reverse)
CooldownFrame_SetDisplayAsPercentage(region.swipe, 1 - fraction)
end
local function clearSwipe(region)
if swipeShows(region, "clear") then return end
region.swipe:Clear()
end
-- UpdateProgress dispatches here after setting duration/expirationTime:
-- a timed state runs the swipe (when enabled), a paused one holds it at
-- the frozen fraction, a static one holds it at value/total. The swipe
-- runs for either draw flag -- an edge with the wedge switched off is
-- upstream's own combination.
function region:UpdateTime()
local swipe = self.swipe
if not (self.cooldownSwipe or self.cooldownEdge) then clearSwipe(self); return end
if self.paused then
local fraction = 0
if self.duration and self.duration > 0 then
fraction = (self.remaining or 0) / self.duration
end
holdSwipe(self, fraction, self:SwipeReverse())
elseif self.duration and self.duration > 0 then
local reverse = self:SwipeReverse()
if swipeShows(self, "run", self.expirationTime, self.duration, reverse) then return end
swipe:SetPaused(false)
swipe:SetReverse(reverse)
swipe:SetCooldownFromExpirationTime(self.expirationTime, self.duration)
else
clearSwipe(self)
end
end
function region:UpdateValue()
if (self.cooldownSwipe or self.cooldownEdge) and (self.total or 0) > 0 then
holdSwipe(self, (self.value or 0) / self.total, self:SwipeReverse())
else
clearSwipe(self)
end
end
region:Hide()
return region
end,
modify = function(region, data)
function region:SetRegionWidth(w) self.regionWidth = w; self:SetWidth(w); self:UpdateInnerOuterSize() end
function region:SetRegionHeight(h) self.regionHeight = h; self:SetHeight(h); self:UpdateInnerOuterSize() end
function region:SetDesaturated(b) self.iconTex:SetDesaturated(b and true or false) end
function region:Color(r, g, b, a) self.iconTex:SetVertexColor(r, g, b, a or 1) end
-- Two draw flags on one swipe, either of which keeps it running. A change
-- re-drives from the current state: the edge flag applies only from the
-- next SetCooldown, and either flag can be what starts the swipe. An
-- unchanged flag does nothing, since an active condition re-applies its
-- properties on every run, and re-arming then would blank the sweep each
-- time.
function region:SetCooldownSwipe(b)
b = b and true or false
if b == self.cooldownSwipe then return end
self.cooldownSwipe = b
self.swipe:SetDrawSwipe(b)
self.swipeMode = nil
if self.state then WA.regionPrototype.UpdateProgress(self) end
end
function region:SetCooldownEdge(b)
b = b and true or false
if b == self.cooldownEdge then return end
self.cooldownEdge = b
self.swipe:SetDrawEdge(b)
self.swipeMode = nil
if self.state then WA.regionPrototype.UpdateProgress(self) end
end
function region:SetSwipeColor(r, g, b, a)
self.swipeColor = { r, g, b, a }
self.swipe:SetSwipeColor(r, g, b, a)
end
function region:SetSwipeEdgeColor(r, g, b, a)
self.swipeEdgeColor = { r, g, b, a }
self.swipe:SetEdgeColor(r, g, b, a)
end
function region:SetInverse(v)
self.inverse = v and true or false
if self.state then WA.regionPrototype.UpdateProgress(self) end
end
-- Zoom crops the texcoords inward from the fixed 0.07 border trim (zoom=0
-- keeps the default look; zoom=1 shows the center ~46%).
function region:SetZoom(z)
local inset = 0.07 + (z or 0) * 0.20
self.iconTex:SetTexCoord(inset, 1 - inset, inset, 1 - inset)
end
-- Icon resolution (WA2's Icon.lua region:UpdateIcon): manual mode uses
-- displayIcon; automatic falls back to it when the trigger gives no icon.
function region:SetIconSource(source) self.iconSource = source; self:UpdateIcon() end
function region:SetIcon(path) self.displayIcon = path; self:UpdateIcon() end
function region:UpdateIcon()
local path
if self.iconSource == 0 then
path = self.displayIcon
else
path = (self.state and self.state.icon) or self.displayIcon
end
self.iconTex:SetTexture(WA.DrawableTexture(path) or "Interface\\Icons\\INV_Misc_QuestionMark")
end
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
region:SetRegionAlpha(data.alpha)
region:SetDesaturated(data.desaturate)
region:SetZoom(data.zoom)
-- Fields first, then one push each: the setters re-drive the state, and
-- modify has not reached the state machine yet.
region.cooldownSwipe = data.cooldownSwipe and true or false
region.cooldownEdge = data.cooldownEdge and true or false
local sc = data.swipeColor or { 0, 0, 0, 0.8 }
region:SetSwipeColor(sc[1], sc[2], sc[3], sc[4])
local ec = data.swipeEdgeColor or { 1, 0.82, 0.35, 1 }
region:SetSwipeEdgeColor(ec[1], ec[2], ec[3], ec[4])
region.swipe:SetDrawSwipe(region.cooldownSwipe)
region.swipe:SetDrawEdge(region.cooldownEdge)
region.swipeMode = nil
region.inverse = data.inverse and true or false
local col = data.color or { 1, 1, 1, 1 }
region:Color(col[1], col[2], col[3], col[4])
region.iconSource = data.iconSource
region.displayIcon = data.displayIcon
region:UpdateIcon()
WA.regionPrototype.ApplyPosition(region, data)
WA.regionPrototype.ApplyProgressConfig(region, data)
WA.regionPrototype.modifyFinish(region, data)
end,
})
-- Follows WeakAuras2's AuraBar.lua (icon beside the bar rather than over it, a
-- bar texture, a background behind the fill, orientation + inverse, a spark),
-- minus upstream's SmoothStatusBarMixin/LibSharedMedia, which don't exist here.
--
-- The fill is two plain textures (bg + fg) rather than a native StatusBar, for
-- the same reason upstream hand-rolls its own `barPrototype`: a StatusBar owns
-- its texture's coordinates -- it recomputes them on every SetValue to crop the
-- fill -- so a bar texture can never be *rotated* inside one, and this client
-- has no SetRotatesTexture to ask for it either (absent from ClassicAPI, unused
-- by every other addon here). Bar art is a horizontal grain, so a vertical bar
-- drawn through a StatusBar stretches that grain the wrong way. Owning the crop
-- means the 90-degree rotation is just the texcoords we hand it, and it also
-- makes the two _INVERSE orientations free -- no SetReverseFill needed.
--
-- The 8-argument SetTexCoord form the rotation needs is the corner form
-- (ULx,ULy, LLx,LLy, URx,URy, LRx,LRy); it works on this client.
local BAR_ORIENTATIONS = { "HORIZONTAL", "HORIZONTAL_INVERSE", "VERTICAL", "VERTICAL_INVERSE" }
-- Upstream's own wording (Types.lua orientation_types): the label names the
-- direction the *leading edge travels as the bar drains*, not where the fill
-- sits -- so "Right to Left" is the familiar left-anchored bar.
-- The client's own casting-bar spark, and what an unusable one falls back to:
-- an atlas name or a foreign addon's file would otherwise paint the engine's
-- missing-texture block at the full spark width and height.
local SPARK_DEFAULT = "Interface\\CastingBar\\UI-CastingBar-Spark"
local BAR_ORIENTATION_LABELS = {
HORIZONTAL = "Right to Left",
HORIZONTAL_INVERSE = "Left to Right",
VERTICAL = "Bottom to Top",
VERTICAL_INVERSE = "Top to Bottom",
}
local ICON_SIDES = { "LEFT", "RIGHT" }
-- Condition properties render `values` as a key -> label map (the key is what
-- gets stored and passed to the setter), unlike the options `select` widget's
-- plain array -- so this can't just point at W.BarTextures() itself.
-- Texture names are their own labels; derived so the two lists can't drift.
local BAR_TEXTURE_LABELS = {}
for _, name in ipairs(WA.Widgets.BarTextures()) do
BAR_TEXTURE_LABELS[name] = name
end
local function isVertical(o)
return o == "VERTICAL" or o == "VERTICAL_INVERSE"
end
local function isInverse(o)
return string.find(o, "INVERSE") ~= nil
end
local function progressbarAnchor(display, point, area)
return { display = display, point = point, area = area }
end
local PROGRESSBAR_SUB_ANCHORS = {
TOPLEFT = progressbarAnchor("Background / Top Left", true), TOP = progressbarAnchor("Background / Top", true), TOPRIGHT = progressbarAnchor("Background / Top Right", true),
LEFT = progressbarAnchor("Background / Left", true), CENTER = progressbarAnchor("Background / Center", true), RIGHT = progressbarAnchor("Background / Right", true),
BOTTOMLEFT = progressbarAnchor("Background / Bottom Left", true), BOTTOM = progressbarAnchor("Background / Bottom", true), BOTTOMRIGHT = progressbarAnchor("Background / Bottom Right", true),
INNER_TOPLEFT = progressbarAnchor("Background inner / Top Left", true), INNER_TOP = progressbarAnchor("Background inner / Top", true), INNER_TOPRIGHT = progressbarAnchor("Background inner / Top Right", true),
INNER_LEFT = progressbarAnchor("Background inner / Left", true), INNER_CENTER = progressbarAnchor("Background inner / Center", true), INNER_RIGHT = progressbarAnchor("Background inner / Right", true),
INNER_BOTTOMLEFT = progressbarAnchor("Background inner / Bottom Left", true), INNER_BOTTOM = progressbarAnchor("Background inner / Bottom", true), INNER_BOTTOMRIGHT = progressbarAnchor("Background inner / Bottom Right", true),
ICON_TOPLEFT = progressbarAnchor("Icon / Top Left", true), ICON_TOP = progressbarAnchor("Icon / Top", true), ICON_TOPRIGHT = progressbarAnchor("Icon / Top Right", true),
ICON_LEFT = progressbarAnchor("Icon / Left", true), ICON_CENTER = progressbarAnchor("Icon / Center", true), ICON_RIGHT = progressbarAnchor("Icon / Right", true),
ICON_BOTTOMLEFT = progressbarAnchor("Icon / Bottom Left", true), ICON_BOTTOM = progressbarAnchor("Icon / Bottom", true), ICON_BOTTOMRIGHT = progressbarAnchor("Icon / Bottom Right", true),
SPARK = progressbarAnchor("Spark", true),
bar = progressbarAnchor("Full bar", true, true), icon = progressbarAnchor("Icon", true, true),
fg = progressbarAnchor("Foreground", true, true), bg = progressbarAnchor("Background", true, true),
}
-- Which two corners of the bar the fill is pinned to. The pair is always the
-- edge the texture's u = 0 lands on below, so the fill grows away from it.
local BAR_ALIGN = {
HORIZONTAL = { "TOPLEFT", "BOTTOMLEFT" },
HORIZONTAL_INVERSE = { "TOPRIGHT", "BOTTOMRIGHT" },
VERTICAL = { "TOPLEFT", "TOPRIGHT" },
VERTICAL_INVERSE = { "BOTTOMLEFT", "BOTTOMRIGHT" },
}
-- Corner texcoords cropping the texture to u in [0, p], rotated 90 degrees for
-- the vertical orientations so the art's grain runs along the fill axis (ref
-- WA2 AuraBar GetTexCoordFunctions, ported as-is). Returns the eight corner
-- values in SetTexCoord's order.
local BAR_TEXCOORDS = {
HORIZONTAL = function(p)
return 0, 0, 0, 1, p, 0, p, 1
end,
HORIZONTAL_INVERSE = function(p)
return p, 0, p, 1, 0, 0, 0, 1
end,
VERTICAL = function(p)
return 0, 1, p, 1, 0, 0, p, 0
end,
VERTICAL_INVERSE = function(p)
return p, 0, 0, 0, p, 1, 0, 1
end,
}
-- Which edge of `region.bar` a texture placed along the fill axis rides for
-- each orientation -- the same edge BAR_ALIGN pins the fill to, since the fill
-- grows away from that edge and anything riding the fill axis measures its
-- distance from it too.
local SPARK_ANCHOR = {
HORIZONTAL = "LEFT",
HORIZONTAL_INVERSE = "RIGHT",
VERTICAL = "TOP",
VERTICAL_INVERSE = "BOTTOM",
}
-- Corner texcoords for a manual (non-AUTO) spark rotation -- only multiples of
-- 90 degrees, pure texcoord shuffling, no rotation API needed (ref WA2 AuraBar
-- GetTexCoordSpark). Upstream's version returns TL,TR,BL,BR and reorders to
-- TL,BL,TR,BR at its call site for SetTexCoord; this returns already in
-- SetTexCoord order (TL,BL,TR,BR), matching BAR_TEXCOORDS' convention, so a
-- caller passes the result straight through.
local SPARK_CORNER_COORDS = { 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1 }
local function GetTexCoordSpark(degree, mirror)
local offset = (degree or 0) / 90
local TLx, TLy = SPARK_CORNER_COORDS[2 + offset], SPARK_CORNER_COORDS[1 + offset]
local TRx, TRy = SPARK_CORNER_COORDS[3 + offset], SPARK_CORNER_COORDS[2 + offset]
local BLx, BLy = SPARK_CORNER_COORDS[1 + offset], SPARK_CORNER_COORDS[4 + offset]
local BRx, BRy = SPARK_CORNER_COORDS[4 + offset], SPARK_CORNER_COORDS[3 + offset]
if mirror then
TLx, TRx = TRx, TLx
TLy, TRy = TRy, TLy
BLx, BRx = BRx, BLx
BLy, BRy = BRy, BLy
end
return TLx, TLy, BLx, BLy, TRx, TRy, BRx, BRy
end
-- Published so subtick can reuse the same 90-degree-step texcoord shuffle
-- instead of a second copy of the same math.
WA.GetTexCoordSpark = GetTexCoordSpark
-- Recomputes the spark's texcoords. AUTO reuses the bar's own corner coords
-- (the art turns with the bar, same as bg/fg); MANUAL is the ported rotation
-- above. Depends on orientation as well as the spark fields, so this runs from
-- layoutBar (geometry/orientation changes) as well as the spark setters.
local function updateSparkRotation(region)
local spark = region.spark
if not spark then return end
local o = region.orientation or "HORIZONTAL"
if (region.sparkRotationMode or "AUTO") == "AUTO" then
local coords = BAR_TEXCOORDS[o] or BAR_TEXCOORDS.HORIZONTAL
spark:SetTexCoord(coords(1))
else
spark:SetTexCoord(GetTexCoordSpark(tonumber(region.sparkRotation) or 0, region.sparkMirror))
end
end
-- Fraction of the remaining distance closed per second once smoothProgress
-- ticks the static path toward a new target; EPSILON is how close counts as
-- arrived, snapping the last sliver instead of crawling toward it forever.
local SMOOTH_RATE = 8
local SMOOTH_EPSILON = 0.001
-- Clamps a raw 0..1 fraction and applies `inverse` -- shared by SetProgress
-- and the static path's smoothing target, so a tween settles on exactly the
-- same number a snap would have used. The aura's configured inverse and the
-- active state's inverse combine by XOR, matching upstream's effectiveInverse.
local function clampProgress(region, p)
if not p or p < 0 then p = 0 elseif p > 1 then p = 1 end
if (region.inverse and true or false) ~= (region.stateInverse and true or false) then p = 1 - p end
return p
end
-- Centers `texture` on `bar`, `distance` out along the fill axis from the
-- orientation's anchor edge, plus a cross/along offset (ox, oy). The one copy
-- of the four-case sign/axis choice -- both the spark and any sub-region
-- riding the fill axis go through this, so the two can never drift apart on a
-- vertical bar.
local function placeOnBar(bar, o, texture, distance, ox, oy)
local anchor = SPARK_ANCHOR[o] or SPARK_ANCHOR.HORIZONTAL
texture:ClearAllPoints()
if o == "HORIZONTAL" then
texture:SetPoint("CENTER", bar, anchor, distance + ox, oy)
elseif o == "HORIZONTAL_INVERSE" then
texture:SetPoint("CENTER", bar, anchor, -distance + ox, oy)
elseif o == "VERTICAL" then
texture:SetPoint("CENTER", bar, anchor, ox, -distance + oy)
else -- VERTICAL_INVERSE
texture:SetPoint("CENTER", bar, anchor, ox, distance + oy)
end
end
-- Shows/hides and places the spark at the fill's leading edge, `extent` along
-- the fill axis from region.bar's origin corner (fillBar's own coordinate, so
-- the two can never disagree about where the leading edge is). Placement runs
-- even at extent == 0 -- the spark can legitimately sit at the origin edge --
-- so this cannot be folded into fillBar's extent <= 0 early return, which
-- leaves fg unresized (and its rect stale) at zero progress.
local function placeSpark(region, o, extent, p)
local spark = region.spark
if not spark then return end
if not region.sparkEnabled then spark:Hide(); return end
local hidden = region.sparkHidden or "NEVER"
local visible = hidden == "NEVER"
or (hidden == "FULL" and p < 1)
or (hidden == "EMPTY" and p > 0)
or (hidden == "BOTH" and p > 0 and p < 1)
if not visible then spark:Hide(); return end
placeOnBar(region.bar, o, spark, extent, region.sparkOffsetX or 0, region.sparkOffsetY or 0)
spark:Show()
end
-- The fill orientations that grow in the same screen direction their gradient
-- runs -- SetGradientAlpha's HORIZONTAL goes left to right and its VERTICAL
-- bottom to top, so a left-anchored horizontal fill and a bottom-anchored
-- vertical one advance *with* the ramp while the other two advance against it.
-- Which end of the colour pair sits at the fill's leading edge follows from that.
local GRADIENT_WITH_FILL = {
HORIZONTAL = true,
VERTICAL_INVERSE = true,
}
-- Whether the gradient axis is the fill axis, and so whether the fill fraction
-- changes the colours (below). A perpendicular gradient never has its axis
-- shortened and needs no correction at all.
local function gradientTracksFill(region)
if not region.enableGradient then return false end
return isVertical(region.gradientOrientation or "HORIZONTAL")
== isVertical(region.orientation or "HORIZONTAL")
end
local function lerp4(r1, g1, b1, a1, r2, g2, b2, a2, t)
return r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t, a1 + (a2 - a1) * t
end
-- Applies the fill's tint: a plain vertex colour, or the gradient pair.
--
-- SetGradientAlpha interpolates across the texture's *geometry*, and fillBar
-- shrinks that geometry to the fill fraction -- so handing it the configured
-- pair would squeeze the whole start-to-end ramp into however much of the bar is
-- filled. Upstream keeps its fg texture full-size and reveals a window onto it
-- with a mask. Since the interpolation is linear, the same picture comes out of
-- interpolating the *colours* on the shrunk texture instead, with no mask --
-- whichever
-- end of the pair falls on the fill's leading edge is replaced by the colour the
-- full-bar ramp would have had there. Alpha rides along with RGB.
local function updateForegroundColor(region)
local c = region.barColor or { 1, 1, 1, 1 }
if not region.enableGradient then
region.fg:SetVertexColor(c[1], c[2], c[3], c[4] or 1)
return
end
local c2 = region.barColor2 or { 1, 1, 0, 1 }
local r1, g1, b1, a1 = c[1], c[2], c[3], c[4] or 1
local r2, g2, b2, a2 = c2[1], c2[2], c2[3], c2[4] or 1
if gradientTracksFill(region) then
local p = region.progress or 0
if p < 0 then p = 0 elseif p > 1 then p = 1 end
if GRADIENT_WITH_FILL[region.orientation or "HORIZONTAL"] then
r2, g2, b2, a2 = lerp4(r1, g1, b1, a1, r2, g2, b2, a2, p)
else
r1, g1, b1, a1 = lerp4(r1, g1, b1, a1, r2, g2, b2, a2, 1 - p)
end
end
region.fg:SetGradientAlpha(region.gradientOrientation or "HORIZONTAL",
r1, g1, b1, a1, r2, g2, b2, a2)
end
-- Sizes and crops the fill texture to region.progress. Both have to move
-- together: the texture is cropped to the same fraction it is scaled to, or the
-- art would squash instead of being revealed.
local function fillBar(region)
local o = region.orientation or "HORIZONTAL"
local align = BAR_ALIGN[o] or BAR_ALIGN.HORIZONTAL
local coords = BAR_TEXCOORDS[o] or BAR_TEXCOORDS.HORIZONTAL
local p = region.progress or 0
local fg = region.fg
local vertical = isVertical(o)
local extent = (vertical and (region.barH or 0) or (region.barW or 0)) * p
-- A zero-dimension texture is not worth asking the client to draw.
if extent <= 0 then
fg:Hide()
else
fg:ClearAllPoints()
fg:SetPoint(align[1], region.bar, align[1])
fg:SetPoint(align[2], region.bar, align[2])
-- Two corners on one edge fix the cross axis, leaving the fill axis free
-- to be set explicitly.
if vertical then fg:SetHeight(extent) else fg:SetWidth(extent) end
fg:SetTexCoord(coords(p))
-- Resizing means a gradient along the fill axis spans only the drawn
-- portion, so its colours have to be re-derived from the new fraction
-- (see updateForegroundColor). Gated, so a bar without such a gradient
-- keeps the zero-extra-work path through this hot function.
if gradientTracksFill(region) then updateForegroundColor(region) end
fg:Show()
end
placeSpark(region, o, extent, p)
end
-- The static path's smoothProgress OnUpdate. A single shared function value
-- rather than a fresh closure per call, so re-installing it on every
-- UpdateValue is free -- and, since all the animation state lives on the
-- region (progress/targetProgress) rather than in an upvalue, it must be
-- installed unconditionally: a GetScript guard meant to avoid replacing an
-- in-flight ease can't tell this handler apart from UpdateTime's countdown
-- OnUpdate, and would leave that countdown running against a state that no
-- longer has a meaningful expiration.
local function smoothOnUpdate()
local delta = this.targetProgress - this.progress
local absDelta = delta < 0 and -delta or delta
if absDelta < SMOOTH_EPSILON then
this.progress = this.targetProgress
this:SetScript("OnUpdate", nil)
else
this.progress = this.progress + delta * math.min(1, arg1 * SMOOTH_RATE)
end
fillBar(this)
end
-- The timed path's OnUpdate, shared by the progress bar and the progress
-- texture: the fill recomputed from the clock every frame.
local function countdownOnUpdate()
if not this:IsShown() then this:SetScript("OnUpdate", nil); return end
local remain = this.expirationTime - GetTime()
this:SetProgress(remain / this.duration)
if remain <= 0 then this:SetScript("OnUpdate", nil) end
end
-- Lays the icon and the bar out so they share the region's box without
-- overlapping: the icon takes a square off one end of the *fill* axis, the bar
-- takes the rest (WA2's AuraBar orientHorizontal/orientVertical). icon_side's
-- stored LEFT/RIGHT means top/bottom on a vertical bar -- upstream reuses the
-- one field the same way, so the labels change but the data doesn't.
--
-- One divergence from upstream: the bar's *cross* axis is pinned to the region's
-- own edges, not to the icon square's corners. Upstream anchors the bar corner
-- to the icon corner, which is identical whenever the region is shaped for its
-- orientation (the square is then exactly the cross-axis extent) but insets the
-- bar by an arbitrary amount when it isn't -- e.g. a vertical bar left at the
-- default 200x18 loses its left edge to the 18px square's own centering.
local function layoutBar(region)
local bar, iconFrame = region.bar, region.iconFrame
iconFrame:ClearAllPoints()
bar:ClearAllPoints()
local w, h = region.regionWidth or 0, region.regionHeight or 0
local vertical = isVertical(region.orientation)
if region.iconVisible then
-- Square, sized to the smaller dimension so it can never overflow the
-- region (WA2's own `math.min(self.height, self.width)`). For a
-- region shaped for its orientation that smaller dimension *is* the
-- cross axis.
local size = math.min(w, h)
iconFrame:SetWidth(size)
iconFrame:SetHeight(size)
if not vertical then
region.barW, region.barH = w - size, h
if region.iconSide == "RIGHT" then
iconFrame:SetPoint("RIGHT", region, "RIGHT")
bar:SetPoint("TOPLEFT", region, "TOPLEFT")
bar:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -size, 0)
else
iconFrame:SetPoint("LEFT", region, "LEFT")
bar:SetPoint("TOPLEFT", region, "TOPLEFT", size, 0)
bar:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT")
end
else
region.barW, region.barH = w, h - size
if region.iconSide == "RIGHT" then
iconFrame:SetPoint("BOTTOM", region, "BOTTOM")
bar:SetPoint("TOPLEFT", region, "TOPLEFT")
bar:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", 0, size)
else
iconFrame:SetPoint("TOP", region, "TOP")
bar:SetPoint("TOPLEFT", region, "TOPLEFT", 0, -size)
bar:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT")
end
end
iconFrame:Show()
else
iconFrame:Hide()
region.barW, region.barH = w, h
bar:SetAllPoints(region)
end
-- The background is the whole texture, rotated the same way as the fill so
-- the empty part of the bar shares its grain.
local coords = BAR_TEXCOORDS[region.orientation or "HORIZONTAL"] or BAR_TEXCOORDS.HORIZONTAL
region.bg:SetTexCoord(coords(1))
updateSparkRotation(region)
fillBar(region)
end
WA.RegisterRegionType("progressbar", {
displayName = "Progress Bar",
description = "A bar that drains or fills with the time left, with an optional icon.",
defaults = {
width = 200,
height = 18,
alpha = 1,
texture = "Blizzard",
textureSource = "LSM",
textureInput = "",
barColor = { 0.2, 0.6, 1, 1 },
barColor2 = { 1, 1, 0, 1 },
enableGradient = false,
gradientOrientation = "HORIZONTAL",
backgroundColor = { 0, 0, 0, 0.5 },
orientation = "HORIZONTAL",
inverse = false,
smoothProgress = false,
icon = true,
icon_side = "LEFT",
icon_color = { 1, 1, 1, 1 },
desaturate = false,
iconSource = -1,
displayIcon = "",
zoom = 0,
useAdjustededMin = false,
adjustedMin = "",
useAdjustededMax = false,
adjustedMax = "",
progressSource = -1,
progressSourceManualValue = 0,
progressSourceManualTotal = 100,
spark = false,
sparkTexture = SPARK_DEFAULT,
sparkColor = { 1, 1, 1, 1 },
sparkWidth = 10,
sparkHeight = 30,
sparkBlendMode = "ADD",
sparkOffsetX = 0,
sparkOffsetY = 0,
sparkRotationMode = "AUTO",
sparkRotation = 0,
sparkMirror = false,
sparkDesaturate = false,
sparkHidden = "NEVER",
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
},
icon = "Interface\\Icons\\Spell_Nature_TimeStop",
getSubRegionAnchors = function() return PROGRESSBAR_SUB_ANCHORS end,
-- List-row preview: a background + a fill sized to a fraction of the bar
-- along the orientation's axis, an icon square carved out on icon_side when
-- shown. Not the runtime's cropped-texcoord bar (layoutBar/fillBar above) --
-- this box is too small for that fidelity to read, so it's plain
-- width/height + SetPoint.
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
local bg = frame:CreateTexture(nil, "BACKGROUND")
frame.bg = bg
local fill = frame:CreateTexture(nil, "ARTWORK")
frame.fill = fill
local icon = frame:CreateTexture(nil, "OVERLAY")
frame.icon = icon
return frame
end,
modifyThumbnail = function(frame, data)
local W = WA.Widgets
local size = frame:GetHeight() or 0
local o = data.orientation or "HORIZONTAL"
local vertical = isVertical(o)
-- The mock bar is a rectangle centred in the square box, not the box
-- itself (WA2's thumbnail draws a 26x15 bar in a 32px frame). A bar
-- filling the square would read as an icon, which is the one thing this
-- preview exists to tell apart -- and a full-box icon carve-out would
-- then leave the fill no length at all.
local long, thick = size * 0.82, size * 0.47
local barW, barH = long, thick
if vertical then barW, barH = thick, long end
local bgc = data.backgroundColor or { 0, 0, 0, 0.5 }
frame.bg:ClearAllPoints()
frame.bg:SetWidth(barW)
frame.bg:SetHeight(barH)
frame.bg:SetPoint("CENTER", frame, "CENTER")
frame.bg:SetTexture(bgc[1], bgc[2], bgc[3], bgc[4] or 1)
-- The icon square eats one end of the bar's long axis. icon_side is
-- LEFT/RIGHT only, so a vertical bar carves from its bottom.
local iconLen = 0
frame.icon:ClearAllPoints()
if data.icon then
iconLen = thick
frame.icon:SetWidth(thick)
frame.icon:SetHeight(thick)
frame.icon:SetTexture(WA.ResolveDisplayIcon(data) or "Interface\\Icons\\INV_Misc_QuestionMark")
if vertical then
frame.icon:SetPoint("BOTTOM", frame.bg, "BOTTOM")
elseif data.icon_side == "RIGHT" then
frame.icon:SetPoint("RIGHT", frame.bg, "RIGHT")
else
frame.icon:SetPoint("LEFT", frame.bg, "LEFT")
end
frame.icon:Show()
else
frame.icon:Hide()
end
local path = (data.textureSource == "Picker") and data.textureInput or W.BarTexturePath(data.texture)
frame.fill:SetTexture(path)
local bc = data.barColor or { 0.2, 0.6, 1, 1 }
frame.fill:SetVertexColor(bc[1], bc[2], bc[3], bc[4] or 1)
local extent = (long - iconLen) * 0.6
frame.fill:ClearAllPoints()
if extent <= 0 then
frame.fill:Hide()
else
if not vertical then
frame.fill:SetWidth(extent)
frame.fill:SetHeight(thick)
if o == "HORIZONTAL_INVERSE" then
if data.icon and data.icon_side == "RIGHT" then
frame.fill:SetPoint("RIGHT", frame.icon, "LEFT")
else
frame.fill:SetPoint("RIGHT", frame.bg, "RIGHT")
end
else
if data.icon and data.icon_side ~= "RIGHT" then
frame.fill:SetPoint("LEFT", frame.icon, "RIGHT")
else
frame.fill:SetPoint("LEFT", frame.bg, "LEFT")
end
end
else
frame.fill:SetWidth(thick)
frame.fill:SetHeight(extent)
if o == "VERTICAL_INVERSE" then
frame.fill:SetPoint("TOP", frame.bg, "TOP")
elseif data.icon then
frame.fill:SetPoint("BOTTOM", frame.icon, "TOP")
else
frame.fill:SetPoint("BOTTOM", frame.bg, "BOTTOM")
end
end
frame.fill:Show()
end
end,
properties = WA.regionPrototype.AddProgressProperties(WA.regionPrototype.AddProperties({
-- Both axes share one upper bound: a VERTICAL bar is a tall narrow region,
-- so a height capped near a horizontal bar's thickness would make that
-- orientation unbuildable from the options tab.
width = { display = "Width", setter = "SetRegionWidth", type = "number", min = 1, softMin = 8, softMax = 800, step = 1 },
height = { display = "Height", setter = "SetRegionHeight", type = "number", min = 1, softMax = 800, step = 1 },
texture = { display = "Bar Texture", setter = "SetBarTexture", type = "list", values = BAR_TEXTURE_LABELS },
textureSource = { display = "Texture Source", setter = "SetBarTextureSource", type = "list", values = { LSM = "Bundled", Picker = "Custom path" } },
textureInput = { display = "Texture Path", setter = "SetBarTextureInput", type = "string" },
barColor = { display = "Bar Color", setter = "Color", type = "color" },
barColor2 = { display = "Gradient End Color", setter = "SetBarColor2", type = "color" },
enableGradient = { display = "Gradient Enabled", setter = "SetGradientEnabled", type = "bool" },
gradientOrientation = { display = "Gradient Orientation", setter = "SetGradientOrientation", type = "list", values = { HORIZONTAL = "Horizontal", VERTICAL = "Vertical" } },
backgroundColor = { display = "Background Color", setter = "SetBackgroundColor", type = "color" },
orientation = { display = "Orientation", setter = "SetOrientation", type = "list", values = BAR_ORIENTATION_LABELS },
inverse = { display = "Inverse", setter = "SetInverse", type = "bool" },
icon = { display = "Show Icon", setter = "SetIconVisible", type = "bool" },
icon_side = { display = "Icon Side", setter = "SetIconSide", type = "list", values = { LEFT = "Left", RIGHT = "Right" } },
icon_color = { display = "Icon Color", setter = "SetIconColor", type = "color" },
desaturate = { display = "Desaturate", setter = "SetDesaturated", type = "bool" },
zoom = { display = "Zoom", setter = "SetZoom", type = "number", min = 0, max = 1, step = 0.05 },
iconSource = { display = "Icon Source", setter = "SetIconSource", type = "list", values = { [-1] = "Automatic", [0] = "Manual" }, default = 0 },
displayIcon = { display = "Manual Icon", setter = "SetIcon", type = "icon" },
sparkColor = { display = "Spark Color", setter = "SetSparkColor", type = "color" },
sparkWidth = { display = "Spark Width", setter = "SetSparkWidth", type = "number", min = 1, max = 400, step = 1 },
sparkHeight = { display = "Spark Height", setter = "SetSparkHeight", type = "number", min = 1, max = 400, step = 1 },
})),
options = function(data)
local W = WA.Widgets
local fields = {
{ type = "header", name = "Progress Bar" },
{
type = "select", name = "Texture source", key = "textureSource",
values = { "LSM", "Picker" },
labels = { LSM = "Bundled", Picker = "Custom path" },
get = function() return data.textureSource or "LSM" end,
set = function(v)
data.textureSource = v
WA.Add(data, true)
-- Repaints the tab: the field below swaps between the swatch
-- dropdown and the free-text path box.
WA.RefreshOptions()
end,
},
(data.textureSource or "LSM") == "Picker" and {
type = "input", name = "Texture path", key = "textureInput",
get = function() return data.textureInput end,
set = function(v) data.textureInput = v; WA.Add(data, true) end,
} or {
type = "select", name = "Bar texture", key = "texture",
values = W.BarTextures(), swatches = W.BarTextureSwatches(),
get = function() return data.texture end,
set = function(v) data.texture = v; WA.Add(data, true) end,
},
{
type = "color", name = "Bar color", key = "barColor",
get = function() return data.barColor end,
set = function(v) data.barColor = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Gradient", key = "enableGradient",
get = function() return data.enableGradient end,
set = function(v)
data.enableGradient = v
WA.Add(data, true)
-- Repaints the tab: the gradient end colour and direction
-- fields below only apply with the gradient on.
WA.RefreshOptions()
end,
},
}
if data.enableGradient then
table.insert(fields, {
type = "color", name = "Gradient end colour", key = "barColor2",
get = function() return data.barColor2 end,
set = function(v) data.barColor2 = v; WA.Add(data, true) end,
})
table.insert(fields, {
type = "select", name = "Gradient direction", key = "gradientOrientation",
values = { "HORIZONTAL", "VERTICAL" },
labels = { HORIZONTAL = "Horizontal", VERTICAL = "Vertical" },
get = function() return data.gradientOrientation end,
set = function(v) data.gradientOrientation = v; WA.Add(data, true) end,
})
end
local barFields = {
{
type = "color", name = "Background color", key = "backgroundColor",
get = function() return data.backgroundColor end,
set = function(v) data.backgroundColor = v; WA.Add(data, true) end,
},
{
type = "select", name = "Orientation", key = "orientation", half = true,
values = BAR_ORIENTATIONS,
labels = BAR_ORIENTATION_LABELS,
get = function() return data.orientation end,
set = function(v)
-- An INVERSE flip reverses which end the fill grows from, so
-- the icon has to jump sides to stay on the same physical end
-- of the bar.
if isInverse(v) ~= isInverse(data.orientation) then
data.icon_side = data.icon_side == "LEFT" and "RIGHT" or "LEFT"
end
-- Swap the dimensions when the *axis* changes (not on a mere
-- direction flip): the default 200x18 becomes a bar 18px tall
-- whose icon square (min(w,h), also 18) eats the entire fill
-- axis otherwise, leaving nothing to draw. Flipping back swaps
-- again, so this is self-inverse rather than a one-way edit of
-- saved data. The axis change also moves the icon to the bar's
-- other physical end, same as the INVERSE case above -- both
-- can fire on one change and cancel out.
if isVertical(v) ~= isVertical(data.orientation) then
data.width, data.height = data.height, data.width
data.icon_side = data.icon_side == "LEFT" and "RIGHT" or "LEFT"
end
data.orientation = v
WA.Add(data, true)
-- Repaints the tab: the icon-side labels and the size sliders
-- below all read off what just changed.
WA.RefreshOptions()
end,
},
{
type = "range", name = "Alpha", key = "alpha", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.alpha or 1 end,
set = function(v) data.alpha = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Inverse (fill as it expires)", key = "inverse",
get = function() return data.inverse end,
set = function(v) data.inverse = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Smooth progress", key = "smoothProgress",
get = function() return data.smoothProgress end,
set = function(v) data.smoothProgress = v; WA.Add(data, true) end,
},
{ type = "header", name = "Icon" },
{
type = "toggle", name = "Show icon", key = "icon", half = true,
get = function() return data.icon end,
set = function(v)
data.icon = v
WA.Add(data, true)
-- Repaints the tab: the icon fields below only apply with the
-- icon on, so they appear/disappear with this toggle.
WA.RefreshOptions()
end,
},
}
for _, f in ipairs(barFields) do
table.insert(fields, f)
end
if data.icon ~= false then
local iconFields = {
{
type = "toggle", name = "Desaturate", key = "desaturate", half = true,
get = function() return data.desaturate end,
set = function(v) data.desaturate = v; WA.Add(data, true) end,
},
{
type = "select", name = "Icon side", key = "icon_side", half = true,
values = ICON_SIDES,
-- A vertical bar keeps the same two stored values; only how they
-- read changes, so the labels follow the orientation.
labels = isVertical(data.orientation)
and { LEFT = "Top", RIGHT = "Bottom" }
or { LEFT = "Left", RIGHT = "Right" },
get = function() return data.icon_side end,
set = function(v) data.icon_side = v; WA.Add(data, true) end,
},
{
type = "range", name = "Zoom", key = "zoom", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.zoom end,
set = function(v) data.zoom = v; WA.Add(data, true) end,
},
{
type = "select", name = "Icon source", key = "iconSource",
values = { -1, 0 },
labels = { [-1] = "Automatic (trigger)", [0] = "Manual" },
get = function() return data.iconSource end,
set = function(v) data.iconSource = v; WA.Add(data, true); WA.RefreshList() end,
},
{
type = "icon", name = "Manual icon", key = "displayIcon",
get = function() return data.displayIcon end,
set = function(v) data.displayIcon = v; WA.Add(data, true); WA.RefreshList() end,
},
{
type = "color", name = "Icon color", key = "icon_color",
get = function() return data.icon_color end,
set = function(v) data.icon_color = v; WA.Add(data, true) end,
},
}
for _, f in ipairs(iconFields) do
table.insert(fields, f)
end
end
local sparkFields = {
{ type = "header", name = "Spark" },
{
type = "toggle", name = "Show spark", key = "spark", half = true,
get = function() return data.spark end,
set = function(v)
data.spark = v
WA.Add(data, true)
-- Repaints the tab: every other spark field below only applies
-- with the spark on, so they appear/disappear with this toggle.
WA.RefreshOptions()
end,
},
}
for _, f in ipairs(sparkFields) do
table.insert(fields, f)
end
if data.spark then
local sparkDetailFields = {
{
type = "input", name = "Spark texture", key = "sparkTexture",
get = function() return data.sparkTexture end,
set = function(v) data.sparkTexture = v; WA.Add(data, true) end,
},
{
type = "color", name = "Spark color", key = "sparkColor",
get = function() return data.sparkColor end,
set = function(v) data.sparkColor = v; WA.Add(data, true) end,
},
{
type = "range", name = "Spark width", key = "sparkWidth", min = 1, max = 200, step = 1, half = true,
get = function() return data.sparkWidth end,
set = function(v) data.sparkWidth = v; WA.Add(data, true) end,
},
{
type = "range", name = "Spark height", key = "sparkHeight", min = 1, max = 200, step = 1, half = true,
get = function() return data.sparkHeight end,
set = function(v) data.sparkHeight = v; WA.Add(data, true) end,
},
{
type = "range", name = "Spark X offset", key = "sparkOffsetX", min = -100, max = 100, step = 1, half = true,
get = function() return data.sparkOffsetX end,
set = function(v) data.sparkOffsetX = v; WA.Add(data, true) end,
},
{
type = "range", name = "Spark Y offset", key = "sparkOffsetY", min = -100, max = 100, step = 1, half = true,
get = function() return data.sparkOffsetY end,
set = function(v) data.sparkOffsetY = v; WA.Add(data, true) end,
},
{
type = "select", name = "Spark blend mode", key = "sparkBlendMode",
values = { "BLEND", "ADD" },
labels = { BLEND = "Blend", ADD = "Add" },
get = function() return data.sparkBlendMode end,
set = function(v) data.sparkBlendMode = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Desaturate", key = "sparkDesaturate", half = true,
get = function() return data.sparkDesaturate end,
set = function(v) data.sparkDesaturate = v; WA.Add(data, true) end,
},
{
type = "toggle", name = "Mirror", key = "sparkMirror", half = true,
get = function() return data.sparkMirror end,
set = function(v) data.sparkMirror = v; WA.Add(data, true) end,
},
{
type = "select", name = "Spark rotation mode", key = "sparkRotationMode",
values = { "AUTO", "MANUAL" },
labels = { AUTO = "Automatic", MANUAL = "Manual" },
get = function() return data.sparkRotationMode end,
set = function(v)
data.sparkRotationMode = v
WA.Add(data, true)
-- Repaints the tab: the rotation field below is meaningless
-- (and hidden) unless the mode is Manual.
WA.RefreshOptions()
end,
},
}
for _, f in ipairs(sparkDetailFields) do
table.insert(fields, f)
end
if data.sparkRotationMode == "MANUAL" then
table.insert(fields, {
type = "select", name = "Spark rotation", key = "sparkRotation",
values = { 0, 90, 180, 270 },
labels = { [0] = "0", [90] = "90", [180] = "180", [270] = "270" },
get = function() return data.sparkRotation end,
set = function(v) data.sparkRotation = v; WA.Add(data, true) end,
})
end
table.insert(fields, {
type = "select", name = "Spark visibility", key = "sparkHidden",
values = { "NEVER", "FULL", "EMPTY", "BOTH" },
labels = { NEVER = "Always", FULL = "Hide when full", EMPTY = "Hide when empty", BOTH = "Hide at full and empty" },
get = function() return data.sparkHidden end,
set = function(v) data.sparkHidden = v; WA.Add(data, true) end,
})
end
local sizeFields = {
{ type = "header", name = "Size" },
{
type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.width end,
set = function(v) data.width = v; WA.Add(data, true) end,
},
{
type = "range", name = "Height", key = "height", min = 1, softMax = 800, step = 1, half = true,
get = function() return data.height end,
set = function(v) data.height = v; WA.Add(data, true) end,
},
}
for _, f in ipairs(sizeFields) do
table.insert(fields, f)
end
for _, f in ipairs(WA.regionPrototype.ProgressOptions(data)) do
table.insert(fields, f)
end
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do
table.insert(fields, f)
end
return fields
end,
create = function(parent, data)
local region = CreateFrame("Frame", nil, parent)
-- A plain Frame holding the two fill textures, not a StatusBar -- see the
-- header comment above layoutBar for why we crop the fill ourselves.
-- Both textures live on it rather than on the region: a texture on the
-- region would be covered by any child frame (a child's draw layers all
-- sit above its parent's).
local bar = CreateFrame("Frame", nil, region)
region.bar = bar
local bg = bar:CreateTexture(nil, "BACKGROUND")
bg:SetAllPoints(bar)
region.bg = bg
local fg = bar:CreateTexture(nil, "ARTWORK")
region.fg = fg
-- OVERLAY draws above the fill's ARTWORK layer, so the spark rides on
-- top of the fill rather than being covered by it.
local spark = bar:CreateTexture(nil, "OVERLAY")
region.spark = spark
-- The icon gets a frame of its own so it can be levelled above the bar --
-- otherwise a texture on the region sits under it, and the icon disappears
-- wherever the two overlap.
local iconFrame = CreateFrame("Frame", nil, region)
local iconTex = iconFrame:CreateTexture(nil, "ARTWORK")
iconTex:SetAllPoints(iconFrame)
iconTex:SetTexCoord(0.07, 0.93, 0.07, 0.93)
region.iconFrame = iconFrame
region.iconTex = iconTex
-- Subtext anchors to the bar, not the whole region, so "%p at RIGHT"
-- tracks the end of the fill rather than sitting past the icon.
region.subRegionAnchor = bar
-- Three levels wide, which is what sets proto.SUB_STEP: the region
-- itself, the bar over it, the icon over that. Called from modifyFinish
-- rather than from modify, because a subbackground row moves the region
-- and these have to land above wherever it ended up. ApplyPosition's
-- SetParent resets them either way.
function region:ApplyInternalFrameLevels()
local base = self:GetFrameLevel()
self.bar:SetFrameLevel(base + 1)
self.iconFrame:SetFrameLevel(base + 2)
end
WA.regionPrototype.create(region)
function region:GetSubAnchorTarget(key)
if key == "region" or key == "bar" then return self.bar end
if key == "icon" then return self.iconFrame end
if key == "fg" then return self.fg end
if key == "bg" then return self.bg end
if key == "spark" or key == "SPARK" then return self.spark end
if string.find(key, "^ICON_") then return self.iconFrame end
return self.bar
end
-- Name/time text rides on %n/%p subtext elements; this only paints the
-- icon + drives the fill (below). The %c refresh goes first so every
-- subtext that follows reads values computed once.
function region:Update()
local state = self.state
if not state then return end
self:RefreshCustomText(true)
self:UpdateIcon()
WA.regionPrototype.UpdateProgress(self)
end
-- Progress is always a 0..1 fraction, so `inverse` is a single
-- subtraction and the timed/static paths share one setter.
function region:SetProgress(p)
self.progress = clampProgress(self, p)
fillBar(self)
end
-- Static progress (a Health/Power generic trigger, or the manual
-- progress source): a fixed value/total fill. With smoothProgress off
-- this snaps like before, clearing any OnUpdate. With it on, an
-- OnUpdate eases region.progress toward the target and clears itself
-- once it converges.
--
-- Only this path is smoothed. The timed path below recomputes progress
-- from the clock on every frame of its own OnUpdate, so it is already
-- smooth; a frame has one OnUpdate script, and installing a second one
-- here would fight the countdown for it instead of complementing it.
function region:UpdateValue()
local total = self.total or 0
local target = clampProgress(self, total > 0 and (self.value or 0) / total or 1)
if not self.smoothProgress then
self:SetScript("OnUpdate", nil)
self.progress = target
fillBar(self)
return
end
self.targetProgress = target
self:SetScript("OnUpdate", smoothOnUpdate)
end
-- The bar *fill* still needs a per-frame recompute (pfUI's
-- StatusBarOnUpdate) -- that's the region's own animation, separate from
-- the %p subtext (which repaints via FrameTick). Text no longer lives here.
function region:UpdateTime()
if self.paused then
local remain = self.remaining or 0
if self.duration and self.duration > 0 then
self:SetProgress(remain / self.duration)
else
self:SetProgress(1)
end
self:SetScript("OnUpdate", nil)
return
end
if self.duration and self.duration > 0 then
self:SetScript("OnUpdate", countdownOnUpdate)
else
self:SetProgress(1)
self:SetScript("OnUpdate", nil)
end
end
-- The one door a sub-region restricted to progressbar (via its own
-- `supports`) may use to reach the bar's geometry: orientation, the
-- usable width/height layoutBar already carved out for the icon, and
-- the frame to parent onto.
function region:GetBarGeometry()
return self.orientation or "HORIZONTAL", self.barW or 0, self.barH or 0, self.bar
end
function region:PlaceOnBar(texture, distance, ox, oy)
placeOnBar(self.bar, self.orientation or "HORIZONTAL", texture, distance, ox, oy)
end
-- The third door: whether the fill is reading backwards. `PlaceOnBar`
-- doesn't apply this itself -- the spark rides region.progress, which
-- clampProgress has already flipped, and flipping again there would be
-- double-counted.
function region:GetInverse()
return (self.inverse and true or false) ~= (self.stateInverse and true or false)
end
-- The fourth door: the displayed fill fraction, 0..1, with `inverse`
-- already applied by clampProgress -- not the raw progress value.
function region:GetProgress()
return self.progress or 0
end
region:Hide()
return region
end,
modify = function(region, data)
local W = WA.Widgets
function region:SetRegionWidth(w) self.regionWidth = w; self:SetWidth(w); layoutBar(self) end
function region:SetRegionHeight(h) self.regionHeight = h; self:SetHeight(h); layoutBar(self) end
function region:SetOrientation(o) self.orientation = o; layoutBar(self) end
function region:SetIconVisible(b) self.iconVisible = b and true or false; layoutBar(self) end
function region:SetIconSide(s) self.iconSide = s; layoutBar(self) end
-- SetTexture drops the tint with the old texture object, so the fg colour
-- is re-applied after through UpdateForegroundColor -- calling
-- SetVertexColor directly here would silently flatten an active gradient
-- back to a solid tint on every texture change. The background has no
-- gradient state, so it keeps re-applying its own copy directly; its
-- texcoords are per-orientation and layoutBar restores those.
function region:SetBarTexture(name)
self.barTexture = name
local path = (self.textureSource == "Picker") and self.textureInput or W.BarTexturePath(name)
self.fg:SetTexture(path)
self:UpdateForegroundColor()
-- The background is the same art tinted dark, so the empty part of
-- the bar reads as the same object as the filled part.
self.bg:SetTexture(path)
local b = self.backgroundColor or { 0, 0, 0, 0.5 }
self.bg:SetVertexColor(b[1], b[2], b[3], b[4] or 1)
layoutBar(self)
end
function region:SetBarTextureSource(v) self.textureSource = v; self:SetBarTexture(self.barTexture) end
function region:SetBarTextureInput(v) self.textureInput = v; self:SetBarTexture(self.barTexture) end
-- Gradient and vertex tint replace each other on the same texture object,
-- not layers -- SetGradientAlpha and SetVertexColor both stomp whatever
-- the other last set. Every caller that would have tinted self.fg routes
-- through here instead, so enabling/disabling the gradient always leaves
-- the texture in one consistent state.
function region:UpdateForegroundColor() updateForegroundColor(self) end
function region:Color(r, g, b, a)
self.barColor = { r, g, b, a or 1 }
self:UpdateForegroundColor()
end
function region:SetBarColor2(r, g, b, a)
self.barColor2 = { r, g, b, a or 1 }
self:UpdateForegroundColor()
end
function region:SetGradientEnabled(b)
self.enableGradient = b and true or false
self:UpdateForegroundColor()
end
function region:SetGradientOrientation(o)
self.gradientOrientation = o
self:UpdateForegroundColor()
end
function region:SetBackgroundColor(r, g, b, a)
self.backgroundColor = { r, g, b, a or 1 }
self.bg:SetVertexColor(r, g, b, a or 1)
end
function region:SetInverse(b)
self.inverse = b and true or false
-- Re-derive the fill from the live state so the flip is immediate
-- rather than waiting for the next OnUpdate tick or state change.
if self.state then WA.regionPrototype.UpdateProgress(self) end
end
function region:SetSparkEnabled(b) self.sparkEnabled = b and true or false; fillBar(self) end
function region:SetSparkTexture(path) self.spark:SetTexture(WA.DrawableTexture(path) or SPARK_DEFAULT) end
function region:SetSparkColor(r, g, b, a) self.spark:SetVertexColor(r, g, b, a or 1) end
function region:SetSparkWidth(w) self.spark:SetWidth(w) end
function region:SetSparkHeight(h) self.spark:SetHeight(h) end
function region:SetSparkBlendMode(mode) self.spark:SetBlendMode(mode) end
function region:SetSparkDesaturate(b) self.spark:SetDesaturated(b and true or false) end
function region:SetSparkOffsetX(x) self.sparkOffsetX = x; fillBar(self) end
function region:SetSparkOffsetY(y) self.sparkOffsetY = y; fillBar(self) end
function region:SetSparkRotationMode(mode) self.sparkRotationMode = mode; updateSparkRotation(self) end
function region:SetSparkRotation(deg) self.sparkRotation = deg; updateSparkRotation(self) end
function region:SetSparkMirror(b) self.sparkMirror = b and true or false; updateSparkRotation(self) end
function region:SetSparkHidden(mode) self.sparkHidden = mode; fillBar(self) end
function region:SetDesaturated(b) self.iconTex:SetDesaturated(b and true or false) end
function region:SetIconColor(r, g, b, a) self.iconTex:SetVertexColor(r, g, b, a or 1) end
function region:SetZoom(z)
local inset = 0.07 + (z or 0) * 0.20
self.iconTex:SetTexCoord(inset, 1 - inset, inset, 1 - inset)
end
-- Same icon resolution as the icon region (WA2's Icon.lua UpdateIcon).
function region:SetIconSource(source) self.iconSource = source; self:UpdateIcon() end
function region:SetIcon(path) self.displayIcon = path; self:UpdateIcon() end
function region:UpdateIcon()
local path
if self.iconSource == 0 then
path = self.displayIcon
else
path = (self.state and self.state.icon) or self.displayIcon
end
self.iconTex:SetTexture(WA.DrawableTexture(path) or "Interface\\Icons\\INV_Misc_QuestionMark")
end
WA.regionPrototype.ApplyPosition(region, data)
region.inverse = data.inverse and true or false
region.smoothProgress = data.smoothProgress and true or false
region.orientation = data.orientation
region.iconVisible = data.icon ~= false
region.iconSide = data.icon_side
region.sparkEnabled = data.spark and true or false
region.sparkOffsetX = data.sparkOffsetX or 0
region.sparkOffsetY = data.sparkOffsetY or 0
region.sparkRotationMode = data.sparkRotationMode or "AUTO"
region.sparkRotation = data.sparkRotation or 0
region.sparkMirror = data.sparkMirror and true or false
region.sparkHidden = data.sparkHidden or "NEVER"
region:SetSparkTexture(data.sparkTexture)
local spc = data.sparkColor or { 1, 1, 1, 1 }
region:SetSparkColor(spc[1], spc[2], spc[3], spc[4])
region:SetSparkWidth(data.sparkWidth or 10)
region:SetSparkHeight(data.sparkHeight or 30)
region:SetSparkBlendMode(data.sparkBlendMode or "ADD")
region:SetSparkDesaturate(data.sparkDesaturate)
updateSparkRotation(region)
-- Full until a state supplies a real fraction, so a display being
-- configured (or previewed) isn't an empty box. Preserved across a
-- re-modify so a config edit can't blank a running bar.
region.progress = region.progress or 1
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
region:SetRegionAlpha(data.alpha)
region.barColor = data.barColor or { 0.2, 0.6, 1, 1 }
region.barColor2 = data.barColor2 or { 1, 1, 0, 1 }
region.enableGradient = data.enableGradient and true or false
region.gradientOrientation = data.gradientOrientation or "HORIZONTAL"
region.backgroundColor = data.backgroundColor or { 0, 0, 0, 0.5 }
region.textureSource = data.textureSource
region.textureInput = data.textureInput
region:SetBarTexture(data.texture)
region:SetZoom(data.zoom)
region:SetDesaturated(data.desaturate)
local ic = data.icon_color or { 1, 1, 1, 1 }
region:SetIconColor(ic[1], ic[2], ic[3], ic[4])
region.iconSource = data.iconSource
region.displayIcon = data.displayIcon
region:UpdateIcon()
WA.regionPrototype.ApplyProgressConfig(region, data)
WA.regionPrototype.modifyFinish(region, data)
end,
})
-- Follows WeakAuras2's Text.lua: a region that *is* its text, where icon and
-- progressbar merely carry text as sub-regions. The font block -- face, size,
-- outline, both justifications, spacing, shadow -- is TextCore.lua's, shared with
-- subtext, so what lives here is the string, the frame around it, and the
-- decision about when to re-resolve.
--
-- Per-symbol format settings sit under upstream's key layout for this region,
-- displayText_format_<symbol>_<setting>, the region-side twin of subtext's
-- text_text_format_*.
local TEXT_FORMAT_PREFIX = "displayText_format_"
local function textFormatGetter(data)
return function(key, default)
local v = data[TEXT_FORMAT_PREFIX .. key]
if v == nil then return default end
return v
end
end
-- Every string this region might end up showing: the configured one, plus any a
-- condition can swap in through the displayText property. A condition-supplied
-- string has to format identically to a typed one, so its symbols need formatters
-- built alongside (WA2's Text.lua walks data.conditions for the same reason).
-- The options rows stay one per symbol of the text being edited -- these are only
-- for the formatter build.
local function displayTexts(data)
local texts = { data.displayText or "" }
local conditions = data.conditions or {}
for i = 1, #conditions do
local changes = conditions[i].changes or {}
for c = 1, #changes do
local change = changes[c]
if change.property == "displayText" and type(change.value) == "string" then
table.insert(texts, change.value)
end
end
end
return texts
end
-- Where the string sits inside a region larger than it. SetJustifyH/V decide only
-- how wrapped lines align against the string's *own* box, so without anchoring at
-- the justify point a left-justified text still floats in the middle of a wide
-- region -- WA2's Text.lua anchors at data.justify for exactly this. Our justify
-- is the shared font block's pair, so both axes are honoured rather than
-- upstream's horizontal one alone.
local TEXT_ANCHORS_BY_JUSTIFY = {
TOP = { LEFT = "TOPLEFT", CENTER = "TOP", RIGHT = "TOPRIGHT" },
MIDDLE = { LEFT = "LEFT", CENTER = "CENTER", RIGHT = "RIGHT" },
BOTTOM = { LEFT = "BOTTOMLEFT", CENTER = "BOTTOM", RIGHT = "BOTTOMRIGHT" },
}
local function textAnchorPoint(data)
local D = WA.textCore.DEFAULTS
local row = TEXT_ANCHORS_BY_JUSTIFY[data.justifyV or D.justifyV]
or TEXT_ANCHORS_BY_JUSTIFY.MIDDLE
return row[data.justify or D.justifyH] or "CENTER"
end
-- Auto mode: size the region to whatever the string just rendered as. Both
-- dimensions are measured, since GetStringHeight is absent here and GetHeight
-- stands in for it -- a FontString with no explicit height reports its own text's,
-- wrapping included.
--
-- The result is written back to `data`, not merely onto the frame: group layout
-- (childRect) computes a child's box from data.width/height and returns a
-- degenerate point for a child that has neither, so a size living only on the
-- frame would let a dynamic group stack its siblings straight through this one.
-- The equality check is what keeps that affordable -- writing and relaying out
-- unconditionally would be a group relayout on every frame of a ticking %p.
--
-- The floor is the same recoverability argument as the " " substitution above: a
-- measurement can legitimately come back 0 (a font the client refused renders
-- nothing and measures nothing), and a region of no size cannot be clicked, which
-- is how one is dragged or resized back into existence.
local MIN_TEXT_DIM = 8
local function textAutoSize(region, data)
local fs = region.text
-- Measured detached, so an enclosing group's scale doesn't inflate the
-- reading, then put back (WA2 Text.lua).
local host = fs:GetParent()
fs:SetParent(UIParent)
local w = fs:GetStringWidth() or 0
-- GetStringHeight is the engine's own reading; GetHeight stands in when it
-- measures nothing.
local h = fs:GetStringHeight() or 0
if h == 0 then h = fs:GetHeight() or 0 end
fs:SetParent(host)
if w < MIN_TEXT_DIM then w = MIN_TEXT_DIM end
if h < MIN_TEXT_DIM then h = MIN_TEXT_DIM end
if w == data.width and h == data.height then return end
data.width, data.height = w, h
region:SetWidth(w)
region:SetHeight(h)
if data.parent then WA.RelayoutGroup(data.parent) end
end
local function textCreate(parent, data)
local region = CreateFrame("Frame", nil, parent)
-- The FontString sits on a child frame rather than on the region, the trap
-- SubText.lua documents: a child frame's draw layers all sit above its
-- parent's, so a border sub-region built as a child frame would paint over
-- text created directly on the region.
local textFrame = CreateFrame("Frame", nil, region)
textFrame:SetAllPoints(region)
region.textFrame = textFrame
region.text = textFrame:CreateFontString(nil, "OVERLAY")
function region:ApplyInternalFrameLevels()
self.textFrame:SetFrameLevel(self:GetFrameLevel() + 1)
end
WA.regionPrototype.create(region)
-- One stable function object, so ConfigureSubscribers can take it back off the
-- bus -- a closure built per call could only ever be added. The custom-text
-- refresh is *not* here: modifyFinish subscribes the prototype's own tick
-- ahead of this one, so the values are already fresh by the time this
-- re-resolves the string.
region.frameTick = function() region:UpdateText() end
function region:UpdateText()
local str = WA.ReplacePlaceHolders(self.displayText or "", self, self.formatters)
-- An empty resolved string is substituted rather than shown: a zero-width
-- region can't be clicked, and clicking it is how one is dragged or resized
-- back into existence (WA2 Text.lua).
if str == "" then str = " " end
self:SetDisplayString(str)
end
function region:Update()
self:RefreshCustomText(true)
self:UpdateText()
end
region:Hide()
return region
end
local function textModify(region, data)
function region:Color(r, g, b, a) self.text:SetTextColor(r, g, b, a or 1) end
-- Not merely SetTextHeight: upstream re-calls SetFont at the new size first
-- (Text.lua), so a condition-driven size change re-enters the read-back that
-- keeps a face the client refuses from leaving the string unrendered. Routed
-- through textCore rather than opening a second SetFont call site, which is
-- also what applies the SetTextHeight the client's size ceiling needs.
function region:SetTextHeight(size)
WA.textCore.Apply(self.text, data, "", size, WA.textCore.REGION_KEYS)
end
-- A condition can replace the whole string, and that changes two answers, not
-- one: what the resolved text is, and whether this region belongs on the
-- per-frame bus at all. WA2 keeps them as ConfigureTextUpdate and
-- ConfigureSubscribers; without the second, a condition swapping in a %p
-- renders a countdown once and then freezes.
function region:ChangeText(msg)
self.displayText = msg or ""
self:ConfigureTextUpdate()
self:ConfigureSubscribers()
end
function region:ConfigureTextUpdate()
self.needsFrameTick = WA.TextNeedsFrameTick(self.displayText, self.everyFrameFormatters)
or (self.customTextFunc ~= nil and self.customTextMode == "update"
and WA.ContainsCustomPlaceHolder(self.displayText))
or false
end
function region:ConfigureSubscribers()
self.subRegionEvents:RemoveSubscriber("FrameTick", self.frameTick)
if self.needsFrameTick then
self.subRegionEvents:AddSubscriber("FrameTick", self.frameTick)
end
WA.regionPrototype.RefreshFrameTick(self)
self:RefreshCustomText(true)
self:UpdateText()
end
local texts = displayTexts(data)
region.formatters, region.everyFrameFormatters =
WA.CreateFormatters(texts, textFormatGetter(data), data)
WA.textCore.Apply(region.text, data, "", nil, WA.textCore.REGION_KEYS)
local point = textAnchorPoint(data)
region.text:ClearAllPoints()
region.text:SetPoint(point, region, point)
-- Two sizing modes, and they differ in who owns the box. Fixed is a region
-- like any other -- its width and height are settings, the string wraps inside
-- them, and the mover resizes it. Auto has no size of its own: the string is
-- measured after every SetText and the region follows it, so the setters are
-- deliberately *absent*, which is what turns the mover's resize handles off
-- (MoverSizer gates on region.SetRegionWidth). They are cleared rather than
-- left alone, since one modify can follow another in the other mode.
if data.automaticWidth == "Fixed" then
function region:SetRegionWidth(w) self:SetWidth(w); self.text:SetWidth(w) end
function region:SetRegionHeight(h) self:SetHeight(h) end
function region:SetDisplayString(str) WA.textCore.SetText(self.text, str) end
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
else
region.SetRegionWidth = nil
region.SetRegionHeight = nil
function region:SetDisplayString(str)
WA.textCore.SetText(self.text, str)
textAutoSize(self, data)
end
-- Unbinds a width a previous Fixed pass left on the string, or it would
-- keep wrapping at that column and measure short.
region.text:SetWidth(0)
end
region:SetRegionAlpha(data.alpha)
local col = data.color or { 1, 1, 1, 1 }
region:Color(col[1], col[2], col[3], col[4])
WA.regionPrototype.ApplyPosition(region, data)
-- The region's own FrameTick subscription is installed *after* modifyFinish,
-- which drops every subscriber before rebuilding the sub-regions'.
WA.regionPrototype.modifyFinish(region, data)
region.displayText = data.displayText or ""
region:ConfigureTextUpdate()
region:ConfigureSubscribers()
end
local PROGTEX_ORIENTATIONS = {
"HORIZONTAL", "HORIZONTAL_INVERSE", "VERTICAL", "VERTICAL_INVERSE",
"CLOCKWISE", "ANTICLOCKWISE",
}
local PROGTEX_ORIENTATION_LABELS = {
HORIZONTAL = "Right to Left", HORIZONTAL_INVERSE = "Left to Right",
VERTICAL = "Bottom to Top", VERTICAL_INVERSE = "Top to Bottom",
-- "Fills", because unlike the linear labels beside them these name the
-- direction the wedge is laid out from the start angle, not the direction
-- its edge travels: a draining aura's progress falls, so a clockwise fill
-- empties anticlockwise. Upstream's own labels say only "Clockwise" and
-- read as a promise about motion.
CLOCKWISE = "Fills Clockwise", ANTICLOCKWISE = "Fills Anticlockwise",
}
-- The two orientation families draw through different primitives: a linear one
-- resizes and crops a single texture, a circular one hands an angle pair to a
-- three-texture WA.Spinner.
local function isCircular(o)
return o == "CLOCKWISE" or o == "ANTICLOCKWISE"
end
local PROGTEX_DEFAULT = "Interface\\AddOns\\WeakestAuras\\textures\\shapes\\Square_FullWhite.tga"
-- Upstream's default crop, and the value that means "no crop": the transform
-- scales the sampled area by sqrt(2) before dividing by 1 + crop, so 0.41
-- cancels it and anything smaller zooms in.
local PROGTEX_CROP_DEFAULT = 0.41
-- Corner texcoords cropping the source to the drawn fraction, in SetTexCoord's
-- (UL, LL, UR, LR) order. Ported from WA2 LinearProgressTextureBase's
-- ApplyProgressToCoordFunctions with startProgress pinned at 0, since there are
-- no overlays here to occupy a sub-range.
--
-- These deliberately do *not* match BAR_TEXCOORDS: the bar rotates its source
-- 90 degrees for the vertical pair, and a progress texture must not. Bar art is
-- a grain that has to run along the fill axis; a progress texture is a picture
-- the user chose, and standing it on its side is a bug. Rotating also crops the
-- wrong source axis -- the drawn rect then shrinks on one axis while the crop
-- shrinks the other, so the art squashes instead of being revealed.
local PROGTEX_TEXCOORDS = {
HORIZONTAL = function(p)
return 0, 0, 0, 1, p, 0, p, 1
end,
HORIZONTAL_INVERSE = function(p)
return 1 - p, 0, 1 - p, 1, 1, 0, 1, 1
end,
VERTICAL = function(p)
return 0, 1 - p, 0, 1, 1, 1 - p, 1, 1
end,
VERTICAL_INVERSE = function(p)
return 0, 0, 0, p, 1, 0, 1, p
end,
}
-- The progress crop first, then the display transform on top of it --
-- upstream's order in LinearProgressTextureBase.UpdateTextures, and it is
-- load-bearing: rotating first would spin the axis the progress crop then
-- measures along.
--
-- The transform is the spinner's, shared verbatim, which is what gives the two
-- orientation families one meaning for `crop_x` and friends -- and upstream's
-- meaning, so a WeakAuras2 aura's values travel untranslated. `crop_x` is a
-- sampling SCALE about the texture centre (larger samples wider), not an inset
-- of each edge; the sqrt(2) growth inside TransformPoint is exactly what the
-- 1.41 default cancels, so the two halves only make sense together.
local function progTexCoords(o, p, rotation, mirror, cropX, cropY, userX, userY)
local coords = PROGTEX_TEXCOORDS[o] or PROGTEX_TEXCOORDS.HORIZONTAL
local ULx, ULy, LLx, LLy, URx, URy, LRx, LRy = coords(p)
local transform = WA.TextureCoords.TransformPoint
local scaleX, scaleY = 1 + (cropX or 0), 1 + (cropY or 0)
local texRotation = rotation or 0
ULx, ULy = transform(ULx, ULy, scaleX, scaleY, texRotation, mirror, false, userX, userY)
LLx, LLy = transform(LLx, LLy, scaleX, scaleY, texRotation, mirror, false, userX, userY)
URx, URy = transform(URx, URy, scaleX, scaleY, texRotation, mirror, false, userX, userY)
LRx, LRy = transform(LRx, LRy, scaleX, scaleY, texRotation, mirror, false, userX, userY)
return ULx, ULy, LLx, LLy, URx, URy, LRx, LRy
end
-- Which two corners of the region the fill is pinned to -- the edge the crop
-- above keeps, so the art stays put and the fill grows away from it. WA2's
-- LinearProgressTextureBase orientationToAnchorPoint, as a corner pair. The
-- vertical entries are the opposite edge from BAR_ALIGN's, for the same reason
-- PROGTEX_TEXCOORDS is not BAR_TEXCOORDS.
local PROGTEX_ALIGN = {
HORIZONTAL = { "TOPLEFT", "BOTTOMLEFT" },
HORIZONTAL_INVERSE = { "TOPRIGHT", "BOTTOMRIGHT" },
VERTICAL = { "BOTTOMLEFT", "BOTTOMRIGHT" },
VERTICAL_INVERSE = { "TOPLEFT", "TOPRIGHT" },
}
-- Six wedge textures -- three per layer -- built on the first circular
-- orientation, so a linear region never pays for textures it will not draw.
local function progTexSpinners(region)
if region.foregroundSpinner then return true end
local bgSpinner = WA.Spinner.Create(region, "BACKGROUND")
local fgSpinner = WA.Spinner.Create(region, "ARTWORK")
region.backgroundSpinner, region.foregroundSpinner = bgSpinner, fgSpinner
-- The look setters run before the first circular orientation reaches the
-- region, so the values they cached are pushed once here; from now on each
-- setter reaches the spinner directly.
fgSpinner:SetTexture(region.fgTexture or PROGTEX_DEFAULT)
bgSpinner:SetTexture(region.bgTexture or PROGTEX_DEFAULT)
local fc = region.fgColor or { 1, 1, 1, 1 }
local bc = region.bgColor or { 0.5, 0.5, 0.5, 0.5 }
fgSpinner:SetColor(fc[1], fc[2], fc[3], fc[4] or 1)
bgSpinner:SetColor(bc[1], bc[2], bc[3], bc[4] or 1)
fgSpinner:SetDesaturated(region.desaturateForeground)
bgSpinner:SetDesaturated(region.desaturateBackground)
return true
end
-- The wedge span this region's angles describe. Matches upstream's normalize:
-- both ends into [0, 360), then lift the end above the start -- which is what
-- turns the 0/360 default pair into a full circle rather than a zero span.
local function progTexAngles(region)
local a1 = (region.startAngle or 0) % 360
local a2 = (region.endAngle or 360) % 360
if a2 <= a1 then a2 = a2 + 360 end
return a1, a2
end
-- Sizes and crops the fill texture to region.progress. Both have to move
-- together, exactly as in fillBar: the texture is cropped to the same fraction
-- of the same axis it is scaled to, or the art squashes instead of revealing.
local function progTexFill(region)
-- SetTexCoord(progTexCoords(o, p, region.rotation, region.mirror, region.crop_x, region.crop_y, region.user_x, region.user_y))
local o = region.orientation or "HORIZONTAL"
local align = PROGTEX_ALIGN[o] or PROGTEX_ALIGN.HORIZONTAL
local p = region.progress or 0
if p < 0 then p = 0 elseif p > 1 then p = 1 end
local fg = region.foreground
if not fg then return end
-- The per-frame half of the circular path: one angle pair and nothing else.
-- Everything the wedges are made of is pushed by progTexBackground, which
-- only ever runs off a setter.
if isCircular(o) then
local spinner = region.foregroundSpinner
if not spinner then return end
local a1, a2 = progTexAngles(region)
if o == "ANTICLOCKWISE" then
spinner:SetProgress(a1 + (a2 - a1) * (1 - p), a2)
else
spinner:SetProgress(a1, a1 + (a2 - a1) * p)
end
return
end
local vertical = isVertical(o)
local extent = (vertical and (region.regionHeight or 0) or (region.regionWidth or 0)) * p
-- A zero-dimension texture is not worth asking the client to draw.
if extent <= 0 then fg:Hide(); return end
fg:ClearAllPoints()
fg:SetPoint(align[1], region, align[1])
fg:SetPoint(align[2], region, align[2])
-- Two corners on one edge fix the cross axis, leaving the fill axis free to
-- be set explicitly.
if vertical then fg:SetHeight(extent) else fg:SetWidth(extent) end
fg:SetTexCoord(progTexCoords(o, p, region.animRotation or region.rotation, region.mirror, region.crop_x, region.crop_y, region.user_x, region.user_y))
fg:Show()
end
-- The background, and with it everything about a circular fill that is not the
-- angle pair: which family draws, the wedge size, and the texcoord transform.
-- Every caller is a setter or modify -- never the per-frame path -- because
-- each of these rebuilds all three wedges of both spinners.
local function progTexBackground(region)
local background = region.background
if not background then return end
local o = region.orientation or "HORIZONTAL"
if isCircular(o) and progTexSpinners(region) then
background:Hide()
region.foreground:Hide()
local fgSpinner, bgSpinner = region.foregroundSpinner, region.backgroundSpinner
local w, h = region.regionWidth or 0, region.regionHeight or 0
local scaleX = 1 + (region.crop_x or 0)
local scaleY = 1 + (region.crop_y or 0)
local texRotation = region.animRotation or region.rotation or 0
local mirror = region.mirror and true or false
fgSpinner:SetWidth(w); fgSpinner:SetHeight(h)
bgSpinner:SetWidth(w); bgSpinner:SetHeight(h)
fgSpinner:SetMirror(mirror); bgSpinner:SetMirror(mirror)
fgSpinner:SetTexRotation(texRotation); bgSpinner:SetTexRotation(texRotation)
fgSpinner:SetCropX(scaleX); bgSpinner:SetCropX(scaleX)
fgSpinner:SetCropY(scaleY); bgSpinner:SetCropY(scaleY)
bgSpinner:SetProgress(progTexAngles(region))
fgSpinner:Show(); bgSpinner:Show()
return
end
-- Leaving the circular family releases the wedges, and the client holds a
-- texture's vertex offsets until something explicitly clears them.
if region.foregroundSpinner then
region.foregroundSpinner:Hide(); region.foregroundSpinner:Reset()
region.backgroundSpinner:Hide(); region.backgroundSpinner:Reset()
end
background:Show()
background:SetTexCoord(progTexCoords(o, 1, region.animRotation or region.rotation, region.mirror, region.crop_x, region.crop_y, region.user_x, region.user_y))
end
WA.RegisterRegionType("progresstexture", {
displayName = "Progress Texture",
description = "A linear texture that fills from a progress value.",
defaults = {
foregroundTexture = PROGTEX_DEFAULT,
backgroundTexture = PROGTEX_DEFAULT,
sameTexture = true,
foregroundColor = { 1, 1, 1, 1 },
backgroundColor = { 0.5, 0.5, 0.5, 0.5 },
desaturateForeground = false,
desaturateBackground = false,
width = 200, height = 32, alpha = 1,
orientation = "HORIZONTAL", inverse = false, mirror = false, rotation = 0,
startAngle = 0, endAngle = 360,
crop_x = PROGTEX_CROP_DEFAULT, crop_y = PROGTEX_CROP_DEFAULT, user_x = 0, user_y = 0,
progressSource = -1, progressSourceManualValue = 0, progressSourceManualTotal = 100,
anchorFrameType = "SCREEN", selfPoint = "CENTER", anchorPoint = "CENTER",
xOffset = 0, yOffset = 0, frameStrata = 1,
},
icon = PROGTEX_DEFAULT,
getSubRegionAnchors = function() return TEXTURE_SUB_ANCHORS end,
properties = WA.regionPrototype.AddProgressProperties(WA.regionPrototype.AddProperties({
foregroundColor = { display = "Foreground Color", setter = "Color", type = "color" },
backgroundColor = { display = "Background Color", setter = "SetBackgroundColor", type = "color" },
desaturateForeground = { display = "Desaturate Foreground", setter = "SetForegroundDesaturated", type = "bool" },
desaturateBackground = { display = "Desaturate Background", setter = "SetBackgroundDesaturated", type = "bool" },
orientation = { display = "Orientation", setter = "SetOrientation", type = "list", values = PROGTEX_ORIENTATION_LABELS },
inverse = { display = "Inverse", setter = "SetInverse", type = "bool" },
mirror = { display = "Mirror", setter = "SetMirror", type = "bool" },
rotation = { display = "Texture Rotation", setter = "SetTexRotation", type = "number", min = 0, max = 360, step = 1 },
crop_x = { display = "Crop X", setter = "SetCropX", type = "number", min = 0, max = 2, step = 0.01 },
crop_y = { display = "Crop Y", setter = "SetCropY", type = "number", min = 0, max = 2, step = 0.01 },
user_x = { display = "Re-center X", setter = "SetUserX", type = "number", min = -0.5, max = 0.5, step = 0.01 },
user_y = { display = "Re-center Y", setter = "SetUserY", type = "number", min = -0.5, max = 0.5, step = 0.01 },
foregroundTexture = { display = "Foreground Texture", setter = "SetForegroundTexture", type = "texture" },
backgroundTexture = { display = "Background Texture", setter = "SetBackgroundTexture", type = "texture" },
})),
-- The wedge pair is built with the frame and never per bind: a row is
-- rebound to a different aura on every scroll tick and frames cannot be
-- destroyed here.
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
frame.bg = frame:CreateTexture(nil, "BACKGROUND")
frame.fg = frame:CreateTexture(nil, "ARTWORK")
frame.bgSpinner = WA.Spinner and WA.Spinner.Create(frame, "BACKGROUND")
frame.fgSpinner = WA.Spinner and WA.Spinner.Create(frame, "ARTWORK")
return frame
end,
-- The same fixed 60% fill either family draws, so switching orientation in
-- the editor changes the row's shape and nothing else.
modifyThumbnail = function(frame, data)
local size = frame:GetHeight() or 32
local o = data.orientation or "HORIZONTAL"
local bc = data.backgroundColor or { 0.5, 0.5, 0.5, 0.5 }
local c = data.foregroundColor or { 1, 1, 1, 1 }
local p = 0.6
if isCircular(o) and frame.fgSpinner then
frame.bg:Hide(); frame.fg:Hide()
local fgSpinner, bgSpinner = frame.fgSpinner, frame.bgSpinner
local w, h = frame:GetWidth() or 0, size
fgSpinner:SetTexture(WA.DrawableTexture(data.foregroundTexture) or PROGTEX_DEFAULT)
bgSpinner:SetTexture(WA.DrawableTexture(data.backgroundTexture) or PROGTEX_DEFAULT)
fgSpinner:SetColor(c[1], c[2], c[3], c[4] or 1)
bgSpinner:SetColor(bc[1], bc[2], bc[3], bc[4] or 1)
fgSpinner:SetWidth(w); fgSpinner:SetHeight(h)
bgSpinner:SetWidth(w); bgSpinner:SetHeight(h)
fgSpinner:SetCropX(1 + (data.crop_x or 0)); bgSpinner:SetCropX(1 + (data.crop_x or 0))
fgSpinner:SetCropY(1 + (data.crop_y or 0)); bgSpinner:SetCropY(1 + (data.crop_y or 0))
fgSpinner:SetTexRotation(data.rotation or 0); bgSpinner:SetTexRotation(data.rotation or 0)
fgSpinner:SetMirror(data.mirror and true or false)
bgSpinner:SetMirror(data.mirror and true or false)
local a1, a2 = progTexAngles(data)
bgSpinner:SetProgress(a1, a2)
if o == "ANTICLOCKWISE" then
fgSpinner:SetProgress(a1 + (a2 - a1) * (1 - p), a2)
else
fgSpinner:SetProgress(a1, a1 + (a2 - a1) * p)
end
fgSpinner:Show(); bgSpinner:Show()
return
end
-- Leaving the circular family releases the wedges, and the client holds
-- a texture's vertex offsets until something explicitly clears them.
if frame.fgSpinner then
frame.fgSpinner:Hide(); frame.fgSpinner:Reset()
frame.bgSpinner:Hide(); frame.bgSpinner:Reset()
end
local vertical = isVertical(o)
-- Without the corner transforms a circular aura has no wedges to draw,
-- and gets the square hint the linear bar cannot be mistaken for.
local long, thick = size * 0.82, isCircular(o) and size * 0.82 or size * 0.35
frame.bg:SetWidth(vertical and thick or long); frame.bg:SetHeight(vertical and long or thick)
frame.bg:SetPoint("CENTER", frame, "CENTER")
frame.bg:SetTexture(bc[1], bc[2], bc[3], bc[4])
frame.bg:Show()
frame.fg:SetTexture(WA.DrawableTexture(data.foregroundTexture) or PROGTEX_DEFAULT)
frame.fg:SetVertexColor(c[1], c[2], c[3], c[4] or 1)
frame.fg:SetWidth(vertical and thick or long * p); frame.fg:SetHeight(vertical and long * p or thick)
frame.fg:SetPoint(vertical and "BOTTOM" or "LEFT", frame.bg, vertical and "BOTTOM" or "LEFT")
frame.fg:Show()
end,
options = function(data)
local fields = {
{ type = "header", name = "Progress Texture" },
{ type = "texture", name = "Foreground texture", key = "foregroundTexture", get = function() return data.foregroundTexture end, set = function(v) data.foregroundTexture = v; WA.Add(data, true); WA.RefreshList() end },
{ type = "toggle", name = "Same texture", key = "sameTexture", get = function() return data.sameTexture end, set = function(v) data.sameTexture = v; WA.Add(data, true); WA.RefreshOptions() end },
{ type = "color", name = "Foreground color", key = "foregroundColor", half = true, get = function() return data.foregroundColor end, set = function(v) data.foregroundColor = v; WA.Add(data, true) end },
{ type = "color", name = "Background color", key = "backgroundColor", half = true, get = function() return data.backgroundColor end, set = function(v) data.backgroundColor = v; WA.Add(data, true) end },
{ type = "toggle", name = "Desaturate foreground", key = "desaturateForeground", half = true, get = function() return data.desaturateForeground end, set = function(v) data.desaturateForeground = v; WA.Add(data, true) end },
{ type = "toggle", name = "Desaturate background", key = "desaturateBackground", half = true, get = function() return data.desaturateBackground end, set = function(v) data.desaturateBackground = v; WA.Add(data, true) end },
{ type = "select", name = "Orientation", key = "orientation", get = function() return data.orientation end, set = function(v) data.orientation = v; WA.Add(data, true); WA.RefreshOptions() end, values = PROGTEX_ORIENTATIONS, labels = PROGTEX_ORIENTATION_LABELS },
{ type = "toggle", name = "Inverse", key = "inverse", half = true, get = function() return data.inverse end, set = function(v) data.inverse = v; WA.Add(data, true) end },
{ type = "toggle", name = "Mirror", key = "mirror", half = true, get = function() return data.mirror end, set = function(v) data.mirror = v; WA.Add(data, true) end },
{ type = "range", name = "Texture rotation", key = "rotation", min = 0, max = 360, step = 1, half = true, get = function() return data.rotation or 0 end, set = function(v) data.rotation = v; WA.Add(data, true) end },
{ type = "range", name = "Alpha", key = "alpha", min = 0, max = 1, step = 0.05, half = true, get = function() return data.alpha or 1 end, set = function(v) data.alpha = v; WA.Add(data, true) end },
{ type = "range", name = "Crop X", key = "crop_x", min = 0, max = 2, step = 0.01, half = true, get = function() return data.crop_x end, set = function(v) data.crop_x = v; WA.Add(data, true) end },
{ type = "range", name = "Crop Y", key = "crop_y", min = 0, max = 2, step = 0.01, half = true, get = function() return data.crop_y end, set = function(v) data.crop_y = v; WA.Add(data, true) end },
{ type = "range", name = "Re-center X", key = "user_x", min = -0.5, max = 0.5, step = 0.01, half = true, get = function() return data.user_x end, set = function(v) data.user_x = v; WA.Add(data, true) end },
{ type = "range", name = "Re-center Y", key = "user_y", min = -0.5, max = 0.5, step = 0.01, half = true, get = function() return data.user_y end, set = function(v) data.user_y = v; WA.Add(data, true) end },
{ type = "header", name = "Size" },
{ type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 800, step = 1, half = true, get = function() return data.width end, set = function(v) data.width = v; WA.Add(data, true) end },
{ type = "range", name = "Height", key = "height", min = 1, softMin = 8, softMax = 800, step = 1, half = true, get = function() return data.height end, set = function(v) data.height = v; WA.Add(data, true) end },
}
-- The arc the fill sweeps, which only a circular orientation has; it
-- belongs beside the orientation that introduced it, not at the tail.
if isCircular(data.orientation) then
-- Re-centering is linear-only, here as upstream: the spinner's own
-- transform has no user-offset term at all.
for i = #fields, 1, -1 do
if fields[i].key == "user_x" or fields[i].key == "user_y" then
table.remove(fields, i)
end
end
local at = #fields + 1
for i, f in ipairs(fields) do if f.key == "orientation" then at = i + 1 end end
table.insert(fields, at, { type = "range", name = "End angle", key = "endAngle", min = 0, max = 360, step = 1, half = true, get = function() return data.endAngle end, set = function(v) data.endAngle = v; WA.Add(data, true) end })
table.insert(fields, at, { type = "range", name = "Start angle", key = "startAngle", min = 0, max = 360, step = 1, half = true, get = function() return data.startAngle end, set = function(v) data.startAngle = v; WA.Add(data, true) end })
end
if not data.sameTexture then
table.insert(fields, 3, { type = "texture", name = "Background texture", key = "backgroundTexture", get = function() return data.backgroundTexture end, set = function(v) data.backgroundTexture = v; WA.Add(data, true); WA.RefreshList() end })
end
for _, f in ipairs(WA.regionPrototype.ProgressOptions(data)) do table.insert(fields, f) end
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do table.insert(fields, f) end
return fields
end,
create = function(parent)
local region = CreateFrame("Frame", nil, parent)
local bg = region:CreateTexture(nil, "BACKGROUND")
-- The fill is a plain texture anchored to the region and resized, not a
-- clipped viewport onto a full-size one: a ScrollFrame clips to a
-- rectangle, which is the shape the resize already gives, so it bought
-- nothing but a second coordinate space to keep in step.
local fg = region:CreateTexture(nil, "ARTWORK")
bg:SetAllPoints(region)
region.background, region.foreground = bg, fg
WA.regionPrototype.create(region)
function region:Update()
if self.state then WA.regionPrototype.UpdateProgress(self) end
end
function region:SetProgress(p)
self.progress = clampProgress(self, p)
progTexFill(self)
end
-- Clearing the script first is what makes a static fill hold still: the
-- timed path below leaves a countdown installed that recomputes progress
-- from expirationTime on every frame, and it outlives the state that
-- armed it. A region reaches here from a timed one whenever the options
-- preview hands back a previewed window, so this is not hypothetical.
function region:UpdateValue()
self:SetScript("OnUpdate", nil)
local total = self.total or 0
self:SetProgress(total > 0 and (self.value or 0) / total or 1)
end
-- The fill is this region's own animation and has to be recomputed from
-- the clock every frame, exactly as the progressbar's does -- deriving
-- the fraction once per state change leaves a timed aura frozen at
-- whatever fraction it held when the state arrived.
function region:UpdateTime()
if self.paused then
local remain = self.remaining or 0
if self.duration and self.duration > 0 then
self:SetProgress(remain / self.duration)
else
self:SetProgress(1)
end
self:SetScript("OnUpdate", nil)
return
end
if self.duration and self.duration > 0 then
self:SetScript("OnUpdate", countdownOnUpdate)
else
self:SetProgress(1)
self:SetScript("OnUpdate", nil)
end
end
region:Hide()
return region
end,
modify = function(region, data)
-- Each look setter caches what it resolved: a circular orientation can
-- arrive after them (modify sets the art and the colors first), and the
-- spinners are built at that point out of the cache.
function region:SetRegionWidth(v) self.regionWidth = v; self:SetWidth(v); progTexBackground(self); progTexFill(self) end
function region:SetRegionHeight(v) self.regionHeight = v; self:SetHeight(v); progTexBackground(self); progTexFill(self) end
function region:SetForegroundTexture(v)
self.fgTexture = WA.DrawableTexture(v) or PROGTEX_DEFAULT
self.foreground:SetTexture(self.fgTexture)
if self.foregroundSpinner then self.foregroundSpinner:SetTexture(self.fgTexture) end
progTexFill(self)
end
function region:SetBackgroundTexture(v)
self.bgTexture = WA.DrawableTexture(v) or PROGTEX_DEFAULT
self.background:SetTexture(self.bgTexture)
if self.backgroundSpinner then self.backgroundSpinner:SetTexture(self.bgTexture) end
progTexBackground(self)
end
function region:Color(r, g, b, a)
self.fgColor = { r, g, b, a or 1 }
self.foreground:SetVertexColor(r, g, b, a or 1)
if self.foregroundSpinner then self.foregroundSpinner:SetColor(r, g, b, a or 1) end
end
function region:SetBackgroundColor(r, g, b, a)
self.bgColor = { r, g, b, a or 1 }
self.background:SetVertexColor(r, g, b, a or 1)
if self.backgroundSpinner then self.backgroundSpinner:SetColor(r, g, b, a or 1) end
end
function region:SetForegroundDesaturated(v)
self.desaturateForeground = v and true or false
self.foreground:SetDesaturated(self.desaturateForeground)
if self.foregroundSpinner then self.foregroundSpinner:SetDesaturated(self.desaturateForeground) end
end
function region:SetBackgroundDesaturated(v)
self.desaturateBackground = v and true or false
self.background:SetDesaturated(self.desaturateBackground)
if self.backgroundSpinner then self.backgroundSpinner:SetDesaturated(self.desaturateBackground) end
end
function region:SetOrientation(v) self.orientation = v; progTexBackground(self); progTexFill(self) end
function region:SetInverse(v) self.inverse = v and true or false; if self.state then WA.regionPrototype.UpdateProgress(self) end end
function region:SetMirror(v) self.mirror = v and true or false; progTexBackground(self); progTexFill(self) end
function region:SetTexRotation(v) self.rotation = v or 0; progTexBackground(self); progTexFill(self) end
function region:SetAnimRotation(v) self.animRotation = v; progTexBackground(self); progTexFill(self) end
function region:GetBaseRotation() return self.rotation or 0 end
function region:SetCropX(v) self.crop_x = math.max(0, v or 0); progTexBackground(self); progTexFill(self) end
function region:SetCropY(v) self.crop_y = math.max(0, v or 0); progTexBackground(self); progTexFill(self) end
-- The stored X shift is negated on the way to the texture, upstream's
-- own sign for this key, so a positive value moves the art right.
function region:SetUserX(v) self.user_x = -math.max(-0.5, math.min(0.5, v or 0)); progTexBackground(self); progTexFill(self) end
function region:SetUserY(v) self.user_y = math.max(-0.5, math.min(0.5, v or 0)); progTexBackground(self); progTexFill(self) end
region:SetRegionWidth(data.width); region:SetRegionHeight(data.height)
region:SetRegionAlpha(data.alpha)
region:SetForegroundTexture(data.foregroundTexture); region:SetBackgroundTexture(data.sameTexture and data.foregroundTexture or data.backgroundTexture)
local fc = data.foregroundColor or { 1, 1, 1, 1 }; region:Color(fc[1], fc[2], fc[3], fc[4])
local bc = data.backgroundColor or { 0.5, 0.5, 0.5, 0.5 }; region:SetBackgroundColor(bc[1], bc[2], bc[3], bc[4])
region:SetForegroundDesaturated(data.desaturateForeground); region:SetBackgroundDesaturated(data.desaturateBackground)
region.orientation = data.orientation; region.inverse = data.inverse
region.startAngle = data.startAngle or 0; region.endAngle = data.endAngle or 360
region.rotation = data.rotation; region.mirror = data.mirror and true or false
region:SetCropX(data.crop_x or PROGTEX_CROP_DEFAULT)
region:SetCropY(data.crop_y or PROGTEX_CROP_DEFAULT)
region:SetUserX(data.user_x); region:SetUserY(data.user_y)
region.progress = region.progress or 1
progTexBackground(region)
progTexFill(region)
WA.regionPrototype.ApplyPosition(region, data)
WA.regionPrototype.ApplyProgressConfig(region, data); WA.regionPrototype.modifyFinish(region, data)
end,
})
WA.RegisterRegionType("text", {
displayName = "Text",
description = "A line of text on its own, driven by the same placeholders.",
icon = "Interface\\Icons\\INV_Misc_Note_01",
defaults = {
displayText = "%p",
-- Auto is upstream's default and the reason the region type is worth
-- having: a box that hugs its own glyphs. width/height are still seeded
-- because Auto writes its measurements back into them and group layout
-- reads them from there.
automaticWidth = "Auto",
width = 200,
height = 20,
alpha = 1,
-- The rest of the font block is deliberately absent: TextCore reads every
-- one of its keys through a default, so a saved aura needs no migration to
-- pick up a key added later. Size and colour are here because a condition
-- restores a property's base from data, and a nil base is not a size.
fontSize = 12,
color = { 1, 1, 1, 1 },
-- The three customText* keys are deliberately absent, not seeded. Their
-- defaults belong to the region prototype, which owns the function for
-- every region type -- and for the source itself a nil is what tells the
-- options renderer this has never been configured, where "" means the user
-- cleared it and it stays cleared.
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
},
-- No width/height here, unlike the other leaf types and matching upstream: in
-- Auto mode the box is a measurement rather than a setting, and a condition
-- offering to change it would silently do nothing.
properties = WA.regionPrototype.AddProperties({
color = { display = "Color", setter = "Color", type = "color" },
fontSize = { display = "Font Size", setter = "SetTextHeight", type = "number", min = 6, softMax = 72, step = 1 },
displayText = { display = "Text", setter = "ChangeText", type = "string" },
}),
-- List-row preview: the configured string, not a resolved one -- there is no
-- state behind a list row, and showing "%p" is what tells the reader what the
-- aura will say. A FontString isn't clipped by the frame it sits on, so the
-- box's width is imposed on it and the string is cut rather than left to run
-- out across the row. Upstream masks a live preview with a self-scrolling
-- ScrollFrame; that is a lot of machinery for a 32px square.
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
local fs = frame:CreateFontString(nil, "ARTWORK")
fs:SetPoint("CENTER", frame, "CENTER")
frame.fs = fs
return frame
end,
modifyThumbnail = function(frame, data)
local box = frame:GetHeight() or 32
local fs = frame.fs
local size = math.floor(box / 3)
if size < 6 then size = 6 end
WA.textCore.Apply(fs, data, "", size, WA.textCore.REGION_KEYS)
fs:SetJustifyH("CENTER")
fs:SetJustifyV("MIDDLE")
fs:SetWidth(box)
WA.textCore.SetText(fs, WA.Utf8Sub(data.displayText or "", 12))
end,
create = textCreate,
modify = textModify,
options = function(data)
local fields = {
{ type = "header", name = "Text" },
{
type = "multiline", name = WA.TextSymbolLabel("Display text"),
key = "displayText", height = 60,
get = function() return data.displayText end,
-- Re-renders the tab: the Format rows below are one per symbol in
-- this string, so editing it changes which rows exist.
set = function(v)
data.displayText = v
WA.SetDefaultFormatters(v, textFormatGetter(data),
function(key, value) data[TEXT_FORMAT_PREFIX .. key] = value end, data)
WA.Add(data, true)
WA.RefreshOptions()
end,
},
{
type = "range", name = "Size", key = "fontSize", min = 6, softMax = 72, step = 1, half = true,
get = function() return data.fontSize end,
set = function(v) data.fontSize = v; WA.Add(data, true) end,
},
{
type = "range", name = "Alpha", key = "alpha", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.alpha or 1 end,
set = function(v) data.alpha = v; WA.Add(data, true) end,
},
{
type = "color", name = "Color", key = "color",
get = function() return data.color or { 1, 1, 1, 1 } end,
set = function(v) data.color = v; WA.Add(data, true) end,
},
}
-- Directly under the text field, whose label can only name the built-in
-- symbols.
local hint = WA.TextSymbolHint(data)
if hint then table.insert(fields, 2, { type = "description", name = hint }) end
local fontFields = WA.textCore.OptionFields(data, "region:textfont",
function(key) return data[key] end,
function(key, v) data[key] = v; WA.Add(data, true) end, WA.textCore.REGION_KEYS)
for i = 1, #fontFields do table.insert(fields, fontFields[i]) end
local get = textFormatGetter(data)
local formatFields = WA.FormatOptionFields(data.displayText, get,
function(key, v) data[TEXT_FORMAT_PREFIX .. key] = v; WA.Add(data, true) end,
data, TEXT_FORMAT_PREFIX)
if #formatFields > 0 then
-- Folded for the reason subtext folds its own: every %symbol grows a
-- row, and with two or three symbols those rows bury the text field
-- being edited under settings that get chosen once.
local S = WA.OptionsState
local key = "region:textformat"
local collapsed = S.isCollapsed(data, key, true)
table.insert(fields, {
type = "disclosure", name = "Format Options",
summary = WA.FormatSummary(data.displayText, get, data),
collapsed = collapsed,
onToggle = function()
S.setCollapsed(data, key, not collapsed)
WA.RefreshOptions()
end,
})
if not collapsed then
for i = 1, #formatFields do table.insert(fields, formatFields[i]) end
end
end
table.insert(fields, { type = "header", name = "Size" })
table.insert(fields, {
type = "select", name = "Sizing", key = "automaticWidth",
values = { "Auto", "Fixed" },
labels = { Auto = "Fit to the text", Fixed = "Fixed, text wraps inside" },
get = function() return data.automaticWidth or "Auto" end,
set = function(v)
data.automaticWidth = v
WA.Add(data, true)
-- Repaints the tab: the two sliders below exist only in Fixed mode,
-- Auto's box being a measurement rather than a setting.
WA.RefreshOptions()
end,
})
if data.automaticWidth == "Fixed" then
table.insert(fields, {
type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.width end,
set = function(v) data.width = v; WA.Add(data, true) end,
})
table.insert(fields, {
type = "range", name = "Height", key = "height", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.height end,
set = function(v) data.height = v; WA.Add(data, true) end,
})
end
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do
table.insert(fields, f)
end
return fields
end,
})
-- Upstream's Empty.lua: a frame that draws nothing of its own and exists to
-- carry sub-regions (§7). thumbnailIcon dresses the list row only -- the region
-- itself stays invisible whatever it holds.
WA.RegisterRegionType("empty", {
displayName = "Empty Base Region",
description = "Shows nothing itself; a bare frame that sub elements draw on.",
icon = "Interface\\Buttons\\UI-Quickslot2",
defaults = {
width = 200,
height = 200,
alpha = 1,
thumbnailIcon = "",
anchorFrameType = "SCREEN",
selfPoint = "CENTER",
anchorPoint = "CENTER",
xOffset = 0,
yOffset = 0,
frameStrata = 1,
},
-- Only the prototype set, matching upstream: with nothing drawn there is no
-- color, texture or size worth a condition.
properties = WA.regionPrototype.AddProperties({}),
getSubRegionAnchors = function() return TEXTURE_SUB_ANCHORS end,
-- Upstream's thumbnail: a quickslot border around whatever icon the author
-- picked to stand for the aura in the list.
-- Upstream stacks both textures on OVERLAY and relies on creation order to
-- put the icon over the border; two layers say the same thing without
-- leaning on same-layer ordering this client does not promise.
createThumbnail = function(parent)
local frame = CreateFrame("Frame", nil, parent)
local border = frame:CreateTexture(nil, "ARTWORK")
border:SetAllPoints(frame)
border:SetTexture("Interface\\Buttons\\UI-Quickslot2")
border:SetTexCoord(0.2, 0.8, 0.2, 0.8)
local icon = frame:CreateTexture(nil, "OVERLAY")
icon:SetAllPoints(frame)
frame.icon = icon
return frame
end,
modifyThumbnail = function(frame, data)
local path = WA.DrawableTexture(data.thumbnailIcon)
if path then
frame.icon:SetTexture(path)
frame.icon:Show()
else
frame.icon:Hide()
end
end,
options = function(data)
local fields = {
{ type = "header", name = "Settings" },
{
type = "range", name = "Alpha", key = "alpha", min = 0, max = 1, step = 0.05, half = true,
get = function() return data.alpha or 1 end,
set = function(v) data.alpha = v; WA.Add(data, true) end,
},
{
type = "icon", name = "Thumbnail icon", key = "thumbnailIcon",
get = function() return data.thumbnailIcon end,
set = function(v) data.thumbnailIcon = v; WA.Add(data, true); WA.RefreshList() end,
},
{ type = "header", name = "Size" },
{
type = "range", name = "Width", key = "width", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.width end,
set = function(v) data.width = v; WA.Add(data, true) end,
},
{
type = "range", name = "Height", key = "height", min = 1, softMin = 8, softMax = 800, step = 1, half = true,
get = function() return data.height end,
set = function(v) data.height = v; WA.Add(data, true) end,
},
}
for _, field in ipairs(WA.regionPrototype.PositionOptions(data)) do table.insert(fields, field) end
return fields
end,
create = function(parent)
local region = CreateFrame("Frame", nil, parent)
WA.regionPrototype.create(region)
function region:Update() end
region:Hide()
return region
end,
modify = function(region, data)
function region:SetRegionWidth(width) self:SetWidth(width) end
function region:SetRegionHeight(height) self:SetHeight(height) end
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
region:SetRegionAlpha(data.alpha)
WA.regionPrototype.ApplyPosition(region, data)
WA.regionPrototype.modifyFinish(region, data)
end,
})
-- Upstream registers a second region type out of the same file (Text.lua L393):
-- a text region that renders "Region type %s not supported" and nothing else.
-- WA.RegionSpecFor routes an aura naming a type this addon lacks here, at the
-- point a region is built -- so an imported progresstexture is a box on screen
-- saying what is wrong, rather than a row in the list that is silently never
-- drawn.
--
-- `internal`, so nothing offers to create one or convert to it: it stands in for
-- an absence, and an absence is not a thing to pick.
--
-- Nothing here may read the type's own defaults. MergeDefaults resolves them
-- through WA.regionTypes and finds none for an unknown type, so every field this
-- modify touches carries its own fallback.
WA.RegisterRegionType("fallback", {
displayName = "Unsupported",
description = "Stands in for a region type this addon does not have.",
internal = true,
icon = "Interface\\Icons\\INV_Misc_QuestionMark",
create = textCreate,
modify = function(region, data)
function region:SetDisplayString(str) WA.textCore.SetText(self.text, str) end
-- One static string with no state behind it, so the region never resolves
-- a placeholder and never joins the per-frame bus.
function region:Update() end
region.displayText = ""
region.formatters, region.everyFrameFormatters = nil, nil
region.customTextFunc = nil
WA.textCore.Apply(region.text, data, "", nil, WA.textCore.REGION_KEYS)
region.text:SetWidth(0)
region.text:ClearAllPoints()
region.text:SetPoint("CENTER", region, "CENTER")
region:SetRegionAlpha(data.alpha or 1)
WA.regionPrototype.ApplyPosition(region, data)
WA.regionPrototype.modifyFinish(region, data)
local requested = data.regionType
if requested == "progresstexture"
and (data.orientation == "CLOCKWISE" or data.orientation == "ANTICLOCKWISE") then
requested = requested .. " orientation " .. tostring(data.orientation)
end
WA.textCore.SetText(region.text, "Region type " .. tostring(requested) .. " not supported")
region:SetWidth(math.max(region.text:GetStringWidth() or 0, MIN_TEXT_DIM))
region:SetHeight(math.max(region.text:GetHeight() or 0, MIN_TEXT_DIM))
end,
options = function(data)
local fields = {
{ type = "header", name = "Unsupported region" },
{ type = "description", name = "This aura asks for a \"" .. tostring(data.regionType)
.. "\" display, which WeakestAuras does not have. Its triggers, conditions and load "
.. "settings are intact -- pick another Region type on the Info tab to show it as "
.. "something this addon can draw." },
}
for _, f in ipairs(WA.regionPrototype.PositionOptions(data)) do
table.insert(fields, f)
end
return fields
end,
})