Files
WeakestAuras/StateMachine.lua
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

1338 lines
54 KiB
Lua

-- WeakestAuras -- the state machine: the glue between trigger systems (which
-- produce states) and regions (which consume them). Everything meets at the
-- state table. Mirrors WA2's WeakAuras.lua glue (§2/§3/§6-7).
-- Upstream section refs (§N) point at design/architecture/weakauras2-reference.md
--
-- Two things cross the producer<->glue boundary and nothing else: a trigger
-- system fetches its per-trigger allstates via WA.GetTriggerStateForTrigger,
-- mutates them (setting state.changed on any field change), then calls
-- WA.UpdatedTriggerState(id). The glue combines states across triggers, decides
-- visibility, and drives regions through region:Update()/Expand()/Collapse().
if WeakestAuras.disabled then return end
local WA = WeakestAuras
-- triggerState[id] (§3): per-display combination state + per-trigger
-- allstates. [triggernum] = allstates (cloneId -> state; "" is the base key).
-- Kept file-local; Debug reads it via WA.GetDisplayTriggerState.
local triggerState = {}
-- regions[id] = { regionType = "icon", byClone = { [cloneId] = frame } }. Keyed
-- by display id and re-keyed on rename (WA.Rename) -- everything the engine owns
-- is id-keyed, matching upstream. The base clone "" is display-owned; non-empty
-- clones are acquired from clonePools and returned when they leave the state set.
local regions = {}
local clonePools = {}
-- Monotonic per-region-frame stamp, in acquisition order. `pairs(entry.byClone)` is a
-- hash walk whose order can differ between two passes over the same set, so a dynamic
-- group with no opinion about two clones would move them relative to each other for want
-- of a tiebreak. This is that tiebreak, and it reproduces upstream's ordering rule: a
-- clone sits where it was inserted, and a clone released and re-acquired goes to the end.
local cloneSeq = 0
-- loaded[id] = true while a display's load conditions pass (Load.lua, §11).
-- "Loaded" is distinct from "added": WA.Add compiles every display's
-- triggers/region/conditions; only a loaded display is *registered* with its
-- trigger systems (in their scan/event index) and allowed to show. An unloaded
-- display keeps its compiled state but produces nothing and stays hidden.
local loaded = {}
-- standby[id] = true while a display is *not* loaded but every character-level
-- constraint passes -- only a transient one (combat, zone, group, stance, ...)
-- is holding it back. A label for the options window and nothing more: the
-- engine decides what to register and show from `loaded` alone, so this must
-- stay a table of its own rather than an extra value in `loaded`, which is read
-- as a bare truthy in WA.Add's two paths below.
local standby = {}
-- Forward decl: WA.Add (above the preview section) re-derives a forced aura's
-- preview states synchronously after a recompile. Assigned in the preview
-- section.
local fakeForced
-- forced[id] = true for every leaf the preview builds states for (forced
-- visibility, §14 FakeStatesFor). A group in the options list
-- contributes its leaf descendants here, so selecting or eye-toggling a group
-- previews its children -- which is what makes an inactive dynamic group's
-- layout visible to debug. Read by WA.Add (a forced leaf is collapse-exempt)
-- and the trigger watchers (they skip a forced leaf so a real scan doesn't fight
-- the fake). The union of the selection preview + eye pins; see WA.SetPreview.
WA.forced = {}
-- True while the /wa options window is open. Mutes every non-forced display's
-- real state (see UpdatedTriggerState) so config starts from a blank board and
-- reveals only what you're editing -- WA2's SetFakeStates behavior.
WA.optionsOpen = false
-- Trigger *systems* (runtime state producers), keyed by trigger.type. Distinct
-- from Data.lua's WA.triggerTypes (the options-side registry).
WA.triggerSystems = {}
-- ---------------------------------------------------------------------------
-- Trigger system registration + lookup
-- ---------------------------------------------------------------------------
function WA.RegisterTriggerSystem(types, system)
for i = 1, #types do
WA.triggerSystems[types[i]] = system
end
end
-- The system owning triggernum n of this display, or nil.
function WA.GetTriggerSystem(data, triggernum)
local trigger = WA.GetTrigger(data, triggernum)
return trigger and trigger.type and WA.triggerSystems[trigger.type] or nil
end
-- Each distinct registered system exactly once -- Delete/Rename fan out to all
-- of them since a display's own systems may already be gone by then.
-- safecall-wrapped so one system's error (e.g. a loadFunc side effect
-- throwing) can't silently abort the rest of applyLoad -- previously an
-- uncaught error here left loaded[id] claiming success (WA.Add sets it before
-- this runs) while the system that threw never actually finished
-- registering, with nothing printed to explain why.
local function eachSystem(fn)
local seen = {}
for _, system in pairs(WA.triggerSystems) do
if not seen[system] then
seen[system] = true
WA.safecall("trigger system", fn, system)
end
end
end
-- ---------------------------------------------------------------------------
-- Regions and clone pools
-- ---------------------------------------------------------------------------
local function clonePool(regionType)
local pool = clonePools[regionType]
if not pool then
pool = {}
clonePools[regionType] = pool
end
return pool
end
local function resetRegionState(frame)
frame.state = nil
frame.states = {}
frame.toShow = false
frame.limited = false
-- Region frames are pooled and cannot be destroyed, so a dynamic-group slide
-- still running on one would go on moving whichever aura takes it next, and
-- a stale layoutPlaced would let that aura's first layout animate from a
-- position belonging to the previous tenant.
if WA.StopLayoutSlide then WA.StopLayoutSlide(frame) end
frame.layoutPlaced = nil
end
local function acquireClone(rt, data, cloneId)
local pool = clonePool(data.regionType)
local frame
for i = #pool, 1, -1 do
if pool[i]._waRegionSpec == rt then
frame = table.remove(pool, i)
break
end
end
if not frame then frame = rt.create(UIParent, data) end
WA.CancelAnimation(frame, true, true, true, true, true, false)
frame.animatingFinish = false
frame.pendingRelease = false
if frame.SetParent then frame:SetParent(UIParent) end
if frame.ClearAllPoints then frame:ClearAllPoints() end
if frame.Collapse then frame:Collapse() end
frame:Hide()
resetRegionState(frame)
frame.id = data.id
frame.cloneId = cloneId
cloneSeq = cloneSeq + 1
frame.cloneSeq = cloneSeq
frame._waRegionType = data.regionType
frame._waRegionSpec = rt
if rt.modify then rt.modify(frame, data) end
return frame
end
local function collapseFrame(frame, data)
if data and data.uid then WA.RunConditions(frame, data.uid, true) end
if frame.Collapse then frame:Collapse() end
end
-- `allowBase` retires a *base* frame (the "" cloneId) as well, which only a
-- regionType switch asks for: the frame is the wrong type from that moment, and
-- **frames cannot be destroyed on this client**, so it has to reach the pool or
-- it is stranded for the session along with every sub-region instance under it.
-- Every other caller leaves the base alone -- it is the aura's permanent frame.
local function releaseClone(entry, cloneId, frame, data, allowBase)
if (cloneId == "" and not allowBase) or entry.byClone[cloneId] ~= frame then return end
if frame.pendingRelease then return end
frame.pendingRelease = true
if data and data.uid then WA.ReleaseConditionsForClone(data.uid, cloneId) end
-- A state-owned hide (TSU's state.autoHide) outlives the state that scheduled
-- it, and would fire against a clone key this frame no longer represents.
if WA.StopStateTimersForClone then
WA.StopStateTimersForClone(frame.id or (data and data.id), cloneId)
end
entry.byClone[cloneId] = nil
local function recycle()
if not frame.pendingRelease then return end
frame.pendingRelease = false
resetRegionState(frame)
frame.id = nil
frame.cloneId = nil
frame._waRegionType = entry.regionType
frame._waRegionSpec = entry.spec
local pool = clonePool(entry.regionType)
for i = 1, #pool do
if pool[i] == frame then return end
end
table.insert(pool, frame)
end
-- `Collapse` runs its callback only for a region that was actually shown
-- (it returns early on `toShow` false), so a frame retired while already
-- hidden has to be recycled directly -- otherwise it lands in neither
-- `byClone` nor the pool and is stranded for the session.
if frame.Collapse and frame.toShow then frame:Collapse(recycle) else recycle() end
end
local function releaseNonBaseClones(entry, data)
local stale = {}
for cloneId in pairs(entry.byClone) do
if cloneId ~= "" then table.insert(stale, cloneId) end
end
for i = 1, #stale do
local cloneId = stale[i]
releaseClone(entry, cloneId, entry.byClone[cloneId], data)
end
end
-- Creates or returns the region frame for (id, cloneId). Rebuilds the whole
-- entry when the display's regionType changed under it. Regions are created
-- lazily and start collapsed (hidden); the state machine expands them.
local function EnsureRegion(id, cloneId)
local data = WeakestAurasDB.displays[id]
if not data then return nil end
-- Not WA.regionTypes directly: an aura naming a type this addon lacks builds
-- through the `fallback` region, which says so on screen rather than leaving a
-- row in the list with nothing behind it.
local rt = WA.RegionSpecFor(data)
if not rt or not rt.create then return nil end
local entry = regions[id]
-- The spec as well as the name, so an aura that was falling back and whose
-- real type has since been registered gets rebuilt rather than kept.
if not entry or entry.regionType ~= data.regionType or entry.spec ~= rt then
if entry then
releaseNonBaseClones(entry, data)
-- Pooled under the *old* type, not merely hidden: the pool is keyed by
-- regionType and matched on the spec, so switching away and back hands
-- the original frame straight back instead of building a second one.
local base = entry.byClone[""]
if base then releaseClone(entry, "", base, data, true) end
end
entry = { regionType = data.regionType, spec = rt, byClone = {} }
regions[id] = entry
end
local frame = entry.byClone[cloneId]
if not frame then
-- Base and clone take the same path: both want a pooled frame of this type
-- if one is going spare, and the base is only ever absent here on the
-- display's first build or straight after a regionType switch retired it.
frame = acquireClone(rt, data, cloneId)
entry.byClone[cloneId] = frame
end
return frame
end
-- (Re)applies saved config to every existing clone of a display -- the single
-- code path both "apply saved config" (WA.Add) and a regionType switch run.
local function SetRegion(data)
local rt = WA.RegionSpecFor(data)
if not rt or not rt.create then return end
EnsureRegion(data.id, "")
local entry = regions[data.id]
if entry and rt.modify then
for _, frame in pairs(entry.byClone) do
rt.modify(frame, data)
end
end
local entry = regions[data.id]
if entry and WA.CancelAnimation then
for _, frame in pairs(entry.byClone) do
if WA.CancelAnimation(frame, true, true, true, true, true, false) then
WA.Animate("display", data.uid, "main", data.animation and data.animation.main, frame, false, nil, true, frame.cloneId)
end
end
end
end
local function CollapseAll(id)
local entry = regions[id]
if not entry then return end
local data = WeakestAurasDB.displays[id]
local base = entry.byClone[""]
if base then collapseFrame(base, data) end
releaseNonBaseClones(entry, data)
end
-- Collapse clones whose cloneId no longer has a state (Show->Show pruning).
local function CollapseStaleClones(id, allstates)
local entry = regions[id]
if not entry then return end
local data = WeakestAurasDB.displays[id]
local base = entry.byClone[""]
if base and not allstates[""] then collapseFrame(base, data) end
-- Built only when there is something to release: this runs on every shown
-- update, and almost none of them have a stale clone.
local stale
for cloneId in pairs(entry.byClone) do
if cloneId ~= "" and not allstates[cloneId] then
stale = stale or {}
table.insert(stale, cloneId)
end
end
for i = 1, (stale and #stale or 0) do
local cloneId = stale[i]
releaseClone(entry, cloneId, entry.byClone[cloneId], data)
end
end
-- Iterate every live region frame (all clones of all displays). Conditions.lua
-- uses this to re-run conditions for shown regions on a global-condition event.
function WA.ForEachRegion(fn)
for id, entry in pairs(regions) do
for _, frame in pairs(entry.byClone) do fn(frame, id) end
end
end
-- Every live clone frame of one display. Regions.lua's dynamicgroup grower uses
-- it to collect a child's visible clones for layout.
function WA.ForEachClone(id, fn)
local entry = regions[id]
if not entry then return end
for cloneId, frame in pairs(entry.byClone) do fn(frame, cloneId) end
end
-- The cloneId -> frame map itself, read-only, for a hot path that walks it with
-- pairs instead of handing ForEachClone a fresh closure per call.
function WA.CloneFrames(id)
local entry = regions[id]
return entry and entry.byClone
end
-- The live on-screen frame for a display (default the base "" clone). Ensures
-- the region rather than only looking it up, so a caller can reach a display's
-- frame before any state has been applied to it.
function WA.GetRegion(id, cloneId)
return EnsureRegion(id, cloneId or "")
end
-- The frame the options mover should outline: the base clone when it is really
-- shown, otherwise the oldest live clone (WA2's SetMoverSizer, which takes
-- `next(clones[id])` for the same reason). A display need not have a base state
-- at all -- a Trigger State Updater naming its own clone keys has none, and
-- neither does an aura trigger whose preview is a staggered clone set -- and
-- outlining the base frame there leaves the border sitting at the display's
-- anchor while everything visible is laid out somewhere else. Oldest rather than
-- upstream's arbitrary `next`, because the mover re-resolves every frame: an
-- unordered pick lets the outline hop between clones as the hash walk changes.
function WA.MoverRegion(id)
local base = EnsureRegion(id, "")
if base and base.toShow then return base end
local entry = regions[id]
if not entry then return base end
local best
for cloneId, frame in pairs(entry.byClone) do
if cloneId ~= "" and frame.toShow
and (not best or (frame.cloneSeq or 0) < (best.cloneSeq or 0)) then
best = frame
end
end
return best or base
end
-- Like GetRegion but never creates -- returns nil if the frame doesn't exist
-- yet. Regions.lua's WA.RelayoutGroup uses it so recomputing a group's box
-- after a child edit doesn't spuriously spin up an unrendered group.
function WA.PeekRegion(id, cloneId)
local entry = regions[id]
return entry and entry.byClone[cloneId or ""]
end
-- Read-only census of every region frame the engine holds, live or pooled
-- (Debug.lua's `/wa regions`). Frames cannot be destroyed on this client, so
-- "how many exist" is the only way to see a leak from inside the game: a count
-- that climbs as a display is switched between region types means frames are
-- being abandoned rather than recycled.
function WA.RegionCensus()
local live, pooled = {}, {}
local liveTotal, pooledTotal = 0, 0
for _, entry in pairs(regions) do
for _ in pairs(entry.byClone) do
live[entry.regionType] = (live[entry.regionType] or 0) + 1
liveTotal = liveTotal + 1
end
end
for regionType, pool in pairs(clonePools) do
local n = #pool
if n > 0 then
pooled[regionType] = n
pooledTotal = pooledTotal + n
end
end
return live, pooled, liveTotal, pooledTotal
end
-- ---------------------------------------------------------------------------
-- Producer-side API + conditions hook
-- ---------------------------------------------------------------------------
-- A trigger system's handle on its own allstates for one trigger -- it mutates
-- this table directly (add/update/remove states, set state.changed), then calls
-- WA.UpdatedTriggerState. nil if the display hasn't been Added yet.
function WA.GetTriggerStateForTrigger(id, triggernum)
local ts = triggerState[id]
if not ts then return nil end
if not ts[triggernum] then ts[triggernum] = {} end
return ts[triggernum]
end
-- Runs a display's condition function against the just-applied states.
-- Conditions.lua overrides this; the no-op default keeps the apply path
-- (ApplyStatesToRegions) working when that file hasn't loaded.
function WA.RunConditions(region, uid, hide) end
-- ---------------------------------------------------------------------------
-- Apply states to regions (§3 ApplyStatesToRegions)
-- ---------------------------------------------------------------------------
-- The state a non-active trigger contributes for one clone (§3): its own state
-- under the exact clone key, else -- for a dotted key like "unit.3" -- whatever
-- the part before the first dot owns, else the base "" state. The dotted step is
-- how a producer namespaces sub-clones under one parent identity and still reads
-- the parent's other triggers; a plain find, since a clone key is data and "." in
-- a pattern would match anything.
local function secondaryState(as, cloneId)
if not as then return nil end
local state = as[cloneId]
if state then return state end
if type(cloneId) == "string" then
local dot = string.find(cloneId, ".", 1, true)
if dot then
local prefix = string.sub(cloneId, 1, dot - 1)
return as[prefix] or as[""]
end
end
return as[""]
end
local function ApplyStatesToRegions(id, activeTrigger, allstates)
local ts = triggerState[id]
local data = WeakestAurasDB.displays[id]
for cloneId, state in pairs(allstates) do
local region = EnsureRegion(id, cloneId)
if region then
local applyChanges = region.state ~= state or state.changed or not region.toShow
region.state = state
if data.anchorFrameType == "NAMEPLATE" or data.anchorFrameType == "UNITFRAME" then
WA.regionPrototype.ApplyPosition(region, data)
end
region.states = region.states or {}
-- Every trigger's state for this clone is readable (through
-- secondaryState's fallback chain), so text can say %2.p and
-- conditions can check trigger 3 while another trigger drives the
-- display (§3).
for triggernum = 1, ts.numTriggers do
local triggerState = secondaryState(ts[triggernum], cloneId)
if region.states[triggernum] ~= triggerState
or (triggerState and triggerState.changed) then
applyChanges = true
end
region.states[triggernum] = triggerState
end
if applyChanges then
WA.safecall(id, region.Update, region)
region.subRegionEvents:Notify("Update", state, region.states)
region:Expand()
WA.RunConditions(region, data.uid, false)
end
end
end
end
-- ---------------------------------------------------------------------------
-- UpdatedTriggerState (§3): the seven steps
-- ---------------------------------------------------------------------------
function WA.UpdatedTriggerState(id)
local ts = triggerState[id]
if not ts then return end
local data = WeakestAurasDB.displays[id]
if not data then return end
local numTriggers = ts.numTriggers
-- 1. Prune states flagged hidden (show == false is the legacy removal path;
-- deleting the key is the other one, both handled).
for triggernum = 1, numTriggers do
local as = ts[triggernum]
if as then
for cloneId, state in pairs(as) do
if state.show == false then as[cloneId] = nil end
end
end
end
-- 2. Per-trigger active flag + count. Every surviving state is also stamped
-- with the identity fields upstream guarantees (WA2's WeakAuras.lua own
-- UpdatedTriggerState): a trigger system only has to fill in what it knows,
-- and anything downstream can rely on these being there. `id` in particular
-- is what makes %n fall back to the display's own name for a trigger that
-- reports no name of its own (TextReplace's `n` symbol reads name or id).
local triggerCount = 0
for triggernum = 1, numTriggers do
local as = ts[triggernum]
local anyShown = false
if as then
local entry = data.triggers[triggernum]
for _, state in pairs(as) do
state.id = id
state.triggernum = triggernum
state.trigger = entry and entry.trigger
if state.show then anyShown = true end
end
end
if ts.triggers[triggernum] ~= anyShown then
ts.triggers[triggernum] = anyShown
if anyShown then ts.activationTime[triggernum] = GetTime() end
end
if anyShown then triggerCount = triggerCount + 1 end
end
ts.triggerCount = triggerCount
-- 3. Overall show via the disjunctive.
local newShow
if ts.disjunctive == "any" then
newShow = triggerCount > 0
elseif ts.disjunctive == "custom" and ts.triggerLogicFunc then
local ok, res = WA.RunAuraFunc(id, id .. ": custom logic", ts.triggerLogicFunc, ts.triggers)
newShow = (ok and res) and true or false
else -- "all"
newShow = numTriggers > 0 and triggerCount == numTriggers
end
-- While the options window is open, mute every display except the ones the
-- user is actively viewing (selection + eye pins, which the preview ticker
-- force-shows). Everything else is hidden regardless of its real trigger, so
-- config starts from a blank board and you reveal what you work on -- WA2's
-- SetFakeStates/FakeStatesFor. WA.forced leaves are exempt (fakeOne owns them).
if WA.optionsOpen and not WA.forced[id] then
newShow = false
end
-- 4. Resolve the active trigger (fixed number, or first_active scan).
local activeTrigger = ts.activeTriggerMode
if activeTrigger == WA.trigger_modes.first_active then
activeTrigger = 1
for triggernum = 1, numTriggers do
if ts.triggers[triggernum] then activeTrigger = triggernum; break end
end
end
local allstates = ts[activeTrigger]
-- 5. Shown but the active trigger has no state: synthesize a fallback.
if newShow and (not allstates or not next(allstates)) then
local system = WA.GetTriggerSystem(data, activeTrigger)
local fallback = {}
if system and system.CreateFallbackState then
system.CreateFallbackState(data, activeTrigger, fallback)
end
fallback.show = true
fallback.changed = true
ts[activeTrigger] = ts[activeTrigger] or {}
ts[activeTrigger][""] = fallback
allstates = ts[activeTrigger]
end
ts.activeStates = allstates
-- 6. Apply by transition.
local wasShown = ts.show
ts.show = newShow
if newShow then
ApplyStatesToRegions(id, activeTrigger, allstates)
CollapseStaleClones(id, allstates)
elseif wasShown then
CollapseAll(id)
end
-- 7. Reset changed flags.
for triggernum = 1, numTriggers do
local as = ts[triggernum]
if as then
for _, state in pairs(as) do state.changed = false end
end
end
-- This display's clones just changed visibility, which changes its parent
-- group's layout: a dynamicgroup re-arranges its now-visible children, a
-- static group refreshes its box. Cheap and idempotent; the batching happens
-- naturally since all of this id's clones settle within this one call.
if data.parent then WA.RelayoutGroup(data.parent) end
if WA.regionPrototype and WA.regionPrototype.SyncOptionsAnchorMarkers then
WA.regionPrototype.SyncOptionsAnchorMarkers()
end
end
-- Toggle the config-mode mute (WA2 SetFakeStates/ClearFakeStates). Opening
-- re-evaluates every rendered display so non-forced ones hide; closing restores
-- their real states. OptionsFrame calls this from the panel's show/hide.
function WA.SetOptionsOpen(open)
WA.optionsOpen = open and true or false
if WA.regionPrototype and WA.regionPrototype.SetOptionsAnchors then
WA.regionPrototype.SetOptionsAnchors(WA.optionsOpen)
end
for id in pairs(triggerState) do
WA.safecall(id, WA.UpdatedTriggerState, id)
end
end
-- ---------------------------------------------------------------------------
-- Add / Remove / Rename (§3 pAdd, scaled)
-- ---------------------------------------------------------------------------
local function anchorDependency(data)
if not data then return nil end
local target = WA.GetAnchorAuraID and WA.GetAnchorAuraID(data)
if target then return target end
return data.parent
end
function WA.CheckForAnchorCycle(source)
local visited = {}
local current = source
while current do
if visited[current] then return true end
visited[current] = true
current = anchorDependency(WeakestAurasDB.displays[current])
end
return false
end
-- The warning outlives the cycle on purpose, and is not in the namespaces WA.Add
-- clears. Breaking the cycle is what this function *does*, so a clear keyed on
-- "no cycle found" would fire on the very next recompile and take the notice away
-- before the user could read it. It has no natural clear at all: the saved setting
-- was destroyed, so the report stands until the aura is deleted or the session
-- ends -- and losing it on a reload is correct, since the reset is not repeated.
local function resetAnchorCycle(data)
if not data or not WA.CheckForAnchorCycle(data.id) then return false end
data.anchorFrameType = "UIPARENT"
data.anchorFrameFrame = nil
WA.ReportForAura(data.id, "anchorcycle", "warning",
"Warning: anchoring in aura '" .. tostring(data.id)
.. "' was reset because it creates an anchoring cycle.", true)
return true
end
local function dependencyOrder(ids)
local ordered, added, visiting = {}, {}, {}
local function visit(id)
if added[id] then return end
if visiting[id] then return end
visiting[id] = true
local data = WeakestAurasDB.displays[id]
local anchor = WA.GetAnchorAuraID and WA.GetAnchorAuraID(data)
if anchor and ids[anchor] then visit(anchor) end
local parent = data and data.parent
if parent and ids[parent] then visit(parent) end
visiting[id] = nil
added[id] = true
table.insert(ordered, id)
end
for id in pairs(ids) do visit(id) end
return ordered
end
-- Recomputes the standby label for a display whose load state is `isLoaded`.
-- Returns whether it changed, so a caller that skipped the load transition can
-- still repaint the list. A loaded display is never on standby.
local function setStandby(data, isLoaded)
local id = data.id
local was = standby[id]
standby[id] = (not isLoaded and WA.EvalLoadStatic(data)) and true or nil
return standby[id] ~= was
end
-- Registers or unregisters a compiled display with its trigger systems by its
-- desired load state, and shows/hides accordingly (§11). Loading hands
-- the display to each system's LoadDisplays (which starts scanning / registers
-- events); unloading calls UnloadDisplays, wipes the produced states, and
-- collapses the region. Sets loaded[id] to the new state. Callers decide when to
-- (re)run UpdatedTriggerState -- LoadDisplays already force-initializes status
-- triggers, so WA.Add/SetDisplayLoaded only need it for the aura watcher path.
--
-- Repaints the aura list whenever the label it renders moved. Both routes here
-- need it and neither is the options window: a zone/combat change comes through
-- ScanForLoads, and an edit on the Load tab comes through WA.Add, which never
-- touched WA.RefreshList -- the row's load state would sit stale until the user
-- next clicked something. WA.RefreshList is itself a no-op while the panel is
-- closed, so the login-time AddAllDisplays sweep costs nothing.
local function applyLoad(data, shouldLoad)
local id = data.id
shouldLoad = shouldLoad and true or false
local wasLoaded, wasStandby = loaded[id], standby[id]
loaded[id] = shouldLoad
setStandby(data, shouldLoad)
if shouldLoad then
eachSystem(function(system) if system.LoadDisplays then system.LoadDisplays({ id }) end end)
if not wasLoaded and WA.RunActionCodeForLoad then WA.RunActionCodeForLoad(data, "load") end
else
if wasLoaded and WA.RunActionCodeForLoad then WA.RunActionCodeForLoad(data, "unload") end
eachSystem(function(system) if system.UnloadDisplays then system.UnloadDisplays({ id }) end end)
local ts = triggerState[id]
if ts then
-- Drop every produced state so a later reload starts clean (§11:
-- unloading wipes trigger state); ts.show=false so the reload's first
-- UpdatedTriggerState sees the region as currently hidden.
for triggernum = 1, ts.numTriggers do ts[triggernum] = {} end
ts.show = false
end
CollapseAll(id)
if data.parent then WA.RelayoutGroup(data.parent) end
end
if (loaded[id] ~= wasLoaded or standby[id] ~= wasStandby) and WA.RefreshList then
WA.RefreshList()
end
end
-- The load system's entry point when a display's load state may have flipped
-- (Load.lua's ScanForLoads, §11). Idempotent -- a no-op when the state is
-- unchanged or the display isn't compiled (a group, or not yet Added).
function WA.SetDisplayLoaded(data, shouldLoad)
local id = data.id
if not triggerState[id] then return end
shouldLoad = shouldLoad and true or false
if (loaded[id] and true or false) == shouldLoad then
-- No load transition, but a character-level constraint can still have
-- flipped under an unloaded display (a level-up, a spell learned), which
-- moves it between Standby and Not Loaded.
if setStandby(data, shouldLoad) and WA.RefreshList then WA.RefreshList() end
return
end
applyLoad(data, shouldLoad)
if shouldLoad then WA.UpdatedTriggerState(id) end
end
-- Read-only (Debug.lua's /wa load): is this display currently loaded?
function WA.IsDisplayLoaded(id)
return loaded[id] and true or false
end
-- The three-state load label the aura list renders, plus the leaf counts its
-- tooltip needs: "loaded" / "standby" / "unloaded", nLoaded, nStandby, nLeaves.
-- A group rolls its whole subtree up by priority, matching WA2's own header
-- test -- any leaf loaded makes the group loaded, else any leaf on standby
-- makes it standby. A leaf counts as one leaf, so both cases read the same.
function WA.DisplayLoadState(id)
local data = WeakestAurasDB.displays[id]
if not data then return "unloaded", 0, 0, 0 end
if not WA.IsGroup(data) then
if loaded[id] then return "loaded", 1, 0, 1 end
if standby[id] then return "standby", 0, 1, 1 end
return "unloaded", 0, 0, 1
end
local nLoaded, nStandby, nLeaves = 0, 0, 0
local children = data.controlledChildren or {}
for i = 1, #children do
local _, cl, cs, cn = WA.DisplayLoadState(children[i])
nLoaded, nStandby, nLeaves = nLoaded + cl, nStandby + cs, nLeaves + cn
end
local state = "unloaded"
if nLoaded > 0 then state = "loaded" elseif nStandby > 0 then state = "standby" end
return state, nLoaded, nStandby, nLeaves
end
-- Compiles a customTriggerLogic string ("function(t) return t[1] and not t[2]
-- end") into a callable (§3). User-authored, so it goes through WA.LoadFunction
-- like every other custom-code site -- which is also where it picks up the
-- sandbox: writes from it used to land in the real global namespace.
local function compileTriggerLogic(str, id)
if not str or str == "" then return nil end
return WA.LoadFunction(str, id .. ": custom logic")
end
-- Renders/repositions a group's container frame (no triggers/state -- see
-- WA.Add's group branch). Re-anchors existing child frames to it afterward, so
-- a group (re)built after its children already have frames pulls them in;
-- children created later resolve the group on demand via ApplyPosition. Nested
-- child groups recurse.
function WA.AddGroup(data)
SetRegion(data) -- runs groupModify: positions frame, sizes box, draws border
local kids = data.controlledChildren or {}
for i = 1, #kids do
local cd = WeakestAurasDB.displays[kids[i]]
if cd and WA.IsGroup(cd) then
WA.AddGroup(cd)
elseif cd then
-- Every live clone, not only the base one. A display that produces
-- clones keeps its visible frames under other keys, and those need the
-- reparent as much as the base frame does.
WA.ForEachClone(cd.id, function(cf)
WA.regionPrototype.ApplyPosition(cf, cd)
end)
end
end
-- ApplyPosition anchors a dynamicgroup's child CENTER-to-CENTER and zeroes its
-- offset, that being the origin the grower measures from -- so on its own it
-- stacks every child and every clone on the group's centre. The layout is what
-- re-issues the offsets, and it has to run here or a group edit that reaches
-- this path (a drag, a resize, a reparent) leaves the whole set piled up.
--
-- Static groups relayout here too, and for the ordering rather than the
-- offsets: SetRegion above sized this group's box before the recursion below
-- gave any child group one, so a group holding a group measured it as empty.
WA.RelayoutGroup(data.id)
end
-- Re-anchors a child whose group membership just changed and refreshes the
-- boxes of the groups it left and joined. The runtime side of Data.lua's
-- reparent primitives (WA.AddChildToGroup/RemoveChildFromGroup/ReorderAura):
-- a child's frame is parented by data.parent now (ApplyPosition), so moving it
-- between groups must re-anchor it. Safe before render -- an unrendered child
-- simply anchors correctly when first shown. A moved child that is itself a
-- group carries its own children (they SetParent to its frame).
function WA.RefreshMembership(childId, oldParent, newParent)
local cd = WeakestAurasDB.displays[childId]
if cd then
local cf = WA.PeekRegion(childId, "")
if cf then WA.regionPrototype.ApplyPosition(cf, cd) end
end
-- The group this child is leaving no longer iterates it, so a clone left
-- behind its limit would stay invisible forever. The relayout below
-- re-applies the new parent's limit (if any) once it does iterate it.
WA.ForEachClone(childId, function(frame) frame:SetLimited(false) end)
if oldParent then WA.RelayoutGroup(oldParent) end
if newParent and newParent ~= oldParent then WA.RelayoutGroup(newParent) end
end
local addDisplay
-- Brackets one re-derivation with the two things that have to span the whole of
-- it -- the code-warning reset and the ambient attribution the compile reporters
-- read -- and only then runs it. A wrapper rather than lines inside addDisplay
-- because that has three exits and a group leaves by the first of them, which is
-- exactly the path a dynamic group's custom sort, grow and anchor compile on.
function WA.Add(data, simpleChange)
-- The aura list's row previews render this display's own appearance fields
-- (Regions.lua's modifyThumbnail), so any edit that reaches here -- including
-- the pure-visual fast path below -- has to repaint the row, not just the
-- region. WA.RefreshList is a no-op while the options panel is closed.
if WA.RefreshList then WA.RefreshList() end
-- Saved and restored rather than cleared to nil, so a nested Add cannot strand
-- the outer one's id -- a stale id is worse than none, since it misattributes
-- the next aura's failure instead of falling back to chat.
--
-- The pcall is what makes the restore unconditional, and it is load-bearing
-- rather than defensive: an Add that throws is caught by the safecall around it
-- (WA.AddMany), so without this the id would survive the failure and every
-- later compile in the session would be blamed on that one aura. The error is
-- re-raised at level 0 so the callers that already handle it still do.
local prevCompiling = WA.compilingAuraId
WA.compilingAuraId = data.id
-- Both code namespaces dropped *before* anything recompiles or re-dispatches,
-- and inside the bracket so the repaint they ask for is deferred with the rest.
-- This is what makes fixing a broken field take its warning away: the compile
-- and runtime reporters only ever set, so a failure that no longer happens
-- would otherwise stay on the row forever, which is worse than never having
-- reported it. Clearing also rearms the once-per-key chat line, so an edit
-- that leaves the code still broken says so again.
if WA.ClearWarningPrefix then
WA.ClearWarningPrefix(data.uid, "compile:")
WA.ClearWarningPrefix(data.uid, "runtime:")
end
local ok, err = pcall(addDisplay, data, simpleChange)
WA.compilingAuraId = prevCompiling
-- Every warning this Add changed lands on the row in one repaint, now that no
-- prototype's resolver can be re-entered under this aura's id.
if WA.FlushWarningRepaint then WA.FlushWarningRepaint() end
if not ok then error(err, 0) end
end
function addDisplay(data, simpleChange)
if WA.IsGroup(data) then WA.AddGroup(data); return end
-- Recomputed here rather than at each field's setter: four fields across two
-- action blocks and every condition decide it, and this is the one place all
-- of them have already been written.
if WA.UpdateSoundWarnings then WA.UpdateSoundWarnings(data) end
resetAnchorCycle(data)
local id = data.id
if WA.CompileActions then WA.CompileActions(data) end
-- Fast-path (WA2's pAdd simpleChange): a pure-visual edit (region/subregion
-- appearance) doesn't change which states are produced or the load status, so
-- skip the collapse + trigger/condition/load recompile. Re-apply the region's
-- visuals -- modify rebuilds subregions too (RegionPrototype.modifyFinish) -- and
-- let UpdatedTriggerState re-layer states + conditions on the fresh base. Only
-- valid once the display has been fully Added (triggerState exists); the first
-- Add and any structural edit (trigger/load/condition/regionType) fall through.
if simpleChange and triggerState[id] then
SetRegion(data)
-- A size/offset edit shifts this aura's contribution to its group's box.
if data.parent then WA.RelayoutGroup(data.parent) end
-- This path leaves the states alone, so a previewed display's are still
-- there and re-applying them is all it needs. Re-deriving the preview would
-- run the trigger's own code again, which a colour edit or a drag step has
-- no business doing.
if WA.forced[id] or loaded[id] then WA.UpdatedTriggerState(id) end
return
end
local triggers = data.triggers
local numTriggers = #triggers
-- Collapse-first baseline (WA2's pAdd): a recompile reuses the live region
-- frame, so force it hidden before re-deriving -- otherwise a recompile that now
-- resolves to hidden (e.g. switching Show always->on-cooldown while off cooldown)
-- strands the previously-visible frame on screen. The re-derivation below re-shows
-- it synchronously if it should stay up (GenericTrigger via force_events, aura via
-- TriggerAura.LoadDisplays' sync rescan), so there's no visible flicker. The
-- previewed aura is exempt -- its visibility is owned by the preview ticker, which
-- we re-run synchronously at the end instead (WA2's FakeStatesFor snapshot).
if not WA.forced[id] then CollapseAll(id) end
-- The fresh ts's `show` must describe the region as it actually is right now,
-- since UpdatedTriggerState only collapses on a shown->hidden transition. The
-- collapse above makes that false for the normal path -- but the forced path
-- skipped it, so a still-expanded preview must carry its `show` over or a
-- recompile that now resolves to hidden strands the frame on screen with no
-- transition left to take it down (e.g. flipping the disjunctive back to "all").
local prev = triggerState[id]
local ts = {
disjunctive = triggers.disjunctive or "all",
numTriggers = numTriggers,
activeTriggerMode = triggers.activeTriggerMode or WA.trigger_modes.first_active,
triggerLogicFunc = nil,
triggerLogicSource = nil,
triggers = {},
triggerCount = 0,
activationTime = {},
activatedConditions = {},
show = (WA.forced[id] and prev and prev.show) and true or false,
activeStates = nil,
}
if ts.disjunctive == "custom" then
ts.triggerLogicSource = triggers.customTriggerLogic
-- Edited code, so whatever the old code cached in aura_env is stale.
if not prev or prev.triggerLogicSource ~= ts.triggerLogicSource then
WA.ClearAuraEnv(id)
end
ts.triggerLogicFunc = compileTriggerLogic(triggers.customTriggerLogic, id)
end
for i = 1, numTriggers do ts[i] = {} end
triggerState[id] = ts
-- Clear every system's prior registration for this id first, so a trigger
-- switched across systems (e.g. aura -> a generic prototype) doesn't leave
-- the old system still producing states into this id's slots. Cheap and safe
-- (each Delete no-ops on an id it doesn't own); the current systems re-Add
-- below.
eachSystem(function(system) if system.Delete then system.Delete(id) end end)
-- Each system compiles its own triggers out of data.triggers (it iterates,
-- picking its own types), so call each owning system once.
local called = {}
for i = 1, numTriggers do
local system = WA.GetTriggerSystem(data, i)
if system and not called[system] then
called[system] = true
if system.Add then system.Add(data) end
end
end
SetRegion(data)
-- Compile this display's conditions before the first state application, so
-- the RunConditions call inside UpdatedTriggerState has something to run
-- (a no-op unless Conditions.lua has overridden it).
WA.LoadConditions(data)
-- The systems above only compiled this display (they don't self-register into
-- their active scan/event index anymore). applyLoad registers it iff its load
-- conditions currently pass -- so an unloaded aura is compiled-but-dark, and a
-- recompile (this WA.Add) re-decides load state every time (§11). Runs
-- unconditionally rather than through SetDisplayLoaded's transition guard: the
-- Delete above cleared the systems' registration, so even an unchanged load
-- state must re-register.
applyLoad(data, WA.EvalLoad(data))
if WA.forced[id] then
-- Re-derive the preview in the same frame: the recompile wiped its states
-- and the region was left untouched above, and nothing else will rebuild
-- them -- the ticker only rolls existing deadlines forward. This is also
-- what makes an edit show up, since the preview runs the trigger's code.
if fakeForced then fakeForced(id) end
elseif loaded[id] then
WA.UpdatedTriggerState(id)
end
-- A recompile can change this aura's size/offset (regionType switch, etc.),
-- so refresh its group's box now that its frame reflects the new config.
if data.parent then WA.RelayoutGroup(data.parent) end
end
function WA.Remove(data)
local id = data.id
CollapseAll(id)
local entry = regions[id]
if entry then
local base = entry.byClone[""]
if base then base:Hide(); base.state = nil; base.states = {} end
regions[id] = nil
end
triggerState[id] = nil
loaded[id] = nil
standby[id] = nil
WA.ClearAuraEnv(id)
if WA.ClearActionFunctions then WA.ClearActionFunctions(id) end
WA.UnloadConditions(data)
eachSystem(function(system) if system.Delete then system.Delete(id) end end)
-- Its group's box no longer needs to cover this child.
if data.parent then WA.RelayoutGroup(data.parent) end
end
function WA.Rename(oldId, newId)
if triggerState[oldId] then
triggerState[newId] = triggerState[oldId]
triggerState[oldId] = nil
end
if regions[oldId] then
regions[newId] = regions[oldId]
regions[oldId] = nil
for _, frame in pairs(regions[newId].byClone) do frame.id = newId end
end
if loaded[oldId] ~= nil then
loaded[newId] = loaded[oldId]
loaded[oldId] = nil
end
if standby[oldId] ~= nil then
standby[newId] = standby[oldId]
standby[oldId] = nil
end
WA.RenameAuraEnv(oldId, newId)
if WA.RenameActionFunctions then WA.RenameActionFunctions(oldId, newId) end
eachSystem(function(system) if system.Rename then system.Rename(oldId, newId) end end)
end
-- Builds runtime state + regions for every display (leaf and group). Called
-- once after all Register*/NormalizeAll have run (OptionsFrame.lua, the last
-- file). WA.Add routes groups to WA.AddGroup; order is irrelevant since a
-- child's ApplyPosition ensures its group frame on demand and AddGroup
-- re-anchors children that already exist.
function WA.AddMany(list)
local ids = {}
for i = 1, #(list or {}) do
local data = list[i]
if data and data.id then ids[data.id] = true end
end
for id in pairs(ids) do resetAnchorCycle(WeakestAurasDB.displays[id]) end
local order = dependencyOrder(ids)
for i = 1, #order do
local id = order[i]
local data = WeakestAurasDB.displays[id]
if data then WA.safecall(id, WA.Add, data) end
end
end
function WA.AddAllDisplays()
local ids = {}
for id in pairs(WeakestAurasDB.displays) do ids[id] = true end
local order = dependencyOrder(ids)
for i = 1, #order do
local id = order[i]
local data = WeakestAurasDB.displays[id]
if data then WA.safecall(id, WA.Add, data) end
end
end
-- Debug read-only accessor (Debug.lua's /wa states).
function WA.GetDisplayTriggerState(id)
return triggerState[id]
end
-- Read-only clone lifecycle accounting, grouped by region type.
function WA.GetClonePoolStats(regionType)
local stats = {}
local function bucket(kind)
local out = stats[kind]
if not out then
out = { live = 0, pooled = 0 }
stats[kind] = out
end
return out
end
for _, entry in pairs(regions) do
local out = bucket(entry.regionType)
for cloneId in pairs(entry.byClone) do
if cloneId ~= "" then out.live = out.live + 1 end
end
end
for kind, pool in pairs(clonePools) do
local out = bucket(kind)
out.pooled = #pool
end
if regionType then return stats[regionType] or { live = 0, pooled = 0 } end
return stats
end
-- ---------------------------------------------------------------------------
-- Forced visibility / preview states (§14 + FakeStatesFor). While the
-- options window is open, any number of leaves can be "forced" -- shown with
-- preview states so they can be sized/positioned/debugged even when their
-- real trigger doesn't match. A group forces its leaf descendants, so selecting
-- (or eye-toggling) a dynamic group fake-shows its children and the grower lays
-- them out instead of stacking them at the baseline. Two sources merge into
-- WA.forced: the current selection preview (transient) and per-row eye pins
-- (persist across selection until options closes).
-- ---------------------------------------------------------------------------
local pinned = {} -- leaf id -> true: eye-pinned (persists across selection)
local selectionLeaves = {} -- leaf id -> true: from the current selection preview
local previewTicker
local PREVIEW_ROLL = 1 -- seconds between roll-forwards; WA2's UpdateFakeTimers cadence
-- The leaf ids under id (id itself if it's a leaf), accumulated into `into`.
local function collectLeaves(id, into)
local data = WeakestAurasDB.displays[id]
if not data then return end
if WA.IsGroup(data) then
local kids = data.controlledChildren or {}
for i = 1, #kids do collectLeaves(kids[i], into) end
else
into[id] = true
end
end
-- Drops every state one trigger holds, leaving the allstates table itself in
-- place -- a TSU's helper methods live on its metatable, and replacing the table
-- would strand the one its compiled code was handed. WA2's SetAllStatesHidden.
local function wipeStates(ts, triggernum)
local as = ts[triggernum]
if not as then return end
for cloneId in pairs(as) do as[cloneId] = nil end
end
-- Builds one leaf's preview states and re-runs the state machine so the region
-- shows. Each trigger's own system produces them: the generic system runs the
-- real trigger function under a synthetic "OPTIONS" event and keeps whatever it
-- produced, the aura system synthesises (§14, WA2's UpdateFakeStatesFor).
--
-- *Every* trigger is previewed, not just trigger 1: the combination in
-- UpdatedTriggerState still runs over the results, so previewing only one leaves
-- disjunctive="all" (and any custom logic) unsatisfied -- the preview would stay
-- dark for exactly the multi-trigger displays that need it most. WA2 reaches the
-- same place from the other end, by ignoring the combination entirely while its
-- options window is open.
local function fakeOne(id)
local data = WeakestAurasDB.displays[id]
local ts = triggerState[id]
if not data or not ts then return end
for triggernum = 1, ts.numTriggers do
wipeStates(ts, triggernum)
local system = WA.GetTriggerSystem(data, triggernum)
if system and system.CreateFakeStates then
WA.safecall(id .. ": preview trigger " .. triggernum,
system.CreateFakeStates, data, triggernum)
end
-- A trigger type no system claims (an import naming one this client has
-- no producer for) would otherwise preview as nothing, and a forced leaf
-- that draws nothing is the one thing the preview exists to prevent.
local states = WA.GetTriggerStateForTrigger(id, triggernum)
if states and not next(states) then
states[""] = { show = true, changed = true, name = data.id,
icon = "Interface\\Icons\\INV_Misc_QuestionMark",
progressType = "timed", duration = 7, expirationTime = GetTime() + 7 }
end
end
WA.UpdatedTriggerState(id)
end
fakeForced = fakeOne -- resolve the forward decl WA.Add uses
-- Retires a leaf's preview states; the watcher re-establishes the real ones (or
-- the region goes dark) on its next scan.
local function retireFake(id)
local ts = triggerState[id]
if not ts then return end
for triggernum = 1, ts.numTriggers do wipeStates(ts, triggernum) end
WA.UpdatedTriggerState(id)
-- The wipe above deleted every state, including ones a still-true status
-- trigger owns -- and an event-driven system only writes state when its event
-- fires, so "in combat" would read as inactive until the next regen event.
-- Re-run each system's force pass now (the aura scanner's 0.2s poll, which
-- skips forced ids, needs no help).
eachSystem(function(system) if system.ForceUpdate then system.ForceUpdate({ id }) end end)
end
-- WA2's UpdateFakeTimers: a preview state whose countdown has run out is rolled
-- forward by its own duration, so the preview keeps animating without re-running
-- the trigger. Only the deadline moves -- the state is whatever its system
-- produced, and re-deriving it belongs to the events that change it (selection,
-- eye pin, recompile).
local function previewTick()
local now = GetTime()
for id in pairs(WA.forced) do
local ts = triggerState[id]
local changed = false
if ts then
for triggernum = 1, ts.numTriggers do
local as = ts[triggernum]
if as then
for _, state in pairs(as) do
if state.progressType == "timed"
and type(state.expirationTime) == "number" and state.expirationTime < now
and type(state.duration) == "number" and state.duration > 0 then
state.expirationTime = now + state.duration
state.changed = true
changed = true
end
end
end
end
end
if changed then WA.UpdatedTriggerState(id) end
end
end
-- Rebuilds WA.forced = pinned + selectionLeaves, retiring the previews that
-- dropped out, building the ones that joined, and (re)starting/stopping the
-- roll-forward ticker to match.
local function recomputeForced()
local nextSet = {}
for id in pairs(pinned) do nextSet[id] = true end
for id in pairs(selectionLeaves) do nextSet[id] = true end
for id in pairs(WA.forced) do
if not nextSet[id] then WA.forced[id] = nil; retireFake(id) end
end
for id in pairs(nextSet) do
-- Only what just joined: fakeOne runs the leaf's trigger code, so re-running
-- it for every already-previewed leaf on every selection change would pay
-- that cost again for displays whose preview has not changed (WA2's
-- FakeStatesFor, which returns early when the visibility already matches).
local joined = not WA.forced[id]
WA.forced[id] = true
if joined then fakeOne(id) end
end
if next(WA.forced) then
if not previewTicker then previewTicker = C_Timer.NewTicker(PREVIEW_ROLL, previewTick) end
elseif previewTicker then
previewTicker:Cancel()
previewTicker = nil
end
end
-- The selection preview: fake the selected aura, replacing whatever the previous
-- selection contributed. Selecting a group reveals all its children; selecting a
-- *child* reveals its whole parent group too, so you edit a member in the
-- context of the group's layout rather than in isolation (the mover still
-- attaches to just the selected node). Called on every selection change; nil
-- clears the selection contribution.
function WA.SetPreview(id)
selectionLeaves = {}
if id then
local data = WeakestAurasDB.displays[id]
local revealId = id
if data and data.parent and WeakestAurasDB.displays[data.parent] then
revealId = data.parent
end
collectLeaves(revealId, selectionLeaves)
end
recomputeForced()
end
-- Eye toggle for a list row: pins/unpins id's leaves so they stay forced across
-- selection changes. A group toggles all its leaf descendants together -- pin
-- all unless already all-pinned, then unpin all (WA2's Priority/Recheck).
-- The eye toggle over several displays at once, for the aura list's bucket
-- headers: every leaf under any of them is pinned, unless they all already are,
-- in which case they're all released. Mirrors WA2's header view button, which
-- drives PriorityShow/PriorityHide across the bucket's children.
function WA.ToggleForcedMany(ids)
local leaves = {}
for i = 1, #ids do collectLeaves(ids[i], leaves) end
local allPinned = true
for lid in pairs(leaves) do if not pinned[lid] then allPinned = false; break end end
for lid in pairs(leaves) do
if allPinned then pinned[lid] = nil else pinned[lid] = true end
end
recomputeForced()
end
function WA.ToggleForced(id)
local leaves = {}
collectLeaves(id, leaves)
local allPinned = true
for lid in pairs(leaves) do if not pinned[lid] then allPinned = false; break end end
for lid in pairs(leaves) do
if allPinned then pinned[lid] = nil else pinned[lid] = true end
end
recomputeForced()
end
-- A single leaf's config visibility for the eye: 2 = eye-pinned (stays shown
-- across selection), 1 = shown only because it's the current selection, 0 =
-- hidden/muted. Mirrors WA2's view.visibility priorities (2 eye, 1 pick, 0 off).
local function leafVis(id)
if pinned[id] then return 2 elseif selectionLeaves[id] then return 1 else return 0 end
end
-- Eye state for rendering a row. A leaf reads leafVis directly; a group rolls up
-- its leaves the WA2 way (all shown-and-pinned -> 2, all hidden -> 0, else 1
-- partial), so the icon shows at a glance whether the whole subtree is pinned,
-- partly visible, or dark.
-- The same roll-up across several displays (a bucket header's eye).
function WA.ForcedStateMany(ids)
local leaves = {}
for i = 1, #ids do collectLeaves(ids[i], leaves) end
local count, all2, all0 = 0, 0, 0
for lid in pairs(leaves) do
count = count + 1
local v = leafVis(lid)
if v == 2 then all2 = all2 + 1 end
if v == 0 then all0 = all0 + 1 end
end
if count == 0 then return 0 end
if all2 == count then return 2 end
if all0 == count then return 0 end
return 1
end
function WA.ForcedState(id)
local data = WeakestAurasDB.displays[id]
if not data then return 0 end
if not WA.IsGroup(data) then return leafVis(id) end
local leaves = {}
collectLeaves(id, leaves)
local count, all2, all0 = 0, 0, 0
for lid in pairs(leaves) do
count = count + 1
local v = leafVis(lid)
if v == 2 then all2 = all2 + 1 end
if v == 0 then all0 = all0 + 1 end
end
if count == 0 then return 0 end
if all2 == count then return 2 end
if all0 == count then return 0 end
return 1
end
-- Wipe every forced contribution (options closing); retires all fakes.
function WA.ClearForced()
pinned = {}
selectionLeaves = {}
recomputeForced()
end