diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index bf61b900..4e413aaa 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -155,6 +155,17 @@ local pihGateOpen = true -- true = show (gate spell ready), false = dark -- nil = watcher drives; true/false = held by hand until `/df debug pi auto`. local pihManual = nil +-- ★★★ SHOW IN COMBAT ONLY (2026-09-10), an option Krathe asked for. +-- ⚠ FORWARD-DECLARED. pihShouldShow reads pihGateEnabled, declared a couple of hundred lines +-- below, while pihSet -- which is above it -- has to call it. Same idiom as pihSyncWatcher. +local pihCombatOnly = false +local pihShouldShow +-- ☠ AND pihGateEnabled, HOISTED FOR THE SAME REASON -- caught by the _ENV globals diff, +-- not by review: pihSet logs it and pihSet is declared ABOVE the original `local`, so the +-- read compiled to a nil GLOBAL and the log line would have reported "nil" forever while +-- looking perfectly correct in the source. Its value is still assigned at its own site. +local pihGateEnabled = true + -- The helper sound choice, written by the settings panel through PIH_SetSound and restored on -- login by PIH_ApplySaved. ⚠ SILENT UNTIL CHOSEN -- nil registers nothing, because an -- audio cue nobody asked for is the fastest way to have a feature switched off wholesale. @@ -206,10 +217,28 @@ end -- resolve -- taking the first is a choice, not a derivation. Party first because that is where -- the feature is used. A preset whose settings table survived a Remove no longer shadows one -- that actually has a helper, because the marks decide. +-- ☠☠ THE *LIVE* DESIGNER, NOT THE MODE BASE (2026-09-10). This read GetModeBaseAuraDesigner, +-- which is the EDITOR's variant -- Presets.lua says so directly: "GetMode*Designer ... the +-- ACTIVE designer a mode resolves to right now ... Used by LIVE consumers (SoundEngine, +-- migrations) that must match what's on screen. The EDITOR uses the GetModeBase* variants". +-- The helper is a live consumer and was reading the editor's answer. +-- +-- ☠ WHAT IT COST, from Krathe's raid: a raid auto-layout can point its own AD PRESET at +-- something other than the mode base -- his 21-30 layout uses a "Flex 21-30" preset. The +-- frames render from THAT (DF:ResolveAuraDesigner honours the overlay) and it holds no +-- helper, so nothing draws; this read carried on finding the helper in the BASE preset and +-- happily armed the gate, the roles and THE SOUND for a preset that is not on screen. +-- ⇒ "I could hear the sound trigger but did not see the border or PI icon at all" is this, +-- and so is a status readout that reports a healthy helper while nothing renders. The +-- feature could not diagnose itself because its two halves were reading different presets. +-- ⚠ IT DOES NOT MAKE HIS HELPER APPEAR -- the records genuinely are not in that preset, +-- which is a choice the preset system offers and the user has to make. What it fixes is +-- the engine agreeing with the screen: no helper there means silent, ungated, and a +-- readout that SAYS so. local function pihSettings() - if not DF.GetModeBaseAuraDesigner then return nil end + if not DF.GetModeAuraDesigner then return nil end for _, mode in ipairs(PIH_MODES) do - local adDB = DF:GetModeBaseAuraDesigner(mode) + local adDB = DF:GetModeAuraDesigner(mode) local s = adDB and adDB.pihelper if s and pihHasHelper(adDB) then return s end end @@ -372,11 +401,64 @@ local function pihStopTicker() if pihReadyTicker then pihReadyTicker:Cancel(); pihReadyTicker = nil end end +-- ★★★ THE FEATURE SWITCH, AS DISTINCT FROM THE COOLDOWN GATE (2026-09-09). +-- +-- ☠☠ "DISABLE" USED TO MEAN "DELETE THE RECORDS". The helper had no enabled flag of its own +-- -- its existence WAS its records -- so the panel's tick implemented off as a wholesale +-- delete with a stash to fake reversibility. Every bug in that area had one root: a switch +-- pretending to be a switch while actually being a delete. Krathe, 2026-09-09: "when I disable +-- the PI tracker, it seems to remove my border effect I added", then "it should function like +-- the rest of AD" -- and the rest of AD writes ONE BOOLEAN (modeDB.auraDesignerEnabled) and +-- deletes nothing. +-- ⇒ The records stay exactly where they are. This is what makes them not draw, and it costs +-- one file-local, because the gate's own machinery already means "dark, and nothing will +-- open it". +-- +-- ⚠ TWO SWITCHES, THREE STATES, AND THEY DO NOT COLLAPSE INTO ONE: +-- enabled = false -> forced DARK (the feature is off) +-- enabled, gateEnabled = false -> forced OPEN (never hide, not even on cooldown) +-- enabled, gateEnabled -> the watcher drives +-- Reusing pihGateEnabled for both would make "off" and "always show" the same field. +local pihEnabled = true + local function pihSet(dark) pihGateOpen = not dark - local n = 0 + local n, deferred, hSkip = 0, 0, 0 if DF.AuraContainer and DF.AuraContainer.SetHelperGate then - n = DF.AuraContainer.SetHelperGate(dark) + n, deferred, hSkip = DF.AuraContainer.SetHelperGate(dark) + deferred, hSkip = deferred or 0, hSkip or 0 + end + + -- ★★★ ONE LINE PER EDGE, IN THE LOG, CARRYING EVERYTHING (2026-09-10). + -- ☠ KRATHE CANNOT RUN A SLASH COMMAND MID-RAID, and said so twice before I listened. + -- A readout behind "/df debug pi" is useless for a fault that only happens with twenty + -- people in combat: by the time it can be typed, the state has moved. This goes in the + -- persisted log, so a reload is the whole reporting procedure. + -- ⚠ THE COUNTS WERE THE THING THAT LIED. "98 containers" looked healthy while every + -- placed slot among them had merely QUEUED -- so pushed and deferred are separate, and + -- a non-zero deferred is the fault named outright. + -- ⚠ CONTEXT ON EVERY LINE, because the question is never only "did it flip": which + -- conditions were in force decides whether the flip was even right. + -- ⚠ ONE LINE PER EDGE -- twice a Power Infusion cycle, not per frame -- and guarded by + -- DebugActive so the slot walk that builds it is not paid for with logging off. + if DF.DebugActive and DF:DebugActive("AURADESIGNER") then + local AC = DF.AuraContainer + local tot, dk, pend, parked = 0, 0, 0, 0 + if AC and AC.GetHelperSlotStatus then tot, dk, pend, parked = AC.GetHelperSlotStatus() end + local hTot, hLive, hDark = 0, 0, 0 + if AC and AC.GetHelperHandleStatus then hTot, hLive, hDark = AC.GetHelperHandleStatus() end + local allow = AC and AC.GetHelperAllowedPlayers and AC.GetHelperAllowedPlayers() + local nAllow = 0 + if allow then for _ in pairs(allow) do nAllow = nAllow + 1 end end + DF:Debug("AURADESIGNER", + "PIH gate -> %s | pushed=%d deferred=%d handlesSkipped=%d" + .. " | groups %d tot/%d live/%d dark | slots %d tot/%d dark/%d pending/%d parked" + .. " | enabled=%s gateEnabled=%s combatOnly=%s inCombat=%s manual=%s | players=%s", + dark and "DARK" or "OPEN", n, deferred, hSkip, + hTot, hLive, hDark, tot, dk, pend, parked, + tostring(pihEnabled), tostring(pihGateEnabled), tostring(pihCombatOnly), + tostring(DF.playerInCombat), tostring(pihManual), + allow and tostring(nAllow) or "everyone") end -- Sound rides the SAME edge as the visuals. It is not a container, so the gate cannot -- reach it -- without this it would keep announcing while we are silent. @@ -386,11 +468,19 @@ local function pihSet(dark) -- ⚠ Never under a manual hold: the tick body refuses to act while held (below), -- so a ticker started here would idle at 2 Hz for the rest of the session. Handing -- control back re-enters through pihSet and starts it then, if still dark. - if not pihReadyTicker and pihManual == nil and C_Timer and C_Timer.NewTicker then + -- ⚠ ...AND NEVER WHILE THE FEATURE IS OFF. This ticker exists to REOPEN the gate when + -- the cooldown clears, which for a disabled helper would undo the very thing the + -- switch just did -- and poll at 2 Hz forever to do it. + if not pihReadyTicker and pihManual == nil and pihEnabled + and C_Timer and C_Timer.NewTicker then pihReadyTicker = C_Timer.NewTicker(0.5, function() -- Held by hand: never fight a gate the user is holding themselves. if pihManual ~= nil then return end - if pihReadReady() then + -- Re-asked every tick, not only at start: the switch can move under us. + if not pihEnabled then return end + -- ⚠ THE COMPOSITE, not the spell alone: with "combat only" on, a cooldown + -- clearing out of combat must NOT reopen the gate. + if pihShouldShow() then pihStopTicker() if not pihGateOpen then pihSet(false) end end @@ -409,20 +499,81 @@ end -- Off means FORCE OPEN and stay there: the watcher stops driving, so a cooldown starting or -- ending changes nothing. Not "ignore the events" -- the gate is genuinely open, which is what -- the setting says. -local pihGateEnabled = true +-- ⚠ DECLARED ABOVE (with pihCombatOnly): pihSet reads it and is written earlier in the +-- file. This is the assignment, not the declaration. +pihGateEnabled = true + +-- ★★ EVERY GLOBAL CONDITION, IN ONE ANSWER. As against helperUnitExcluded, which is the +-- per-UNIT half (role, and the named-player list) -- this is the half that is true of the +-- whole helper at once. +-- +-- ☠ THE COMBAT TEST COMES FIRST AND IGNORES THE COOLDOWN GATE. "Show in combat only" and +-- "show while Power Infusion is on cooldown" are independent: someone who has switched the +-- cooldown gate OFF still means it when they say combat only, and folding this in after the +-- `not pihGateEnabled` early-return would have silently ignored them. +-- +-- ☠☠ `inCombat` IS AN ARGUMENT BECAUSE OF AN EVENT RACE. DF.playerInCombat is the house +-- source and is written by Core.lua from PLAYER_REGEN_DISABLED / _ENABLED -- the same two +-- events this file now watches. Handler order between two frames is not defined, so reading +-- the flag from inside our own handler can see the value from BEFORE the transition and +-- resolve the gate backwards. The regen branch passes the truth the event itself carries; +-- every other caller omits it and gets the flag, which by then has settled. +-- ⚠ NEVER InCombatLockdown() -- that is the addon-restriction state, not the player's +-- combat state, and this addon has a standing rule about the difference. +function pihShouldShow(inCombat) + if inCombat == nil then inCombat = DF.playerInCombat and true or false end + if pihCombatOnly and not inCombat then return false end + if not pihGateEnabled then return true end -- cooldown gate off: never hide for THAT + return pihReadReady() +end + +-- Stored on the helper; pushed by PIH_ApplySaved and by the panel through this setter. +function Engine:PIH_SetCombatOnly(on) + pihCombatOnly = on and true or false + if not pihEnabled then return pihCombatOnly end -- the feature switch outranks it + pihManual = nil + pihSet(not pihShouldShow()) + return pihCombatOnly +end + +-- The FEATURE switch. Off is a forced dark that nothing reopens; on hands control back to +-- whichever of the two remaining states applies. See pihEnabled for the three-state table. +-- ⚠ CALLED AFTER PIH_SetGateEnabled, always: turning the feature back on has to resume from +-- the gate's own setting, so that setting must already be in place. PIH_ApplySaved orders +-- them; so does the panel's P.PIH_Apply. +function Engine:PIH_SetEnabled(on) + pihEnabled = on and true or false + -- A manual hold is a debugging affordance and must not survive either transition -- the + -- same reasoning as the gate switch below. + pihManual = nil + if not pihEnabled then + pihSet(true) -- dark, and nothing will open it + elseif not pihGateEnabled then + pihSet(false) -- the gate is switched off: never hide + else + pihSet(not pihShouldShow()) -- resume from every live condition + end + return pihEnabled +end function Engine:PIH_SetGateEnabled(on) pihGateEnabled = on and true or false + -- ⚠ THE FEATURE SWITCH OUTRANKS THIS ONE. With the helper off, neither branch below may + -- run: "never hide" and "resume from the cooldown" both mean SHOW, and there is nothing + -- to show. Without this, ticking the cooldown option while disabled lit the helper up. + if not pihEnabled then return pihGateEnabled end + -- ⚠ BOTH ARMS GO THROUGH pihShouldShow NOW. Switching the cooldown gate off no longer + -- means "open" outright -- "combat only" may still be holding it shut, and forcing it + -- open here would have ignored that setting entirely. if not pihGateEnabled then pihManual = nil - pihSet(false) -- open, and nothing will shut it + pihSet(not pihShouldShow()) else -- ⚠ Re-enabling releases a manual hold too. Without this, "gate enabled" and -- "held by hand" could both be true at once, with the watcher suspended and nothing -- on screen to say so. pihManual = nil - local ready = pihReadReady() - pihSet(not ready) -- resume from the real cooldown state + pihSet(not pihShouldShow()) -- resume from every live condition end return pihGateEnabled end @@ -456,14 +607,71 @@ end -- out: roles, the gate, and above all the sound registrations, which would otherwise keep -- playing for a helper that no longer exists anywhere. local pihSyncWatcher -- defined beside the watcher below; registration follows helper existence +-- ★★★ NOT A PRIEST: THE WHOLE FEATURE IS A NO-OP (2026-09-10). +-- +-- ☠ REPORTED FROM THE ALPHA: helper borders and icons rendering for NON-PRIESTS. There was +-- no class gate on this side at all -- only the Options UI checked (DF.IsPIHelperAvailable +-- hides the page, Rows.lua omits the pool tab), and hiding the controls does nothing about +-- records that already exist. A profile shared across an account, an imported preset, or a +-- priest's own profile opened on an alt all carry the marked records, and the factory renders +-- what the pool holds -- it has never asked whose class it is. +-- +-- ⚠ DARK, NOT "NO HELPER". The `if not s` branch below opens the gate ("nothing is left to +-- hide"), which is right when the pool genuinely holds nothing and exactly wrong here: a +-- non-priest with marked records needs them SUPPRESSED, and an open gate renders them. The +-- two cases look alike and mean opposite things, which is why this is its own branch rather +-- than another condition on that one. +-- +-- ⚠ PIH_SetEnabled(false) IS THE LEVER, not a new one. It is the feature switch: forced dark +-- that nothing reopens, the readiness ticker refused, the manual hold released, and sound +-- disarmed through pihSet. Everything a class gate needs, already written and already tested. +-- ⚠ THE RECORDS ARE NOT TOUCHED. Deleting a priest's work because their alt logged in would +-- be destroying data over a display question -- and the same profile on the priest must come +-- back intact. Suppression only. +-- +-- ⚠ READ AT CALL TIME, NOT AT LOAD. UnitClass("player") is not dependable before login, and +-- this function runs on login and on every profile switch, which is exactly when it is. +-- A character's class cannot change, so there is nothing to re-check afterwards. +local function pihIsPriest() + local _, class = UnitClass("player") + return class == "PRIEST" +end +Engine.PIH_IsPriest = pihIsPriest + function Engine:PIH_ApplySaved() + if not pihIsPriest() then + if DF.AuraContainer then + if DF.AuraContainer.SetHelperExcludedRoles then + DF.AuraContainer.SetHelperExcludedRoles(nil) + end + if DF.AuraContainer.SetHelperAllowedPlayers then + DF.AuraContainer.SetHelperAllowedPlayers(nil) + end + end + -- Cleared BEFORE the switch, so nothing can re-arm behind it: PIH_SetEnabled goes + -- through pihSet, which disarms against whatever cfg is standing at that moment. + Engine:PIH_SetSound(nil) + Engine:PIH_SetEnabled(false) + if pihSyncWatcher then pihSyncWatcher() end + return false + end local s = pihSettings() if not s then if DF.AuraContainer and DF.AuraContainer.SetHelperExcludedRoles then DF.AuraContainer.SetHelperExcludedRoles(nil) end + -- ☠ AND THE ALLOWLIST, ON THE RESET PATH AS MUCH AS THE APPLY ONE. A named-player list + -- left pushed after a switch to a profile with no helper would go on narrowing a + -- feature that is not there -- and would then narrow the NEXT helper the user builds, + -- from a list they wrote somewhere else entirely. Same reasoning as the roles above, + -- and the same reason this whole branch exists. + if DF.AuraContainer and DF.AuraContainer.SetHelperAllowedPlayers then + DF.AuraContainer.SetHelperAllowedPlayers(nil) + end pihManual = nil pihGateEnabled = true + pihCombatOnly = false -- a stale hold would outlive the profile that set it + pihEnabled = true -- no helper here; the switch has nothing to suppress Engine:PIH_SetSound(nil) -- tears down every live registration pihSet(false) -- open; nothing is left to hide if pihSyncWatcher then pihSyncWatcher() end @@ -475,7 +683,45 @@ function Engine:PIH_ApplySaved() for _ in pairs(s.roles or {}) do any = true break end DF.AuraContainer.SetHelperExcludedRoles(any and s.roles or nil) end + -- ★ THE NAMED-PLAYER ALLOWLIST, stored as an ARRAY (the picker's order of entry) and + -- pushed as a MAP (the container asks "is this unit in it", once per unit per push). + -- ⚠ AN EMPTY LIST IS nil, NOT AN EMPTY MAP. The container reads a present map as "these + -- players and nobody else", so an empty one would silence the helper completely -- for a + -- user who had added two names and removed them again, which is exactly the moment they + -- would expect it to go back to normal rather than break. + -- ⚠ AND `playersOn` DECIDES WHETHER IT IS PUSHED AT ALL (2026-09-11). The list used to be + -- its own switch -- names meant narrowing, none meant everyone -- which made "stop + -- narrowing tonight" and "throw the names away" the same action. Krathe: "I might want to + -- add my raid team to the list but turn off showing only for those players in a pug group + -- without having to add/remove them all each time." + -- ⚠ ABSENT MEANS ON, so every profile written before today loads exactly as it did. + -- ⚠ THE PANEL'S P.PIH_Apply MAKES THE SAME DECISION THE SAME WAY. These are the two halves + -- of one push (login and live edit) and they have drifted apart once already -- the live + -- half simply did not exist, so an edited list did nothing until the next reload. + if DF.AuraContainer and DF.AuraContainer.SetHelperAllowedPlayers then + local map + if s.playersOn ~= false then + for _, fullName in ipairs(s.players or {}) do + if type(fullName) == "string" and fullName ~= "" then + map = map or {} + map[fullName] = true + end + end + end + DF.AuraContainer.SetHelperAllowedPlayers(map) + end + -- ⚠ BEFORE THE GATE SETTERS, because both resolve through pihShouldShow, which reads + -- this. Loading it afterwards would settle the gate from the OLD value and leave it + -- wrong until the next transition -- the same ordering the sound line below records. + pihCombatOnly = s.combatOnly == true Engine:PIH_SetGateEnabled(s.gateEnabled ~= false) + -- ⚠ AFTER THE GATE: turning the feature on resumes from the gate's setting, so the gate + -- has to be in place first (PIH_SetEnabled says the same from its side). + -- ⚠ DEFAULTS TRUE FOR A PROFILE THAT PREDATES THE FLAG. `enabled` did not exist before + -- 2026-09-09, and every such profile that has helper records had a WORKING helper -- so + -- absent must read as on, or the fix for a destructive switch would silently switch + -- everyone off. A profile with no records shows nothing either way. + Engine:PIH_SetEnabled(s.enabled ~= false) -- After the gate, never before: SetSound arms against the gate's current state, so calling -- it first would arm against the state we are about to leave. Engine:PIH_SetSound(s.soundOn and s.soundLSMKey or nil) @@ -519,11 +765,21 @@ pihWatcher:RegisterEvent("PLAYER_ENTERING_WORLD") -- GROUP_ROSTER_UPDATE is for SOUND: registrations are per unit and are otherwise only made -- on gate edges, so anyone who joined after the last edge got no cue -- and the player's own -- no-register guard went stale when sorting moved them to another token. +-- ⚠ BOTH COMBAT TRANSITIONS, and the EXIT half is the one that goes missing: this addon has +-- a standing note that a gate keyed on combat needs a refresh on entering AND on leaving, +-- and that leaving is the half people forget. Registered unconditionally rather than with +-- the setting -- the watcher only exists for a priest who has a helper at all, and two more +-- events on that frame is cheaper than a re-registration dance every time the tick moves. local PIH_WATCH_EVENTS = { "SPELL_UPDATE_COOLDOWN", "SPELL_UPDATE_CHARGES", - "UNIT_SPELLCAST_SUCCEEDED", "GROUP_ROSTER_UPDATE" } + "UNIT_SPELLCAST_SUCCEEDED", "GROUP_ROSTER_UPDATE", + "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED" } local pihWatching = false pihSyncWatcher = function() - local want = pihSettings() ~= nil + -- ⚠ AND NOT FOR A NON-PRIEST, whatever the pool holds. PIH_ApplySaved already forces + -- the feature off for them, so the watcher's own tick would early-out anyway -- but a + -- registration that can only ever decline to act is five events on every alt of every + -- priest who shares a profile, for a feature they cannot enable. Same fact, one test. + local want = pihIsPriest() and pihSettings() ~= nil -- The container's own backstop frame follows the same fact, from the same test -- one -- definition of "a helper exists" driving both registrations. Called unconditionally -- (it is idempotent) so it self-corrects even when our own state has not moved. @@ -558,7 +814,29 @@ pihWatcher:SetScript("OnEvent", function(_, event, unit, _, spellID) end return end + -- ★★ COMBAT TRANSITIONS. Ahead of the cooldown guards below, deliberately: those + -- return early when the cooldown gate is switched off, and "combat only" is a separate + -- setting that still has to act for that user. + -- ☠ THE EVENT CARRIES THE TRUTH, and we pass it rather than reading DF.playerInCombat. + -- Core.lua writes that flag from these same two events, and handler order between two + -- frames is undefined -- so reading it here can see the value from BEFORE the + -- transition and resolve the gate backwards. See pihShouldShow. + if event == "PLAYER_REGEN_DISABLED" or event == "PLAYER_REGEN_ENABLED" then + if not pihCombatOnly then return end -- nothing here concerns anyone else + if not pihEnabled or pihManual ~= nil then return end + local want = pihShouldShow(event == "PLAYER_REGEN_DISABLED") + if want ~= pihGateOpen then + local n = pihSet(not want) + DF:Debug("AURADESIGNER", "PIH gate -> %s on combat %s (%d container%s)", + want and "OPEN" or "DARK", + event == "PLAYER_REGEN_DISABLED" and "start" or "end", + n, n == 1 and "" or "s") + end + return + end + if event ~= "PLAYER_ENTERING_WORLD" then + if not pihEnabled then return end -- feature off: the gate stays dark if not pihGateEnabled then return end -- switched off: nothing shuts or opens it if pihManual ~= nil then return end end @@ -579,13 +857,19 @@ pihWatcher:SetScript("OnEvent", function(_, event, unit, _, spellID) -- this, a saved "don't hide" was overridden by the cooldown read below: reload -- mid-cooldown and the helper hid anyway -- the exact opposite of the setting -- -- for the rest of that cooldown. - if not pihGateEnabled or pihManual ~= nil then return end + -- ⚠ pihEnabled joins the same re-check, and for the identical reason: ApplySaved has + -- just loaded it, and the cooldown read below would otherwise light up a helper the + -- user has switched off. + -- ⚠ pihGateEnabled IS NO LONGER AN EARLY-OUT HERE. It used to be, because it was the + -- only condition below; pihShouldShow now folds it in alongside "combat only", and + -- returning early would skip the combat test for anyone with the cooldown gate off. + if not pihEnabled or pihManual ~= nil then return end -- ☠ THE ONE PLACE isActive MAY SHUT THE GATE. On load we never saw the cast, so a -- reload mid-cooldown would otherwise leave the helper showing for the rest of it. -- Safe here specifically because nothing is being cast at this instant, so a true -- reading is a real cooldown rather than a GCD. - local ready = pihReadReady() - if ready ~= pihGateOpen then pihSet(not ready) end + local want = pihShouldShow() + if want ~= pihGateOpen then pihSet(not want) end return end @@ -599,8 +883,9 @@ pihWatcher:SetScript("OnEvent", function(_, event, unit, _, spellID) -- already open there is nothing to do and no reason to pay for a cooldown read -- and -- this event fires on every global cooldown, all fight long. if pihGateOpen then return end - local ready = pihReadReady() - if not ready then return end + -- ⚠ THE COMPOSITE. A cleared cooldown is not enough on its own when the helper is set + -- to combat only and we are standing in a city. + if not pihShouldShow() then return end local n = pihSet(false) DF:Debug("AURADESIGNER", "PIH gate -> OPEN, cooldown cleared (%d container%s)", n, n == 1 and "" or "s") @@ -697,6 +982,18 @@ SlashCmdList["DFPI"] = function(msg) local t = {}; for k in pairs(r) do t[#t + 1] = k end; table.sort(t) return table.concat(t, ", ") end)()) + -- ⚠ READ OFF THE CONTAINER, NOT THE PROFILE, and that is the whole value of the + -- line. The panel stores this list and something has to PUSH it; when the push + -- was missing (it was, until 2026-09-10) the profile showed names and the engine + -- held none, with nothing anywhere able to say so. A field that repeated the + -- setting would have agreed with the panel and hidden the fault. + :Field("players watched", (function() + local AC = DF.AuraContainer + local m = AC and AC.GetHelperAllowedPlayers and AC.GetHelperAllowedPlayers() + if not m then return "everyone (no list)" end + local t = {}; for k in pairs(m) do t[#t + 1] = k end; table.sort(t) + return ("%d: %s"):format(#t, table.concat(t, ", ")) + end)()) :Field("watching events", pihWatching and "yes" or "no (no helper installed)") -- The per-frame wiring, which no setting above can show. Registrations counted at the -- LAST arm pass (armed on zero frames = the login-ordering failure); containers @@ -709,6 +1006,33 @@ SlashCmdList["DFPI"] = function(msg) -- would teach the reader to ignore the line that matters. (pihSoundCfg and pihGateOpen and pihLastArmCount == 0 and GetNumGroupMembers and GetNumGroupMembers() > 1) and "bad" or "neutral") + -- ★★ THE PLACED SLOTS, WHICH NOTHING ABOVE COULD SEE. Krathe, over four reports: + -- the border cleared and the icon did not; the sound played and nothing drew; the + -- group and border drew and the icon did not. Every one of those is a slot whose + -- LAST PUSH disagrees with the gate, and no field here could show it. + -- ⚠ READ THIS AGAINST "gate intends" ABOVE: + -- gate OPEN + dark 0 + pending 0 -> the slots agree; look elsewhere. + -- gate OPEN + pending > 0 -> a push deferred to PLAYER_REGEN_ENABLED and + -- not yet drained. Power Infusion is pressed IN + -- COMBAT, so this is the expected shape of the + -- "icon lags the border" report. + -- gate OPEN + dark > 0 -> a per-UNIT exclusion (role, or the named + -- player list), not the gate. + :Field("helper slots", (function() + local AC = DF.AuraContainer + if not (AC and AC.GetHelperSlotStatus) then return "n/a" end + local total, dark, pending, parked = AC.GetHelperSlotStatus() + if total == 0 then return "none (no placed helper effect)" end + return ("%d total, %d would go dark, %d push deferred, %d parked") + :format(total, dark, pending, parked) + end)(), (function() + local AC = DF.AuraContainer + if not (AC and AC.GetHelperSlotStatus) then return "neutral" end + local _, _, pending = AC.GetHelperSlotStatus() + -- A deferral outstanding while the gate is open IS the fault, so it is marked as + -- one -- that is the whole point of adding this line. + return (pending > 0 and pihGateOpen) and "bad" or "neutral" + end)()) :Field("gated containers live", (function() local AC = DF.AuraContainer local n = 0 diff --git a/DandersFrames/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua index d392905c..8ce9e421 100644 --- a/DandersFrames/AuraDesigner/Factory.lua +++ b/DandersFrames/AuraDesigner/Factory.lua @@ -1643,6 +1643,11 @@ local PLACED_BORDER_KEYS = { "BorderAnimationInset", "BorderAnimationOffsetX", "BorderAnimationOffsetY", "BorderAnimationMask", "BorderAnimationSidesAxis", "BorderAnimationCornerLength", "BorderAnimationProcStart", + -- ⚠ ADDED 2026-09-10 WITH THE SETTING ITSELF. Every scalar BuildSpec folds into + -- spec.animation belongs here or the border goes stale until /reload -- and this one + -- is also STRUCTURAL below, because a container button's animation groups are frozen + -- at build and cannot be retuned in place. + "BorderAnimationBlendMode", -- Colour-source keys: not exposed by the AD border UI today (source is always CUSTOM), -- but hashed defensively so an imported profile or a future class/role border option -- can't leave the border stale until /reload. @@ -1698,6 +1703,7 @@ local function rawBorderAnimStructTok(t, borderOn) .. "," .. tostring(t.BorderAnimationSidesAxis) .. "," .. tostring(t.BorderAnimationCornerLength) .. "," .. tostring(t.BorderAnimationProcStart) + .. "," .. tostring(t.BorderAnimationBlendMode) .. "," .. colSig(t.BorderAnimationColor) end @@ -1994,7 +2000,18 @@ local function buildPlacedStyle(indicator, isSquare, borderSpec, defs) -- Every sibling path (square fill above, filter/debuff groups, missing badge) -- already used 0. local inset = borderSpec and borderArtInset(borderSpec) or 0 - style.icon = { show = not hideIcon, inset = inset } + -- ★ staticSpellID: PIN THE ART TO ONE SPELL, whatever aura matched. + -- ☠ IT IS THE CONTAINER'S OWN FIELD, NOT A NEW ONE. AuraContainer's styleButton has + -- honoured iconSpec.staticSpellID since the curated-art work -- it was simply never + -- reachable from a placed indicator, because nothing wrote it onto one. + -- ⚠ WHO WRITES IT: the Power Infusion Helper, on its Icon surface, with Power + -- Infusion's own id. Its trigger is a list of other people's cooldowns and its + -- MESSAGE is "infuse this player" -- so the picture must not be whichever cooldown + -- happened to match, which is the scope objection that got Icon cut once already. + -- ⚠ NOT VALIDATED HERE. A number is what the container asks for and an unknown one + -- resolves to no texture, which is the same outcome as the field being absent. + local staticID = tonumber(indicator.staticSpellID) + style.icon = { show = not hideIcon, inset = inset, staticSpellID = staticID } end -- Cooldown swipe: Blizzard drives it from the matched aura's Duration object @@ -2277,7 +2294,21 @@ end -- edit. The tracked spell-ID map used to live here; it is live-tunable via -- candidateFilters and now rides placedTuningSig. local function placedStructSig(isSquare, hideIcon, showStacks, showDuration, borderOn, indicator, defs) + -- ⚠ DERIVED HERE RATHER THAN PASSED. Both call sites already hand over the indicator, and + -- an eighth positional argument on a seven-argument sig is how the wrong value gets passed + -- at one of two sites and nobody notices for a month. A square never binds an icon, so the + -- question only means anything on the icon branch. + local staticArt = (not isSquare) and tonumber(indicator.staticSpellID) and true or false return (isSquare and "sq" or "ic") + -- ★ PINNED ART IS STRUCTURAL, and it is structural for a BIND-ONCE reason rather than + -- a region one. bindNative registers slot.dfIcon with Blizzard's SetIcon exactly once + -- per slot (slot._boundIcon) and skips that registration when the spec pins a spell -- + -- because a bound icon is repainted from the matched aura, which is the one thing a + -- pinned picture must not do. A slot pooled from one kind to the other would keep the + -- binding decision it was created with, so the two kinds must never share a slot. + -- ⚠ THE FLAG, NOT THE ID. Changing WHICH spell is pinned only needs the texture set + -- again (ApplyStyle does that); changing WHETHER one is pinned changes the binding. + .. "|" .. (staticArt and "sa" or "") .. "|" .. (hideIcon and "hi" or "") .. "|" .. (showStacks and "st" or "") .. "|" .. (showDuration and "du" or "") @@ -2900,7 +2931,9 @@ local function alertCompanionCoSig(frame, indicator, isBar, alpha, defs) "sx=" .. tostring(sx), "sy=" .. tostring(sy), -- scale is icon/square-only (the bar has no global for it, and buildBarLayout reads -- it raw), so this passes `defs` not `gdefs` — on a bar both resolve identically. - "sc=" .. tostring(tonumber(defOf(indicator, "scale", isBar and nil or defs, 1)) or 1), + -- (Was `isBar and nil or defs`, which always yields defs; written out so the + -- expression says what it does. Same result either way, per the note above.) + "sc=" .. tostring(tonumber(defOf(indicator, "scale", defs, 1)) or 1), "fo=" .. tostring(defOf(indicator, "durationFont", gdefs, nil)), "al=" .. tostring(alpha), }, "|") @@ -4428,13 +4461,16 @@ function Factory:SetHelperSoundsArmed(frame, armed, map, cfg) -- Power Infusion back onto the priest. if UnitIsUnit(frame.unit, "player") then return 0, "own unit (never registered, by design)" end - -- ☠ ROLE EXCLUSION HOLDS HERE TOO. The visual gate skips excluded roles at the - -- container funnel, which sound never passes through -- without this, a tank's cooldown - -- played the cue while nothing marked them: a signal with nobody to act on. Checked at - -- arm time, the same staleness window as everything else on this path. - if DF.AuraContainer and DF.AuraContainer.IsHelperRoleExcluded - and DF.AuraContainer.IsHelperRoleExcluded(frame.unit) then - return 0, "role excluded" + -- ☠ THE PER-UNIT NARROWINGS HOLD HERE TOO -- role, and now the named-player allowlist. + -- The visual gate applies them at the container funnel, which sound never passes through: + -- without this a tank's cooldown played the cue while nothing marked them, a signal with + -- nobody to act on. Checked at arm time, the same staleness window as everything else on + -- this path. + -- ⚠ ONE VERB (IsHelperUnitExcluded), not a test per narrowing, so a third one added later + -- cannot reach the visuals and miss the sound -- which is exactly how this one started. + if DF.AuraContainer and DF.AuraContainer.IsHelperUnitExcluded + and DF.AuraContainer.IsHelperUnitExcluded(frame.unit) then + return 0, "unit excluded (role or player list)" end local argKey, argVal = resolveSoundArg(cfg or {}) diff --git a/DandersFrames/Features/ElementAppearance.lua b/DandersFrames/Features/ElementAppearance.lua index a6bfdeb4..25d5a7a7 100644 --- a/DandersFrames/Features/ElementAppearance.lua +++ b/DandersFrames/Features/ElementAppearance.lua @@ -1885,7 +1885,8 @@ function DF:DebugADAlphaHosts(unit) tostring(entryKey), kind, okW and "OK" or "REFUSED", okR and "OK" or "refused", tostring(rawequal(f, h.frame)), latched), - okW and nil or "BAD") + -- `okW and nil or "BAD"` toned every line BAD, successes included. + (not okW) and "BAD" or nil) end end end diff --git a/DandersFrames/FilterRegistry/Registry.lua b/DandersFrames/FilterRegistry/Registry.lua index c5c86d14..c615fb56 100644 --- a/DandersFrames/FilterRegistry/Registry.lua +++ b/DandersFrames/FilterRegistry/Registry.lua @@ -161,6 +161,113 @@ function R:DuplicateFilter(srcRef, name) return id end +-- ============================================================ +-- ★★ A CURATED CUSTOM FILTER — a list WE seeded, not one the user built +-- ============================================================ +-- ☠ THE TWO KINDS OF CUSTOM FILTER BEHAVE DIFFERENTLY, AND THE DIFFERENCE IS WHOSE LIST IT +-- IS. One the user built by adding spells: membership IS the truth, so removing a spell with +-- the ✕ is exactly right -- they put it there. One WE seeded from a curated set (the Power +-- Infusion Helper's cooldown list): removing a spell is destructive and unrecoverable, and +-- there was no way back. Krathe, 2026-09-09: "it's a pre created list by us that should +-- toggle on off and be able to reset to default if someone ticks something off." +-- +-- ⭐ SO A CURATED FILTER GETS WHAT A PRESET HAS: a per-spell ENABLED layer over a membership +-- nobody edits, plus a reset. Deliberately the same shape as R:IsSpellEnabled / +-- R:SetSpellEnabled / R:ResetPreset -- the two kinds of list now answer the same questions, +-- so the UI can offer the same controls. +-- +-- ⚠ THE STATE LIVES ON THE FILTER, not in the preset overrides table. It is per-filter data +-- with the filter's own lifetime: it travels with a profile export and it dies with a +-- DeleteCustomFilter, neither of which would be true of a side table keyed by filter id. +-- ⚠ ABSENT MEANS ENABLED, so every existing custom filter behaves byte-for-byte as before +-- and only one that has actually been ticked off carries anything. +-- ⚠ `dfDefaults` IS THE MARK *AND* THE ANSWER. Its presence says "we seeded this"; its +-- contents say what back-to-default means. One field, so the two cannot disagree. +function R:IsCustomSpellEnabled(cfId, spellID) + local f = self:GetCustomFilter(cfId) + return not (f and f.disabled and f.disabled[spellID]) +end + +function R:SetCustomSpellEnabled(cfId, spellID, enabled) + local f = self:GetCustomFilter(cfId) + if not f then return end + if enabled then + if f.disabled then + f.disabled[spellID] = nil + if not next(f.disabled) then f.disabled = nil end + end + else + f.disabled = f.disabled or {} + f.disabled[spellID] = true + end +end + +-- How many of a custom filter's spells are ON, and how many there are. +-- ⚠ ONE COUNTER, THREE CONSUMERS: the Filter Designer's left list, its right-hand header, and +-- R:ListFilters (which the Buff Bar's picker reads). They showed a plain total each, computed +-- three ways, and none of them moved when a curated list's spell was ticked off -- Krathe, +-- 2026-09-09: "the number does not change as I tick them on/off". A count that ignores the +-- control next to it is worse than no count. +-- ⚠ A HAND-BUILT FILTER ANSWERS enabled == total, because IsCustomSpellEnabled is true when +-- there is no disabled set -- so those rows keep the single number they have always shown and +-- no caller needs to branch on which kind it is. +function R:CustomFilterCounts(cfId) + local f = self:GetCustomFilter(cfId) + if not f then return 0, 0 end + local on, total = 0, 0 + for sid in pairs(f.spells) do + total = total + 1 + if self:IsCustomSpellEnabled(cfId, sid) then on = on + 1 end + end + for rid in pairs(f.rawIDs) do + total = total + 1 + if self:IsCustomSpellEnabled(cfId, rid) then on = on + 1 end + end + return on, total +end + +-- Has a curated list been altered from its default? The same question IsPresetModified asks +-- of a preset, and the same answer shape, so a row can carry the same "modified" dot. +function R:IsCuratedFilterModified(cfId) + local f = self:GetCustomFilter(cfId) + return (f and f.disabled and next(f.disabled)) and true or false +end + +-- Is this a list we seeded, i.e. one with a default to go back to? +function R:IsCuratedFilter(cfId) + local f = self:GetCustomFilter(cfId) + return (f and type(f.dfDefaults) == "table") and true or false +end + +-- Record what this filter's default membership is. Called by whoever seeds it, right after +-- it is created and filled. +function R:SetCuratedDefaults(cfId, spellIDs) + local f = self:GetCustomFilter(cfId) + if not f then return end + local d = {} + for _, sid in ipairs(spellIDs or {}) do + sid = tonumber(sid) + if sid then d[sid] = true end + end + f.dfDefaults = next(d) and d or nil +end + +-- ⚠ RESTORES, IT DOES NOT PRUNE. Everything we seeded comes back and every tick comes back +-- on; a spell the USER added to our list afterwards is theirs and stays. "Reset to default" +-- here means "undo what I turned off", which is what it is reached for -- a reset that also +-- silently threw away someone's own additions would be the destructive act this replaces. +function R:ResetCuratedFilter(cfId) + local f = self:GetCustomFilter(cfId) + if not (f and type(f.dfDefaults) == "table") then return false end + f.disabled = nil + for sid in pairs(f.dfDefaults) do + -- Through AddSpellToCustom so an id still snaps to its canonical record, exactly as + -- it did when the list was seeded. "exists" is the ordinary case, not an error. + self:AddSpellToCustom(cfId, sid) + end + return true +end + -- Returns "spell" (known — snapped to canonical), "raw" (unknown id), or "exists" function R:AddSpellToCustom(id, spellID) local f = self:GetCustomFilter(id) @@ -759,13 +866,12 @@ function R:ListFilters(isLinked) end) for _, cfId in ipairs(customs) do local cf = self:GetCustomFilter(cfId) - local n = 0 - if cf then - for _ in pairs(cf.spells) do n = n + 1 end - for _ in pairs(cf.rawIDs) do n = n + 1 end - end + -- ⚠ THROUGH THE SHARED COUNTER, which honours a curated list's per-spell ticks. This + -- counted membership twice over and reported enabled == total unconditionally, so a + -- ticked-off spell still counted as on everywhere this list is read. + local on, total = self:CustomFilterCounts(cfId) out[#out + 1] = { kind = "custom", key = cfId, custom = true, - name = (cf and cf.name) or cfId, enabled = n, total = n } + name = (cf and cf.name) or cfId, enabled = on, total = total } end return out end @@ -997,8 +1103,86 @@ end -- See the fail-open branch inside ResolveSelection. local failOpenLogged = setmetatable({}, { __mode = "k" }) +-- ★★★ A CUSTOM FILTER MAY NAME OTHER FILTERS (`f.includes`), AND THIS IS WHERE THAT IS READ. +-- +-- ☠ WHAT IT REPLACES: COPYING. The Power Infusion Helper's three amplifier ticks used to copy +-- 51 spell IDs out of the Trinkets, Potions and Racials lists INTO its own, so its list read +-- 91 and the same spells existed in two places. Krathe, 2026-09-10: "despite the fact those +-- additional filters link to our actual filters, ticking them on actually just adds those to +-- the PI helper filter, so they are now twice on? this is very confusing." He is right: each +-- row has a pencil that opens the real list, which promises a REFERENCE, and the tick made a +-- COPY. The pencil was a promise the tick did not keep. +-- +-- ⚠ HERE AND NOT IN THE AURA DESIGNER, and the reason is that a filter must mean ONE thing. +-- The AD resolves a filter ref through DF:ResolveADFilterRef, the layout groups resolve a +-- selection of their own, and the helper's SOUND registrations resolve a third way +-- (Engine.lua's pihResolvedMap) -- and the user can also pick that same list in the Buff Bar's +-- own picker. Folding in any one of those would make one list mean different things in +-- different places, which is a worse fault than the copy it replaces. +-- +-- ⚠ INERT FOR EVERY FILTER WITHOUT `includes`, which today is every filter but ours: no +-- allocation, the caller's own table is handed straight back, and nothing else in this file +-- learns a new shape. +-- +-- ☠ ONE LEVEL, DELIBERATELY, AND SO NO RECURSION GUARD IS NEEDED. An included filter's own +-- includes are NOT folded. Nothing writes a chain today (the helper includes two presets and +-- one flat list of ours), and "resolve until it stops changing" on the render path is a cycle +-- waiting to be created by hand-editing a profile. If a chain is ever wanted, it needs a seen +-- set and a depth cap, not the removal of this sentence. +-- ⚠ `selection.noIncludes` OPTS OUT, for a consumer that names its sources itself. The Power +-- Infusion Helper's Cooldown Icons group can be set to show, say, class cooldowns only while +-- the Triggers tab still fires on trinkets -- and it says so by listing the sources it wants. +-- Without this flag it could not: selecting our cooldown list would drag that list's own +-- includes in behind it, and "cooldowns only" would be unsayable. +local function foldIncludes(self, selection) + if selection and selection.noIncludes then return selection end + if not (selection and selection.customs) then return selection end + local addP, addC + for cfId in pairs(selection.customs) do + local f = self:GetCustomFilter(cfId) + local inc = f and f.includes + if type(inc) == "table" then + for k in pairs(inc.presets or {}) do + if not (selection.presets and selection.presets[k]) then + addP = addP or {}; addP[k] = true + end + end + for k in pairs(inc.customs or {}) do + -- ⚠ Skip one already selected, or a filter that included itself would be + -- merely redundant rather than a problem. (It cannot loop: see above.) + if not selection.customs[k] then addC = addC or {}; addC[k] = true end + end + end + end + if not (addP or addC) then return selection end + -- ☠ A COPY, NEVER A MUTATION. Callers pass PROFILE tables here (db.buffFilterSelection, an + -- Aura Designer group's filterSelection) -- the same fact the fail-open latch below had to + -- learn the hard way. Writing the expansion into one would put it in SavedVariables and in + -- every profile export, where it would look like a selection the user made. + local out = { uncategorised = selection.uncategorised, presets = {}, customs = {} } + for k, v in pairs(selection.presets or {}) do out.presets[k] = v end + for k, v in pairs(selection.customs or {}) do out.customs[k] = v end + for k in pairs(addP or {}) do out.presets[k] = true end + for k in pairs(addC or {}) do out.customs[k] = true end + return out +end + +-- Everything an `includes` filter pulls in, as one flat selection -- for a caller that needs to +-- COUNT or LIST what a filter really covers rather than resolve it to a spell map. +function R:ExpandSelection(selection) + return foldIncludes(self, selection) +end + function R:ResolveSelection(selection, showAll) if showAll or not selection then return { kind = "all" } end + -- ⚠ BEFORE THE FAIL-OPEN TEST BELOW, not after. A filter whose only content is its + -- includes has no presets and no customs of its own, and the empty-selection branch + -- resolves to SHOW EVERYTHING -- so folding afterwards would turn "watch the trinket + -- list" into "watch every buff in the game". + -- ⚠ A FOLD ALWAYS ADDS, so the table that comes back is never emptier than the one that + -- went in -- which is also why the fail-open latch below still dedupes: a folded selection + -- (a fresh table, and so a fresh latch key) can never reach that branch. + selection = foldIncludes(self, selection) local anySel = (selection.presets and next(selection.presets)) or (selection.customs and next(selection.customs)) if not anySel and not selection.uncategorised then @@ -1083,9 +1267,15 @@ function R:ResolveSelection(selection, showAll) for cfId in pairs(selection.customs) do local f = self:GetCustomFilter(cfId) if f then + -- ⚠ THE PER-SPELL TICK ON A CURATED LIST, and it is the exact mirror of the + -- preset arm above (IsSpellEnabled). Absent state means enabled, so a + -- hand-built custom filter -- which has no ticks and never will -- resolves + -- byte-for-byte as it always did. for sid in pairs(f.spells) do - local rec = R.ByID[sid] - if rec then addLiveRecordIDs(self, map, rec) else map[sid] = true end + if self:IsCustomSpellEnabled(cfId, sid) then + local rec = R.ByID[sid] + if rec then addLiveRecordIDs(self, map, rec) else map[sid] = true end + end end -- What is left in rawIDs is genuinely unknown to the database, so -- there is no record to narrow. A direct mute is still honoured: @@ -1093,7 +1283,9 @@ function R:ResolveSelection(selection, showAll) -- "muting never reveals" has to hold on every path, not just the -- ones with a record behind them. for rid in pairs(f.rawIDs) do - if not self:IsSpellIDMuted(rid) then map[rid] = true end + if self:IsCustomSpellEnabled(cfId, rid) and not self:IsSpellIDMuted(rid) then + map[rid] = true + end end end end diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 845dac33..6ce62368 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -1409,7 +1409,33 @@ end -- -- The dead map is a populated set matching nothing, never an empty table: an empty include -- set reads as "no selection", which the engine is free to treat as "everything passes". -local HELPER_GATE_DEAD_CF = { includeSpellIDs = { [1] = true } } +-- ☠☠☠ THE DEAD FILTER IS THE VERIFIED PARK LEVER, NOT AN INCLUDE MAP (2026-09-11). +-- +-- This was { includeSpellIDs = { [1] = true } } -- "match only spell 1", i.e. nothing. +-- Krathe's raid logs proved the broadcast reaching it: 30 group handles and 30 slots on +-- every edge, pushed=60, deferred=0, skipped=0, in combat -- and the helper icons and +-- borders carried on showing on his allowed players while Power Infusion was on cooldown. +-- The gate flipped, the push landed, the filter did not blank the slot. +-- +-- ☠ BECAUSE includeSpellIDs CAN FAIL OPEN. It is evaluated INSIDE +-- CanApplyIdentityCandidateFilters, and when the identity gate declines -- range, +-- visibility, a cinematic, any of the reasons the latch work exists -- the include map is +-- skipped entirely and EVERY helpful aura passes (see the IDENTITY-GATE EXPOSURE note +-- below, and memory §15: "gate decline => include AND exclude maps SKIPPED"). A "dead" +-- filter built on it is dead only while the gate happens to be applied, which in a raid +-- is intermittently -- and every report of this bug was intermittent. +-- +-- ✅ maxDuration = 0 IS EVALUATED OUTSIDE THAT GATE, in readable Lua +-- (DoesAuraPassCandidateFilters), and excludes every aura unconditionally: a timed one +-- fails `duration > 0`, a permanent one fails `duration == 0`. Verified in game +-- 2026-08-30 against a unit carrying eight live buffs -- see SLOT_PARK_CF, which is this +-- same lever and is why parking works when this did not. +-- +-- ⚠ A SEPARATE CONSTANT FROM SLOT_PARK_CF, DELIBERATELY. _cf() returns this by identity +-- and the value-tracked push compares against it; sharing the park's table would make a +-- gated slot indistinguishable from a parked one to every reader that asks "which lock +-- is this", and the two are cleared by different things. +local HELPER_GATE_DEAD_CF = { maxDuration = 0 } local helperGateDark = false -- ☠ OWNERSHIP: `config.dfGate`. The gate must only ever darken our own effects. The first cut @@ -1477,6 +1503,18 @@ local function helperUnitRole(unit) return DF.GetUnitRole and DF:GetUnitRole(unit) end +-- ★★ THE NAMED-PLAYER ALLOWLIST (2026-09-10). Krathe: "in guild groups it would be useful to +-- only have the PI alert for the DPS you know who should be getting PI instead of every DPS in +-- the raid who uses a CD." +-- +-- ⚠ AN ALLOWLIST, AND THEREFORE nil MEANS EVERYONE. The roles table above is an EXCLUDE list, +-- so an empty one excludes nobody and the two read in opposite directions -- which is right +-- for what each says, and exactly why the nil case is spelled out at both ends. A profile that +-- has never used this has no list, and nothing changes for it. +-- ⚠ KEYED "Name-Realm", the same key the picker writes (GUI.RosterSnapshot). Realm-qualified +-- because a guild group can be cross-realm and because two Bobs is not a hypothetical. +local helperAllowedPlayers = nil -- e.g. { ["Bob-Draenor"] = true }; nil = everyone + local function helperRoleExcluded(unit) if not (helperExcludedRoles and unit) then return false end local role = helperUnitRole(unit) @@ -1484,10 +1522,34 @@ local function helperRoleExcluded(unit) return helperExcludedRoles[role] == true end +-- ⚠ THE NAME TEST FAILS OPEN TWICE OVER, and both are deliberate: no list means everyone (an +-- allowlist nobody has written is not a filter), and a unit whose name we cannot read is +-- shown rather than hidden. The failure this feature can afford is marking one person too +-- many; the one it cannot is going silent for the raid because a name lookup blinked. +-- ☠ "" AS WELL AS nil FOR THE REALM -- see GUI.RosterSnapshot, which writes these keys. Which +-- of the two UnitName returns for a same-realm unit is not something to bet a key on, and +-- "Bob-" would match nothing while looking exactly like a name that should. +local function helperPlayerExcluded(unit) + if not (helperAllowedPlayers and unit) then return false end + local name, realm = UnitName(unit) + if not name or name == "" then return false end -- fail open + if realm == "" then realm = nil end + return not helperAllowedPlayers[name .. "-" .. (realm or GetRealmName())] +end + +-- Both narrowings, one verb. Every consumer asks this rather than picking a test, so the +-- container funnel and the sound path cannot come to different answers about one unit. +local function helperUnitExcluded(unit) + return helperRoleExcluded(unit) or helperPlayerExcluded(unit) +end + -- Shared with the SOUND path (Factory): sound registers per unit and never passes the --- container funnel, so role exclusion must be answerable from outside it -- or a cue plays +-- container funnel, so exclusion must be answerable from outside it -- or a cue plays -- for a unit nothing marks. -function AuraContainer.IsHelperRoleExcluded(unit) return helperRoleExcluded(unit) end +-- ⚠ THE NAME KEPT ITS "Role", so the one caller in Factory.lua did not have to change while +-- the ANSWER widened. That is the wrong trade -- a name that describes half of what it does +-- is how the next reader gets it wrong -- so the verb is renamed and the caller with it. +function AuraContainer.IsHelperUnitExcluded(unit) return helperUnitExcluded(unit) end -- A record's candidateFilters REPLACES the config-wide set for that group/slot -- (the dispel overlay's per-type slots) — see normalizeFilters. @@ -1656,7 +1718,7 @@ local function recordCandidateFilters(rec, config) -- Ownership is read off the CONFIG, never off the map -- see `config.dfGate` above. -- ORDER: helper gate FIRST, caster lock second. A gated-dark map is the dead map and -- needs no lock; everything live gets the PLAYER-token caster lock (see applyCasterLock). - if config.dfGate and (helperGateDark or helperRoleExcluded(config.unit)) then + if config.dfGate and (helperGateDark or helperUnitExcluded(config.unit)) then return HELPER_GATE_DEAD_CF end return applyCasterLock(rec.f, rec.candidateFilters or config.candidateFilters) @@ -2930,7 +2992,21 @@ local EMPTY_DUR_SPEC = {} local function bindNative(slot, config) local style = config.style or {} - if slot.dfIcon and slot.SetIcon and not slot._boundIcon then + -- ☠ A PINNED ICON IS NEVER BOUND, AND NOT BINDING IT IS THE WHOLE MECHANISM. + -- SetIcon hands our texture to Blizzard, which then repaints it from the MATCHED AURA on + -- every display update -- so a slot that pins its own art (style.icon.staticSpellID, set + -- once by styleButton) must stay unbound or the art it was given is overwritten by the + -- first aura that matches. Unbound, the texture is an ordinary DF-owned region: nothing + -- else writes it, and the ENGINE still owns whether the button is SHOWN at all, which is + -- exactly the division we want -- Blizzard decides "does this unit match", we decide what + -- the marker looks like. + -- ⚠ Only reachable through the Power Infusion Helper's Icon surface today. Its trigger is + -- a list of other people's cooldowns and its message is "infuse this player", so the + -- picture is Power Infusion rather than whichever cooldown matched. + -- ⚠ _boundIcon IS BIND-ONCE PER SLOT, so a slot must not be pooled between the two kinds + -- -- placedStructSig carries the pinned-vs-dynamic flag for exactly that reason. + local pinnedArt = style.icon and style.icon.staticSpellID + if slot.dfIcon and slot.SetIcon and not slot._boundIcon and not pinnedArt then slot._boundIcon = true slot:SetIcon(slot.dfIcon) end @@ -6404,6 +6480,14 @@ function Handle:SetUnit(unit) -- Same re-seed for visibility: the new unit may already be outside your world, and -- that edge will not fire again just because a handle changed hands. self:_setVisLatch(AuraContainer._invisibleUnits[unit] or nil) + -- ☠ THE HELPER GATE FOLLOWS THE UNIT HERE TOO. config.unit is updated above, so + -- recordCandidateFilters would DERIVE the right answer -- but nothing re-pushes it, + -- and the bounce below re-parses against whatever map the container is still holding. + -- Same fault as the slot lane (see SetSlotOwnerUnit), same narrowing to our own + -- configs so a retarget does not re-tune every group in the addon. + if self.config.dfGate and self.backend and self.backend.applyGroupTuning then + pcall(self.backend.applyGroupTuning, self.backend) + end -- In combat, defer JUST the retarget (a full rebuild would leak a container + N -- buttons every combat on roster churn); "retarget" re-runs SetUnit at regen. if InCombatLockdown() then self:_queueOp("retarget"); return end @@ -6638,9 +6722,14 @@ end -- ☠ ONLY OUR CONTAINERS. applyGroupTuning runs an immediate UpdateAllAuras per group key and -- has no equality guard of its own, so broadcasting to every handle in the addon would cost a -- full aura re-parse on each one for a gate flip that concerns a handful. +-- ⚠ THE COUNTS COME BACK SPLIT: handles pushed, handles SKIPPED, slots pushed, slots +-- queued. One combined number could not say which LANE ignored a gate edge, and the two +-- lanes fail for completely different reasons -- a handle is skipped when it is destroyed +-- or has no backend, a slot when it is parked or the push is refused. Krathe's log showed +-- 72 pushed and icons still on screen, and no way to tell which 72. function AuraContainer.SetHelperGate(dark) helperGateDark = dark and true or false - local n = 0 + local n, hSkip = 0, 0 for h in pairs(AuraContainer._handles or {}) do local b = h and h.backend if b and b.applyGroupTuning and not h._destroyed and helperGateHandleIsOurs(h) then @@ -6649,7 +6738,12 @@ function AuraContainer.SetHelperGate(dark) -- and protection is identical. A gate edge walks every owned handle twice a Power -- Infusion cycle, in combat, so this is exactly the path that rule was written for. local ok = pcall(b.applyGroupTuning, b) - if ok then n = n + 1 end + if ok then n = n + 1 else hSkip = hSkip + 1 end + elseif h and h.config and h.config.dfGate then + -- OURS, and not reachable: destroyed, or its backend is gone (a build + -- deferred to combat end). It renders whatever it last had, and nothing + -- here can correct it -- so it is COUNTED rather than passed over in silence. + hSkip = hSkip + 1 end end -- ☠ SLOTS TOO, NARROWED TO OURS. SetAuraSlotCandidateFilters has no engine-side @@ -6658,17 +6752,76 @@ function AuraContainer.SetHelperGate(dark) -- not a local: the registry is declared thousands of lines below this function, and a -- later-declared local here would silently read as a nil global (this file has been -- bitten by exactly that; see the note above GateAppliesTo). + local deferred = 0 for h in pairs(AuraContainer._slotHandles or {}) do if h.config and h.config.dfGate and h._applyHelperGate then - local ok, applied = pcall(h._applyHelperGate, h) - if ok and applied then n = n + 1 end + local ok, applied, queued = pcall(h._applyHelperGate, h) + if ok and applied then + n = n + 1 + if queued then deferred = deferred + 1 end + end end end - return n + -- ⚠ REPORTED SEPARATELY. A queued slot is not a pushed one, and counting them + -- together is what made a log full of healthy-looking edges hide a raid's worth of + -- icons that never went dark. + return n, deferred, hSkip end function AuraContainer.GetHelperGate() return helperGateDark end +-- ★★★ WHAT THE HELPER'S SLOTS ARE ACTUALLY CARRYING (2026-09-10). +-- +-- ☠ THE READOUT COULD SEE EVERY SETTING AND NONE OF THE WIRING. "/df debug pi" already +-- prints what the gate INTENDS against what the chokepoint SAYS -- deliberately as two lines, +-- because they are allowed to differ -- and the sound registrations per frame. It could not +-- see the third state, which is what Krathe kept hitting: a slot whose LAST PUSH was the dead +-- filter while the gate has since re-opened, so the border and the group render and one placed +-- icon does not. Four reports, four different theories, no measurement. +-- +-- ⚠ `pending` IS THE ONE THAT NAMES THE CAUSE. SlotHandle:_applyHelperGate cannot call a +-- native tuning setter in combat, so it defers to PLAYER_REGEN_ENABLED -- and Power Infusion +-- is pressed in combat by definition. A slot sitting at pending>0 with the gate OPEN is that +-- deferral, visible for the first time. +-- ⚠ DERIVED, NEVER STORED. Each answer is re-asked off the live handle, so this cannot drift +-- from what the slots are doing -- the fault every "same config, different outcome" hunt in +-- this addon has come down to. +-- Returns: total helper slots, how many would be handed the DEAD filter right now, how many +-- are waiting on a deferred push, and how many are parked. +-- The HANDLE lane's equivalent -- how many group containers carry our mark, and how many of +-- those are currently reachable (a live backend that can be tuned). The Cooldown Icons +-- group is a handle, not a slot, so nothing in GetHelperSlotStatus can see it. +function AuraContainer.GetHelperHandleStatus() + local total, live, dark = 0, 0, 0 + for h in pairs(AuraContainer._handles or {}) do + if h and h.config and h.config.dfGate then + total = total + 1 + if h.backend and h.backend.applyGroupTuning and not h._destroyed then + live = live + 1 + end + if helperGateDark or helperUnitExcluded(h.config.unit) then dark = dark + 1 end + end + end + return total, live, dark +end + +function AuraContainer.GetHelperSlotStatus() + local total, dark, pending, parked = 0, 0, 0, 0 + for h in pairs(AuraContainer._slotHandles or {}) do + if h and h.config and h.config.dfGate then + total = total + 1 + if h.parked then parked = parked + 1 end + if h._pendingTuning then pending = pending + 1 end + -- The verdict the slot would be handed on its next push, asked the same way + -- SlotHandle:_cf asks it. + if helperGateDark or helperUnitExcluded(h.owner and h.owner.unit) then + dark = dark + 1 + end + end + end + return total, dark, pending, parked +end + function AuraContainer.SetHelperExcludedRoles(roles) helperExcludedRoles = roles return AuraContainer.SetHelperGate(helperGateDark) -- re-push so it takes effect now @@ -6676,6 +6829,19 @@ end function AuraContainer.GetHelperExcludedRoles() return helperExcludedRoles end +-- The named-player allowlist. `map` is { ["Name-Realm"] = true } or nil for everyone. +-- ⚠ THE SAME RE-PUSH THE ROLES GET, for the same reason: the container is carrying an answer +-- derived from the old list until something makes it ask again. +-- ⚠ AND THE SAME EVENT SET COVERS IT. PIH_REGEN_EVENTS already re-pushes on GROUP_ROSTER_UPDATE, +-- which is what fires when the named player actually joins -- so a list written before the raid +-- forms takes effect the moment they walk in, with no work of its own. +function AuraContainer.SetHelperAllowedPlayers(map) + helperAllowedPlayers = map + return AuraContainer.SetHelperGate(helperGateDark) +end + +function AuraContainer.GetHelperAllowedPlayers() return helperAllowedPlayers end + -- Backstop for pushes swallowed during lockdown by the pcall'd native setters. Idempotent, -- out of combat, and the same shape the identity gate already uses for its combat-exit -- re-verify. @@ -8374,7 +8540,7 @@ end function SlotHandle:_cf() local cf = self._lastCandidateFilters if cf ~= nil and self.config and self.config.dfGate - and (helperGateDark or helperRoleExcluded(self.owner and self.owner.unit)) then + and (helperGateDark or helperUnitExcluded(self.owner and self.owner.unit)) then return HELPER_GATE_DEAD_CF end return cf @@ -8393,12 +8559,69 @@ function SlotHandle:_applyHelperGate() if self.parked then return false end local c = self.owner and self.owner.container if not c then return false end - if InCombatLockdown() then + -- ★★★ ATTEMPT, THEN DEFER -- and it used to be defer-always (2026-09-10). + -- + -- ☠ WHAT DEFER-ALWAYS COST, from Krathe's raid log: 33 clean gate edges, "DARK on cast" + -- and "OPEN, cooldown cleared" alternating across 72-98 containers -- the watcher, the + -- gate and the broadcast all working perfectly, and the placed icons still showing on + -- people while his Power Infusion was on cooldown. In a raid you are in combat for the + -- whole pull, so EVERY edge on a placed slot queued for PLAYER_REGEN_ENABLED and none + -- of them landed during the fight. "The border DID go away but the PI icon did not" was + -- this, from the first report onwards. + -- + -- ⚠ AND THE COUNT IN THAT LOG WAS OVER-REPORTING, which is why it read as healthy: the + -- deferred branch returned TRUE, so SetHelperGate counted a queued slot as a pushed + -- one. It returns a second value now and the caller counts them apart. + -- + -- ⚠ THE PRECEDENT IS IN THE SAME WALK. SetHelperGate's other half, applyGroupTuning, + -- has always called native tuning setters on this edge with NO combat guard at all -- + -- in combat, twice per Power Infusion cycle, and its own note says so. Two lanes of one + -- broadcast cannot both be right about whether that is allowed; the guard was the + -- inconsistency, not the unguarded call. + -- ⚠ STILL DEFERS IF THE CALL ACTUALLY FAILS, which is the point of trying: a refusal is + -- now MEASURED rather than assumed, and the regen replay is still there to catch it. + local ok = pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) + if not ok then self._pendingTuning = true registerSlotRegen(self) - return true + return true, true -- queued, not pushed + end + + -- ★★★ ...AND ARM THE PROCESSOR, or the push is a note nobody reads until later. + -- + -- ☠ SetAuraSlotCandidateFilters IS A DIRTY-MARK, NOT A REPAINT. The container re-reads + -- on its next processor pass -- the unit's next UNIT_AURA, or the next OnUpdate while + -- visible (68569; see the note at the top of this file). So "stop matching" is a + -- request the engine honours WHEN IT NEXT LOOKS, and how long that takes depends + -- entirely on what is happening to that unit. + -- ⇒ Krathe, 2026-09-10: "the PI icon does clear when I use PI on them but it seems to + -- take longer than the border/icons and other indicators." It is not stuck, it is + -- QUEUED -- and the intermittency is the queue: the frame-level border is painted by + -- DF on the same frame, while the slot waits for traffic that may be a moment or a + -- couple of seconds away. + -- ⚠ UpdateAllAuras IS ITSELF ONLY A MARK -- it arms the processor rather than parsing + -- inline -- so this collapses the wait to the next frame rather than making it + -- instant. That is the whole of what is available; there is no synchronous re-parse. + -- ⚠ ON THE GATE EDGE ONLY. This function runs twice per Power Infusion cycle, not per + -- frame and not per aura event, so arming here costs nothing measurable. + -- ⚠ pcall(fn, self), NOT pcall(function() ... end) -- this file's own rule, recorded at + -- applyGroupTuning's tail: the closure form allocates one per call for no gain and the + -- protection is identical. SetHelperGate's own note names THIS path as the reason the + -- rule exists. + -- ⚠ NOT IN THE COMBAT BRANCH ABOVE: nothing was pushed there, so there is no new filter + -- to re-read and arming would be pure work. + -- ☠ AND THE COMBAT DEFERRAL DOES NOT COME BACK THROUGH HERE -- checked, not assumed. + -- _replayTuning clears _cfPushed and calls _pushFilter, which re-derives park vs live + -- at drain time and pushes candidates ITSELF; it never re-enters this function. So a + -- gate edge that happened in combat still drains without an arm of its own, and picks + -- up whatever the combat-exit kick does (reparseContainer / the chunked bounce). + -- ⚠ _pushFilter IS DELIBERATELY LEFT ALONE. Arming there would cover the drain -- and + -- also park, restore and the death latch, which is a far wider blast radius than the + -- symptom this line answers. If the lag is ever seen on THOSE paths it should be its + -- own change, with its own testing. + if type(c.UpdateAllAuras) == "function" then + pcall(c.UpdateAllAuras, c) end - pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) return true end @@ -8634,6 +8857,23 @@ function AuraContainer:SetSlotOwnerUnit(frame, unit) for _, h in pairs(owner.slots) do pcall(function() h:_setDeathLatch(latched) end) pcall(function() h:_setVisLatch(invis) end) + -- ☠☠ AND THE HELPER GATE, WHICH IS UNIT STATE TOO -- the omission Krathe found: + -- "it's not working in my raid but I could see it on people before my auto layout + -- kicked in." Auto layout is a MASS RETARGET. A slot gated dark for unit A (a tank, + -- excluded by role, or someone off a named-player list) migrates to unit B and keeps + -- the DEAD candidate filter it was handed for A -- because _cf() re-derives at READ + -- time but nothing PUSHES after a retarget, and the next push only comes on a gate + -- edge. The verdict travelled with the container. + -- ⚠ EXACTLY THE CLASS THE TWO LINES ABOVE EXIST FOR. Death and visibility are + -- re-seeded here because "the new unit may already be dead, and that edge will never + -- fire again"; role exclusion is the same sentence with a different noun. It was + -- missed because it is derived rather than stored, which makes it look like it + -- cannot go stale -- the DERIVATION is fresh, the PUSH is not. + -- ⚠ NARROWED TO OURS, like SetHelperGate's own walk: SetAuraSlotCandidateFilters + -- has no engine-side equality guard, so an unnarrowed call would re-parse every + -- placed indicator in the addon on every retarget -- and a raid auto-layout change + -- retargets the whole roster at once. + if h.config and h.config.dfGate then pcall(h._applyHelperGate, h) end end -- ☠ SetUnit ALONE DOES NOT RENDER THE RETARGET — it writes the token and marks -- FullAuraRebuild, but it cannot ARM the private-side dirty processor, so the @@ -9381,7 +9621,9 @@ do local ok, can = GateAssistProbe(unit) if not ok then return true, "assist-err(open)" end if issecretvalue and issecretvalue(can) then can = true end - return can and true or false, can and nil or "cannot-assist" + -- `can and nil or "cannot-assist"` named every trusted unit cannot-assist too. + if can then return true, nil end + return false, "cannot-assist" end -- created, shownN, hiddenN, unreadableN, widthTxt. Child buttons belong diff --git a/DandersFrames/Frames/Border.lua b/DandersFrames/Frames/Border.lua index 2c80050d..e5027427 100644 --- a/DandersFrames/Frames/Border.lua +++ b/DandersFrames/Frames/Border.lua @@ -193,6 +193,41 @@ end local ANIM_GOLD = { r = 0.95, g = 0.95, b = 0.32, a = 1 } local ANIM_WHITE = { r = 1, g = 1, b = 1, a = 1 } +-- ★★ THE EFFECT'S BLEND MODE, A SETTING RATHER THAN A CONSTANT (2026-09-10). +-- +-- ☠ THE BUG THIS ANSWERS. Krathe: "if I've set it to red it will show orange when over a +-- yellow border." DF Chase's sparkles were created with a hardcoded SetBlendMode("ADD"), and +-- ADD means "add my colour to whatever is behind me" -- so the effect showed its true colour +-- over the dark icon in the corner of his frame and turned orange the moment it crossed the +-- yellow border. Working exactly as written, and not what a colour picker promises. +-- +-- ☠☠ AND IT WAS INCONSISTENT, WHICH IS THE REAL FAULT. When c4b4e5eb replaced LibCustomGlow +-- with our own effects, each one had a blend mode chosen by hand: DF_ORBIT and DF_PROC got +-- ADD, and the other three got none at all, which is BLEND. Same colour picker, two different +-- meanings depending on which effect you happened to pick, and nothing anywhere saying so. +-- +-- ⚠ THE DEFAULTS ARE EXACTLY WHAT WAS HARDCODED. Nobody's frames change appearance. ADD is +-- WHY a glow reads as glowing -- the same art in BLEND looks like a flat sticker on a dark +-- frame -- so the two glow effects keep it and the choice is now the user's to make. +-- ⚠ DF_PULSATE IS ABSENT ON PURPOSE. It has no textures of its own: it modulates the border's +-- OWN edges, which already have spec.blendMode. Offering a second blend mode for it would be +-- two controls over one texture, and the loser would be whichever ran last. +local ANIM_BLEND_DEFAULT = { + DF_ORBIT = "ADD", -- DF Chase: additive sparkles + DF_PROC = "ADD", -- DF Proc: additive glow flipbook + -- DF_DASH / DF_PIXEL / BLINK / DF_FLASH: BLEND, which is what setting nothing gave them. +} +local ANIM_BLEND_VALID = { BLEND = true, ADD = true, MOD = true, DISABLE = true } + +-- ⚠ VALIDATED, because this value reaches SetBlendMode, which throws on an unknown string -- +-- and it comes out of SavedVariables, which is to say out of anywhere. An unrecognised mode +-- falls back to the effect's own default rather than erroring the whole animation pass. +local function animBlendMode(anim) + local want = anim and anim.blendMode + if want and ANIM_BLEND_VALID[want] then return want end + return (anim and ANIM_BLEND_DEFAULT[anim.type]) or "BLEND" +end + -- Resolve a colour from either an array {r,g,b,a} or a keyed {r=,g=,b=,a=} -- table, so consumers can pass whichever they already store. local function readColor(color) @@ -370,6 +405,10 @@ function Border:BuildSpec(dbTable, prefix, ctx) mask = dbTable[k("BorderAnimationMask")], sidesAxis = dbTable[k("BorderAnimationSidesAxis")], cornerLength = dbTable[k("BorderAnimationCornerLength")], + -- ⚠ NIL MEANS THE EFFECT'S OWN DEFAULT, not BLEND -- see ANIM_BLEND_DEFAULT. An + -- absent key has to keep DF Chase and DF Proc additive, or every existing profile + -- would quietly change appearance on the first login after this shipped. + blendMode = dbTable[k("BorderAnimationBlendMode")], -- PROC only: play the one-shot "proc start" flash on each start. -- Opt-in (default off) because PROC is used here as a CONTINUOUS -- border animation that re-applies often; the flash is a one-shot @@ -1272,6 +1311,12 @@ local function setupOrbitParticles(border, anim) local r, g, b, a = readColor(anim.color or ANIM_GOLD) border.orbitTex = border.orbitTex or {} local tex = border.orbitTex + -- ☠ OUTSIDE THE CREATION GUARD, AND THAT IS THE HALF THAT MAKES IT A SETTING. These + -- textures are POOLED and reused across every re-apply, so a blend mode set only on the + -- `if not t` branch is the mode the first frame happened to be born with -- changing the + -- option would do nothing at all until a reload, which reads as the option being broken. + -- Same reason SetVertexColor is out here. See animBlendMode. + local bm = animBlendMode(anim) for i = 1, total do local t = tex[i] if not t then @@ -1279,10 +1324,10 @@ local function setupOrbitParticles(border, anim) t:SetTexture(ORBIT_SHINE_TEX) t:SetTexCoord(ORBIT_SHINE_COORD[1], ORBIT_SHINE_COORD[2], ORBIT_SHINE_COORD[3], ORBIT_SHINE_COORD[4]) t:SetDesaturated(true) - t:SetBlendMode("ADD") tex[i] = t end t:SetParent(host) + t:SetBlendMode(bm) t:SetVertexColor(r, g, b, a) t:Show() end @@ -1700,8 +1745,21 @@ end -- Redraw all four edges' dashes at a marching offset (counter-clockwise: -- bottom → left → top → right, matching the highlight system). +-- ⚠ STAMPED ON CHANGE, NOT EVERY FRAME. The dash pool is 96 fixed textures and this runs on +-- the OnUpdate tick, so the mode is compared once and written only when it actually moved. +-- The pool is created at a fixed size and never grows, so nothing can be born unstamped. +local function stampDashBlend(border, pool) + local bm = border._animBlend or "BLEND" + if border._dashBlendApplied == bm then return end + border._dashBlendApplied = bm + for _, edge in pairs(pool) do + for _, d in ipairs(edge) do d:SetBlendMode(bm) end + end +end + local function drawDashes(border, offset, th, inset, r, g, b, a) local pool = ensureDashPool(border) + stampDashBlend(border, pool) local fw, fh = border._knownW or border:GetWidth(), border._knownH or border:GetHeight() if not fw or not fh or fw <= 0 or fh <= 0 then return end local width = fw - inset * 2 @@ -1756,6 +1814,7 @@ local function buildDashAnims(border, anim, th, inset, r, g, b, a, marchSpeed) for i = 1, count do local dashStart = startPos + (i - 1) * P local q = marchQuad(e, i) + q:SetBlendMode(animBlendMode(anim)) q:SetColorTexture(r, g, b, a) q:ClearAllPoints() if ed.horiz then @@ -1808,6 +1867,10 @@ local function setupPixelParticles(border, anim) local r, g, b, a = readColor(anim.color or ANIM_GOLD) border.pixelTex = border.pixelTex or {} local tex = border.pixelTex + -- ⚠ DF Pixel set NO blend mode, which is BLEND -- so this is the same picture it has + -- always drawn, now said out loud and overridable. Half the effects were additive and half + -- were not, with nothing anywhere admitting to it; see ANIM_BLEND_DEFAULT. + local bm = animBlendMode(anim) for i = 1, N do local t = tex[i] if not t then @@ -1816,6 +1879,7 @@ local function setupPixelParticles(border, anim) tex[i] = t end t:SetParent(host) + t:SetBlendMode(bm) t:SetVertexColor(r, g, b, a) t:Show() end @@ -1933,6 +1997,7 @@ local function buildPixelAnims(border, anim) local at = first + (i - 1) * space local q = marchQuad(e, i) q:SetTexture(PIXEL_TEX) + q:SetBlendMode(animBlendMode(anim)) q:SetVertexColor(r, g, b, a) q:SetSize(ed.sx, ed.sy) q:ClearAllPoints() @@ -2036,24 +2101,27 @@ local function setupProcGlow(border, anim) -- its final frame — so playing the burst fully then swapping to the loop reads -- as one smooth motion (no cross-fade). Alpha-only visibility (never IsShown — -- a secret boolean on container buttons). + -- Applied on every pass, not on the create branch: these textures are pooled and reused, + -- so a mode set at birth is the mode the first frame happened to get. See animBlendMode. + local bm = animBlendMode(anim) local t = border.procTex if not t then t = host:CreateTexture(nil, "OVERLAY") - t:SetBlendMode("ADD") border.procTex = t end t:SetParent(host); t:ClearAllPoints(); t:SetAllPoints(host) if border._procAtlas then t:SetTexture(border._procAtlas.file) end + t:SetBlendMode(bm) t:SetDesaturated(desat); t:SetVertexColor(r, g, b, a) local s = border.procStartTex if not s then s = host:CreateTexture(nil, "OVERLAY") - s:SetBlendMode("ADD") border.procStartTex = s end s:SetParent(host); s:ClearAllPoints() s:SetPoint("CENTER", host, "CENTER", 0, 0) -- size set in the tick (needs the rect) if border._procStartAtlas then s:SetTexture(border._procStartAtlas.file) end + s:SetBlendMode(bm) s:SetDesaturated(desat); s:SetVertexColor(r, g, b, a) -- anim.procStart = the "Hide Intro Flash" toggle (default nil/false plays it). local showIntro = not anim.procStart @@ -2279,7 +2347,11 @@ local function setupFlashGlow(border, anim) ants:SetParent(host); ants:ClearAllPoints() ants:SetPoint("CENTER", host, "CENTER", 0, 0) -- size set in the tick (0.85 × F) ants:SetTexture(FLASH_ANTS_TEX) + -- ⚠ EVERY PASS, on the pooled textures -- see animBlendMode. DF Flash set no mode, so its + -- default is the BLEND it has always drawn with. + local bm = animBlendMode(anim) for _, t in next, { spark, inner, innerOver, outer, outerOver, ants } do + t:SetBlendMode(bm) t:SetDesaturated(desat); t:SetVertexColor(r, g, b, 1); t:Show() end border._flashMaxA = a -- the colour's alpha caps every layer @@ -2761,6 +2833,11 @@ local function animSpecHash(anim) tostring(anim.mask), tostring(anim.sidesAxis), tostring(anim.cornerLength), tostring(anim.procStart), + -- ☠ IN THE HASH OR THE SETTING IS INERT. StartAnimation returns early on an unchanged + -- hash, so a blend mode missing from here would apply only when some OTHER tunable + -- moved -- the "change it and nothing happens, nudge the frequency and it appears" + -- symptom this file already carries a note about for the driver check. + tostring(anim.blendMode), tostring(cr), tostring(cg), tostring(cb), tostring(ca), }, "|") end @@ -2802,6 +2879,13 @@ function Border:StartAnimation(border, spec) end end + -- The resolved blend mode, stashed for the paths that cannot reach `anim`: the DF Dash + -- OnUpdate tick redraws its pooled quads through drawDashes, which is handed geometry and + -- a colour and nothing else. Set here rather than threaded through four call layers. + -- ⚠ animSpecHash CARRIES blendMode, so a change to it fails the equality above and reaches + -- this line -- without that the setting would look inert until some other tunable moved. + border._animBlend = animBlendMode(anim) + -- DF_PULSATE retune-in-place: the spec changed, but if a DF Pulsate is -- already running on this border, NEVER tear it down — just update its -- period. A frequency change (or any unrelated spec churn from a diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index cb4312ee..f2692aef 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -228,7 +228,6 @@ L["Are you sure?"] = true L["Aura Designer Template"] = true L["Auto-Create Profiles"] = true L["Auto-create profiles for loadouts"] = true -L["Back"] = true L["Binding:"] = true L["Bindings only cast their assigned spell"] = true L["cast a resurrection spell instead."] = true @@ -469,6 +468,9 @@ L["%d override"] = true L["%d overrides"] = true L["%d players"] = true L["%d spells"] = true +-- The header on a CURATED custom filter once some of its spells are ticked off. The plain +-- form above still serves every other case, so an ordinary filter's header never changes. +L["%d of %d spells"] = true L["%d-%d players"] = true L["%d-%d%%"] = true L["%d-%ds"] = true @@ -2074,6 +2076,17 @@ L["Animation Length"] = true L["Animation Particles"] = true L["Animation Scale"] = true L["Animation Thickness"] = true +-- ★★ HOW THE EFFECT'S COLOUR MIXES WITH WHAT IS BEHIND IT. Krathe, 2026-09-10: "if I've set +-- it to red it will show orange when over a yellow border... I'm sure we used to offer up a +-- blend mode for animation?" We never did -- L["Border Blend Mode"] governs the border's own +-- EDGES, not the effect over them, which is an easy pair to read as one control. +-- ⚠ THE TOOLTIP NAMES HIS EXACT SYMPTOM, because that is the sentence that tells someone +-- looking at an orange effect they picked red for which control they are looking for. +-- ⚠ "Default" IS A REAL OPTION, not a placeholder: each effect had its own hardcoded mode +-- (DF Chase and DF Proc additive, the rest not), and Default is how a profile keeps it. +-- The value labels reuse L["Blend"] / L["Add"] / L["Modulate"] / L["Disable"] / L["Default"]. +L["Animation Blend Mode"] = true +L["How the effect's colour mixes with what is behind it. Add brightens whatever it crosses, so a red effect reads orange over a yellow border — it is what makes a glow glow. Blend draws the colour exactly as picked. Default keeps this effect's original look."] = true L["Border Animation"] = true L["Blink"] = true L["Corner Length"] = true @@ -2935,24 +2948,51 @@ L["Interrupted: %s"] = true L["Left-Click:"] = true L["Right-Click:"] = true --- Power Infusion Helper (Aura Designer, priest only). One block, one card, and the card --- flips between adding and removing -- so the two titles are a pair and must stay one --- verb apart in every locale. -L["POWER INFUSION HELPER"] = true -L["Add the helper"] = true -L["Remove the helper"] = true +-- Power Infusion Helper — its OWN page as of 2026-09-08 (Auras > Power Infusion Helper, +-- priest only). It used to be a block inside the Aura Designer whose card flipped between +-- "Add the helper" and "Remove the helper"; it is an enable tick now, so those two strings +-- and the removal caption that went with them are gone rather than left for translators to +-- work on text nobody will ever see. +-- +-- The page's NAV ENTRY. Title case, because nav labels are title case everywhere in this +-- addon — and a separate string rather than a case transform of anything, since in a locale +-- where case is not a presentation choice a transform is wrong in both places. +L["Power Infusion Helper"] = true +-- ⚠ ONE VERB APART FROM THE DESIGNER'S OWN "Enable Aura Designer", on purpose: two features +-- that turn on the same way should read the same way. +L["Enable Power Infusion Helper"] = true +-- The pool tab's tooltip, beside My Buffs / Debuffs / Any Buff. Three lines like its +-- neighbours: what the pool is for, how you work it, and the spec-scope fact they all state. +L["Who is worth casting Power Infusion on, and how that shows on the frame."] = true +L["Set up its Triggers, then add effects the same way as any other pool."] = true +-- The sentence under the tick — the only place the feature explains itself, and it stays on +-- screen while the tick is OFF, which is exactly when someone needs to read it. L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."] = true -L["Deletes its indicators and its spell lists. Nothing else is touched."] = true --- The three signals. Adding the helper turns on the first one only; the other two are ticked --- on afterwards, so each label has to stand alone with just the line beneath it for context. -L["What to Show"] = true -L["Big cooldown"] = true --- How the surface pickers behave. What the controls cannot show on their own: which --- surfaces stack, and which pick one winner. --- Surface picker. Every surface is listed; one already held by a signal on the same spell list --- says what picking it does, because the two trade places rather than one being refused. -L["%s (swap with %s)"] = true -L["Already has active Power Infusion"] = true +-- ⚠ The helper's surface menu reuses the addon's existing L["Square"] and the frame-level +-- labels; it needs no names of its own. It briefly had a "Power Infusion icon" string when +-- Icon was the surviving placed surface -- that was reversed the same afternoon (the helper +-- highlights that someone popped a cooldown; it does not track WHICH one, so a per-buff +-- icon promised detail the feature never delivers) and the string went with it. +-- ★★ THE PANEL'S TWO HALVES, in the order the feature is reasoned about: decide what COUNTS +-- as worth infusing, then decide how it gets SHOWN. "What to Show" was the old heading for +-- the second half and led the panel -- the answer before the question -- which is why it read +-- as a pile of settings. Retired 2026-09-08 along with "Never Show On", whose two role ticks +-- are inside Triggers now. +-- ⚠ "Triggers" is this panel's own heading and needs no qualifier: the page it lives on is +-- already named Power Infusion Helper. L["Indicators"] is the addon's existing key, reused. +L["Triggers"] = true +-- ★ The Effects tab's add row, and the per-effect remove beside each one. A signal can hold +-- several surfaces now (border AND health bar AND a square), so both of these repeat. +-- ⚠ "Remove %s" TAKES THE SURFACE'S OWN NAME -- "Remove Border", "Remove Health Bar" -- and +-- that is not decoration: with several effects listed, a bare "Remove" would sit beside three +-- rows looking identical and the user could not tell which one it acted on. +-- ☠ THE HELPER'S PRIVATE EFFECTS TAB IS GONE (2026-09-09) AND SEVEN KEYS WENT WITH IT: +-- its own "ADD AN EFFECT" heading, the two route cards that asked WHICH SIGNAL, their +-- descriptions, its empty-state line, and the surface dropdown's swap label. The Effects +-- tab is the DESIGNER'S now -- same heading, same tiles, same effect cards -- and there is +-- no signal to choose: everything added is "worth infusing". +L["Add an effect"] = true +L["Remove %s"] = true -- Clash warnings. Shown only on the three surfaces that take a single winner, and each names -- the remedy that already exists rather than describing the problem. -- The offender is NAMED: "something else colours the border" sends someone hunting through @@ -2966,31 +3006,187 @@ L["%s already colours this text. Only one can show — raise this signal's prior L["%s and %d more"] = true L["Another effect"] = true -- Shared settings. These live on the helper, not on each effect: they are statements about --- who you would infuse, and there is only one answer per player. -L["Never Show On"] = true +-- who you would infuse, and there is only one answer per player. They sit on the Triggers TAB +-- -- excluding a role is a statement about what counts, not about how it is drawn -- so the +-- old "Never Show On" heading went with the regroup. +-- ⚠ THE BOX IS "Roles" AND THE TAB IS "Triggers" (Krathe, 2026-09-09). It was Triggers for +-- both, so the tab opened with a box repeating its own name -- which says nothing, while the +-- thing it could have said (this box is the ROLE filter) went unsaid. +L["Roles"] = true L["Groups without assigned roles show everyone."] = true -L["Hide the helper while your Power Infusion is on cooldown"] = true +-- ★★ THE NAMED-PLAYER ALLOWLIST. "In guild groups it would be useful to only have the PI +-- alert for the DPS you know who should be getting PI instead of every DPS in the raid who +-- uses a CD" (Krathe, 2026-09-10). +-- ☠ THE NOTE IS LOAD-BEARING, NOT DECORATION. Every other control on this tab narrows by being +-- ticked ON; this one narrows by having anything in it AT ALL -- so an empty picker looks like +-- a filter that has been switched off when it means the exact opposite. One sentence is the +-- difference between a default and an apparent fault. +L["Players"] = true +-- ★★ THE COOLDOWN-ICON GROUP'S OWN SOURCES. Krathe, 2026-09-10: "we should let people +-- toggle cooldowns and the sub filters on/off so they can pick from any of the 4... it might +-- be the case they want to trigger from a trinket but only show a CD etc." Triggers answers +-- WHEN the helper fires; this answers WHAT the row of icons then shows. +-- ⚠ THE FOOTER IS A STATE READOUT, NOT A CAPTION. Four ticks matching the Triggers tab look +-- identical whether they are INHERITING it or were set by hand to the same thing, and the +-- difference is whether a later change over there still reaches this group. So one line says +-- which, and the button is the way back to following. +-- L["Show"], L["Trinkets"], L["Potions"], L["Racials"] and L["Class cooldowns"] are reused. +L["SHOW"] = true +L["Class cooldowns"] = true +L["Following the Triggers tab. Changing one of these stops that."] = true +L["Follow Triggers"] = true +-- ★★ THE ALLOWLIST'S OWN SWITCH (2026-09-11), and the note shrank to make room for it. +-- ⚠ RETIRED: L["Empty means everyone. Add players here to watch only them."]. Its first +-- sentence described a rule that has moved into the tick's tooltip, where it belongs now that +-- emptiness is no longer what decides anything. +-- ⚠ "ONLY WATCH THESE PLAYERS" reads correctly in both positions -- ticked it is the rule, +-- unticked it is the rule you are not using -- which a label like "Use player list" does not: +-- that one names a mechanism and leaves the reader to work out its effect. +L["Add players here to watch only them."] = true +L["Only watch these players"] = true +-- ⚠ OFF FIRST, because off is the state this switch was asked for: Krathe, 2026-09-11, wants +-- to keep a raid team written down and stop applying it on a pug night. The promise that the +-- list survives is the whole point, so it is the sentence that leads. +-- ⚠ THE EMPTY-LIST RULE SITS ON THE ON LINE, which is the only state it can apply in. +L["Off: the helper watches everyone. Your list is kept for next time."] = true +L["On: only the players listed below. An empty list still means everyone."] = true +-- The compact picker's row button, which toggles. Both states are spelled out because the +-- glyph alone (a chevron, or a tick) says which state you are IN and not what a click does. +L["Click to watch this player."] = true +L["Click to stop watching this player."] = true +-- ⚠ ASKED POSITIVELY, like every other tick on the panel. It was "Hide the helper while your +-- Power Infusion is on cooldown" -- the one control in a box of enables that turned a +-- SUPPRESSION on, which made the box read inconsistently. The stored value is unchanged and +-- still defaults to gating, so this ships UNTICKED and nobody's saved choice changed meaning. +-- ⚠ ...AND THEN SHORTENED. It spelled the whole rule out on the row and wrapped doing it; +-- the rule moved to the tooltip below. What the LABEL has to carry is WHICH cooldown is meant +-- -- this box is otherwise full of other people's -- so it names the spell outright, where +-- "Show while on cooldown" would have read as the tracked one. Krathe's wording, verbatim, +-- capital C included: a label he typed is a label he can find again. +L["Show when Power Infusion is on Cooldown"] = true +-- ⚠ "EFFECTS", NOT "MARKERS". This panel's own word is the one the Effects tab and ACTIVE +-- INDICATORS use; "marker" belongs to the raid target icon and the dispel corner mark, which +-- are other features. Krathe, 2026-09-10: "markers? it should be effects and the wording +-- itself is not very clear on the tooltip rethink it." +-- ⚠ ONE LINE PER STATE, each a plain sentence, off first because off is the default. What +-- these replace stated a consequence of the rule ("so you are never pointed at someone you +-- cannot infuse") before finishing the rule itself. +-- ⚠ The label says "on Cooldown", so these say "off cooldown" / "on cooldown" back rather +-- than reaching for "ready" -- one idea, one word for it. +L["Off: the helper's effects only appear while your Power Infusion is off cooldown."] = true +L["On: they appear even while it is on cooldown."] = true +-- ★ SHOW IN COMBAT ONLY, beside the cooldown gate and independent of it: both conditions +-- have to pass, and the tooltip says so rather than leaving the reader to work out how two +-- conditions on one feature combine. Off first, as above, because off is the default. +L["Show in combat only"] = true +L["Off: the helper works wherever you are."] = true +L["On: nothing shows until you are in combat. Independent of the cooldown setting above -- both have to pass."] = true -- Only watch. Classes rather than specs because the spell data records a class and nothing --- finer; the pointer names the editor that does go spell by spell, so the limit is not a --- dead end. +-- finer; the row links out to the editor that does go spell by spell, so the limit is not +-- a dead end. +-- ★ THE BASELINE BOX: the class ticks, the note, and the button that edits the list they +-- narrow. Its count rides the header because the source has no row of its own. L["Classes and Cooldowns"] = true +-- ⚠ A BUTTON, NOT A PENCIL. The pencils sit on the Additional Filters ROWS, beside the +-- tick that includes each source; this box has no source row -- the class ticks are the +-- control -- so a full-width button reads as belonging to the box rather than to whichever +-- row it happened to be nearest. Krathe, 2026-09-10. +L["Edit Cooldowns"] = true L["Classes"] = true -L["To add or remove single cooldowns, edit the list in the Filter Designer."] = true L["Untick a class to stop watching its cooldowns."] = true -- Sound. The helper owns this entry outright: the generic effects list refuses to show sound -- on a filter-owned record, so it offers no row and no delete button for it either. -L["Play a sound when someone becomes worth infusing"] = true +-- ⚠ THE TICK IS LABELLED "Enable" AND EXPLAINS ITSELF IN A TOOLTIP. Its label was the whole +-- sentence below, under a box already captioned Sound Alert -- the feature stated twice, and +-- wrapping to two lines to do it. Krathe: "too verbose, make it Enable with a tooltip +-- explaining what it does in better english." L["Enable"] and L["Sound Alert"] already exist. +L["Plays your chosen sound when a group member's cooldown makes them worth infusing."] = true L["Only plays while the helper is showing."] = true -- Show When Missing's greyed-out reason on a helper effect (Indicators.lua GateSWM): the -- missing-mode render path is the one place the helper's cooldown gate cannot reach. L["Not available on a Power Infusion Helper signal."] = true --- The icons row on the cooldown signal, and the three amplifier ticks nested under it. --- Amplifiers are one category with three sources: what makes a burst BIGGER, as against the --- cooldown list, which says a burst is happening at all. They are icons only -- a border --- lighting for a trinket on its own would be noise. -L["Cooldowns"] = true +-- ★ THE FOUR TRIGGER SOURCES, one row each with its count and a link to its own list. +-- ⚠ THE ROWS REPLACED A SINGLE BUTTON AND A NOTE explaining that three of the four were +-- not really editable -- Krathe: "the note below the link to edit the cooldown list is +-- silly, the additional filters can also be edited, this really is an unclear mess." They +-- ARE editable now (pihAmplifierIDs honours each preset's ticks and the copy is re-taken +-- on every visit), so each row simply offers the way in and the panel says nothing. +-- ⚠ THE FOURTH SOURCE IS NOT HERE. Class cooldowns live in the "Classes and Cooldowns" +-- box with the ticks that narrow them and the button that edits them -- that box is the +-- baseline, and these three are what you add to it. +-- ⚠ The counts are appended to these labels at render time and need no translating. +L["Additional Filters"] = true +L["Edit this list"] = true +L["Open it in the Filter Designer."] = true +-- The three sources in Additional Filters. Each tick puts that source's spells into the ONE +-- list the helper matches on, so ticking Trinkets makes a trinket proc fire whatever effects +-- have been added on the Effects tab. +-- ⚠ L["Cooldowns"] WENT WITH ITS ROW. The fourth source has no tick of its own any more -- +-- the class ticks are its control, so it is named by the "Classes and Cooldowns" header +-- instead. See P.PIH_CooldownCounts for why a tick there was redundant AND harmful. L["Trinkets"] = true L["Potions"] = true +-- The helper's add block, which stands where the designer's three scope cards stand on every +-- other pool: there is no spell to choose here (the cooldown list IS the spell), so the tile +-- grid is the whole flow. +-- ⚠ THE ICON TILE NEEDS ITS OWN DESCRIPTION. The shared one reads "The spell's own artwork", +-- which is true in the designer and false here. Its key is gone: the answer stopped being +-- "pinned to Power Infusion" the moment the tile grew a second step, and it is now the +-- three-way description a few lines down. +-- ★ THE HELPER'S ADD GRID. Two steps for the icon -- which KIND of indicator, then which +-- ICON -- because the three icon answers are as different from each other as an icon is from +-- a square, and every other choice on that grid is made by looking at a thumbnail. +-- ★★ THREE ANSWERS BEHIND ONE TILE, on two axes: HOW MANY (one effect, or one per +-- cooldown they have up) and WHAT PICTURE (always Power Infusion, or the buff they used). +-- "Cooldown Icons" is the GROUP and used to stand on the main grid beside Border and Square, +-- which put a container among a row of effects -- see pihBuildAddTiles for why that mismatch +-- is what made it confusing rather than merely untidy. +L["Which icon?"] = true +L["Power Infusion"] = true +L["The same picture on everyone worth infusing."] = true +L["Their cooldown"] = true +L["The buff they actually used — one of them, if several are up at once."] = true +L["Power Infusion, their cooldown, or one per cooldown they have up."] = true +L["Cooldown Icons"] = true +L["One icon per cooldown they have up, each showing its own."] = true +-- ⚠ THE SECOND LINE OF A GREYED TILE'S TOOLTIP, and it has to say where the thing WENT -- +-- these tiles are greyed rather than removed precisely because a tile that vanished when you +-- clicked it told nobody anything. +-- ⚠ ONE MESSAGE FOR ALL THREE. The two single-icon tiles briefly shared a different one +-- ("the card below switches which picture it shows") because adding either spent both; they +-- are two independent effects now -- Krathe asked for the pair -- so each greys on its own and +-- the honest instruction is the same as the group's: remove it from the list below. +L["Already added. Remove it from the list below to change it."] = true +-- The cooldown-icon group's collapsed summary, in place of the filter count every other group +-- shows. Its list is the cooldown list, which the Triggers tab owns -- so trinkets, potions +-- and racials reach it automatically as they are ticked there, with no second control here +-- that could disagree. The number is the list's own enabled total, so it moves when they do. +L["%d spells, from your Triggers"] = true +-- ⚠ AND THE PLAIN COUNT, for a group that has been given its own SHOW set. Saying "from your +-- Triggers" on a group that no longer follows them would be the header contradicting the block +-- directly beneath it, which says it has stopped following. +L["%d spells"] = true +-- ★ THE ICON'S TWO CHOICES on the Power Infusion Helper's pool. The picture is pinned to +-- Power Infusion by default -- "infuse this player" -- and can be swapped for the buff they +-- actually used. The caveat in the first tooltip is real and not a hedge: a placed icon +-- renders ONE slot, so with a cooldown and a trinket up together the engine's pick is not +-- ours to make. The second tick is the answer to that, and names the case it is for. +L["Show the triggering cooldown's icon"] = true +L["Off: the Power Infusion icon, on everyone worth infusing. On: the buff they actually used — one of them, if several are up at once."] = true +-- ★★ THE ICON'S FOUR SOURCES, matching the Cooldown Icons group's SHOW block. These replace +-- one tick ("Ignore trinkets, potions and racials") that muted all three amplifiers at once: +-- same mechanism, per source. Krathe, 2026-09-10: "yes build the icon block the same". +-- ⚠ SUBTRACTIVE, AND THE FOOTER SAYS SO. A placed effect is keyed by ONE filter reference, +-- so it can show less than Triggers watches and never more -- showing more needs a filter of +-- its own, which is what the group is for. A source Triggers has off is GREYED rather than +-- hidden, with its own reason, or the two cards would disagree about how many sources exist. +-- L["Class cooldowns"], L["Trinkets"], L["Potions"] and L["Racials"] are reused. +L["This icon only. It can show less than the Triggers tab watches, never more."] = true +L["Switch this on under Triggers first — the helper is not watching it."] = true +L["Every indicator is already in use. Remove one below to add it again."] = true +-- The stub page behind the nav row, reached by the settings SEARCH rather than by clicking +-- the row -- which links straight to the designer's Power Infusion Helper tab. +L["The Power Infusion Helper is a tab inside the Aura Designer."] = true -- Shown under a signal that has no colour and no icons -- a state the panel can reach and -- could not previously explain. Names both remedies; the second form is for a signal with no -- icons row of its own, where the menu is the only door. @@ -2998,6 +3194,18 @@ L["Cooldowns are not showing. Add a display from the dropdown, or tick '%s'."] = L["Move and size the icons under Layout Groups."] = true -- Row labels, so a signal names itself in the effects list rather than reading as its -- spell list. Resolved at render from the mark; never stored. -L["PI Helper — Big cooldown"] = true +-- ⚠ THE FIRST ONE NAMES THE FEATURE, NOT THE TRIGGER. It was "PI Helper — Big cooldown", +-- which is what fires the effect rather than what the effect IS -- the same words on every +-- row, where the row's own identity should be. The effect's TYPE is already on the row as a +-- coloured badge (Icon, Border, Square), so the text does not repeat it. +L["PI Helper"] = true +-- ⚠ THE TWO ICONS ARE NAMED; NOTHING ELSE IS. Every other helper effect is unique on the +-- signal, so its type BADGE distinguishes it and a suffix would print the same word twice on +-- one row. The two icons differ only in their artwork and the badge says "Icon" for both -- +-- so since the pair became addable at once, two rows in ACTIVE INDICATORS read identically. +-- Krathe, 2026-09-10: "a placed PI icon should show as PI Helper - PI Icon / Icon / Icons, +-- right now only the last actually shows." ("Icons" is the GROUP, whose name is stored data.) +L["PI Helper — PI Icon"] = true +L["PI Helper — Icon"] = true L["PI Helper — Already has active Power Infusion"] = true --@end-do-not-package@ diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 3ff26100..c70e0660 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -28,6 +28,8 @@ local CreateCardShell = P.CreateCardShell local ShowBuffCoexistPopup = P.ShowBuffCoexistPopup local ResolveSpec = P.ResolveSpec local IsOtherTab = P.IsOtherTab +local IsPIHelperTab = P.IsPIHelperTab +local ShowsOthersOnly = P.ShowsOthersOnly local IsDebuffTab = P.IsDebuffTab local CurrentAuraPool = P.CurrentAuraPool local PoolKeyPrefix = P.PoolKeyPrefix @@ -109,6 +111,12 @@ local PIH_FILTERS = { cooldowns = "Power Infusion Helper", amplifiers = "Power Infusion Helper (amplifiers)", infused = "Power Infusion Helper (infused)", + -- ★ RACIALS BECAME A LIST (2026-09-10). It was four spell IDs written out below, which + -- made it the one Trigger source with no way in: Krathe, "racial show 4 and no edit + -- pencil?" The four are now the SEED of a curated list of ours, so the row gets the + -- pencil, a count that moves as you tick, and Reset to Default -- the same treatment the + -- cooldown list already has. PIH_RACIAL_IDS stays as the seed and the reset target. + racials = "Power Infusion Helper (racials)", } local PIH_PI_SPELL_ID = 10060 -- Power Infusion, for the "already infused" mark @@ -263,9 +271,38 @@ end -- for one signal disagreeing on screen. The addon's own rule ("never store L[...] as a db -- value") says it plainly; caught in Danders' PR review, and it blocked the merge because bad -- data outlives the fix. `pihSignal` is the stored truth and the label is derived from it. -local function pihLabel(key) - if key == "burst" then return L["PI Helper — Big cooldown"] end +-- ☠ IT NAMES THE FEATURE, NOT THE TRIGGER. The rows read "PI Helper — Big cooldown - Center - +-- Others Only", and Krathe asked the right question of it: "why? It should just say PI Helper +-- - Icon/Border/Square etc". "Big cooldown" is the TRIGGER, which every helper effect shares +-- and which the Triggers tab is entirely about -- so on an effect row it is a constant +-- printed on every line, taking the space where the row's own identity should be. +-- ⚠ THE TYPE IS ALREADY THERE, AS THE BADGE. Every effect row draws a coloured type badge to +-- the left of its name (Icon, Border, Square...), which is how the designer distinguishes two +-- effects on the same spell. Repeating it in the text would be the same word twice on one row. +-- ⚠ THE SECOND SIGNAL KEEPS ITS OWN NAME, because it is genuinely a different thing and the +-- badge cannot say so. Nothing creates one any more (see pihBuildAddTiles), but existing ones +-- still list and delete, and a row that cannot be told apart from its neighbour is a row +-- somebody deletes the wrong one of. +-- ★★ ...AND THE TWO ICONS DO NEED THEIR NAMES (2026-09-10), which is the one exception the +-- argument above generates rather than contradicts. Krathe: "a placed PI icon should show as +-- PI Helper - PI Icon / Icon / Icons, right now only the last actually shows." +-- ⚠ THE BADGE STOPPED BEING ENOUGH THE DAY BOTH COULD EXIST. It says "Icon" for the +-- Power-Infusion-pinned one and "Icon" for the one showing their cooldown -- so the pair +-- Krathe asked for two changes ago arrives as two rows reading identically, which is the +-- "somebody deletes the wrong one" case the note above is about, now reachable. +-- ⚠ STILL NO SUFFIX ON ANY OTHER TYPE. Border, Square and the rest are each unique on the +-- signal, so the badge does distinguish them and repeating it would be the same word twice +-- on one row. The suffix appears where it disambiguates and nowhere else. +-- ☠ staticSpellID'S PRESENCE IS THE ART, as everywhere else -- there is no second field +-- recording the choice (see P.PIH_SetIconShowsAura). A frame-level cfg carries no `type`, +-- so it can never match this branch; only placed instances do. +local function pihLabel(key, cfg) if key == "infused" then return L["PI Helper — Already has active Power Infusion"] end + if key ~= "burst" then return nil end + if type(cfg) == "table" and cfg.type == "icon" then + return cfg.staticSpellID and L["PI Helper — PI Icon"] or L["PI Helper — Icon"] + end + return L["PI Helper"] end -- The effects list (Groups.lua) resolves helper rows through this: same derivation, one @@ -312,9 +349,65 @@ local function pihEnsureFilter(name, presetKeys, extraIDs, wipeFirst) end for _, sid in ipairs(extraIDs or {}) do R:AddSpellToCustom(id, sid) end end + -- ★ RECORD WHAT "DEFAULT" MEANS, every time -- not only on the create branch. + -- ☠ THE MARK IS WHAT MAKES THE LIST BEHAVE LIKE OURS: with dfDefaults set, the Filter + -- Designer gives its rows the on/off tick instead of the destructive ✕ and offers Reset + -- to Default (R:IsCuratedFilter). Krathe, 2026-09-09: "it's a pre created list by us that + -- should toggle on off and be able to reset to default if someone ticks something off." + -- ⚠ OUTSIDE THE SEED BRANCH ON PURPOSE. An EXISTING list is not re-seeded (the note above + -- says why: a list that quietly refills itself is not a list anyone can own) -- but a + -- profile made before the mark existed still needs it, and re-stamping the same values on + -- every call is free and idempotent. + -- ⚠ THE DEFAULT IS THE RECIPE'S SET, not the list's current contents. Anything the user + -- has added since is theirs and is deliberately not part of what a reset restores. + do + local defaults = {} + for _, catKey in ipairs(presetKeys or {}) do + for _, rec in ipairs((R.ByCategory and R.ByCategory[catKey]) or {}) do + defaults[#defaults + 1] = rec.id + end + end + for _, sid in ipairs(extraIDs or {}) do defaults[#defaults + 1] = sid end + if R.SetCuratedDefaults then R:SetCuratedDefaults(id, defaults) end + end return id end +-- ── THE RACIALS LIST ── +-- ★ CREATED WITH THE HELPER, NOT WITH THE TICK. The Racials row on Triggers shows a count and +-- a pencil whether or not the tick is on -- the same as Trinkets and Potions, whose lists are +-- Danders' presets and therefore always exist. A list conjured by the tick would mean the row +-- had no count and a dead pencil until you switched it on, which is the "lying control" this +-- panel keeps being cleaned of. So it is seeded wherever the cooldown list is. +-- ⚠ AND NEVER FROM A TICK. pihSyncTriggerExtras must not call this, for the reason its own +-- note gives: a tick must not conjure the helper into existence. +local function pihEnsureRacialFilter() + return pihEnsureFilter(PIH_FILTERS.racials, nil, PIH_RACIAL_IDS) +end + +-- Every id in a curated list of ours, in ONE place because three callers need it and each +-- would otherwise walk both buckets itself. +-- ⚠ BOTH BUCKETS. AddSpellToCustom files a known id under `spells` and an unknown one under +-- `rawIDs`, and which bucket a racial lands in depends on whether SpellDB knew it when it was +-- added -- so a reader that consults one is right until the database is regenerated. +-- ⚠ `everything` IGNORES THE TICKS, and the two callers want opposite things: the WANT set +-- honours them (an unticked racial must stop firing) and the REMOVAL UNIVERSE must not (an +-- unticked racial is exactly what has to be taken back out of the cooldown list). +local function pihCustomFilterIDs(cfId, everything) + local R = DF.FilterRegistry + local f = cfId and R and R.GetCustomFilter and R:GetCustomFilter(cfId) + if not f then return nil end + local out = {} + for _, bucket in ipairs({ f.spells, f.rawIDs }) do + for sid in pairs(bucket or {}) do + if everything or not R.IsCustomSpellEnabled or R:IsCustomSpellEnabled(cfId, sid) then + out[#out + 1] = sid + end + end + end + return out +end + -- ───────────────────────────────────────────────────────────── -- WHAT EXISTS -- read off the marks, never off a stored list -- ───────────────────────────────────────────────────────────── @@ -346,6 +439,61 @@ local function pihOtherPoolWrite() return P.GetOtherAuras and P.GetOtherAuras() or nil end +-- ★★★ A SIGNAL CAN HOLD SEVERAL SURFACES AT ONCE (2026-09-08). +-- ☠ THE STORE ALREADY ALLOWED IT; ONLY THIS LOOKUP AND ONE GATE SAID OTHERWISE. A pool +-- record can carry many frame-level effects and many placed instances, so "border AND health +-- bar AND a square" was always expressible -- pihFound simply wrote each hit over the last +-- into out[signal], and pihCreateSignal refused a second add with "already on". Krathe wants +-- what the designer does: add several, like the AD tiles. +-- ⇒ pihFoundAll returns EVERY hit per signal; pihFound keeps its old one-per-signal shape +-- over the top, so the dozen existing consumers are untouched by this change. +-- ⚠ ORDERED BY SURFACE, not by pairs(). The pool walk is hash order, so "the first hit" was +-- previously whichever the iterator happened to reach last -- harmless when a signal had one, +-- and a source of flicker the moment it has three. +-- ☠ ITS OWN TABLE RATHER THAN PIH_SURFACE_ORDER, and not for tidiness: that local is declared +-- ~400 lines BELOW here, so naming it would compile as a nil GLOBAL read -- the exact +-- "declared below its first caller" trap UnitExemptFromHelpfulGate documents in this file. +-- Kept in the same order as the menu, and it only has to be self-consistent: this decides +-- which hit is called primary, not what anything renders. +local PIH_RANK = { + border = 1, healthbar = 2, background = 3, nametext = 4, healthtext = 5, + icon = 6, square = 7, bar = 8, +} +local function pihSurfaceRank(typeKey) return PIH_RANK[typeKey] or 99 end + +local function pihFoundAll() + local out = {} + local pool = pihOtherPoolRead() + if type(pool) ~= "table" then return out end + local keys = P.FRAME_LEVEL_TYPE_KEYS or {} + for auraName, auraCfg in pairs(pool) do + if type(auraCfg) == "table" then + for _, typeKey in ipairs(keys) do + local cfg = auraCfg[typeKey] + if type(cfg) == "table" and cfg.pihSignal then + local l = out[cfg.pihSignal] or {} + l[#l + 1] = { auraName = auraName, typeKey = typeKey, cfg = cfg } + out[cfg.pihSignal] = l + end + end + for _, inst in ipairs(auraCfg.indicators or {}) do + if type(inst) == "table" and inst.pihSignal then + local l = out[inst.pihSignal] or {} + l[#l + 1] = { auraName = auraName, typeKey = inst.type, + cfg = inst, indicatorID = inst.id } + out[inst.pihSignal] = l + end + end + end + end + for _, l in pairs(out) do + table.sort(l, function(a, b) return pihSurfaceRank(a.typeKey) < pihSurfaceRank(b.typeKey) end) + end + return out +end +-- ⚠ P.PIH_FoundAll was exported here and never read anywhere. The LOCAL is live -- the create +-- gate, PIH_SurfacesOf and pihPurgeStrayMarks all use it; only the export went. + local function pihFound() local out = {} local pool = pihOtherPoolRead() @@ -374,8 +522,24 @@ local function pihFound() end -- ───────────────────────────────────────────────────────────── --- THE COOLDOWN-ICON GROUP (a Filter Group carrying the burst signal) +-- THE COOLDOWN-ICON GROUP -- RETIRED (schema 5, 2026-09-09) -- ───────────────────────────────────────────────────────────── +-- ☠☠ IT SHIPPED, IT GOT STUCK, AND THE TWO FINDERS BELOW ARE ALL THAT IS LEFT OF IT. +-- The helper used to be able to draw a Filter Group of live cooldown icons, ticked on from +-- a row buried under "Classes and Cooldowns". Krathe, 2026-09-09: "I have stuck PI Helper +-- Cooldown - Icons on my AD despite that not even being an option now for PI helper." +-- ⚠ AND HE WAS RIGHT ABOUT THE SCOPE, WHICH IS WHY IT IS NOT COMING BACK. The helper's +-- question is "is this player worth infusing", not "which cooldown did they press" -- a +-- board of per-spell icons answers the second question at the price of the first. The +-- effects a user adds now are the AD's own: border, health bar, background, name/health +-- text, square, bar, and an Icon that shows POWER INFUSION's artwork rather than the +-- trigger's. +-- ⚠ THE FINDERS SURVIVE THE FEATURE ON PURPOSE. pihSweep deletes any group a shipped +-- build left behind, and PIH_Remove sweeps again as a belt-and-braces -- both need to be +-- able to FIND one, and a profile that has never been swept still has one to find. +-- +-- The original design note, kept because it is the argument that has to be re-made if +-- anyone proposes this again: -- ☠ A FOURTH WAY TO SHOW THE SAME SIGNAL, NOT A FOURTH SIGNAL. A placed icon pins -- max = 1 and shows ONE arbitrary cooldown; a Filter Group shows every matching cooldown the -- unit has running, one icon each -- the richer read of the burst window, and Danders' @@ -395,36 +559,112 @@ end -- (others-only), "infused" is its own one-icon group -- SEPARATE because one container has -- ONE caster rule, and infused needs the opposite rule from everything else (own casts -- allowed; it IS an own cast). User's design, second group session. -local function pihIconGroup(sig) - local groups = P.GetOtherLayoutGroups and P.GetOtherLayoutGroups(false) - for _, g in ipairs(groups or {}) do - if type(g) == "table" and g.pihSignal == (sig or "burst") then return g end +-- ☠☠☠ AND THE HELPER'S OWN DATA IS NOT ALWAYS IN THE HELPER'S OWN STORE. READ THIS BEFORE +-- WRITING ANOTHER FINDER. +-- Every PIH_* question in this file reads adDB.otherAuras / adDB.otherLayoutGroups, because +-- that is where the helper's records BELONG -- the pool decides a record's caster filter and +-- the helper watches other people's cooldowns, so Any Buff is the only pool where one can +-- match anything. That is a statement about where they belong. It is not a statement about +-- where they ARE. +-- +-- ☠ KRATHE'S PROFILE, READ OUT OF SAVEDVARIABLES 2026-09-09 after he reported the same stuck +-- group for the third time: +-- layoutGroups/HolyPriest -> EIGHT "PI Helper — Cooldown icons" groups +-- auras/HolyPriest/@custom:cf9 -> one marked icon indicator +-- otherAuras/@custom:cf12 -> one marked icon indicator (the only one in the +-- store every finder in this file looks in) +-- The old icon tick called P.CreateLayoutGroup, which is POOL-ROUTED off S.activeBuffTab, and +-- the panel it lived on was mounted in S.BuildEffectsHeadArea -- drawn on EVERY pool's +-- Effects tab, not only Any Buff, whatever the comment beside it claimed. So ticking it on My +-- Buffs created a group in the SPEC store; the finder then could not see it, reported the +-- icons as off, and the next tick made another one. Eight times. +-- +-- ⇒ NOTHING COULD REACH THEM. Not the helper (wrong store), not the Layout Groups tab +-- (VisibleLayoutGroups hides marked groups from every pool that is not the helper's), and not +-- the sweep. A record that no control can see is a record no control can turn off, which is +-- exactly what Krathe was looking at. +-- +-- ⇒ SO THE SWEEP HUNTS BY MARK, ACROSS EVERY STORE, and these two walkers are how. The +-- ordinary finders stay narrow on purpose -- they answer "what is the helper showing", and +-- the answer must not include records that cannot work -- but anything CLEANING UP has to +-- look where the data actually went. See [[ad-storage-map]] for the store list itself. +local function pihAllGroupStores(adDB) + local out = {} + if type(adDB.otherLayoutGroups) == "table" then out[#out + 1] = adDB.otherLayoutGroups end + local lg = adDB.layoutGroups + if type(lg) == "table" then + -- ⚠ SPEC-KEYED SINCE V2, WITH A LEGACY FLAT ARRAY STILL POSSIBLE. An entry carrying + -- `.id` is a group record, which means THIS table is the store; otherwise its values + -- are the per-spec arrays. Same test [[ad-storage-map]] records for the font walkers. + if type(lg[1]) == "table" and lg[1].id then + out[#out + 1] = lg + else + for _, arr in pairs(lg) do + if type(arr) == "table" then out[#out + 1] = arr end + end + end end - return nil + return out end -local function pihAnyIconGroup() - local groups = P.GetOtherLayoutGroups and P.GetOtherLayoutGroups(false) - for _, g in ipairs(groups or {}) do - if type(g) == "table" and g.pihSignal then return g end +local function pihAllAuraPools(adDB) + local out = {} + if type(adDB.otherAuras) == "table" then out[#out + 1] = adDB.otherAuras end + if type(adDB.auras) == "table" then + -- adDB.auras is SPEC-KEYED: its values are the pools, not the records. + for _, poolT in pairs(adDB.auras) do + if type(poolT) == "table" then out[#out + 1] = poolT end + end end - return nil + return out end --- The icon group counts as existing: without this, unticking all three signals while the --- icons stay on would flip the card back to "Add" and hide the panel -- stranding a running --- group with no control left that can reach it. -function P.PIH_Exists() - return next(pihFound()) ~= nil or pihAnyIconGroup() ~= nil +-- ⚠ pihAnyIconGroup AND pihIconGroup ARE GONE WITH THE LAST THING THAT USED THEM. Both +-- searched adDB.otherLayoutGroups for a mark and handed the id to a store-routed delete -- +-- one store, one id -- which is the shape that failed three times. pihPurgeStrayMarks hunts +-- by mark across every store instead, so there is nothing left for a single-store finder to +-- answer that is not a wrong answer waiting to happen. + +-- ★ THE PREVIEW'S POOL — the helper's records and nothing else (2026-09-08). +-- ☠ THE SHARED POOL IS NOT THE HELPER'S POOL. Its records live in Any Buff alongside +-- whatever the user has built there themselves, so handing the preview painter +-- CurrentAuraPool() on the helper's own page would render their unrelated indicators on a +-- canvas that claims to be about Power Infusion. Wrong in the one direction a preview must +-- never be wrong: it would show something the page does not control. +-- ⚠ THE SAME TABLES, NOT COPIES. RefreshPreviewEffects paints from the cfg tables it is +-- given, so sharing them is what keeps this canvas identical to the designer's rather than +-- a second renderer that can drift. It also means a colour picked on a signal row is on the +-- preview the moment the page redraws, with nothing to keep in step. +function S.PIH_PreviewPool() + local out = {} + local pool = pihOtherPoolRead() + if type(pool) ~= "table" then return out end + local keys = P.FRAME_LEVEL_TYPE_KEYS or {} + for auraName, auraCfg in pairs(pool) do + if type(auraCfg) == "table" then + local mine = false + for _, typeKey in ipairs(keys) do + local cfg = auraCfg[typeKey] + if type(cfg) == "table" and cfg.pihSignal then mine = true break end + end + if not mine then + for _, inst in ipairs(auraCfg.indicators or {}) do + if type(inst) == "table" and inst.pihSignal then mine = true break end + end + end + if mine then out[auraName] = auraCfg end + end + end + return out end --- "On" means "shows somewhere": a colour effect, the icon group, or both. This is what lets --- the master tick survive "None" -- an icons-only signal is still a signal. --- ⚠ Strong's icon representation is its AMPLIFIER HALF (icons cannot make the --- cooldown-AND-amplifier judgement), which is why its tick is labelled by what it shows. --- Only the cooldown signal has an icon row of its own now: the amplifier list rides into the --- SAME group through the three ticks nested under it, and infused draws as a placed Icon. -local PIH_ICON_OF = { burst = "cooldowns" } +-- ☠ THE ICON GROUP NO LONGER COUNTS, BECAUSE IT NO LONGER EXISTS (schema 5, 2026-09-09). +-- It used to: an icons-only helper had no marked effect, so without the second test the +-- enable tick read off while a group was still drawing. The group is gone -- see the +-- cooldown-icon block below -- so the marks are once again the whole answer. +function P.PIH_Exists() + return next(pihFound()) ~= nil +end -- ───────────────────────────────────────────────────────────── -- SHARED SETTINGS @@ -448,6 +688,29 @@ end -- Push the shared settings into the running engine. Config alone changes nothing: the gate -- reads its own state, so a saved setting that was never pushed is a setting that does not -- apply until something else happens to re-derive it. +-- ★★★ THE FEATURE SWITCH -- ONE STORED BOOLEAN, LIKE THE DESIGNER'S OWN (2026-09-09). +-- +-- ☠ IT USED TO BE DERIVED FROM WHETHER RECORDS EXIST, which is why "off" had to DELETE them. +-- Krathe: "it should function like the rest of AD" -- and AD writes modeDB.auraDesignerEnabled +-- and deletes nothing. See the engine's pihEnabled for the render half. +-- +-- ⚠ NOT IN PIH_Settings' DEFAULT TABLE, deliberately. Seeding `enabled` there would make the +-- backfill below unreachable -- the exact "a defaults entry seeds the key so the presence- +-- gated migration never fires" trap this addon has hit three times. The default lives HERE, +-- where it can still tell "never set" from "set to false". +-- ⚠ ABSENT MEANS ON IFF RECORDS EXIST. A profile from before the flag with helper records had +-- a working helper, so it must come back on; one with no records was showing nothing, so it +-- comes back off and the tick reads honestly. Written once, so this is a real backfill rather +-- than a recomputation that could flip later. +-- ⚠ THE ENGINE DEFAULTS TRUE for the same absent case (`s.enabled ~= false`) and does NOT +-- consult the records -- it cannot, cheaply, from the always-loaded half. That is safe +-- because a profile with no records draws nothing whatever the flag says. +function P.PIH_IsEnabled() + local s = P.PIH_Settings() + if s.enabled == nil then s.enabled = P.PIH_Exists() end + return s.enabled and true or false +end + function P.PIH_Apply() local s = P.PIH_Settings() if DF.AuraContainer and DF.AuraContainer.SetHelperExcludedRoles then @@ -455,9 +718,46 @@ function P.PIH_Apply() for _ in pairs(s.roles or {}) do any = true break end DF.AuraContainer.SetHelperExcludedRoles(any and s.roles or nil) end - -- Gate off means "never hide": force the gate open and leave it there. + -- ☠☠ AND THE PLAYER LIST, WHICH THIS FORGOT. Krathe, 2026-09-10: "I've added a list of + -- players and it was showing the effects on other players not just them." The list was + -- written to the profile and pushed NOWHERE: only Engine:PIH_ApplySaved sent it to the + -- container, and that runs on login and profile switch -- so a list edited in the panel + -- did nothing at all until the next reload, while the panel showed it filled in. + -- ⚠ THE SAME SHAPE AS THE ROLES ABOVE, and that is the tell: they are the two halves of + -- one narrowing (helperUnitExcluded reads both), and only one of them was here. A + -- setting the panel stores but never pushes is the "lying control" this feature has + -- been cleaned of three times; it arrived again through a path nobody re-read. + -- ⚠ ARRAY IN, MAP OUT -- the same conversion Engine:PIH_ApplySaved does, and empty is + -- nil rather than an empty map: a present map means "these players and nobody else", so + -- an empty one would silence the helper for someone who just removed their last name. + -- ⚠ AND NOTHING AT ALL WHEN THE SWITCH IS OFF (see P.PIH_PlayersOn): the names stay in the + -- profile, the container is simply never told about them. Pushing nil rather than skipping + -- the call is the point -- a list left pushed from before the switch moved would go on + -- narrowing a feature the user has just told to stop. + if DF.AuraContainer and DF.AuraContainer.SetHelperAllowedPlayers then + local map + if s.playersOn ~= false then + for _, fullName in ipairs(s.players or {}) do + if type(fullName) == "string" and fullName ~= "" then + map = map or {} + map[fullName] = true + end + end + end + DF.AuraContainer.SetHelperAllowedPlayers(map) + end local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine + -- ⚠ BEFORE THE GATE, because the gate setter resolves through pihShouldShow and that + -- reads this. Pushed afterwards it would settle the gate from the OLD value and leave + -- it wrong until the next combat transition. The engine's own load path (PIH_ApplySaved) + -- orders these two the same way, and for the same reason. + if Engine and Engine.PIH_SetCombatOnly then Engine:PIH_SetCombatOnly(s.combatOnly == true) end + -- Gate off means "never hide for the COOLDOWN": combat-only may still be holding it. if Engine and Engine.PIH_SetGateEnabled then Engine:PIH_SetGateEnabled(s.gateEnabled ~= false) end + -- ...then the FEATURE switch, which outranks it. Order matters: turning the helper back on + -- resumes from the gate's setting, so that setting has to be in place first. The engine + -- says the same thing from its own side (Engine:PIH_SetEnabled). + if Engine and Engine.PIH_SetEnabled then Engine:PIH_SetEnabled(P.PIH_IsEnabled()) end -- After the gate, never before: the sound arms against the gate's current state, so doing -- it first would arm against the state we are about to leave. if P.PIH_ApplySound then P.PIH_ApplySound() end @@ -465,93 +765,19 @@ function P.PIH_Apply() if Engine and Engine.PIH_SyncWatcher then Engine:PIH_SyncWatcher() end end --- ───────────────────────────────────────────────────────────── --- RETAINED CUSTOMISATIONS -- remove-and-re-add keeps the user's edits --- ───────────────────────────────────────────────────────────── --- ☠ DELETING A SIGNAL USED TO DELETE THE USER'S WORK WITH IT. The recipe creates --- ordinary effects, the user customises them through the effect's own card (border --- style, thickness, animation, any typeCfg field), and a remove-then-re-add came back --- with the recipe's defaults -- everything they had done, silently gone. So every --- delete path stashes a DEEP COPY of the doomed cfg into the helper's own settings --- table first (adDB.pihelper.retainedCfg -- the one piece of helper state that --- SURVIVES a remove), and every create path restores from it. --- --- ⚠ DEEP COPIES BOTH WAYS. The stash lives in the profile beside the live pools, and --- a shared table reference between the two is exactly what profile export and the --- AD's shared-table conventions punish: edit one, silently edit the other. -local function pihDeepCopy(src) - if type(src) ~= "table" then return src end - local out = {} - for k, v in pairs(src) do out[k] = pihDeepCopy(v) end - return out -end - --- ☠ WHAT THE RECIPE OWNS, IN ONE TABLE, because scattered re-stamps are how a field --- gets restored that must not be. These are the fields the create path MUST write for --- the helper to be correct -- everything else on a stashed cfg is the user's and is --- restored verbatim: --- pihSignal -- the mark: ownership itself, the field every PIH_* question reads --- othersOnly -- the caster rule; per-signal correctness (infused deliberately inverts it) --- enabled -- a re-added signal must be live, or "on" would show nothing --- conditions -- kept owned though no signal sets one today: a chain names filter IDS and --- the lists are re-minted on every create, so a stashed chain from an older --- build would come back pointing at filters that no longer exist --- id, type -- placed-instance identity, minted fresh per placement --- NOT owned, deliberately: colour, healthbar mode, and every other appearance field. --- The recipe writes them as DEFAULTS on a fresh create; a stashed copy is the user's --- choice and outranks them. -local PIH_RECIPE_OWNED = { - pihSignal = true, othersOnly = true, enabled = true, conditions = true, - id = true, type = true, -} - -local function pihStash(key, hit) - local s = P.PIH_Settings() - if not (s and hit and type(hit.cfg) == "table") then return end - s.retainedCfg = s.retainedCfg or {} - -- The surface rides along: a stash is only restored onto the SAME surface, - -- because the five surfaces do not share a settings vocabulary (see pihCapture). - s.retainedCfg[key] = { surface = hit.typeKey, cfg = pihDeepCopy(hit.cfg) } -end - --- Overlay the stash onto a freshly created cfg. Runs AFTER the recipe's default --- stamps, so a stashed field wins over a default -- and the owned list keeps it from --- touching anything the recipe must control (those were stamped before this runs and --- are skipped here, so they stand). -local function pihRestoreInto(cfg, key, surface) - local s = P.PIH_Settings() - local kept = s and s.retainedCfg and s.retainedCfg[key] - if not (kept and kept.surface == surface and type(kept.cfg) == "table") then return end - for k, v in pairs(kept.cfg) do - if not PIH_RECIPE_OWNED[k] then cfg[k] = pihDeepCopy(v) end - end -end - --- The icon groups get the same treatment: position, size, per-group appearance are --- the user's; the mark, the caster rule, the ticked lists and the group's identity --- are the recipe's (filterSelection is DERIVED state -- the icon ticks read and --- write it live, and restoring a stale copy would re-tick lists the user turned off). -local PIH_GROUP_OWNED = { - id = true, name = true, pihSignal = true, othersOnly = true, filterSelection = true, -} - -local function pihStashGroup(g) - local s = P.PIH_Settings() - if not (s and type(g) == "table" and g.pihSignal) then return end - s.retainedCfg = s.retainedCfg or {} - s.retainedCfg.iconGroups = s.retainedCfg.iconGroups or {} - s.retainedCfg.iconGroups[g.pihSignal] = pihDeepCopy(g) -end - -local function pihRestoreGroup(g) - local s = P.PIH_Settings() - local kept = s and s.retainedCfg and s.retainedCfg.iconGroups - and s.retainedCfg.iconGroups[g.pihSignal] - if type(kept) ~= "table" then return end - for k, v in pairs(kept) do - if not PIH_GROUP_OWNED[k] then g[k] = pihDeepCopy(v) end - end -end +-- ☠☠ THE RETAINED-CUSTOMISATION STASH IS GONE (2026-09-09), AND SO IS THE REASON FOR IT. +-- pihStashHits / pihKeptCfg / pihKeptSurfaces / pihRestoreInto / PIH_RECIPE_OWNED existed to +-- survive the enable tick's round trip, back when "off" DELETED every helper record. It does +-- not: the tick writes adDB.pihelper.enabled and the records stay exactly where they are, so +-- there is nothing to remember and nothing to lay back over a rebuild. +-- ⚠ THE BUG THAT KILLED THE MODEL, for anyone tempted to bring it back: the stash held ONE +-- surface per signal (written when a signal WAS one effect), while a signal can hold several. +-- Border + icon + square went in, one was stashed, all three were deleted, and re-enabling +-- rebuilt the one. Krathe: "when I disable the PI tracker, it seems to remove my border +-- effect I added." A switch that has to remember what it destroyed will keep finding new +-- things it forgot; a switch that destroys nothing cannot. +-- ⚠ adDB.pihelper.retainedCfg survives in old profiles. Inert, a few bytes, and deliberately +-- not swept: a migration that deletes data to tidy up is a worse trade than the bytes. -- ───────────────────────────────────────────────────────────── -- BUILDING AND UNBUILDING ONE SIGNAL @@ -581,51 +807,336 @@ local function pihRefresh() if RefreshPreviewEffects then RefreshPreviewEffects() end end --- ☠ THE AMPLIFIER LIST KEEPS ITS ID ACROSS A CHANGE. Strong window's conditions name this --- list by reference, so deleting and re-creating it would leave those conditions pointing at a --- list that no longer exists -- a signal that quietly stops firing and reads as a bug in the --- gate. The contents are rewritten in place instead. -local function pihSyncAmplifierFilter(s) - local presets = {} - if s.potions then presets[#presets + 1] = PIH_SEED.amplifiers.potions end - if s.trinkets then presets[#presets + 1] = PIH_SEED.amplifiers.trinkets end - -- ⚠ RACIALS ARRIVE AS IDS, NOT AS A PRESET. Every racial record carries only - -- `cats = { racials = true }`, so there is no offensive-racial category to name -- the four - -- worth marking are listed by hand in PIH_RACIAL_IDS and ride as extra ids. - local ids = s.racials and PIH_RACIAL_IDS or nil - if #presets == 0 and not ids then - -- ⚠ Wipe in place rather than just declining: the "As icons" ticks may still - -- point at this list, and an early return left it holding the previous ticks' spells - -- -- icons for amplifiers the user had switched off. - local R = DF.FilterRegistry - local id = pihFilterIdByName(PIH_FILTERS.amplifiers) - local f = id and R and R.GetCustomFilter and R:GetCustomFilter(id) - if f then f.spells, f.rawIDs = {}, {} end - return nil +-- ★★★ TRINKETS / POTIONS / RACIALS ARE TRIGGERS NOW, NOT A SECOND LIST (schema 5, 2026-09-09). +-- ☠ THEY USED TO FEED A SEPARATE "amplifiers" FILTER WHOSE ONLY CONSUMER WAS THE COOLDOWN-ICON +-- GROUP. Retire the group and those three ticks write to nothing -- three controls that look +-- live and change the world not at all, which is the exact class of lying control this panel +-- keeps being cleaned of. +-- ⇒ They join the ONE list the helper actually matches on. Krathe's own words for what a +-- trigger is: "people pick WHAT will show the effect -- i.e this CD/trinket being used and PI +-- is not on CD and role/class etc match." A trinket proc IS that, so it belongs in the list +-- that answers it. +-- +-- ⚠ ADD AND REMOVE, AGAINST A FIXED UNIVERSE. Unticking has to take the spells back out, and +-- "take out whatever is not ticked" needs to know what the ticks could ever have put in -- +-- otherwise an untick would either do nothing or strip the user's own hand-added spells. The +-- universe is the same three sources read with every tick on, so this touches those ids and +-- nothing else: anything a user adds in the Filter Designer is untouched in both directions. +-- ☠ RACIALS ARRIVE AS IDS, NOT AS A PRESET. Every racial record carries only +-- `cats = { racials = true }`, so there is no offensive-racial category to intersect -- the +-- four worth marking are listed by hand in PIH_RACIAL_IDS. +-- ⚠ `everything` IGNORES THE PRESET TICKS, and the two callers need opposite answers. +-- The WANT set is what should be in our list, so it honours them. The REMOVAL UNIVERSE is +-- everything these ticks could ever have put there, so it must not -- filter it and a spell +-- the user has just unticked in the preset drops out of the universe, is never visited by +-- the removal loop, and stays in our list forever. The narrowing that makes preset edits +-- REACH the helper would have made one direction of them unreachable. +local function pihAmplifierIDs(s, everything) + local R = DF.FilterRegistry + local out = {} + -- ★ THE PRESET'S OWN TICKS ARE HONOURED, which is what makes editing one REACH the + -- helper. This walked every record in the category, so unticking a trinket in the + -- Filter Designer changed nothing here -- the panel offered a route to a list whose + -- edits went nowhere, and no wording could make that read as anything but broken. + -- ⚠ Paired with the re-sync on the Triggers build (S.BuildPIHelperCard): reading the + -- ticks is only half of it if nobody reads them again after they change. + local function addCat(catKey) + for _, rec in ipairs((R and R.ByCategory and R.ByCategory[catKey]) or {}) do + if rec.id and (everything or not R.IsSpellEnabled + or R:IsSpellEnabled(catKey, rec)) then + out[#out + 1] = rec.id + end + end + end + if s.potions then addCat(PIH_SEED.amplifiers.potions) end + if s.trinkets then addCat(PIH_SEED.amplifiers.trinkets) end + if s.racials then + -- ★ THE LIST IF THERE IS ONE, THE SEED IF THERE IS NOT. Racials is a curated list of + -- ours now, so its ticks are honoured exactly as a preset's are -- but the list only + -- exists once the helper does, and pihSyncTriggerExtras may reach this before then. + -- The literal is what the list will be seeded WITH, so the fallback is not a + -- different answer, only an earlier one. + local rids = pihCustomFilterIDs(pihFilterIdByName(PIH_FILTERS.racials), everything) + for _, id in ipairs(rids or PIH_RACIAL_IDS) do out[#out + 1] = id end end - return pihEnsureFilter(PIH_FILTERS.amplifiers, presets, ids, true) + return out +end + +local PIH_ALL_AMPLIFIERS = { potions = true, trinkets = true, racials = true } + +-- ★★★ THE ICON'S TWO CHOICES (2026-09-09) -- what picture, and what it is allowed to show. +-- +-- ☠ THE PICTURE IS staticSpellID's PRESENCE, and there is no second field recording the +-- choice. Pinned = Power Infusion, absent = the engine binds and paints whatever matched. +-- One truth: the field that DOES the thing is the field the control reads. +-- +-- ⚠ "ONE OF THEM, IF SEVERAL ARE UP" IS THE HONEST CAVEAT, and it is why the second control +-- exists at all. A placed icon renders ONE slot; when a player has a class cooldown and a +-- trinket proc and a racial running together, which one the engine hands us is not ours to +-- choose and can change between parses. That is exactly the case the "Also count" ticks make +-- COMMON rather than rare -- racials and trinkets are things people press ALONGSIDE a +-- cooldown, which is what made them "amplifiers" in the first place. +-- +-- ⭐ SO THE NARROWING RIDES mutedSpellIDs -- the SAME store the Tracked IDs ticks write, not a +-- parallel one. That is the whole reason this needs no new render concept: the resolver +-- already narrows a placement by its mutes (narrowByPlacementMutes), already handles +-- "everything muted = matches nothing", and the fine-grained per-ID ticks are simply the same +-- setting at maximum resolution. A coarse control and a fine control over one store. +-- ⚠ AND THE TICK IS DERIVED, NEVER STORED. It reads "are ALL the currently-ticked amplifier +-- ids muted on this record", so ticking Racials ON in Triggers later makes it read FALSE by +-- itself -- the new ids are not muted, and the icon really can show them. Storing a boolean +-- would leave the box claiming "ignored" while racials appeared. Same doctrine as the class +-- ticks: the tick reads the list, the click edits the list, nothing in between can disagree. +P.PIH_PI_SPELL_ID = PIH_PI_SPELL_ID + +function P.PIH_AmplifierIDs() + return pihAmplifierIDs(P.PIH_Settings()) +end + +-- ★★★ ONE PLACED EFFECT'S OWN SOURCES (2026-09-10), the same four the Cooldown Icons group +-- got -- Krathe: "yes build the icon block the same". +-- +-- ⭐ THROUGH mutedSpellIDs, WHICH ALREADY DID THIS COARSELY. A placed effect is keyed by ONE +-- filter reference, so it cannot carry a selection of its own the way the group can; what it +-- can carry is a per-record NARROWING of the resolved map (narrowByPlacementMutes in +-- Factory.lua). "Ignore trinkets, potions and racials" was that mechanism with one tick over +-- all three sources at once. These are the same mechanism with the tick per source. +-- ⚠ SO IT CAN ONLY EVER SUBTRACT, and that is the honest shape rather than a limitation to +-- apologise for: the Triggers tab decides what the helper watches, and an effect may show +-- less than that. An effect showing something Triggers does not watch would need its own +-- filter, which is what the GROUP is for. +-- ☠ CLASS COOLDOWNS IS THE ONE THAT CANNOT BE MUTED AWAY CHEAPLY. Muting it means muting +-- every seeded id, which is forty entries in the record for "show trinkets only" -- workable, +-- and it is what this does, because the alternative is a fourth source the box has to explain +-- the absence of. +-- ☠ A TABLE FIELD, NOT A LOCAL, AND luac IS WHY -- third refusal today. This file is at +-- Lua's 200-local ceiling in its main chunk, so `local function` here is a compile error +-- rather than a preference. P.* costs no local slot. +function P.PIH_SourceIDs(key) + if key == "cooldowns" then return pihSeedIDs() end + local one = { trinkets = false, potions = false, racials = false } + one[key] = true + return pihAmplifierIDs(one, true) end -local function pihCreateSignal(key, surfaceOverride) +-- Is this source currently SHOWN on this record? Muted-in-full = off. +-- ⚠ A SOURCE THE TRIGGERS TAB IS NOT WATCHING READS OFF, not on: the box would otherwise +-- offer a lit tick for spells that can never arrive. P.PIH_IconSourceAvailable is what the +-- panel greys on. +function P.PIH_IconSourceOn(rec, key) + if not P.PIH_IconSourceAvailable(key) then return false end + local ids = P.PIH_SourceIDs(key) + if not ids[1] then return false end + local m = type(rec) == "table" and rec.mutedSpellIDs + if type(m) ~= "table" then return true end -- nothing muted = everything shows + for _, id in ipairs(ids) do + if not m[id] then return true end -- one survivor is enough to read ON + end + return false +end + +-- Cooldowns is always available (the helper is built on it); the other three follow the +-- Triggers ticks, because muting what is not watched changes nothing. +function P.PIH_IconSourceAvailable(key) + if key == "cooldowns" then return true end + return P.PIH_Settings()[key] == true +end + +function P.PIH_SetIconSourceOn(rec, key, on) + if type(rec) ~= "table" then return end + local ids = P.PIH_SourceIDs(key) + if on then + if type(rec.mutedSpellIDs) == "table" then + for _, id in ipairs(ids) do rec.mutedSpellIDs[id] = nil end + -- ⚠ EMPTY GOES. An empty mute table is a narrowing that narrows nothing, and it + -- would travel in every profile export looking like a setting. + if not next(rec.mutedSpellIDs) then rec.mutedSpellIDs = nil end + end + else + rec.mutedSpellIDs = rec.mutedSpellIDs or {} + for _, id in ipairs(ids) do rec.mutedSpellIDs[id] = true end + end + pihRefresh() +end + +-- ☠ P.PIH_IgnoresAmplifiers / P.PIH_SetIgnoreAmplifiers WENT WITH THE TICK THEY SERVED +-- (2026-09-10). That was one checkbox muting all three amplifier sources at once; the icon +-- card offers the four sources individually now (P.PIH_IconSourceOn above) through the +-- same per-record mutes. +-- ⚠ NOTHING TO MIGRATE, which is why they could simply go: a record saved by the old tick +-- carries exactly the mutes the new readers read, so "all three ignored" reads back as +-- three ticks off with no conversion step and no schema bump. + +-- The picture. `on` = show the cooldown's own artwork; off = pin Power Infusion. +-- ⚠ STRUCTURAL: placedStructSig carries the pinned-vs-dynamic flag (bindNative's SetIcon bind +-- is once per slot), so the container must rebuild rather than restyle. pihRefresh's +-- InvalidateAuraLayout + ForceRefreshAllFrames is that rebuild. +function P.PIH_SetIconShowsAura(rec, on) + if type(rec) ~= "table" then return end + -- ☠ EXPLICIT if, NOT `on and nil or PIH_PI_SPELL_ID` -- that always yields the spell + -- id, so "show the cooldown's own artwork" pinned Power Infusion regardless. Found in + -- the sweep after the same idiom broke the players tick (2026-09-14). + if on then + rec.staticSpellID = nil + else + rec.staticSpellID = PIH_PI_SPELL_ID + end + pihRefresh() +end + +-- ★★★ THE THREE AMPLIFIER TICKS WRITE A REFERENCE, NOT A COPY (2026-09-10). +-- +-- ☠ WHAT THIS FUNCTION USED TO DO, AND WHY IT WAS WRONG. It copied every spell id out of the +-- Trinkets, Potions and Racials lists into our cooldown list, so ticking all three took it +-- from 40 spells to 91 and the same ids existed in two places at once. Krathe: "despite the +-- fact those additional filters link to our actual filters, ticking them on actually just adds +-- those to the PI helper filter, so they are now twice on? this is very confusing." +-- ⇒ Each row has a PENCIL that opens the real list. That promises a reference; the tick made a +-- copy. The row was writing a cheque the mechanism did not cash, and no wording fixes that. +-- +-- ★ WHAT REPLACES IT: `f.includes`, read by R:ResolveSelection (see foldIncludes there for why +-- the fold lives in the registry and not in the Aura Designer). Our list stays the 40 class +-- cooldowns -- which is what "Edit Cooldowns" has always claimed to open -- and names the +-- other three rather than swallowing them. +-- +-- ⭐ AND A WHOLE HAZARD CLASS GOES WITH THE COPY. Gone: the removal universe, the `everything` +-- parameter's second reader, and yesterday's guard against an amplifier tick deleting a class +-- cooldown that happened to be in one of those lists. None of them were defending against +-- anything real -- they were defending against the copy. +-- ⚠ ALSO GONE: hand-rolling "honour the preset's own ticks". ResolveSelection has always done +-- that for a selected preset (recordSelected calls IsSpellEnabled), so a trinket switched off +-- in the Filter Designer now stops firing here for free rather than by our re-derivation. +-- +-- ⚠ NEVER pihEnsureFilter HERE. This runs from a tick, and a tick must not conjure the +-- helper's cooldown list into existence -- that is the enable switch's job. +-- ⚠ WHOLESALE, NOT INCREMENTAL. The whole `includes` table is rebuilt from the three ticks +-- every call, so it cannot drift from them and there is nothing to take back out. +-- ⭐ AND THAT IS WHAT SURVIVES AN IMPORT. R:ImportCustomFilters copies `spells` and `rawIDs` +-- and nothing else, so an imported helper list arrives with no includes at all -- while the +-- three ticks travel in adDB.pihelper with the rest of the profile. The next Triggers build +-- re-derives from them and the references are back, because the ticks are the truth and this +-- table is only ever their shadow. +local function pihSyncTriggerExtras(s) + local R = DF.FilterRegistry + if not R then return end + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + local f = id and R.GetCustomFilter and R:GetCustomFilter(id) + if not f then return end + local presets, customs = {}, {} + if s.trinkets then presets[PIH_SEED.amplifiers.trinkets] = true end + if s.potions then presets[PIH_SEED.amplifiers.potions] = true end + if s.racials then + local rid = pihFilterIdByName(PIH_FILTERS.racials) + -- ⚠ NO FALLBACK TO THE SEED IDs HERE. Without the list there is nothing to point at, + -- and quietly copying the four in would be the exact behaviour this change removes. + -- The list is seeded wherever the cooldown list is, so this is a first-run ordering + -- window and not a state anyone stays in. + if rid then customs[rid] = true end + end + -- nil rather than an empty table: `includes` absent is the shape every other filter has, + -- and foldIncludes short-circuits on it. + local want = (next(presets) or next(customs)) + and { presets = presets, customs = customs } or nil + + -- ☠☠ A CHANGE HERE MUST INVALIDATE THE AURA LAYOUT, AND UNTIL NOW NOTHING DID. + -- The two consumers of this list do NOT resolve it the same way: + -- · the SOUND registrations resolve fresh on every arm (Engine.lua pihResolvedMap); + -- · the VISUALS go through DF:ResolveADFilterRef, which MEMOISES the resolved map + -- and only clears when DF.auraLayoutVersion moves. + -- So a rewrite of `includes` with no version bump leaves the border and the icon + -- matching yesterday's spell set while the cue plays off today's -- a trinket that + -- makes a NOISE and draws NOTHING, which is a fault with no visible cause at all. + -- ⚠ P.PIH_SetAmplifier already invalidated via pihRefresh; the paths that did not are + -- the ones nobody clicks: the schema sweep (which writes these for the first time on + -- every upgrading profile) and the Triggers panel build. + -- ⚠ ONLY ON A REAL CHANGE. This runs on every panel build and every sweep, and an + -- unconditional invalidate there would re-resolve every filter ref in the addon each + -- time the tab is opened. + local function sameSet(a, b) + for k in pairs(a or {}) do if not (b and b[k]) then return false end end + for k in pairs(b or {}) do if not (a and a[k]) then return false end end + return true + end + local had = f.includes + local changed = (had == nil) ~= (want == nil) + or (had and want and not (sameSet(had.presets, want.presets) + and sameSet(had.customs, want.customs))) + f.includes = want + if changed and DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end +end + +local function pihCreateSignal(key, surfaceOverride, showsAura) local def = PIH_SIGNALS[key] if not def then return false, "no such signal" end - if pihFound()[key] then return true, "already on" end + -- ☠ THE GATE IS PER SURFACE NOW, NOT PER SIGNAL (2026-09-08). It used to refuse any + -- second add outright -- "already on" -- which is what made a signal one-surface-only. + -- The STORE never required that: a pool record holds many frame effects and many placed + -- instances. Krathe wants the designer's behaviour, several at once. + -- ⚠ STILL REFUSES A DUPLICATE OF THE SAME SURFACE, and that part is not optional: two + -- border effects on one record cannot both exist (one key, one value) and two identical + -- squares would be an invisible double that only the store can see. + -- ★★ ...EXCEPT THAT TWO ICONS ARE NOT NECESSARILY A DUPLICATE (2026-09-10). Krathe: "we + -- can't add Power Infusion and Their CD at the same time, we should allow this if we can?" + -- We can, and the store always could -- pihPlace's own note says so: a placed target mints + -- an INSTANCE, instances are per-id, and "two signals as icons coexist where two frame + -- effects on one key cannot". The blanket refusal was the guard being coarser than the + -- reason for it. + -- ⚠ THE PAIR IS THE POINT: one icon pinned to Power Infusion ("infuse this person") beside + -- one showing the buff they actually pressed ("here is why"). Two icons in the SAME art + -- mode are still the invisible double the guard exists to stop, so that is what it tests + -- now -- the art, not merely the surface. + -- ⚠ ONLY ICONS. A square carries no such distinction, so two of them remain a duplicate. + -- ⚠ Only checked when the caller NAMES a surface. Without an override the target is + -- resolved below from the stash or the signal's default, so the test would be against the + -- wrong thing -- and that path is the plain "turn this signal on", which wants its + -- default surface exactly once. + local existing = pihFoundAll()[key] + if surfaceOverride then + for _, hit in ipairs(existing or {}) do + if hit.typeKey == surfaceOverride then + if surfaceOverride ~= "icon" then return true, "already on" end + -- staticSpellID's PRESENCE is the art choice -- there is no second field + -- recording it, deliberately (see P.PIH_SetIconShowsAura). + local pinned = (type(hit.cfg) == "table") and hit.cfg.staticSpellID ~= nil + if pinned == (not showsAura) then return true, "already on" end + end + end + elseif existing and existing[1] then + return true, "already on" + end local s = P.PIH_Settings() -- ⭐ A RE-ADDED SIGNAL COMES BACK WHERE THE USER LEFT IT. The surface is derived -- from where the mark is found, so after a remove nothing else remembers it -- - -- the stash is the only memory. An explicit dropdown choice still outranks it. - local kept = s.retainedCfg and s.retainedCfg[key] or nil - local tgt = surfaceOverride or (kept and kept.surface) or def.surface + -- the stash is the only memory. A named surface still outranks it. + -- ⚠ THE STASH'S FIRST SURFACE, not "the" surface: a signal can hold several and + -- pihKeptSurfaces returns them in menu order. PIH_Create names each one explicitly, so + -- this fallback only decides where a BARE create lands. + -- ⚠ NO STASH TO CONSULT ANY MORE. This used to prefer the surface the signal was + -- last removed from, because the enable tick DELETED and rebuilt. It does not delete, + -- so a re-enable finds its records where it left them and nothing is ever rebuilt from + -- memory -- a bare create is only ever a FIRST create, and its home is the default. + local tgt = surfaceOverride or def.surface local cdId = pihEnsureFilter(PIH_FILTERS.cooldowns, nil, pihSeedIDs()) if not cdId then return false, "could not build the cooldown list" end + -- Seeded alongside, so the Racials row has a list to count and to open from the moment + -- the helper exists -- see pihEnsureRacialFilter. Not fatal if it fails: pihAmplifierIDs + -- falls back to the seed, so the trigger still works, only the pencil is dead. + pihEnsureRacialFilter() -- ☠ RECORDED FOR THE RESIDENT HALF, WHICH CANNOT SEE THIS FILE. The sound registrations run -- in the always-loaded addon and need this list; they used to find it by NAME and were -- looking for the scaffolding filter, so they resolved nothing and no sound could ever play. -- The id travels in the helper's own settings, which the resident half already reads. -- ⚠ An ID rather than a name: a custom filter can be renamed in the Filter Designer. s.cooldownFilterID = cdId + -- ☠ THE EXTRA TRIGGER TICKS ARE REPLAYED THE MOMENT THE LIST EXISTS, and without this + -- they are silently dropped on exactly the path people take. pihSyncTriggerExtras declines + -- when there is no list to write into -- a tick must not conjure the helper into being -- + -- so trinkets ticked while the helper was OFF wrote a setting and nothing else. Enabling + -- then seeded the list from the class cooldowns alone and the tick read on with no spells + -- behind it: the lying control again, one layer down. + pihSyncTriggerExtras(s) local cdRef = DF:MakeADFilterRef("custom", cdId) if not cdRef then return false, "could not name the cooldown list" end @@ -643,7 +1154,22 @@ local function pihCreateSignal(key, surfaceOverride) -- Strong never reaches here as placed -- its menu does not offer these (a placed -- indicator cannot make the cooldown-AND-amplifier judgement) -- but refuse anyway: -- a guard that relies on the menu is a guard that relies on every future menu. - if tgt == "icon" or tgt == "square" then + -- ⚠ BAR JOINED THE PLACED BRANCH, AND ITS ABSENCE WAS A REAL BUG rather than a missing + -- feature. `bar` is a PLACED type (AddFlowEffects says mode = "placed"), so it lives in + -- auraCfg.indicators like an icon and a square -- but the test below named only two of + -- the three, so a bar fell through to the frame branch and EnsureTypeConfig wrote a + -- frame-level key called "bar" that nothing in the Factory ever reads. It could never + -- have drawn. Unreachable while the menu offered no bar; reachable the moment the add + -- tiles did. + if tgt == "icon" or tgt == "square" or tgt == "bar" then + -- ⚠ COUNTED BEFORE THE CREATE, because CreateIndicatorInstance appends to the very + -- list this measures. Used by the nudge below. + local siblings = 0 + if tgt == "icon" then + for _, hit in ipairs(pihFoundAll()[key] or {}) do + if hit.typeKey == "icon" then siblings = siblings + 1 end + end + end local inst = CreateIndicatorInstance and CreateIndicatorInstance(ref, tgt) if not inst then return false, "could not create the indicator" end inst.pihSignal = key @@ -652,10 +1178,51 @@ local function pihCreateSignal(key, surfaceOverride) -- pihCreateSignal's frame branch for why INFUSED must be the exception -- with it, -- that signal could never fire at all. inst.othersOnly = (key ~= "infused") or nil - -- A square has a colour; an icon shows the aura's own artwork. - if tgt == "square" then + -- A square and a bar both carry a colour; an icon carries artwork instead. + if tgt == "square" or tgt == "bar" then inst.color = { r = def.color[1], g = def.color[2], b = def.color[3], a = 1 } end + -- ★★★ THE ICON SHOWS POWER INFUSION, NOT THE COOLDOWN THAT TRIGGERED IT. + -- ☠ AND THAT IS THE WHOLE REASON ICON WAS CUT ONCE ALREADY. Schema 4 retired it + -- because an icon shows a SPECIFIC BUFF'S artwork, which promised per-buff tracking + -- the helper does not do. Krathe, 2026-09-09: "if possible an icon option that shows + -- the PI icon despite the trigger being one of the CD's" -- which dissolves the + -- objection rather than overruling it. The trigger stays the cooldown list; the + -- PICTURE is fixed, so the icon says "infuse this player" and never claims to be + -- reporting which cooldown they pressed. + -- ⚠ staticSpellID is the container's OWN per-indicator override (AuraContainer's + -- iconSpec.staticSpellID), not a field invented here -- the test path already + -- honoured it, and the live path now skips Blizzard's SetIcon bind when it is set so + -- the engine cannot repaint our art with the matched aura's. + -- ★ THE ART IS DECIDED HERE NOW (2026-09-10), not pinned unconditionally and unpinned + -- afterwards. P.PIH_AddSurface used to do the second half by walking EVERY icon the + -- signal held and clearing staticSpellID on all of them -- harmless while a signal + -- could hold only one icon, and a bug the moment it can hold two: adding "Their + -- cooldown" would have stripped the Power Infusion pin off the icon already there. + if tgt == "icon" then + inst.staticSpellID = (not showsAura) and PIH_PI_SPELL_ID or nil + end + -- ★ A SECOND ICON DOES NOT LAND ON TOP OF THE FIRST. Both take the type's default + -- corner, so without this the pair arrives perfectly stacked and reads as one icon + -- that ignored the click. One icon-width plus a gap, away from whichever edge the + -- anchor names, so the new one moves ONTO the frame rather than off it. + -- ⚠ A STARTING POSITION, NOT A LAYOUT. The effect card's own Placement controls own it + -- from here; this only has to make both visible on arrival. + if siblings > 0 then + local step = ((TYPE_DEFAULTS and TYPE_DEFAULTS.icon and TYPE_DEFAULTS.icon.size) + or 24) + 2 + local a = inst.anchor or "" + inst.offsetX = (inst.offsetX or 0) + + (a:find("RIGHT") and -step or step) * siblings + end + -- ☠ NO STACK COUNT. showStacks DEFAULTS TRUE for icons and squares, so every helper + -- marker was drawing one -- a number read off whichever cooldown matched, printed on + -- an icon whose art is pinned to Power Infusion. Krathe, 2026-09-09: "PI does not have + -- stacks, you only get 1 charge." + -- ⚠ STORED false, not merely hidden in the panel, so the record says what the frame + -- draws. The Stack Count group is skipped on these effects too -- see pihNoStacks in + -- AuraDesigner/UI/Indicators.lua for why that one is hidden rather than greyed. + inst.showStacks = false -- ⚠ A COLOUR HAS NO POSITION; AN ICON DOES. Infused defaults to an icon now, and -- the generic default drops it top-left, over the name text. The top-right corner is -- where its retired layout group sat, so this default is unchanged from what anyone was @@ -665,11 +1232,7 @@ local function pihCreateSignal(key, surfaceOverride) -- TOPLEFT), so the field is never nil by the time we see it and the line read as a -- default while doing nothing. Watched top-left in game. A guard that cannot fire is -- worse than no guard -- it says the case is handled. - -- The user's own position still wins: pihRestoreInto runs after this and anchor is not - -- recipe-owned, so a stashed placement comes back over it. if key == "infused" then inst.anchor = "TOPRIGHT" end - -- The user's customisations come back over the defaults; see the stash block. - pihRestoreInto(inst, key, tgt) return true end @@ -703,50 +1266,116 @@ local function pihCreateSignal(key, surfaceOverride) -- Always nil now: no signal judges two things at once since strong window was retired. -- Written explicitly because it CLEARS a chain left behind by an older build. cfg.conditions = nil - -- ⭐ LAST, OVER THE DEFAULTS. Everything above is either recipe-owned (and the - -- overlay skips it) or a default the user's stashed edit is entitled to replace - -- -- the colour and the healthbar mode included. - pihRestoreInto(cfg, key, tgt) return true end -local function pihDeleteSignal(key) - local hit = pihFound()[key] - if not hit then return false end - -- Before anything is deleted: the doomed cfg is the user's work (see the stash block). - pihStash(key, hit) - local pool = pihOtherPoolRead() - local auraCfg = pool and pool[hit.auraName] - if hit.indicatorID and auraCfg and type(auraCfg.indicators) == "table" then - -- A placed representation: remove the instance, not a frame key. Direct removal - -- rather than RemoveIndicatorInstance for the same reason the prune below bypasses - -- CleanupAdHocAura -- that helper resolves the pool off the OPEN TAB, and ours is - -- always the Other pool. - for i, inst in ipairs(auraCfg.indicators) do - if inst.id == hit.indicatorID then table.remove(auraCfg.indicators, i) break end - end - elseif auraCfg then - auraCfg[hit.typeKey] = nil - end - -- Drops the record once its last effect is gone -- the same prune the generic delete button - -- runs, so unticking here and deleting the row there leave the profile identical. - -- ⚠ NOT S.CleanupAdHocAura. It prunes an emptied record out of `CurrentAuraPool()` -- the - -- pool of whichever tab is open -- and ours are always in the Other Buffs pool, so it would - -- do nothing whenever the user happened to be on My Buffs. Same rule, same test - -- (AuraHoldsNoEffects, its own predicate), applied to the pool the record is actually in. - if pool and type(auraCfg) == "table" and P.AuraHoldsNoEffects - and P.AuraHoldsNoEffects(auraCfg) then - pool[hit.auraName] = nil +-- ★★★ THE STORE-WIDE PURGE — every mark that is somewhere no control can reach it. +-- +-- Two kinds, and the difference is what "stray" means for each: +-- GROUPS — ALL of them, in every store. The cooldown-icon group is retired outright +-- (schema 5), so a marked group is stray wherever it sits. +-- EFFECTS — only those OUTSIDE adDB.otherAuras. The other pool is the helper's real home +-- and its records are live; a marked effect in a SPEC pool is a different animal. +-- +-- ☠ A SPEC-POOL HELPER EFFECT CANNOT WORK AND CANNOT BE REMOVED, which is why deleting one is +-- not the loss it looks like. poolFilter answers "HELPFUL|PLAYER" for a My Buffs record before +-- it ever consults othersOnly, so an effect there is asking for "other people's cooldowns, +-- cast by me" -- a condition nobody can satisfy. And no list shows it: pihFound and +-- S.PIH_PreviewPool read the other pool only, while CollectAllEffects hides marked rows from +-- every pool that is not the helper's. It renders nothing, lists nowhere, deletes never. +-- +-- Returns the two counts so the caller can say what it did rather than guessing. +local function pihPurgeStrayMarks() + local adDB = GetAuraDesignerDB() + if type(adDB) ~= "table" then return 0, 0 end + local groups, effects = 0, 0 + + -- ☠☠ THE ONE MARKED GROUP THAT IS NOT STRAY, AND THIS FUNCTION PREDATES IT EXISTING. + -- "Remove EVERY pihSignal group, not the one named burst" was correct when a marked group + -- could only ever be the retired one -- there was no legitimate home for such a group at + -- all. There is now: P.PIH_AddIconGroup puts the Cooldown Icons group in otherLayoutGroups + -- on purpose, and the user adds it from the Effects tab's own grid. + -- ⇒ Without this line the NEXT schema bump -- any schema bump, for any unrelated reason -- + -- silently deletes it on the next Aura Designer build. It has not bitten yet only because + -- the group and schema 8 shipped together, so no profile carrying one has re-run this. + -- ⚠ ONE, NOT "ANYTHING IN THAT STORE". P.PIH_IconGroup returns the FIRST marked group + -- there, which is the one every reader resolves to; a second would be two containers + -- competing for the same corner, and purging it is the right answer. + -- ⚠ THE SAME SHAPE AS THE EFFECT HALF BELOW, which has always exempted its own home + -- (`poolT ~= home`). The group half simply had no home to exempt. + local keepGroup = P.PIH_IconGroup and P.PIH_IconGroup() or nil + for _, store in ipairs(pihAllGroupStores(adDB)) do + for i = #store, 1, -1 do + local g = store[i] + if type(g) == "table" and g.pihSignal and g ~= keepGroup then + table.remove(store, i) + -- The fold state is keyed by id and outlives the record; Groups.lua owns the + -- table, so it owns the forgetting. + if P.ForgetGroupExpandState and g.id then P.ForgetGroupExpandState(g.id) end + groups = groups + 1 + end + end end - return true + + local home = adDB.otherAuras + for _, poolT in ipairs(pihAllAuraPools(adDB)) do + if poolT ~= home then + for auraName, auraCfg in pairs(poolT) do + if type(auraCfg) == "table" then + for _, typeKey in ipairs(P.FRAME_LEVEL_TYPE_KEYS or {}) do + local cfg = auraCfg[typeKey] + if type(cfg) == "table" and cfg.pihSignal then + auraCfg[typeKey] = nil + effects = effects + 1 + end + end + for i = #(auraCfg.indicators or {}), 1, -1 do + local inst = auraCfg.indicators[i] + if type(inst) == "table" and inst.pihSignal then + table.remove(auraCfg.indicators, i) + effects = effects + 1 + end + end + -- ⚠ THE EMPTIED RECORD GOES TOO, and NOT through S.CleanupAdHocAura -- + -- that helper prunes from CurrentAuraPool(), the pool of whichever tab is + -- open, and this walk is deliberately not asking the open tab anything. + if P.AuraHoldsNoEffects and P.AuraHoldsNoEffects(auraCfg) then + poolT[auraName] = nil + end + end + end + end + end + + return groups, effects end +-- ⚠ pihDeleteSignal WENT WITH ITS LAST CALLER (2026-09-09). It removed ONE of a signal's +-- surfaces and stashed the doomed cfg on the way out; both of its callers were the retired +-- surface API (PIH_SetSurface's move, PIH_RemoveSurface's per-row ✕). +-- ⚠ NOTHING WAS LOST WITH IT. Removing one effect is the designer's own ✕ now, and that +-- is deliberate rather than a round trip -- there is nothing to stash. The stash still +-- runs where it is meant to: P.PIH_Remove, which is the ENABLE TICK going off, and which +-- is the one path whose whole promise is that your customisations come back. -- ───────────────────────────────────────────────────────────── --- WHICH SURFACE A SIGNAL DRAWS ON +-- ⚠⚠ THE CLASH WARNING — INTACT, AND CURRENTLY UNREACHED. FLAGGED FOR KRATHE 2026-09-09. +-- ───────────────────────────────────────────────────────────── +-- ☠ THE HAZARD IT WARNS ABOUT IS STILL REAL. Border, name text and health text take a SINGLE +-- winner (pickWinner resolves one candidate per surface from config alone), so a helper border +-- and one of the user's own borders on the same unit means one of them silently does not draw. +-- Health bar and background are MULTI and cannot clash -- see PIH_CONTENDED below. +-- ☠ WHAT WENT IS THE PLACE IT WAS SHOWN, not the machinery. The warning was rendered on the +-- old per-signal rows, and those rows were replaced by the designer's own effect cards, which +-- know nothing about it. So P.PIH_ClashOn / PIH_SiblingContends / PIH_SelfContends and the +-- three helpers under them (pihPools, pihEffectName, pihContends) have NO CALLERS today. +-- ⇒ KEPT RATHER THAN DELETED, deliberately and pending Krathe's call: deleting a safety +-- warning is not a cleanup, and re-deriving this from scratch later costs far more than the +-- lines do. If the answer is "we do not want it", this whole block goes in one cut. +-- ⚠ Do not let it rot silently: it is dead code that LOOKS live, which is the one thing this +-- file keeps auditing itself for. +-- ⚠ PIH_SURFACE_ORDER went with the dropdown that walked it (see the note further down). -- ───────────────────────────────────────────────────────────── -local PIH_SURFACE_ORDER = { "border", "healthbar", "background", "nametext", "healthtext" } - -- ☠ ONLY THREE OF THE FIVE CONTEND, and the difference is watched in game, not read. -- Border, name text and health text resolve through `pickWinner`, which takes ONE winner per -- surface from config alone and tears every other candidate down. Health bar and background @@ -820,131 +1449,70 @@ function P.PIH_ClashOn(surface) return n, name end --- Which OTHER helper signal is sitting on this surface, if any. --- ⚠ ONLY A SIGNAL ON THE *SAME RECORD* BLOCKS A SURFACE, and the first version of this got --- that wrong -- it refused ANY signal sharing a surface, which quietly forbade a configuration --- that works perfectly. +-- ⚠ pihSurfaceTakenBy, pihSiblingContends AND P.PIH_SelfContends WENT WITH THE DROPDOWN AND +-- THE PER-SIGNAL ROWS (2026-09-09). All three asked their question through pihFound(), which +-- returns ONE hit per signal -- fine when a signal had exactly one surface, and wrong the day +-- it could hold several: "does our border contend" was answered about whichever surface the +-- hash order happened to land on, which might be the square. +-- ⇒ P.PIH_ClashText below asks about THE EFFECT IN FRONT OF IT instead -- the card hands over +-- its own config -- so the answer is about the row the badge is on. The sibling term went too: +-- only one signal is creatable now, and one record holds one effect per surface, so "another +-- of OUR signals contends here" is unreachable rather than merely unlikely. + +-- ★★★ THE CLASH WARNING, RESTORED TO THE EFFECT CARD (2026-09-09). -- --- The rule survives the two-signal shape even though nothing shares a record today: a record --- holds one effect per surface, so two signals on one record and one surface is an overwrite -- --- the second replaces the first and a signal disappears. Kept because it is a fact about the --- store rather than about how many signals happen to exist. +-- ☠ THE HAZARD IS REAL AND SILENT. Border, name text and health text resolve through +-- pickWinner, which takes ONE winner per surface from config alone and tears every other +-- candidate down -- so a helper border plus one of the user's own borders means one of them +-- simply does not draw, with nothing on screen to say which or why. Health bar and background +-- are MULTI (collectFrameTints renders each on its own container) and cannot clash, which is +-- what PIH_CONTENDED encodes. -- --- ☠ "Already infused" is a DIFFERENT record, and there the answer flips. Two effects on --- different records CAN share a health bar or a background -- watched in game 2026-08-23, two --- tints on one unit rendered both colours mixed, because collectFrameTints is multi. Blocking --- that was us inventing a limit the engine does not have. On border or either text it is a real --- contest rather than an impossibility, and a contest is what the clash warning is for. -local function pihSurfaceTakenBy(surface, exceptKey) - local mine = PIH_SIGNALS[exceptKey] - if not mine then return nil end - for key, hit in pairs(pihFound()) do - local other = PIH_SIGNALS[key] - if key ~= exceptKey and hit.typeKey == surface and other and other.list == mine.list then - return key - end - end - return nil -end - --- The same question for the CLASH WARNING, which cares about contention rather than --- impossibility: another of our signals, on a different record, on a surface that takes a --- single winner. PIH_ClashOn deliberately skips our own effects when counting the user's -- --- this is what puts the ones that genuinely contend back in. -local function pihSiblingContends(surface, exceptKey) +-- ★ AND ON BORDER THERE IS A WAY TO HAVE BOTH, which is Krathe's own observation +-- (2026-09-09): "with border they can just offset and be able to show two borders like you can +-- with AD anyway?" -- exactly right, and the string has always named it. Ticking "Give this +-- aura its own border" opts that effect OUT of the contest (collectStackedBorders draws it +-- alongside, sorted by priority), so both rings show. The text surfaces have no equivalent +-- opt-out; there the remedy is the Priority slider, which is what their string names. +-- ⚠ SO THE WARNING NAMES A REMEDY THAT STILL EXISTS in both cases -- checked, not assumed: +-- the "own border" checkbox and the Priority slider are both live on the effect card. +-- +-- ⚠ IT VANISHES WHEN THE REMEDY IS APPLIED. pihContends runs the REAL candidacy test on our +-- own cfg first, so ticking "own border" on this effect removes the warning from it -- a +-- warning that survives its own fix teaches people to ignore warnings. +-- ⚠ `cfg` IS THE ROW'S OWN CONFIG, not a lookup. See the note above for why that matters. +function P.PIH_ClashText(cfg, surface) if not PIH_CONTENDED[surface] then return nil end - local mine = PIH_SIGNALS[exceptKey] - if not mine then return nil end - for key, hit in pairs(pihFound()) do - local other = PIH_SIGNALS[key] - -- Through the real candidacy test: a sibling that opted OUT of the contest - -- (custom-mode border, disabled) is not a clash, and warning about it would survive - -- the very fix the warning names. - if key ~= exceptKey and hit.typeKey == surface and other and other.list ~= mine.list - and pihContends(surface, hit.cfg) then - return key - end + if not pihContends(surface, cfg) then return nil end + local n, name = P.PIH_ClashOn(surface) + if n == 0 then return nil end + local who = name or L["Another effect"] + if n > 1 then who = format(L["%s and %d more"], who, n - 1) end + if surface == "border" then + return format( + L["%s already colours the border. Only one can show — tick '%s' on one of them, or move this signal somewhere else."], + who, L["Give this aura its own border"]) end - return nil -end -P.PIH_SiblingContends = pihSiblingContends - --- Does OUR OWN signal actually enter the contest on this surface? The warning has to vanish --- when the named fix is applied to our effect itself -- a warning that survives its own --- remedy teaches people to ignore warnings. -function P.PIH_SelfContends(surface, key) - if not PIH_CONTENDED[surface] then return false end - local hit = pihFound()[key] - return (hit and pihContends(surface, hit.cfg)) and true or false + return format( + L["%s already colours this text. Only one can show — raise this signal's priority, or move it somewhere else."], + who) end -function P.PIH_SurfaceOf(key) - local hit = pihFound()[key] - if hit then return hit.typeKey end - -- Icons-only: the signal is on with no colour, and the dropdown says so. - local which = PIH_ICON_OF[key] - if which and P.PIH_IconsShow and P.PIH_IconsShow(which) then return "none" end - return nil -end - --- The dropdown's option set, rebuilt per signal because what is available depends on where the --- other two are sitting. --- ⭐ EVERY SURFACE IS LISTED, AND AN OCCUPIED ONE SAYS WHAT PICKING IT DOES. -function P.PIH_SurfaceOptions(key) - local labels = S.FRAME_LEVEL_LABELS or {} - local opts = { _order = {} } - for _, surface in ipairs(PIH_SURFACE_ORDER) do - -- Naming the swap is what makes a taken row honest. Two earlier answers were worse and - -- are worth knowing about before anyone changes this back: - -- - -- ☠ GREYING IT IS NOT AVAILABLE. The dropdown has no disabled-row concept. `header = true` - -- is the only thing that stops a row being clickable, and it is the GROUP LABEL treatment, - -- not a disabled state: it uppercases the text, shrinks it to 0.85, draws a separator, and - -- sets a flag that INDENTS EVERY ROW BELOW IT -- so one unavailable entry turned the rest - -- of the menu into its children. Asked for as a real `disabled` row; until it exists, - -- greying here is a misuse of somebody else's mechanism. - -- - -- ⚠ HIDING IT WAS THE OTHER ANSWER, and the user rejected it for the right reason: a - -- missing row reads as "that was never possible", when it is possible and simply taken. - local label = labels[surface] or surface - local takenBy = pihSurfaceTakenBy(surface, key) - opts[surface] = takenBy and format(L["%s (swap with %s)"], label, pihLabel(takenBy)) or label - opts._order[#opts._order + 1] = surface - end - -- "None" makes colour VISIBLY optional -- it is the entry that lets one row enumerate - -- colour-only / icons-only / both. First in the list (user's call): an opt-out reads as - -- the baseline you depart from, not a footnote you discover. An icons-and-sound-only - -- setup is first-class. L["None"] is the addon's existing key, reused. - opts.none = L["None"] - table.insert(opts._order, 1, "none") - -- Placed surfaces, after the colours: one Icon at a spot you choose (the aura's own - -- artwork), or a Square (a flat colour block -- the quietest signal there is). Gated - -- and role-excluded like everything else since the slot lane landed. Offered on both - -- signals now: the one that could not take them judged two things at once, and it is gone. - opts.icon = L["Icon"] - opts.square = L["Square"] - opts._order[#opts._order + 1] = "icon" - opts._order[#opts._order + 1] = "square" - return opts -end - --- ☠ THE COLOUR TRAVELS; NOTHING ELSE DOES. Decided 2026-08-23 with the user. The five surfaces --- do not share a settings vocabulary -- a border has a style, a thickness and an inset, a health --- bar has Replace-vs-Tint and a blend -- so carrying settings across would mean inventing --- equivalences that do not exist. The colour is the one thing every surface genuinely has, and --- it is read from the OLD surface's key and written to the NEW one, because a border keeps its --- colour under a different name (see pihColorKey). --- What travels when a signal moves: its colour and its condition chain, nothing else. Captured --- BEFORE anything is deleted, because a swap deletes both effects before rebuilding either. -local function pihCapture(hit) - return { - colour = hit.cfg[pihColorKey(hit.typeKey)], - conditions = hit.cfg.conditions, - } -end +-- ☠☠ THE SURFACE DROPDOWN'S WHOLE API LIVED HERE AND IS GONE (2026-09-09). +-- P.PIH_SurfaceOf / P.PIH_SurfaceOptions / P.PIH_SetSurface, plus pihCapture and +-- pihSurfaceTakenBy and the PIH_SURFACE_ORDER list they walked. They answered ONE question -- +-- "which single surface is this signal on" -- which is why picking an occupied row had to +-- SWAP two signals: there was nowhere for both to live. +-- ⇒ A signal holds SEVERAL surfaces now and they are added and removed one at a time +-- through the designer's own tiles and effect cards, so "which one" has no answer to give and +-- swapping is not a concept. Every caller went with the dropdown. +-- ⚠ pihPlace SURVIVES: pihSweep's step 4 still uses it to migrate an old Icon to a Square. +-- It is the only reader left, and it passes its own carry table inline. local function pihPlace(key, auraName, surface, carried) - if surface == "icon" or surface == "square" then + -- Bar rides with icon and square for the reason pihCreateSignal spells out: all three are + -- PLACED types and belong in auraCfg.indicators. + if surface == "icon" or surface == "square" or surface == "bar" then local inst = CreateIndicatorInstance and CreateIndicatorInstance(auraName, surface) if not inst then return false end inst.pihSignal = key @@ -952,7 +1520,8 @@ local function pihPlace(key, auraName, surface, carried) -- Same corner a fresh infused icon gets; see pihCreateSignal for why it is assigned -- rather than defaulted. if key == "infused" then inst.anchor = "TOPRIGHT" end - if surface == "square" then + if surface == "icon" then inst.staticSpellID = PIH_PI_SPELL_ID end + if surface == "square" or surface == "bar" then -- Colourless carry falls back to the signal's default, same as the frame branch -- below -- the store's default square is white. local c = carried and carried.colour @@ -994,66 +1563,344 @@ end -- -- Only ever fires between signals on the SAME record, which is the only case that cannot simply -- coexist; see pihSurfaceTakenBy. -function P.PIH_SetSurface(key, surface) +-- ★★★ THE MULTI-SURFACE API (2026-09-08) — add and remove ONE surface at a time. +-- ☠ THESE REPLACE THE DROPDOWN'S "MOVE THE SIGNAL THERE" MODEL. PIH_SetSurface answers +-- "which single surface is this signal on", which is why picking an occupied one had to SWAP +-- two signals -- there was nowhere for both to live. With several surfaces per signal the +-- question changes to "is this surface among the ones it uses", and swapping stops being a +-- concept: two signals wanting a border still contend, but that is the CLASH warning's job +-- and it already says so at the moment it applies. +-- ⚠ BOTH END AT PIH_Apply + pihRefresh, the chokepoint every other helper mutation uses. +-- Writing the record alone leaves the frames on the previous set until something unrelated +-- repaints them -- the same trap the colour picker had. +-- ⚠ `showsAura` IS THE ICON'S ART, ASKED AT ADD TIME. The add flow now picks the picture +-- with a tile rather than leaving it to a tick on the card afterwards, so the create has to +-- be able to carry the answer. nil / false keeps the recipe's pin (Power Infusion). +function P.PIH_AddSurface(key, surface, showsAura) if not PIH_SIGNALS[key] then return false, "no such signal" end - local found = pihFound() - local hit = found[key] + if not surface or surface == "none" then return false, "no surface" end + -- ☠ showsAura GOES IN, IT IS NOT APPLIED AFTERWARDS. This used to call the create and then + -- walk every icon the signal held clearing staticSpellID -- correct while one icon was the + -- most a signal could have, and destructive now that two are allowed: it would have + -- unpinned the Power Infusion icon already on the frame. The create knows which instance + -- it just made; nothing else does, which is exactly why the fix-up loop had to guess. + -- ⚠ AND THE GUARD NEEDS IT TOO -- two icons are only a duplicate when they show the same + -- art. See pihCreateSignal. + local ok, why = pihCreateSignal(key, surface, showsAura) + if ok then P.PIH_Apply() end + pihRefresh() + return ok, why +end - -- "No colour": drop the effect and nothing else. With icons on, the signal lives on as - -- icons-only; with icons off there is nothing left and the signal honestly reads off. - if surface == "none" then - if hit then pihDeleteSignal(key); pihRefresh() end - return true - end - -- Coming FROM icons-only: no effect exists to move, so create one where asked. Fresh - -- default colour -- there was no colour to carry. - if not hit then - local ok, why = pihCreateSignal(key, surface) - pihRefresh() - return ok, why - end - if hit.typeKey == surface then return true end - - -- ☠ A MOVE TOUCHING A PLACED REPRESENTATION takes the simple route: capture, - -- delete, recreate on the same record. No swap machinery -- instances are per-id and - -- never contend -- and the frame-swap path below would try to nil a frame key the - -- instance does not live under. - if hit.indicatorID or surface == "icon" or surface == "square" then - local carried = pihCapture(hit) - pihDeleteSignal(key) - if not pihPlace(key, hit.auraName, surface, carried) then - return false, "could not create the effect" - end - pihRefresh() - return true - end +-- ⚠ P.PIH_RemoveSurface WENT WITH THE PER-SIGNAL ROWS (2026-09-09). Removing one of a +-- signal's surfaces is the designer's ✕ on the effect card now, which deletes the record +-- the same way it deletes any other. What that button did NOT do is re-derive the engine +-- when the last helper effect goes -- see P.PIH_ReDerive below, which is the half worth +-- keeping from this function. - local pool = pihOtherPoolRead() - local auraCfg = pool and pool[hit.auraName] - if not auraCfg then return false, "the record went missing" end +-- ★★★ THE RE-DERIVE, FOR DELETES THAT DID NOT COME THROUGH THE HELPER (2026-09-09). +-- +-- ☠☠ THE CHOKEPOINT STOPPED BEING A CHOKEPOINT WHEN THE EFFECTS TAB BECAME THE DESIGNER'S. +-- pihRefresh's own note records why it exists: "Danders' review found the resident half left +-- armed -- events registered, sound armed -- for a helper with nothing in it". Every helper +-- mutation used to end there, so the re-derive could not be missed. +-- ⇒ Helper effects are now deleted by the DESIGNER'S OWN card, whose ✕ removes the record and +-- runs the AD refresh path -- and knows nothing about the helper. So deleting your last PI +-- effect leaves PIH_Exists() false while the watcher and the sound stay registered for a +-- feature that no longer has anything in it. Exactly the state that review caught, reachable +-- again by a different door. +-- ⚠ IDEMPOTENT AND CHEAP: one pool scan, only on a delete, never in a frame update. Safe to +-- call when the deleted effect was not ours -- it early-outs on PIH_Exists. +-- ⚠ IT DOES NOT REFRESH THE UI. The caller is mid-delete and already runs the designer's own +-- redraw; this is only the ENGINE half, which is the half the designer cannot know about. +function P.PIH_ReDerive() + if P.PIH_Exists() then return end + local E = DF.AuraDesigner and DF.AuraDesigner.Engine + if E and E.PIH_ApplySaved then E:PIH_ApplySaved() end +end - local swapKey = pihSurfaceTakenBy(surface, key) - local swapHit = swapKey and found[swapKey] or nil +-- ★★★ THE COOLDOWN-ICON GROUP, BACK ON PURPOSE THIS TIME (2026-09-09). +-- +-- ☠☠ READ THE RETIREMENT NOTE ABOVE BEFORE TOUCHING THIS. A version of this group shipped, +-- got stuck on Krathe's frames and took three attempts to delete. Every one of those failures +-- was PLUMBING, not the idea: it was created through the pool-routed CreateLayoutGroup from +-- whatever tab happened to be open (so it landed in the SPEC store, where no finder looked), +-- it was switched on by a tick captioned "Icons" buried under Classes and Cooldowns, and the +-- helper had no Layout Groups tab, so nothing could see or configure it. +-- ⇒ WHAT IS DIFFERENT, point by point, because "we fixed it" is not an argument: +-- · CREATED DIRECTLY INTO adDB.otherLayoutGroups. Not through CreateLayoutGroup, whose +-- store depends on the open tab -- the one line that caused the whole mess. +-- · ADDED BY A TILE in the helper's own add grid, beside the surfaces, so it is a visible +-- choice rather than a side effect of a tick. +-- · CONFIGURABLE: the helper's pool has its Layout Groups sub-tab back, and +-- VisibleLayoutGroups already shows exactly the marked groups there. It can be moved, +-- sized and deleted like any other group. +-- · pihPurgeStrayMarks still exists and still finds a marked group in ANY store, so the +-- recovery path that eventually cleaned up the old one is unchanged. +-- +-- ⭐ WHY IT EARNS ITS PLACE: a placed icon is ONE slot and shows one arbitrary match. This +-- shows every cooldown the unit actually has up, one icon each -- which is the answer to +-- "allow it to show multiple icons if they have them up" and was Danders' own recommendation +-- for the feature ("build it on merit, not as a fallback"). +-- ⚠ THE NAME IS STORED DATA, raw and never L[] -- the same rule the three filter names +-- follow. A translated string in the profile is a name that changes when the client does. +-- ★ "Icons", NOT "Cooldowns" (2026-09-10). Krathe: "maybe it should be called PI Helper - +-- Icons not cooldowns as it can be trinkets etc too?" -- right, and more so since the group +-- gained its own SHOW block: it can be set to trinkets only, in which case a name saying +-- Cooldowns is not merely vague but wrong. PIH_ICON_GROUP_OLD_NAME is what the rename +-- migration recognises; see sweep step 12 for why it matches on the exact old string. +-- ☠ ONE LOCAL, NOT TWO. This file is at Lua's 200-local ceiling in its main chunk, so the +-- old name a migration has to recognise lives inline in sweep step 12 rather than beside +-- this one -- which is also where it is explained. luac refuses the second local outright. +local PIH_ICON_GROUP_NAME = "PI Helper — Icons" + +function P.PIH_IconGroup() + for _, g in ipairs((P.GetOtherLayoutGroups and P.GetOtherLayoutGroups(false)) or {}) do + if type(g) == "table" and g.pihSignal then return g end + end + return nil +end - -- Anything else sitting there is not ours to move. Cannot happen on a record identified by a - -- helper spell list, but refusing beats overwriting something we never read. - if auraCfg[surface] ~= nil and not swapHit then return false, "that surface is occupied" end +-- ★★ THE SOURCES SECTION ON THE GROUP'S CARD (2026-09-10), which is what stands where the +-- generic LINKED FILTERS block was dropped. That block offered a filter picker over the +-- helper's own plumbing; this offers the four sources the feature actually has, by name. +-- ⚠ THE SECTION SHAPE IS CollectLayoutGroupSections', because the card and the row layout +-- both run these through their own placer -- see RunCardSections and PaneEnv. `place` sizes +-- and anchors; the builder only says which controls and in what order. +function P.PIH_GroupSourceSection(group) + return { + header = L["Show"], + caption = L["SHOW"], + build = function(env) + local place, host = env.place, env.host + local defs = { + { key = "cooldowns", label = L["Class cooldowns"] }, + { key = "trinkets", label = L["Trinkets"] }, + { key = "potions", label = L["Potions"] }, + { key = "racials", label = L["Racials"] }, + } + for _, d in ipairs(defs) do + local key = d.key + local cb = GUI:CreateCheckbox(host, d.label, nil, nil, nil, + function() return P.PIH_GroupSources(group)[key] end, + function(v) + P.PIH_SetGroupSource(group, key, v) + -- ⚠ Rebuild: the footer below says whether this group is following + -- Triggers, and the first tick is what stops it doing so. + env.Rebuild() + end) + place(cb, 26, { indent = 8 }) + end + -- ⚠ THE FOOTER IS A STATE READOUT, not a caption. Four ticks that happen to match + -- the Triggers tab look identical whether they are INHERITING it or were set by + -- hand to the same thing -- and the difference is whether a later change over + -- there still reaches this group. So the line says which, and offers the way back. + if P.PIH_GroupFollowsTriggers(group) then + local note = GUI:CreateNote(host, L["Following the Triggers tab. Changing one of these stops that."]) + note.fullRow = true + place(note, 34, { indent = 8, stretch = true }) + else + local btn = GUI:CreateButton(host, L["Follow Triggers"], 120, 20, function() + P.PIH_ResetGroupSources(group) + env.Rebuild() + end) + place(btn, 28, { indent = 8, width = false }) + end + end, + } +end - local mine, theirs = pihCapture(hit), swapHit and pihCapture(swapHit) or nil - local vacated = hit.typeKey +-- ★★ WHAT IT WATCHES, ON ITS OWN CARD (2026-09-10). Every other group's header carries a +-- filter count, and this one's card deliberately has no Linked Filters block: its list is the +-- cooldown list, which the Triggers tab owns end to end. That left a card saying nothing at +-- all about its contents -- Krathe: "cooldown icons allows for trinkets + the other filters? +-- don't see the option." +-- ⇒ THE ANSWER IS YES, AUTOMATICALLY, and that is the thing to say. The cooldown list NAMES +-- the trinket, potion and racial lists (pihSyncTriggerExtras writes `includes`), so a tick on +-- Triggers reaches these icons with no second control and no way for the two to disagree. +-- ⚠ P.PIH_WatchedCount, not this list's own size -- since the amplifiers stopped being copied +-- in, our list is 40 and the number a user is looking for is the whole watched set. +-- ⚠ COUNTED OVER THE GROUP'S OWN SOURCES, and the wording follows: a group that has been +-- given its own set is no longer reporting "from your Triggers", and a header that said so +-- while the SHOW block underneath disagreed would be the panel contradicting itself one row +-- apart. Following => the Triggers phrasing; overridden => the count alone. +function P.PIH_IconGroupSummary(group) + local n = P.PIH_WatchedCount and P.PIH_WatchedCount(P.PIH_GroupSources(group)) or 0 + if P.PIH_GroupFollowsTriggers(group) then + return format(L["%d spells, from your Triggers"], n) + end + return format(L["%d spells"], n) +end - auraCfg[vacated] = nil - if swapHit then auraCfg[swapHit.typeKey] = nil end +-- ★★★ THE COOLDOWN-ICON GROUP PICKS ITS OWN SOURCES (2026-09-10). +-- +-- ⭐ WHY IT IS NOT SIMPLY THE TRIGGERS SET. Krathe: "we should let people toggle cooldowns and +-- the sub filters on/off so they can pick from any of the 4... it might be the case they want +-- to trigger from a trinket but only show a CD etc." Triggers answers WHEN the helper fires; +-- this answers WHAT the row of icons then shows, and those are genuinely different questions +-- once you have both a marker and a row. +-- +-- ⚠ ABSENT MEANS FOLLOW, and that is the whole compatibility story. `g.pihSources` unset => +-- the group links the cooldown list and nothing else, whose own `includes` bring in whatever +-- Triggers has ticked -- exactly what it did before this existed, with no migration. +-- ⚠ SET MEANS SPELT OUT. The moment the user touches one tick the group stops inheriting and +-- names all four itself, with selection.noIncludes so the cooldown list is taken literally +-- rather than dragging its own includes in behind it (see foldIncludes in Registry.lua). +-- Materialised from the EFFECTIVE set, so the first click changes exactly the one thing +-- clicked and the other three keep whatever they were showing a moment earlier. +-- +-- ☠ NO FILE-SCOPE TABLE FOR THE FOUR KEYS, and that is not a style choice: this file sits +-- at Lua's 200-local ceiling in its main chunk (see the GetUngroupedIndicators removal, which +-- reclaimed one). A `local PIH_SOURCE_ORDER = {...}` here is a COMPILE ERROR, not a smell -- +-- luac says "too many local variables". The order lives in the section builder below, which +-- is its only reader anyway. +-- +-- The four as they resolve RIGHT NOW: the stored override, or Triggers' own answer. +-- ⚠ COOLDOWNS IS ALWAYS ON WHEN FOLLOWING. It is the baseline the helper is built around -- +-- the class list narrows it, nothing switches it off -- so the inherited answer is `true`, +-- and only an explicit override can drop it. +function P.PIH_GroupSources(g) + local st = P.PIH_Settings() + local src = type(g) == "table" and g.pihSources or nil + if type(src) == "table" then + return { + cooldowns = src.cooldowns ~= false, + trinkets = src.trinkets == true, + potions = src.potions == true, + racials = src.racials == true, + } + end + return { + cooldowns = true, + trinkets = st.trinkets == true, + potions = st.potions == true, + racials = st.racials == true, + } +end + +function P.PIH_GroupFollowsTriggers(g) + return not (type(g) == "table" and type(g.pihSources) == "table") +end - if not pihPlace(key, hit.auraName, surface, mine) then - return false, "could not create the effect" +-- Rebuild filterSelection from the group's effective sources. The ONE place that shape is +-- written, so "what does this group watch" has a single answer. +local function pihApplyGroupSelection(g) + if type(g) ~= "table" then return end + local cdId = pihFilterIdByName(PIH_FILTERS.cooldowns) + if P.PIH_GroupFollowsTriggers(g) then + -- Inherit: link the cooldown list and let its includes do the rest. + g.filterSelection = { presets = {}, customs = cdId and { [cdId] = true } or {} } + return end - if swapHit then pihPlace(swapKey, swapHit.auraName, vacated, theirs) end + local s = P.PIH_GroupSources(g) + local presets, customs = {}, {} + if s.cooldowns and cdId then customs[cdId] = true end + if s.trinkets then presets[PIH_SEED.amplifiers.trinkets] = true end + if s.potions then presets[PIH_SEED.amplifiers.potions] = true end + if s.racials then + local rid = pihFilterIdByName(PIH_FILTERS.racials) + if rid then customs[rid] = true end + end + -- ☠ noIncludes, or "cooldowns only" is unsayable: the cooldown list NAMES the other three + -- and selecting it would bring them along. See foldIncludes. + g.filterSelection = { presets = presets, customs = customs, noIncludes = true } +end +P.PIH_ApplyGroupSelection = pihApplyGroupSelection + +function P.PIH_SetGroupSource(g, key, on) + if type(g) ~= "table" then return end + -- Materialised from what is on screen, so the first click is not also a silent reset of + -- the other three to some other default. + local s = P.PIH_GroupSources(g) + s[key] = on and true or false + g.pihSources = s + pihApplyGroupSelection(g) + pihRefresh() +end +-- Back to inheriting. ⚠ The KEY GOES, rather than being written to match Triggers today: +-- "follow" has to keep following, so a later change on the Triggers tab still reaches it. +function P.PIH_ResetGroupSources(g) + if type(g) ~= "table" then return end + g.pihSources = nil + pihApplyGroupSelection(g) + pihRefresh() +end + +function P.PIH_AddIconGroup() + if P.PIH_IconGroup() then return true end + local adDB = GetAuraDesignerDB() + if not adDB then return false, "no config" end + local cdId = pihEnsureFilter(PIH_FILTERS.cooldowns, nil, pihSeedIDs()) + if not cdId then return false, "could not build the cooldown list" end + -- Seeded alongside, so the Racials row has a list to count and to open from the moment + -- the helper exists -- see pihEnsureRacialFilter. Not fatal if it fails: pihAmplifierIDs + -- falls back to the seed, so the trigger still works, only the pencil is dead. + pihEnsureRacialFilter() + -- ☠ THE OTHER STORE, NAMED. See the note above for what routing this through the + -- pool-aware creator cost last time. + local groups = P.GetOtherLayoutGroups and P.GetOtherLayoutGroups(true) + if not groups then return false, "layout groups unavailable" end + if not adDB.nextOtherLayoutGroupID then adDB.nextOtherLayoutGroupID = 1 end + local id = adDB.nextOtherLayoutGroupID + adDB.nextOtherLayoutGroupID = id + 1 + -- ☠☠ THE SHARED RECORD, AND WRITING IT BY HAND HERE ONCE SHIPPED A BROKEN GROUP. This was + -- a table literal listing the fields it thought a filter group had, and it did not think of + -- `iconSize` or `maxIcons` -- both of which CreateLayoutGroup has always set. The sliders + -- bind those fields directly, so both drew BLANK, and the factory's own fallback for a + -- filter group's max is 8: "Max icons should default to 4 it's showing blank but seems to + -- look like 8? Icon size is also showing blank on the slider" (Krathe, 2026-09-10). + -- ⇒ P.NewLayoutGroupRecord is that list now, and this function overrides only what it + -- genuinely means differently. What stays hand-rolled is the STORE, which is the whole + -- reason this does not call CreateLayoutGroup -- see the note above. + local g = P.NewLayoutGroupRecord(id, PIH_ICON_GROUP_NAME, "filter") + -- ☠ THE MARK. buildFilterGroupConfig stamps dfGate from it, which is what puts these + -- icons under the cooldown gate and the role exclusions with everything else. + g.pihSignal = "burst" + -- ☠ OTHERS ONLY IS NOT INHERITED. poolFilter reads it off THIS group; without it the + -- filter is plain HELPFUL and the priest's own cooldowns light their own frame. The + -- exact trap the first group test found on the effects. + g.othersOnly = true + -- ⚠ THROUGH THE SHARED BUILDER, so a new group and an edited one cannot disagree about + -- the shape. With no pihSources yet this writes exactly what the literal did -- link the + -- cooldown list, inherit its includes -- which is what "follow Triggers" means. + pihApplyGroupSelection(g) + -- Top-right growing left, so a row of cooldown icons runs away from the unit's own name + -- and health text rather than across them. The shared record's TOPLEFT/RIGHT_DOWN is the + -- designer's default for a group the user places themselves. + g.anchor = "TOPRIGHT" + g.growDirection = "LEFT_DOWN" + groups[#groups + 1] = g pihRefresh() return true end +-- ★ WHICH OF THE TWO ICON ARTS A SIGNAL ALREADY HOLDS (2026-09-10). Returns two booleans: +-- pinned (the Power Infusion picture) and dynamic (the buff they actually pressed). +-- ⚠ THE SURFACE IS NO LONGER THE WHOLE ANSWER. P.PIH_SurfacesOf says "an icon exists", which +-- was enough to grey the add tiles while a signal could hold one; it can hold both now, so the +-- grid has to ask which, or one legitimate half of the pair would arrive greyed out. +-- ⚠ READ OFF staticSpellID's PRESENCE, the field that DOES the thing -- there is no second +-- field recording the choice, deliberately (see P.PIH_SetIconShowsAura). +function P.PIH_IconArtHeld(key) + local pinned, dynamic = false, false + for _, hit in ipairs(pihFoundAll()[key] or {}) do + if hit.typeKey == "icon" and type(hit.cfg) == "table" then + if hit.cfg.staticSpellID ~= nil then pinned = true else dynamic = true end + end + end + return pinned, dynamic +end + +-- The surfaces a signal currently holds, in menu order (pihFoundAll sorts them). +-- ⚠ A LIST, NOT A SET: the Effects tab draws one row per entry, in this order, and a set +-- would hand it hash order -- three effects reshuffling themselves on every redraw. +function P.PIH_SurfacesOf(key) + local out = {} + for _, hit in ipairs(pihFoundAll()[key] or {}) do out[#out + 1] = hit.typeKey end + return out +end + -- ───────────────────────────────────────────────────────────── -- SOUND -- ───────────────────────────────────────────────────────────── @@ -1156,202 +2003,57 @@ end -- ⚠ ADDING TURNS ON ONE SIGNAL. Not everything it could build: a click that produces three -- indicators the user did not choose is a click that has decided for them, and two of the three -- are situational. Burst window is the one that is always worth having. --- The Layout Groups names are stored data, like the three filter names -- raw, never L[]. -local PIH_ICON_GROUP_NAME = "PI Helper — Cooldown icons" - --- The two lists the icon group can show. State is READ OFF THE GROUP'S OWN SELECTION -- --- one tick per list, no stored copy -- so editing the group by hand on the Layout Groups tab --- and using these ticks can never disagree. --- ☠ INFUSED IS NOT ONE OF THEM ANY MORE. It had a one-icon layout group of its own, --- which was a second mechanism for what the Icon surface already does with the aura's own --- artwork and a position the user can drag. The surface won: one signal, one representation. -local PIH_ICON_LIST_NAMES = { - cooldowns = PIH_FILTERS.cooldowns, - amplifiers = PIH_FILTERS.amplifiers, -} - -function P.PIH_IconsShow(which) - local g = pihIconGroup("burst") - if not (g and g.filterSelection and g.filterSelection.customs) then return false end - local id = pihFilterIdByName(PIH_ICON_LIST_NAMES[which]) - return (id and g.filterSelection.customs[id]) and true or false -end - -function P.PIH_SetIconsShow(which, on) - if not PIH_ICON_LIST_NAMES[which] then return false, "no such list" end - - local g = pihIconGroup("burst") - - if not on then - if not g then return true end - local id = pihFilterIdByName(PIH_ICON_LIST_NAMES[which]) - if id and g.filterSelection and g.filterSelection.customs then - g.filterSelection.customs[id] = nil - end - -- The last list going deletes the group: the marks are the record, and a group - -- showing nothing is a record of nothing. Through the shared delete, which also - -- sweeps the expanded-card key -- its tab-routed store is safe here because this - -- panel only exists on the Other Buffs tab. - if g.filterSelection and not next(g.filterSelection.customs or {}) then - if P.DeleteLayoutGroup then - pihStashGroup(g) - P.DeleteLayoutGroup(g.id) - end +-- +-- ☠☠ P.PIH_IconsShow / P.PIH_SetIconsShow LIVED HERE AND ARE GONE (schema 5, 2026-09-09), +-- along with the group name and the two-list table they keyed. They were the cooldown-icon +-- group's whole API: a tick that created a Filter Group of live cooldown icons and a reader +-- that answered off the group's own selection. The group is retired -- see the block near +-- pihIconGroup for why -- so an API that can only create one would be a door back to it. +-- ⚠ WHAT REPLACED THE THREE TICKS THAT RODE UNDER IT: pihSyncTriggerExtras, which writes +-- trinkets / potions / racials into the ONE list the helper matches on. The reader is the +-- stored setting itself now, because there is no group left to read the truth off. + +-- ⚠ IT LIVES HERE, NOT BESIDE PIH_IsEnabled, BECAUSE OF ONE UPVALUE. It calls +-- pihCreateSignal, a `local function` declared further up the file than the settings +-- accessors -- referencing it from up there compiles as a nil GLOBAL read, which luac -p +-- is blind to and only the _ENV globals diff catches. Same trap as the one +-- UnitExemptFromHelpfulGate documents. +-- ⚠ TURNING IT ON WITH NOTHING THERE SEEDS THE DEFAULT EFFECT, and that is the one place this +-- differs from AD's switch. AD is enabled and then you add indicators; the helper is a recipe, +-- and a first-ever enable that lit up an empty Effects tab would be a switch with nothing on +-- the other side of it. Only when the pool holds NOTHING of ours -- a re-enable finds its +-- records where it left them and adds nothing. +function P.PIH_SetEnabled(on) + local s = P.PIH_Settings() + s.enabled = on and true or false + if s.enabled and not P.PIH_Exists() then + local ok, why = pihCreateSignal("burst") + if not ok then + DF:DebugWarn("AURADESIGNER", "PIH: could not seed the helper -- %s", tostring(why)) end - pihRefresh() - return true end - - -- ☠ EACH TICK BUILDS ITS OWN LIST IF IT MUST. The box cannot depend on What to - -- Show -- icons-only is a legitimate setup -- so a list no signal ever created is created - -- here, the same way the signals create theirs. - local id - if which == "cooldowns" then - local st = P.PIH_Settings() - id = st and st.cooldownFilterID - if not id then - id = pihEnsureFilter(PIH_FILTERS.cooldowns, nil, pihSeedIDs()) - -- Recording the id is what makes the helper EXIST to the resident half (the - -- gate's watcher keys on it), so an icons-only setup still gets the gate. - if id and st then st.cooldownFilterID = id end - end - elseif which == "amplifiers" then - -- ⚠ NO FIRST-CLICK DEFAULT HERE. The three amplifier ticks are the only thing - -- that links this list, and each has already written its own setting before it calls - -- through -- so defaulting anything on here would be a second writer of the same fact, - -- and it would quietly tick trinkets for someone who asked for racials. - id = pihSyncAmplifierFilter(P.PIH_Settings()) - end - if not id then return false, "could not build the list" end - - if not g then - if not P.CreateLayoutGroup then return false, "layout groups unavailable" end - g = P.CreateLayoutGroup(PIH_ICON_GROUP_NAME, "filter") - if not g then return false, "could not create the group" end - -- ☠ THE MARK is ownership, not content: whichever lists are ticked, this is - -- the one field that puts the icons under "hide while Power Infusion is on cooldown" - -- and the role exclusions (buildFilterGroupConfig reads it and stamps dfGate). - g.pihSignal = "burst" - -- ☠ OTHERS ONLY IS NOT INHERITED FROM ANYTHING. poolFilter reads it off THIS - -- group; without it the filter is plain HELPFUL -- anyone's casts, including the - -- priest's own cooldowns lighting icons on their own frame. The exact trap the first - -- group test found on the effects, closed here at create time. - g.othersOnly = true - -- The user's group edits come back over the defaults; see the stash block. - pihRestoreGroup(g) - end - g.filterSelection.customs[id] = true + P.PIH_Apply() pihRefresh() - return true + return s.enabled end -function P.PIH_Create() - local ok, why = pihCreateSignal("burst") - -- A silent refusal is indistinguishable from a dead button: every PIH_ path that can - -- turn something down returns a reason, and this is where the add card reads it. - if not ok then - DF:DebugWarn("AURADESIGNER", "PIH: could not add the helper -- %s", tostring(why)) - end - if ok then - -- ☠ PUSH THE DEFAULTS NOW. Creating writes the settings table (tanks and - -- healers excluded, gate on) but writing is not applying -- without this push the - -- engine ran on its own defaults until a reload or the first tick of any control, - -- so a freshly added helper marked the tank while the panel said it would not. - P.PIH_Apply() - pihRefresh() - end - return ok, why -end - -function P.PIH_Remove() - local pool = pihOtherPoolRead() - local found = pihFound() - local names, n = {}, 0 - for key, hit in pairs(found) do - -- Wholesale removal skips pihDeleteSignal, so the stash write goes here. - pihStash(key, hit) - names[hit.auraName] = true; n = n + 1 - end - - -- ☠ THE WHOLE RECORD GOES, not only the marked surfaces. A helper record can carry a - -- `sound` entry that the generic effects list refuses to show on a filter-owned record - -- (Groups.lua) -- so it offers no delete button for it, and anything left behind there is - -- unreachable. Safe to take wholesale: a record here is identified BY a helper spell list, - -- so nothing of the user's own can be sitting on it. - if pool then - for name in pairs(names) do pool[name] = nil end - end - - -- The lists go too. They exist only to feed these effects, and three "Power Infusion - -- Helper" entries left in the filter list after the helper is gone are cruft only their - -- author could explain. - -- ☠ BUT THE LISTS ARE ACCOUNT-WIDE AND THE HELPER IS PER-PRESET. Deleting them - -- while another preset still carries helper effects leaves that helper referencing lists - -- that no longer exist -- signals that silently render nothing, with no missing row to - -- explain it. So they only go when no helper mark remains in either mode of this profile. - -- ⚠ Another PROFILE's helper is not scanned: profiles are separate saved-variable - -- branches with their own preset resolution, and walking them all from here is machinery - -- out of proportion to the case. A cross-profile remove leaving orphaned references is - -- accepted and recorded. - local marksElsewhere = false - if DF.GetModeBaseAuraDesigner then - for _, mode in ipairs({ "party", "raid" }) do - local adDB = DF:GetModeBaseAuraDesigner(mode) - for _, auraCfg in pairs((adDB and adDB.otherAuras) or {}) do - if type(auraCfg) == "table" then - for _, tCfg in pairs(auraCfg) do - if type(tCfg) == "table" and tCfg.pihSignal then - marksElsewhere = true - break - end - end - end - if marksElsewhere then break end - end - -- Icon groups reference the cooldown list by id, so they hold it alive too. - for _, g in ipairs((adDB and adDB.otherLayoutGroups) or {}) do - if type(g) == "table" and g.pihSignal then marksElsewhere = true break end - end - if marksElsewhere then break end - end - end - if not marksElsewhere then - local R = DF.FilterRegistry - for _, name in pairs(PIH_FILTERS) do - local id = pihFilterIdByName(name) - if id and R and R.DeleteCustomFilter then R:DeleteCustomFilter(id) end - end - end - - -- The icon groups go with the signals: they are the signals in another shape, and a - -- helper that no longer exists must not leave icons running. - local ig = pihAnyIconGroup() - while ig and P.DeleteLayoutGroup do - pihStashGroup(ig) -- position, size, appearance survive a remove - P.DeleteLayoutGroup(ig.id) - ig = pihAnyIconGroup() - end - - -- ☠ SOUND IS NOT A CONTAINER, so nothing above reaches it. Removing the helper has to - -- silence it explicitly or the announcements outlive the feature that made them. - -- The SETTING is left alone: it is behaviour, and behaviour survives a remove. - local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine - if Engine and Engine.PIH_SetSound then Engine:PIH_SetSound(nil) end - - -- The recorded list id goes with the list. Leaving it would point the resident half at a - -- filter that no longer exists -- harmless today, and exactly the kind of stale pointer that - -- reads as a bug the next time someone adds a helper and it resolves the wrong thing. - local st = P.PIH_Settings() - if st then st.cooldownFilterID = nil end - - -- Re-derive the engine from whatever helper remains (another preset's, or none). This - -- resets roles and the gate, and releases the watcher's registrations when nothing is - -- left to drive. - if Engine and Engine.PIH_ApplySaved then Engine:PIH_ApplySaved() end - - pihRefresh() - return true, ("removed %d signal(s) and their spell lists"):format(n) -end +-- ☠☠ P.PIH_Create AND P.PIH_Remove ARE GONE, AND SO IS EVERYTHING THAT SERVED THEM +-- (2026-09-09). They were the delete-and-rebuild model of the enable tick: Remove deleted +-- every marked record and stashed copies, Create rebuilt from the stash. The tick writes a +-- stored flag now (P.PIH_SetEnabled) and the records are never touched, so both verbs -- and +-- pihStashHits, pihKeptCfg, pihKeptSurfaces, pihRestoreInto and PIH_RECIPE_OWNED with them -- +-- answer a question nobody asks. +-- +-- ⚠ WHAT WENT WITH THEM, SAID OUT LOUD SO NOBODY REDISCOVERS IT AS A BUG: +-- · THE SPELL LISTS ARE NO LONGER AUTO-DELETED. PIH_Remove used to delete the three +-- "Power Infusion Helper" custom filters once no mark remained in either mode. Nothing +-- removes them now -- correct, because the records that reference them are no longer +-- removed either. They are ordinary custom filters, visible and deletable in the Filter +-- Designer, which is where a user would look for them. A lingering list is cruft; a +-- silently deleted one that an effect still points at is a broken effect. +-- · THE RETAINED-CUSTOMISATION STASH IS UNNECESSARY, not lost. It existed only to survive +-- a round trip that no longer destroys anything. adDB.pihelper.retainedCfg may still sit +-- in old profiles; it is inert and costs a few bytes. function P.PIH_SetRole(role, on) local s = P.PIH_Settings() @@ -1362,27 +2064,48 @@ function P.PIH_SetRole(role, on) P.PIH_Apply() end --- ☠ UNTICKING THE LAST AMPLIFIER TAKES STRONG WINDOW WITH IT -- see the empty-amplifier trap in --- pihCreateSignal. This is not a value change; it is the difference between a signal existing --- and not existing. --- The three amplifier ticks -- trinkets, potions, racials -- all write here. They are one --- category with three sources: the things that say how HARD a burst lands, as against the --- cooldown list, which says one is happening at all. +-- The three extra trigger ticks -- trinkets, potions, racials -- all write here. They are one +-- category with three sources: things somebody presses that are worth infusing behind, as +-- against the class cooldowns, which are the same question asked of a spellbook. -- --- ⚠ THE LIST AND THE LINK MOVE TOGETHER. The list is rewritten in place (so anything --- already pointing at it keeps pointing at it), then linked to the icon group or dropped from --- it. With none of the three ticked the list is empty, and a linked empty list is a row that --- reads "on" and draws nothing. +-- ⚠ ONE WRITE, NOT TWO. It used to write the setting, rebuild a separate amplifier filter, +-- and then link or unlink that filter on the cooldown-icon group -- three facts that could +-- disagree. Now the setting is the choice and pihSyncTriggerExtras is the consequence. +-- ★ HOW MANY CLASS COOLDOWNS ARE ACTUALLY LIVE, for the "Classes and Cooldowns" header. +-- +-- ☠ THE COOLDOWNS *TICK* THAT WAS HERE IS GONE, AND IT WAS REDUNDANT AND HARMFUL. Redundant +-- because unticking all thirteen classes IS turning class cooldowns off -- the classes are the +-- control. Harmful because both it and the class ticks READ OFF THE LIST (no stored booleans, +-- deliberately, so nothing can drift): unticking the source removed all forty cooldowns, which +-- made every class tick read off, and ticking it back re-added all forty -- silently undoing +-- whichever classes the user had turned off. A switch that quietly reverts your other choices +-- is worse than no switch, and the alternative -- remembering the class states -- is the stored +-- copy of a derived truth that the class ticks exist to avoid. +-- +-- ⚠ THE SEED SET, NOT THE WHOLE LIST. The list also holds trinkets, potions and racials once +-- those are ticked, and counting them under a "Classes and Cooldowns" header would be a number +-- describing something else. Enabled, too: a spell ticked off in the Filter Designer is in the +-- list and not firing, so it is not live. +function P.PIH_CooldownCounts() + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + local f = id and R and R.GetCustomFilter and R:GetCustomFilter(id) + local ids = pihSeedIDs() + if not f then return 0, #ids end + local on = 0 + for _, sid in ipairs(ids) do + if (f.spells[sid] or f.rawIDs[sid]) + and (not R.IsCustomSpellEnabled or R:IsCustomSpellEnabled(id, sid)) then + on = on + 1 + end + end + return on, #ids +end + function P.PIH_SetAmplifier(which, on) local s = P.PIH_Settings() s[which] = on and true or false - pihSyncAmplifierFilter(s) - local any = (s.potions or s.trinkets or s.racials) and true or false - -- ⚠ NO LONGER SUBORDINATE TO THE COOLDOWN LIST. The amplifier ticks used to link - -- only while the cooldown icons were on, because they were an "include" under that tick. - -- Four equal ticks means any list can show on its own -- potions only is a legitimate - -- setup, and refusing it would make three of the four ticks lie about being equal. - P.PIH_SetIconsShow("amplifiers", any) + pihSyncTriggerExtras(s) pihRefresh() end @@ -1391,12 +2114,145 @@ function P.PIH_SetGateEnabled(on) P.PIH_Apply() end +-- ★ SHOW IN COMBAT ONLY. Stored beside the gate because it is the same KIND of thing: a +-- condition on whether the helper has anything to say at all, not a display choice. +-- ⚠ ABSENT MEANS OFF, so nothing changes for an existing profile -- and the engine reads it +-- the same way (s.combatOnly == true), so there is no defaults entry to keep in step. +function P.PIH_SetCombatOnly(on) + P.PIH_Settings().combatOnly = on and true or nil + P.PIH_Apply() +end + +-- ★★ THE NAMED-PLAYER ALLOWLIST (2026-09-10). Krathe: "in guild groups it would be useful to +-- only have the PI alert for the DPS you know who should be getting PI instead of every DPS in +-- the raid who uses a CD." +-- ⚠ AN ARRAY OF "Name-Realm", the picker's own order of entry, and the same key the pinned +-- frames list writes -- so a name means the same thing in both and could be pasted between +-- them. The ENGINE turns it into a map (see Engine:PIH_ApplySaved); the panel keeps the array +-- because a list you edit has an order and a set does not. +-- ⚠ EMPTY IS ABSENT. Removing the last name has to leave the helper exactly as it was before +-- the first was added, so the key goes rather than becoming an empty table -- the engine reads +-- a present list as "these players and nobody else". +function P.PIH_Players() + return P.PIH_Settings().players or {} +end + +function P.PIH_SetPlayers(list) + local s = P.PIH_Settings() + local out + for _, fullName in ipairs(list or {}) do + if type(fullName) == "string" and fullName ~= "" then + out = out or {} + out[#out + 1] = fullName + end + end + s.players = out + P.PIH_Apply() +end + +-- ★★ THE LIST IS DATA; THE NARROWING IS A SWITCH (2026-09-11). Krathe: "I might want to add my +-- raid team to the list but turn off showing only for those players in a pug group without +-- having to add/remove them all each time." +-- ☠ THE EMPTINESS RULE WAS DOING TWO JOBS AT ONCE -- it stored WHO and decided WHETHER, so the +-- only way to stop narrowing was to destroy the names. That is the same fault as the enable +-- tick that used to delete records: a switch whose off position throws data away. Split them +-- and both become honest. +-- ⚠ ABSENT MEANS ON, and that is exactly backwards-compatible: a profile with names was +-- narrowing and still does; a profile with none was not and still is not (an empty list is +-- everyone either way, below). Only an explicit OFF is stored, so there is no defaults entry to +-- keep in step and nothing for a migration to fire on. +-- ⚠ AN EMPTY LIST IS STILL EVERYONE even with this ON. "Watch nobody" is not a state anyone +-- asks for by emptying a box, and silently blanking the whole feature is the worse failure -- +-- the same reason the engine reads an empty list as nil rather than as an empty map. +function P.PIH_PlayersOn() + return P.PIH_Settings().playersOn ~= false +end + +function P.PIH_SetPlayersOn(on) + -- ☠ EXPLICIT if, NOT `on and nil or false`: with nil as the "true" arm the and/or + -- idiom collapses to false on BOTH inputs, so the tick could be switched off and never + -- back on again (Krathe, 2026-09-14: "I can't seem to tick it, it's not doing anything"). + -- The same trap is called out beside the PTR lane's slot enable bit; it bit here anyway. + if on then + P.PIH_Settings().playersOn = nil + else + P.PIH_Settings().playersOn = false + end + P.PIH_Apply() +end + -- The cooldown list's registry id, for deep-linking straight to it in the Filter Designer. -- nil before the helper exists, which is also when the button that uses it must be dead. function P.PIH_CooldownFilterID() return pihFilterIdByName(PIH_FILTERS.cooldowns) end +-- ...and the racials list's, for the pencil on its row. Same contract: nil until the helper +-- exists, which is when that pencil must not be drawn. +function P.PIH_RacialFilterID() + return pihFilterIdByName(PIH_FILTERS.racials) +end + +-- How many of a preset category are ON, and how many it holds. +-- ⚠ ENABLED / TOTAL, NOT #recs. A spell ticked off in the Filter Designer stops being +-- selected by ResolveSelection (recordSelected calls IsSpellEnabled), so a row reporting the +-- raw size claims a number the engine does not act on -- the fault Krathe caught on the +-- cooldown count, "the number does not change as I tick them on/off". +function P.PIH_PresetCounts(catKey) + local R = DF.FilterRegistry + local recs = (R and R.ByCategory and R.ByCategory[catKey]) or {} + local on = 0 + for _, rec in ipairs(recs) do + if not R.IsSpellEnabled or R:IsSpellEnabled(catKey, rec) then on = on + 1 end + end + return on, #recs +end + +-- ★ EVERYTHING A SET OF SOURCES WATCHES, COUNTED THE WAY THE PANEL COUNTS IT: each ticked +-- source contributing its own ENABLED total. +-- ☠ NOT THE RESOLVED MAP. R:ResolveSelection returns spell IDs with every variant expanded, +-- which for this set is several hundred -- a true number of a thing nobody is counting. The +-- rows on the Triggers tab say 40, 41, 6 and 4; this has to be their sum or the two screens +-- disagree about the same feature. +-- ⚠ `sources` IS AN ARGUMENT, defaulting to the Triggers ticks. Krathe, 2026-09-10: "the +-- number of spells tracked does not seem to update on the show toggles but only on the +-- triggers" -- because this read P.PIH_Settings() outright, so the Cooldown Icons header +-- reported what the HELPER fires on while the card under it listed what the GROUP shows. Two +-- numbers for two different questions, and only one of them was being asked. +-- ⚠ COOLDOWNS IS A SOURCE HERE TOO, not an always-on baseline: the group can switch it off, +-- and a count that added the class list regardless would over-report by forty. +function P.PIH_WatchedCount(sources) + local R = DF.FilterRegistry + local s = sources + if not s then + local st = P.PIH_Settings() + s = { cooldowns = true, trinkets = st.trinkets == true, + potions = st.potions == true, racials = st.racials == true } + end + local total = 0 + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + if s.cooldowns and id and R and R.CustomFilterCounts then + total = R:CustomFilterCounts(id) + end + if s.trinkets then total = total + P.PIH_PresetCounts(PIH_SEED.amplifiers.trinkets) end + if s.potions then total = total + P.PIH_PresetCounts(PIH_SEED.amplifiers.potions) end + if s.racials then total = total + P.PIH_RacialCounts() end + return total +end + +-- How many racials are ON, and how many are in the list. Falls back to the seed's size before +-- the list exists, so the row reads 4 rather than 0 on a helper that has not been created -- +-- which is the number that will be true the moment it is. +function P.PIH_RacialCounts() + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.racials) + if id and R and R.CustomFilterCounts then + local on, total = R:CustomFilterCounts(id) + if total > 0 then return on, total end + end + return #PIH_RACIAL_IDS, #PIH_RACIAL_IDS +end + -- ============================================================ -- GLOBAL VIEW (used by Global tab) -- ============================================================ @@ -3072,14 +3928,78 @@ local function ADCrossBlockText(rec) return nil end +-- ============================================================ +-- THE SUB-TAB STRIP, PER POOL +-- ------------------------------------------------------------ +-- ★★★ ONE DEFINITION FOR BOTH LAYOUTS (2026-09-09). The split panel builds three buttons in +-- S.mainFrame and the popout page hands its list to GUI:BuildDesignerShell -- two strips, and +-- until now two hardcoded copies of the same three entries. +-- +-- ⚠ THE HELPER'S POOL SHOWS TWO, IN THE OTHER ORDER. Krathe, 2026-09-09: "'global' should be +-- Triggers and the first option and Effects should be 2nd with no Layout groups for the PI +-- helper section." +-- · TRIGGERS FIRST, because you cannot sensibly choose how to be told about something you +-- have not yet said you care about. +-- · "Global" IS "Triggers", relabelled -- not a new tab. Every pool's Global tab holds what +-- applies to the whole POOL rather than to one effect, and the helper's roles, classes and +-- cooldown gate are exactly that. Keeping the KEY means SwitchTab, the scroll memory and +-- sixty call sites saying S.SwitchTab("global") need no special case. +-- · NO LAYOUT GROUPS. A layout group is a container of live aura icons; the helper has no +-- per-spell display to arrange, and the one group it used to own is what got stuck on +-- Krathe's frames (see pihSweep step 5). An empty tab that can only be filled with +-- something the feature does not do is a door to the bug that was just closed. +-- +-- ⚠ A VERB, NOT A TABLE. Every label is an L[...] lookup -- a table built at load freezes the +-- locale that was live then -- and the list genuinely differs per pool, which is the second +-- reason it cannot be computed once. +local function SubTabDefs() + if IsPIHelperTab() then + -- ☠ TWO, AND NEVER A THIRD. The Cooldown Icons group briefly grew one -- a Layout + -- Groups tab that appeared the moment you added the group -- and Krathe met it twice: + -- "why is layout groups back showing on PI helper?", then "It's confusing when you add + -- Cooldown Icons from effects and it appears as a layout group, it should just show as + -- a normal effect for PI helper." + -- ⇒ The group is now a card in ACTIVE INDICATORS on the Effects tab, drawn by the + -- designer's own S.CreateLayoutGroupCard (see S.BuildEffectsTab). It is added there and + -- it lives there; a tab that grows and shrinks under the user is gone with it. + return { + { key = "global", label = L["Triggers"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, + { key = "effects", label = L["Effects"], accent = nil }, + } + end + return { + { key = "effects", label = L["Effects"], accent = nil }, -- theme-tracking + { key = "layout", label = L["Layout Groups"], accent = { r = 0.91, g = 0.66, b = 0.25 } }, + { key = "global", label = L["Global"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, + } +end +P.SubTabDefs = SubTabDefs + +-- Which sub-tab a pool can legally be showing. Called wherever the pool changes under a tab +-- that was chosen for the previous one -- the same shape as the Debuffs coercion below, and +-- for the same reason: a strip that no longer draws a button must not leave it selected. +-- ⚠ Answers for EVERY pool, so a caller never has to know which one it is on. +local function CoerceTabForPool(tabKey) + if IsPIHelperTab() then + -- ⚠ "layout" LANDS ON EFFECTS, not on Triggers. The helper has no Layout Groups tab, + -- and the one thing that would have sent someone here asking for it -- the Cooldown + -- Icons group -- is a card in the Effects list now, so Effects is where they meant to + -- go. (Krathe hit the old landing twice; see SubTabDefs.) + if tabKey == "effects" or tabKey == "layout" then return "effects" end + return "global" + end + if tabKey == "effects" and IsDebuffTab() then return "layout" end + return tabKey +end +P.CoerceTabForPool = CoerceTabForPool + -- ── SWITCH TAB ── S.SwitchTab = function(tabKey) - -- Effects is frosted on the Debuffs tab (C2: category groups have no - -- placed indicators) — coerce to Layout Groups (belt-and-braces; the - -- sub-tab button is also frosted). - if tabKey == "effects" and IsDebuffTab() then - tabKey = "layout" - end + -- Every pool's coercion in one call: Effects is frosted on Debuffs (category groups have + -- no placed indicators) and Layout Groups is not drawn at all on the helper's pool. Both + -- are belt-and-braces here -- the strip does not offer the button either way -- but a + -- SwitchTab reached from a stale call site must land somewhere that exists. + tabKey = CoerceTabForPool(tabKey) -- ☠ IN THE POPOUT LAYOUT THERE IS NO TAB PANEL TO REBUILD. The row page -- (AuraDesigner/UI/Rows.lua) has no S.tabBar, no S.tabScrollFrame and no @@ -3149,7 +4069,76 @@ end -- frosts on the Debuffs tab (category groups have no placed indicators). -- Layout Groups is live on BOTH buff tabs (the Other tab hosts the flat -- other-pool group store) — it never frosts anymore. +-- ★★ ...AND RE-LAY THE STRIP, because on the helper's pool it is a DIFFERENT STRIP: two +-- buttons, in the other order, one of them relabelled (see SubTabDefs). +-- ☠ RE-ANCHORED RATHER THAN REBUILT, and the split panel is why. Its three buttons are +-- created once inside S.mainFrame and the pool switch does NOT rebuild that frame -- it calls +-- AuraDesigner_RefreshPage, which redraws the tab CONTENT and leaves the strip alone. So the +-- buttons that exist are the buttons there will be, and the pool decides which of them are +-- shown, in what order, under what label. (The popout layout rebuilds its whole page on a +-- pool switch, so it simply reads SubTabDefs afresh and never comes here.) +-- ⚠ THE GAP MATCHES Editor.lua's TAB_GAP. Two copies of a 4, which is one too many -- but the +-- alternative is exporting a layout constant from a builder into a state module, and the +-- number is checked by eye every time this runs against a strip built with the other one. +local SUBTAB_GAP = 4 +local function ApplySubTabStrip() + if not (tabButtons and S.tabBar) then return end + local defs = SubTabDefs() + local wanted, prev = {}, nil + for _, def in ipairs(defs) do + local btn = tabButtons[def.key] + if btn then + wanted[def.key] = true + btn:ClearAllPoints() + if prev then + btn:SetPoint("TOPLEFT", prev, "TOPRIGHT", SUBTAB_GAP, 0) + else + btn:SetPoint("TOPLEFT", S.tabBar, "TOPLEFT", 0, 0) + end + -- ⚠ THE LABEL IS SET EVERY PASS, not only when it changes. "Global" and + -- "Triggers" are the same button, and a button that kept the label it was built + -- with would read Global on the helper and Triggers everywhere else depending on + -- which pool happened to be open when the panel was created. + if btn.Text then btn.Text:SetText(def.label) end + btn:Show() + prev = btn + end + end + -- ☠ AND THE ONES THIS POOL DOES NOT HAVE ARE UNANCHORED, NOT JUST HIDDEN. A hidden frame + -- still anchors whatever is pointed at it, and the chain above re-points buttons at each + -- other every pass -- leaving a stale link would drag a visible tab off to where a hidden + -- one used to be. + for key, btn in pairs(tabButtons) do + if not wanted[key] then + btn:ClearAllPoints() + btn:Hide() + end + end + local w = S.tabBar:GetWidth() or 0 + local n = #defs + if w > 10 and n > 0 then + local tabW = (w - (n - 1) * SUBTAB_GAP) / n + for _, def in ipairs(defs) do + if tabButtons[def.key] then tabButtons[def.key]:SetWidth(tabW) end + end + end +end +P.ApplySubTabStrip = ApplySubTabStrip + +-- Which POOL tab reads as selected. Lifted out of SetMainTab because the REUSE path needs it +-- too: a page revisit that changes the pool without rebuilding the panel (the Power Infusion +-- Helper's nav row does exactly that) left the strip lit on the pool the panel was BUILT for. +-- Krathe, 2026-09-09: "it takes you to the enable PI page but ... is not highlighting Power +-- Infusion Helper tab up top." +local function SyncPoolTabs() + for key, btn in pairs(mainTabButtons) do + if btn.SetActive then btn:SetActive(key == S.activeBuffTab) end + end +end +P.SyncPoolTabs = SyncPoolTabs + local function UpdateLayoutTabState() + ApplySubTabStrip() local layoutBtn = tabButtons and tabButtons.layout if layoutBtn and layoutBtn.SetDisabled then layoutBtn:SetDisabled(false) @@ -3192,17 +4181,14 @@ local function SetMainTab(tabKey) -- it survive a pool switch. CloseADPicker() if GUI then GUI:CloseAllMenus() end -- an open dropdown (e.g. spec) must not outlive the tab - for key, btn in pairs(mainTabButtons) do - btn:SetActive(key == tabKey) - end + SyncPoolTabs() UpdateSpecDropdownState() UpdateLayoutTabState() - -- Effects is frosted on the Debuffs tab, so land on Layout Groups (the - -- tab's primary surface). Layout Groups is live on both buff tabs — no - -- coercion needed when arriving there. - if S.activeBuffTab == "debuffs" and S.activeTab == "effects" then - S.activeTab = "layout" - end + -- ☠ THE TAB YOU WERE ON MAY NOT EXIST ON THE POOL YOU JUST PICKED. Effects is frosted on + -- Debuffs, and Layout Groups is not drawn at all on the helper's pool -- arriving there + -- from Layout Groups used to leave the strip with nothing selected and the content pane + -- built for a tab that had no button. CoerceTabForPool answers for every pool at once. + S.activeTab = CoerceTabForPool(S.activeTab) -- One entry point swaps every surface: RefreshPage → S.SwitchTab(S.activeTab) -- (list, chips, add menu) + RefreshPlacedIndicators/RefreshPreviewEffects -- (preview, drag targets) — all pool-routed through CurrentAuraPool. @@ -4122,7 +5108,10 @@ S.CreateEffectCard = function(parent, yPos, effect) -- Spell icon (small, before type badge). Other-pool records resolve -- icon/identity spec-independently (nil spec → ad-hoc / SpellDB fallback). - local spec = IsOtherTab() and nil or ResolveSpec() + -- ☠ NOT `IsOtherTab() and nil or ResolveSpec()` -- with nil as the true arm that + -- always yields the spec, so the Other tab resolved its icons spec-keyed after all. + local spec + if not IsOtherTab() then spec = ResolveSpec() end local iconTex = GetAuraIcon(spec, effect.auraName) -- ⚠ A filter-owned record shows our GLYPH here, not a spell icon, and the two -- need different treatment. The 0.08/0.92 crop below exists to trim the border @@ -4186,7 +5175,14 @@ S.CreateEffectCard = function(parent, yPos, effect) -- Warning badge for auras with API-level tracking limitations -- (positioned to the right of the type badge) + -- ★ ...AND THE HELPER'S CLASH WARNING, on the same badge. A helper border sitting under one + -- of the user's own borders draws nothing and says nothing; this is where it says it. See + -- P.PIH_ClashText: it asks about THIS row's own config, names the offender, and names a + -- remedy that still exists (tick "Give this aura its own border" and BOTH rings show, or + -- raise Priority on a text surface). local warnKey = GetAuraWarningKey(spec, effect.auraName) + local clashText = (effect.config and effect.config.pihSignal and P.PIH_ClashText) + and P.PIH_ClashText(effect.config, effect.typeKey) or nil AttachWarningBadge(header, warnKey, { point = "LEFT", relativeTo = badgeBg, @@ -4194,6 +5190,7 @@ S.CreateEffectCard = function(parent, yPos, effect) offsetX = 4, offsetY = 0, size = 16, + text = clashText, }) -- Aura name + anchor/trigger/group info @@ -4217,7 +5214,9 @@ S.CreateEffectCard = function(parent, yPos, effect) end -- Other Buffs: surface the per-effect Others Only state on the collapsed -- header (prototype's "Others only" chip, as a text suffix). - if IsOtherTab() and effect.config and effect.config.othersOnly then + -- ⚠ NOT ON THE HELPER'S POOL, where it is a constant rather than a state -- see + -- P.ShowsOthersOnly for the whole argument. + if ShowsOthersOnly() and effect.config and effect.config.othersOnly then infoStr = infoStr .. " - " .. L["Others Only"] end local infoText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") @@ -4243,6 +5242,11 @@ S.CreateEffectCard = function(parent, yPos, effect) delBtn = GUI:CreateCloseButton(header, { size = 22, onClick = function() + -- ⚠ ASKED BEFORE THE REMOVAL, because after it there is no config left to + -- ask. See P.PIH_ReDerive: the helper's engine half is not the designer's + -- business, and deleting its last effect through this button would otherwise + -- leave the watcher and the sound armed for a feature with nothing in it. + local wasPIH = effect.config and effect.config.pihSignal if isPlaced then RemoveIndicatorInstance(effect.auraName, effect.indicatorID) else @@ -4250,6 +5254,7 @@ S.CreateEffectCard = function(parent, yPos, effect) if auraCfg then auraCfg[effect.typeKey] = nil end S.CleanupAdHocAura(effect.auraName) -- drop emptied ad-hoc "#" entries end + if wasPIH and P.PIH_ReDerive then P.PIH_ReDerive() end expandedCards[cardKey] = nil S.SwitchTab("effects") RefreshPlacedIndicators() @@ -4410,7 +5415,9 @@ S.CreateEffectCard = function(parent, yPos, effect) -- through the pool-pinned proxy; the filter string ("HELPFUL|!PLAYER") -- binds at container build, so toggling is STRUCTURAL (B1 folds it -- into every struct sig → the factory Rebuilds). - if IsOtherTab() and effect.typeKey ~= "sound" then + -- ⚠ ...AND NOT ON THE HELPER'S POOL: there the caster rule is stamped by the recipe + -- and is not the user's to change. P.ShowsOthersOnly carries the reasoning. + if ShowsOthersOnly() and effect.typeKey ~= "sound" then local ooCb = GUI:CreateCheckbox(body, L["Others Only"], proxy, "othersOnly", S.EffectOthersOnlyChanged) ooCb:SetPoint("TOPLEFT", body, "TOPLEFT", 8, -(triggersH + 12)) @@ -4602,7 +5609,17 @@ P.AddFlowEffects = AddFlowEffects -- of that type is actually created with. local DEFAULT_TILE_ICON = "Interface\\Icons\\INV_Misc_QuestionMark" -local function PaintEffectOnThumb(pv, typeKey) +-- ⚠ `staticSpellID` PAINTS THE TILE WITH ONE SPELL'S ART AND FREEZES IT THERE. The question +-- mark is the DESIGNER's honest placeholder: its add flow asks for a type first and a spell +-- second, so at tile-paint time there is genuinely no artwork to show and the picture is +-- swapped in later through pv.spellIcon. +-- ☠ ON THE POWER INFUSION HELPER'S POOL THAT NEVER HAPPENS. There is no spell step -- the +-- cooldown list IS the spell -- so nothing ever came back to swap the placeholder, and the +-- one tile whose whole subject is a fixed picture was the one showing a question mark. +-- Krathe, 2026-09-09: "on the example for icon it has a ? instead of the PI icon (on the GUI, +-- works fine to actually show PI when their CD was active)" -- the live half was already +-- right, which is what narrowed this to the tile. +local function PaintEffectOnThumb(pv, typeKey, staticSpellID) local mock = pv.mockFrame if not mock then return end local c = BADGE_COLORS[typeKey] or GetThemeColor() @@ -4619,12 +5636,18 @@ local function PaintEffectOnThumb(pv, typeKey) local ico = mock:CreateTexture(nil, "OVERLAY", nil, 2) ico:SetSize(size, size) ico:SetPoint("CENTER", ring, "CENTER", 0, 0) - ico:SetTexture(DEFAULT_TILE_ICON) + local pinned = staticSpellID and C_Spell and C_Spell.GetSpellTexture + and C_Spell.GetSpellTexture(staticSpellID) or nil + ico:SetTexture(pinned or DEFAULT_TILE_ICON) ico:SetTexCoord(0.08, 0.92, 0.08, 0.92) -- Swapped for the chosen spell's own artwork once section 1 is answered: -- "the spell's own artwork" is the whole of what this effect does, so the -- picture is only honest when it is that spell's. - pv.spellIcon = ico + -- ☠ ...AND NOT PUBLISHED AT ALL WHEN THE ART IS PINNED. pv.spellIcon is the handle + -- the add pane swaps through; leaving it set on a pinned tile would let the pane + -- repaint Power Infusion with whatever spell was picked, which is the one thing the + -- pinned art exists to prevent. + if not pinned then pv.spellIcon = ico end elseif typeKey == "square" then local size = (defs and defs.size) or 24 @@ -5827,12 +6850,14 @@ P.OpenFilterPopout = OpenFilterPopout -- -- ☠ The card becomes REMOVE once a helper exists on this preset, so there is one place to -- look for both. Create and remove are the same feature seen from either side. --- ⚠ SPLIT INTO PARTS 2026-09-01, ONE DEFINITION STILL. The popout layout's pane --- outgrew its page, so the builder is now: the CARD on its own --- (S.BuildPIHelperCard), a shared section toolkit (pihMakeTools), one body --- function per section, and TWO compositions -- S.BuildPIHelperPane stacks all --- of them for the classic tab exactly as before, and S.PIHelperSections hands --- the row page (AuraDesigner/UI/Rows.lua) the same bodies one popout row each. +-- ⚠ SPLIT INTO PARTS 2026-09-01, ONE DEFINITION STILL. It was split because the popout +-- layout's pane outgrew its page and needed the bodies one row at a time, so the builder +-- became: the CARD on its own (S.BuildPIHelperCard), a shared section toolkit +-- (pihMakeTools), and one body function per section. +-- ⚠ THAT SECOND CONSUMER IS GONE (2026-09-08) -- the helper has its own page and the row +-- band went with the move, taking S.PIHelperSections with it. The split is KEPT anyway: one +-- body per section is what lets the page compose them in a different ORDER (Triggers before +-- Indicators) without touching a single control. -- ── THE ONE-TIME SWEEP ────────────────────────────────────────────────────────────── -- The two-signal shape retired "Big cooldown with a trinket or potion" and moved racials -- out of the cooldown list. Neither change reaches a helper that already exists: its @@ -5847,15 +6872,77 @@ P.OpenFilterPopout = OpenFilterPopout -- ⚠ STAMPED, NOT INFERRED. There is no way to tell "already swept" from "the user -- deliberately put a racial back", so a version stamp decides rather than a heuristic -- -- otherwise the sweep would undo a hand edit on every login. -local PIH_SCHEMA = 2 +-- 3: Square retired, migrated to Border. RETRACTED the same afternoon and never shipped -- +-- see the note on step 4. The number is burned rather than reused, so a client that ran +-- it is not told it is on a schema it never saw. +-- 4: Icon retired as a helper surface, migrated to Square (step 4 in pihSweep). +-- 5: The cooldown-icon GROUP retired outright, and the amplifier list folded into the one +-- cooldown list the helper matches on (steps 5 and 6). Icon comes BACK as a surface at the +-- same time -- pinned to Power Infusion's own artwork, which is what schema 4's objection +-- was actually about -- but nothing needs migrating for that: schema 4 already turned every +-- existing Icon into a Square, and a square is a perfectly good marker to leave someone on. +-- 6: ☠ SCHEMA 5 STAMPED ITSELF AND DELETED NOTHING. Its group delete searched one store by +-- id; Krathe's eight groups are in another (see pihPurgeStrayMarks). So the stamp says +-- "swept" on profiles that were not, and the version has to move for the fixed step to get +-- a second chance at them. A number is cheap; a stamp that lies is not. +-- 7: Stack Count retired on helper markers. showStacks defaults TRUE for icons and +-- squares, so every helper marker ever created is drawing a count read off whichever +-- cooldown matched -- on an icon whose art is pinned to Power Infusion. New ones are +-- stamped false at creation; step 7 does the ones already out there. +-- 8: ☠ THE CURATED MARK NEVER LANDED ON AN EXISTING PROFILE. dfDefaults is what makes +-- the Filter Designer give our list the on/off tick and the Reset button, and it is +-- stamped by pihEnsureFilter -- which only RUNS when the helper creates or repairs a +-- signal. A helper that already exists and is not being edited never calls it, so +-- Krathe's list still showed the destructive ✕ and no Reset. Shipped and reported in +-- one round: "the seeded list does not have the same toggle on/off as other filters +-- and it does not have a reset option?" +-- ⚠ THE LESSON: a mark written by a CREATE path reaches nobody who already has the +-- thing. Stamping in the sweep is what reaches them, and the sweep is the one place +-- that runs for a helper nobody is touching. +local PIH_SCHEMA = 12 local function pihSweep() local s = P.PIH_Settings() - if not s or s.schema == PIH_SCHEMA then return end + if not s then return end + -- ☠☠ THE STEPS ARE GATED ON WHERE THE PROFILE IS COMING FROM, NOT ONLY ON WHETHER IT HAS + -- ARRIVED. Every step used to run whenever the stamp differed, which was harmless while + -- the stamp only ever moved forward by one -- and became destructive the moment it moved + -- for a reason unrelated to a given step. + -- ⚠ THE CONCRETE HAZARD, AND IT WOULD HAVE HIT KRATHE FIRST: step 4 migrates every marked + -- Icon to a Square. Icon is a legitimate surface again in schema 5, so re-running step 4 + -- on the way to 6 would convert an icon the user had just added through the new tiles -- + -- silently, on the next page build, as a side effect of a migration about something else. + -- ⇒ `from` is the version the profile is actually on, and each step names the version it + -- was written for. [[feedback-migration-before-defaults-backfill]] is the sibling trap. + local from = tonumber(s.schema) or 0 + if from == PIH_SCHEMA then return end + + -- ☠☠ THE POOL IS PINNED FOR THE WHOLE SWEEP, AND SKIPPING THIS SHIPPED A BROKEN SWEEP. + -- Several editor helpers this function reaches resolve their STORE from S.activeBuffTab: + -- EnsureAuraConfig (step 4's Icon -> Square migration writes through it) and the shared + -- layout-group delete both do. That was invisible while the only caller was the helper's + -- own panel, which by definition ran on an other-routed tab -- and became wrong the moment + -- the sweep moved to page-build time, where the pool is still whatever the last session + -- left, usually My Buffs. Step 5's delete then searched the SPEC store for a group that + -- lives in the OTHER one, removed nothing, and said nothing about it. + -- ⚠ THIS IS NOT A LIE TOLD TO A SHARED HELPER. The helper's records genuinely live in the + -- Any Buff pool -- that is the constraint the whole feature is built around -- so pointing + -- the pool accessor at it while we work on them is telling it the truth. + -- ⚠ RESTORED UNCONDITIONALLY. Nothing between here and the restore can error out (no + -- pcall, no early return below this line), and a sweep that left the pool moved would hand + -- the page it was called from somebody else's records. + local prevPool = S.activeBuffTab + S.activeBuffTab = "other" local R = DF.FilterRegistry local pool = pihOtherPoolRead() + -- ── STEPS 1-4: ONLY FOR A PROFILE COMING FROM BEFORE SCHEMA 4 ── + -- ⚠ STEP 4 IS THE ONE THAT MAKES THIS MANDATORY: it turns every marked Icon into a + -- Square, and Icon is a supported surface again as of schema 5. Re-running it on the way + -- to 6 would convert an icon the user had just added through the new tiles, silently, on + -- the next page build, as a side effect of a migration about something else entirely. + if from < 4 then -- 1. The retired signal, in both its representations. if type(pool) == "table" then for auraName, auraCfg in pairs(pool) do @@ -5891,67 +6978,342 @@ local function pihSweep() if R.RemoveSpellFromCustom then R:RemoveSpellFromCustom(cdId, sid) end end end - if had then - s.racials = true - pihSyncAmplifierFilter(s) - -- Only link the list if the icons row it rides in is actually on. - if P.PIH_IconsShow("cooldowns") then P.PIH_SetIconsShow("amplifiers", true) end + -- ⚠ THE SETTING ONLY. Step 6 below is what puts the ids back where they belong now, + -- and it runs for every ticked amplifier rather than for racials alone -- so doing it + -- here as well would be two writers of one fact in the same function. + if had then s.racials = true end + end + + -- 3. ☠ THE INFUSED ICON GROUP'S OWN STEP IS GONE, FOLDED INTO STEP 5. It read + -- pihIconGroup("infused") and handed the id to the store-routed delete -- one store, one + -- id, the exact shape that failed three times on the cooldown group. Step 5 takes every + -- marked group in every store, which is a superset of what this did and cannot miss for + -- the reason this did. + + -- 4. ICON IS RETIRED AS A HELPER SURFACE (schema 4, 2026-09-08). + -- ☠ THE REASON IS SCOPE, NOT SHAPE. An icon shows a SPECIFIC BUFF'S artwork, so + -- offering one implies the helper tracks which cooldown each player popped. It does + -- not, and does not need to -- Krathe: "we don't need to track each buff just the fact + -- someone has popped a CD and we highlight in some form." A control that promises + -- per-buff detail the feature never delivers is a control that lies about its scope. + -- + -- ⚠☠ SCHEMA 3 WENT THE OTHER WAY AND IS DELIBERATELY NOT PRESERVED. It retired SQUARE + -- and migrated it to Border, on the argument that a block at a coordinate is a + -- placement and placement is the designer's job. That was shape reasoning; this is + -- scope reasoning, and scope won. Schema 3 shipped nowhere -- it existed for part of one + -- afternoon on one developer's client -- so nothing in the wild ran it, and reviving its + -- inverse would mean tracking which of two contradictory migrations a profile had seen. + -- A profile that DID run it has borders where it had squares; that is a colour on a + -- different surface, not lost work, and it is not worth a third migration to undo. + -- + -- ⚠ MIGRATED, NOT DELETED. Someone running an Icon helper would otherwise open the + -- panel to a signal reading "None" with no explanation -- indistinguishable from their + -- settings having been lost. Square is the nearest honest equivalent: it marks the same + -- unit in the same place, and it is what the surface list offers now. + if type(pool) == "table" then + for auraName, auraCfg in pairs(pool) do + if type(auraCfg) == "table" then + for i = #(auraCfg.indicators or {}), 1, -1 do + local inst = auraCfg.indicators[i] + if type(inst) == "table" and inst.pihSignal and inst.type == "icon" then + local sig = inst.pihSignal + -- Captured BEFORE the removal, the same order pihSwap works in: + -- placing reads the carry and the instance is gone by then. + -- An Icon carries no colour of its own, so the square falls back to + -- the signal's default -- which is what pihPlace does with nil. + local carried = { conditions = inst.conditions } + table.remove(auraCfg.indicators, i) + pihPlace(sig, auraName, "square", carried) + end + end + end + end + end + + -- 5. ☠☠ THE COOLDOWN-ICON GROUP GOES, AND THIS IS THE STEP KRATHE REPORTED. + -- "I have stuck PI Helper Cooldown - Icons on my AD despite that not even being an option + -- now for PI helper" (2026-09-09) -- a Filter Group of live cooldown icons, drawn on every + -- frame and on the designer's own preview, whose only control was a tick that has since + -- been moved, renamed and finally removed. A control that is gone cannot turn its own + -- output off, so the output has to be taken away with it. + -- ⚠ EVERY pihSignal GROUP, not the one named "burst". A shipped build could leave an + -- "infused" one behind too (step 3 only ever ran for a profile that reached schema 4), + -- and the point of this step is that nothing marked as ours survives it. + -- ⚠ THROUGH DeleteLayoutGroup, which also sweeps the expanded-card key -- a raw table + -- remove would leave the editor holding a fold state for a group that no longer exists. + -- ⚠ AND THE STASH GOES. pihRestoreGroup would otherwise lay a deleted group's position + -- and appearance back over the next one created -- and after this step there is no next + -- one, so the stash is a copy of something with nowhere left to go. + end -- from < 4 + + -- ── STEPS 5-6: EVERY PROFILE THAT IS NOT ALREADY ON THE CURRENT SCHEMA ── + -- Both are safe to re-run: one removes records that should not exist, the other writes a + -- set of ids that is derived from the ticks rather than added to them. + + -- ⚠ BY MARK, ACROSS EVERY STORE -- not by id through a store-routed delete. Two earlier + -- attempts failed here and both failed the same way: they searched the ONE store the + -- helper's records are supposed to be in, and Krathe's eight groups are in the spec store. + -- pihPurgeStrayMarks has the full account. + do + local nGroups, nEffects = pihPurgeStrayMarks() + -- ⚠ IN THE LOG, NOT BEHIND A COMMAND. A migration that deletes stored records must + -- say what it deleted, and the debug log is where a report can quote it from. + if (nGroups + nEffects) > 0 then + DF:Debug("AURADESIGNER", + "PIH sweep: removed %d retired icon group(s) and %d unreachable effect(s)", + nGroups, nEffects) + end + if s.retainedCfg then s.retainedCfg.iconGroups = nil end + end + + -- 6. The amplifier list folds into the cooldown list. Whatever the user had ticked keeps + -- meaning what it meant -- "count trinkets too" -- but it now reaches the effects instead + -- of a group that is no longer there. + -- ⚠ THE TICKS ARE READ, NOT RE-DERIVED. s.potions / s.trinkets / s.racials are the + -- user's stored choices and they are what step 6 replays; the old list's CONTENTS are not + -- consulted, because a hand edit made in the Filter Designer to a list that is about to be + -- deleted is not a preference anyone can be held to. + pihSyncTriggerExtras(s) + if R then + local ampId = pihFilterIdByName(PIH_FILTERS.amplifiers) + if ampId and R.DeleteCustomFilter then R:DeleteCustomFilter(ampId) end + end + + -- 7. NO STACK COUNT ON A HELPER MARKER. Placed only -- a frame-level effect has no + -- stacks to begin with -- and set rather than cleared, because the FIELD being absent + -- means "inherit", and the inherited default is true. + -- ⚠ EVERY STORE, like step 5: an old marker can be sitting in a spec pool (see + -- pihPurgeStrayMarks for how it got there). Cheap -- the pools are small and this runs + -- once per profile. + do + local adDB7 = GetAuraDesignerDB() + for _, poolT in ipairs(adDB7 and pihAllAuraPools(adDB7) or {}) do + for _, auraCfg in pairs(poolT) do + if type(auraCfg) == "table" then + for _, inst in ipairs(auraCfg.indicators or {}) do + if type(inst) == "table" and inst.pihSignal then + inst.showStacks = false + end + end + end + end + end + end + + -- 8. STAMP THE CURATED DEFAULTS ON A LIST THAT ALREADY EXISTS. + -- ⚠ THE SAME VALUES pihEnsureFilter WOULD HAVE WRITTEN, from the same seed functions -- + -- not the list's CURRENT contents. Default means what the recipe seeds, so anything the + -- user has added since is theirs and is deliberately not part of what a reset restores. + -- ⚠ Only when the mark is missing: re-stamping would be harmless but re-deriving the + -- seed set on every sweep is work for nothing. + if R and R.SetCuratedDefaults and R.IsCuratedFilter then + local cdId = pihFilterIdByName(PIH_FILTERS.cooldowns) + if cdId and not R:IsCuratedFilter(cdId) then + R:SetCuratedDefaults(cdId, pihSeedIDs()) + end + local infId = pihFilterIdByName(PIH_FILTERS.infused) + if infId and not R:IsCuratedFilter(infId) then + R:SetCuratedDefaults(infId, { PIH_PI_SPELL_ID }) + end + end + + -- 9. THE RACIALS LIST, FOR A HELPER THAT PREDATES IT. Four ids written out in this file + -- became a curated list so the row could have a pencil, a moving count and a reset -- + -- Krathe, 2026-09-10: "racial show 4 and no edit pencil?" + -- ☠ ONLY WHERE THE HELPER ALREADY EXISTS. This sweep runs on every Aura Designer build + -- for a priest, helper or no helper, so an unconditional create would put a filter nobody + -- asked for into the Filter Designer of every priest who has never opened the feature. + -- The cooldown list is the helper's own footprint, so its presence is the condition. + -- ⚠ NO RE-SYNC NEEDED AFTER IT. Step 6 above ran pihSyncTriggerExtras while the list did + -- not exist yet, and pihAmplifierIDs falls back to the same four ids the list is seeded + -- with -- so the set it wrote is the set it would write now, not an earlier guess at it. + if pihFilterIdByName(PIH_FILTERS.cooldowns) then pihEnsureRacialFilter() end + + -- 10. ☠☠ THE COPIED AMPLIFIER SPELLS COME BACK OUT OF THE COOLDOWN LIST. + -- Until now the three amplifier ticks COPIED their lists in, so a helper with all three on + -- carries 91 spells where it should carry 40 -- Krathe's "so they are now twice on? this is + -- very confusing". The ticks write `includes` now (see pihSyncTriggerExtras); this takes + -- back what the old ones left behind, or the list would keep watching those spells twice + -- over and reading 91 forever. + -- ⚠ dfDefaults IS THE FENCE. Anything in the seed stays, whatever else it is also in -- + -- an offensive cooldown that happens to sit in the trinket list is OURS by seed and is not + -- what this step is hunting. Step 8 above guarantees the mark is there to read. + -- ⚠ A HAND-ADDED TRINKET GOES TOO, and that is accepted rather than overlooked: a copied id + -- and one the user typed in are indistinguishable in the store, and the include brings it + -- straight back for anyone who has that source ticked. The alternative is leaving 51 + -- unexplainable rows behind to be safe about one hypothetical. + -- ⚠ THE PER-SPELL TICK GOES WITH THE SPELL. A `disabled` entry for an id that is no longer + -- a member is invisible dead weight that would spring back if the id ever returned. + do + local cdId = pihFilterIdByName(PIH_FILTERS.cooldowns) + local f = cdId and R and R.GetCustomFilter and R:GetCustomFilter(cdId) + if f and R.RemoveSpellFromCustom then + local seeded = f.dfDefaults or {} + local n = 0 + for _, sid in ipairs(pihAmplifierIDs(PIH_ALL_AMPLIFIERS, true)) do + if not seeded[sid] and (f.spells[sid] or f.rawIDs[sid]) then + R:RemoveSpellFromCustom(cdId, sid) + if f.disabled then f.disabled[sid] = nil end + n = n + 1 + end + end + if f.disabled and not next(f.disabled) then f.disabled = nil end + if n > 0 then + DF:Debug("AURADESIGNER", + "PIH sweep: removed %d amplifier spell(s) copied into the cooldown list; " + .. "they are referenced now", n) + end + end + end + -- ...and the references go in, in the same pass. Step 6's own call ran before the racials + -- list was guaranteed to exist (step 9), so the racials arm could have written nothing. + pihSyncTriggerExtras(s) + + -- 11. THE ICON GROUP GETS THE FIELDS ITS HAND-WRITTEN RECORD OMITTED. + -- P.PIH_AddIconGroup built its record as a table literal and forgot `iconSize` and + -- `maxIcons`; both sliders bind the field directly, so both drew blank, and the factory + -- falls back to 8 for a filter group's max. Krathe: "Max icons should default to 4 it's + -- showing blank but seems to look like 8? Icon size is also showing blank on the slider." + -- ⚠ NIL ONLY. A group somebody has already sized is theirs; this fills the gaps a bad + -- create left, it does not restore defaults. + -- ⚠ READ OFF P.NewLayoutGroupRecord rather than typed here, so this step cannot disagree + -- with what a fresh group is made of -- which is the fault it exists to repair. + -- ⚠ ANCHOR AND GROW ARE NOT IN THE LIST. The helper's group means TOPRIGHT/LEFT_DOWN and + -- the shared record means TOPLEFT/RIGHT_DOWN, so filling those from it would move an + -- existing group. They are set on every create and cannot be nil. + do + local g = P.PIH_IconGroup and P.PIH_IconGroup() + if g and P.NewLayoutGroupRecord then + local def = P.NewLayoutGroupRecord(g.id, g.name, "filter") + for _, k in ipairs({ "iconSize", "maxIcons", "iconsPerRow", "spacing", + "offsetX", "offsetY" }) do + if g[k] == nil then g[k] = def[k] end + end end end - -- 3. The infused icon group: the Icon surface replaced it, and nothing represents it now. - local ig = pihIconGroup("infused") - if ig and P.DeleteLayoutGroup then P.DeleteLayoutGroup(ig.id) end + -- 12. THE GROUP IS CALLED "Icons", NOT "Cooldowns". Krathe, 2026-09-10: "maybe it should + -- be called PI Helper - Icons not cooldowns as it can be trinkets etc too?" -- and more so + -- since it gained a SHOW block that can set it to trinkets ONLY, where the old name is not + -- vague but wrong. + -- ⚠ ONLY IF IT STILL HOLDS THE EXACT OLD STRING. The card has an editable Group Name + -- field, so anything else is a name the USER typed -- renaming that would be this addon + -- overwriting their words to satisfy its own tidiness. + -- ☠ THE OLD NAME IS A LITERAL, NOT A CONSTANT, and only because this file is at Lua's + -- 200-local ceiling -- see the note on PIH_ICON_GROUP_NAME. It is written once, here, in + -- the one place that needs to recognise it. + -- ⚠ THE NAME IS STORED DATA, never L[]: a translated string in the profile is a name that + -- changes when the client's language does. Same rule the three filter names follow. + do + local g = P.PIH_IconGroup and P.PIH_IconGroup() + if g and g.name == "PI Helper — Cooldowns" then + g.name = PIH_ICON_GROUP_NAME + end + end s.schema = PIH_SCHEMA + S.activeBuffTab = prevPool if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end end +-- ☠ EXPORTED, AND THE REASON IS WHO NEVER OPENS THIS PANEL. The sweep used to run only from +-- S.BuildPIHelperCard -- so a stuck cooldown-icon group was deleted when, and only when, the +-- user visited the helper's Triggers tab. That is fine for the person who came to complain +-- about it and no use at all to the person who does not know where it came from: the group +-- draws on every frame and on the designer's own preview, and there is no longer any control +-- anywhere that can turn it off. So both designer builders run it for a priest, which makes +-- "open the Aura Designer at all" the condition rather than "find the right tab". +-- ⚠ CHEAP TO CALL ANYWHERE. It early-outs on the schema stamp after the first run. +P.PIH_Sweep = pihSweep S.BuildPIHelperCard = function(parent, opts) opts = opts or {} -- Before anything reads the pool: the panel is the first place an un-swept helper would -- show a control that does not match what is on screen. pihSweep() + -- ⚠ RE-STAMP THE REFERENCES, and this is a REPAIR now rather than a re-sync. The ticks + -- write `includes` on the cooldown list, and that list travels: a profile import copies + -- `spells` and `rawIDs` and nothing else, so an imported helper arrives with its ticks + -- intact and its references missing. Running it here means the first visit puts them back. + -- ☠ IT USED TO BE LOAD-BEARING. The ticks COPIED each preset's spells into our list, so + -- this was how an edit made in the Filter Designer since the last visit reached the helper + -- at all -- and a pencil that opened a list whose edits went nowhere is precisely what + -- made the copy indefensible. References need no such visit; see pihSyncTriggerExtras. + -- ⚠ CHEAP AND IDEMPOTENT: three ticks read, one small table written. + pihSyncTriggerExtras(P.PIH_Settings()) local yPos = opts.startY or 0 local Refresh = opts.Refresh or function() end - local tc = GetThemeColor() - local exists = P.PIH_Exists() - local pihBlock = GUI:CreateChoiceCardGroup(parent, { - title = L["POWER INFUSION HELPER"], - accent = tc, - -- ⚠ WIDTH PASSED, NOT LEFT TO THE ANCHORS. Without it the card keeps its - -- fixed CHOICE_CARD_H and cannot measure its wrapped description -- fine - -- at the classic tab's width, where the sentence fits the floor, but in - -- the 260px popout pane the same sentence wraps past the card's bottom - -- edge. The parent is sized before this builder runs in both arms (the - -- pane by its mount, the classic host by its caller), and the 16 is the - -- block's two 8px insets below. - width = (parent:GetWidth() or 320) - 16, - onToggle = function() Refresh() end, - cards = { - { - title = exists and L["Remove the helper"] or L["Add the helper"], - desc = exists - and L["Deletes its indicators and its spell lists. Nothing else is touched."] - or L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."], - art = { kind = "border", color = { 1.00, 0.82, 0.25 } }, - onClick = function() - if P.PIH_Exists() then P.PIH_Remove() else P.PIH_Create() end - Refresh() - end, - }, - }, - }) - pihBlock:SetPoint("TOPLEFT", 8, yPos) - pihBlock:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - yPos = yPos - (pihBlock.layoutHeight + GUI.Space.section) - -- ☠ THE SECOND RETURN GATES THE CLASSIC SECTIONS -- exists AND pihBlock.expanded. - -- The card group carries its own collapsing header and publishes whether it is - -- open; without asking, folding the header away would hide the card and leave its - -- settings stranded below a closed section, attached to nothing visible. One - -- header, the whole helper. (The row page ignores it: there each section sits - -- behind its own row, and the ROW is the fold.) - return yPos, (exists and pihBlock.expanded) and true or false + -- ⚠ THE STORED FLAG, NOT THE RECORDS. "Is the helper on" and "does it hold any + -- effects" are two different questions since the switch stopped deleting -- a + -- disabled helper keeps every record it had. P.PIH_IsEnabled backfills the flag once + -- for a profile that predates it. + local enabled = P.PIH_IsEnabled() + + -- ── AN ENABLE TICK, NOT AN ADD/REMOVE CARD (2026-09-08) ── + -- ☠ "ADD" AND "REMOVE" WERE THE IMPLEMENTATION TALKING. They were literally true -- + -- the helper creates and deletes Aura Designer records -- but that is plumbing the user + -- was never meant to know about, and on a page whose whole subject IS the helper a card + -- offering to add the thing you came here for is a step with nothing on the other side + -- of it. Krathe, 2026-09-08: "the add/remove helper is a pointless option now and + -- should just be an enable/disable setting like the AD enable/disable." + -- ⚠ SO IT READS LIKE THE DESIGNER'S OWN ENABLE, deliberately: the same banner, the same + -- styled check button, the same left-aligned label. Two features that turn on the same + -- way should look like they turn on the same way. + -- ⚠ AND YOUR SETTINGS SURVIVE THE ROUND TRIP. Unticking still routes to PIH_Remove, + -- which STASHES every customisation (pihStash), and PIH_Create lays the stash back over + -- the fresh defaults (pihRestoreGroup) -- so this behaves like an enable even though + -- records really are created and deleted underneath. That was already true of the old + -- card; the tick just stops making the user think it is a destructive act. + local banner = CreateFrame("Frame", nil, parent, "BackdropTemplate") + -- ⚠ ONE ROW, NOT TWO. The explaining sentence was a second line under the tick; it is a + -- tooltip now (Krathe, 2026-09-08). It earns a hover and not a permanent row: it is read + -- once, by someone deciding whether to turn the feature on, and after that it is a + -- sentence in the way of the settings every visit -- on a page whose own nav entry + -- already says Power Infusion Helper. + banner:SetHeight(32) + banner:SetPoint("TOPLEFT", 8, yPos) + banner:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + GUI:CreatePanelBackdrop(banner, { borderColor = { r = 0.30, g = 0.30, b = 0.30, a = 0.5 } }) + + local cb = CreateFrame("CheckButton", nil, banner, "BackdropTemplate") + cb:SetPoint("TOPLEFT", banner, "TOPLEFT", 10, -10) + DF.GUI:StyleCheckButton(cb) + cb:SetChecked(enabled) + cb:SetScript("OnClick", function(self) + -- ⚠ READ THE STORED FLAG, NOT THE BOX. The tick's own state is what the user just + -- did; the flag is what the profile says. A double click, a profile switch landing + -- mid-build or a stale page would otherwise write the wrong direction. + -- ☠ AND IT NO LONGER DELETES ANYTHING. This used to be + -- `if P.PIH_Exists() then P.PIH_Remove() else P.PIH_Create() end` -- off meant + -- destroying every helper record and stashing a copy to fake reversibility, which is + -- how Krathe's border went missing. The flag is the switch now; the records stay. + P.PIH_SetEnabled(not P.PIH_IsEnabled()) + self:SetChecked(P.PIH_IsEnabled()) + Refresh() + end) + + local cbLabel = banner:CreateFontString(nil, "OVERLAY", "DFFontNormal") + cbLabel:SetPoint("LEFT", cb, "RIGHT", 8, 0) + cbLabel:SetText(L["Enable Power Infusion Helper"]) + cbLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- ⚠ THE HIT AREA IS THE WHOLE BANNER, not just the 16px box. A tooltip on a checkbox the + -- size of a full-stop is a tooltip nobody finds; the row is what the eye is on. + banner:EnableMouse(true) + banner:SetScript("OnEnter", function(self) + GUI:ShowTooltip(self, { + title = L["Power Infusion Helper"], + lines = { L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."] }, + }) + end) + banner:SetScript("OnLeave", function() GUI:HideTooltip() end) + + banner.checkbox = cb + yPos = yPos - (banner:GetHeight() + GUI.Space.section) + -- ☠ THE SECOND RETURN GATES THE SECTIONS, on the SWITCH now rather than on whether + -- records exist. The old card group carried its own collapsing header and published + -- whether it was open, so the sections had to respect a fold that no longer exists -- + -- a banner does not fold. + return yPos, enabled end -- ── THE SECTION TOOLKIT ── @@ -6172,155 +7534,16 @@ local function pihMakeTools(parent, opts) -- Each tick creates or deletes one ordinary effect, which is why the signal rows -- also appear in Active Indicators: they ARE indicators, and hiding them there -- would mean a row you can see the colour of but cannot find. - function t.signalRow(g, key, label) - local Refresh = t.Refresh - - -- ☠ NO MASTER TICK. Each signal used to open with a checkbox that turned the - -- whole thing on and off -- and its state was DERIVED from the two controls under it - -- (a signal is "on" when it has a colour or has icons), so it summarised its own - -- neighbours rather than deciding anything. With three signals that tick answered a - -- real question, "which of these do I want"; with two, both of which are the feature, - -- the only question left is HOW each one shows, and the dropdown's "None" already - -- answers it. Three controls per row became two, and every tick on the panel now does - -- exactly one thing -- which is what the old row could not say: its master tick, its - -- icons tick and its three includes looked identical and worked at three different - -- levels. - local surface = P.PIH_SurfaceOf(key) or "none" - - -- The signal's NAME is the dropdown's label now, sitting above it the way every - -- other setting in these panels is labelled -- the row reads "Big cooldown: Border" - -- rather than needing a tick to say which signal the menu belongs to. - local dd = GUI:CreateDropdown(parent, label, P.PIH_SurfaceOptions(key), - nil, nil, nil, - -- ⚠ NEVER nil: this widget survives a profile switch for one frame, - -- and the shared dropdown's display refresh treats a nil answer as "try - -- the saved-variable fallback", which was never given -- a Lua error on - -- every profile switch away from the helper. "none" is a value the menu - -- owns, so a dying row reads honestly until it is rebuilt away. - function() return P.PIH_SurfaceOf(key) or "none" end, - function(v) - local ok, why = P.PIH_SetSurface(key, v) - if not ok then DF:DebugWarn("AURADESIGNER", - "PIH: surface %s refused -- %s", tostring(v), tostring(why)) end - Refresh() -- the other rows' menus re-grey around it - end, - nil) - -- The standalone form stamps its own row height, so the shared constant is the - -- honest measurement rather than the literal an inline dropdown needed. - g:AddWidget(dd, GUI.RowHeight.dropdown) - - -- ⚠ THE CLASH WARNING, AND IT IS SCOPED ON PURPOSE. pickWinner decides from - -- config alone and never asks what is on the unit, so a clash is fully knowable - -- while someone is setting it up -- no guessing, no "this might happen". - -- It appears only on the three surfaces that actually take a single winner, and - -- it names the fix that exists rather than describing the problem. - -- ⚠ Only while OUR effect is actually in the contest: the named fix - -- can be applied to our own signal too (custom-mode border), and the warning - -- must go when it is. - local selfIn = P.PIH_SelfContends(surface, key) - local clashes, who = 0, nil - if selfIn then clashes, who = P.PIH_ClashOn(surface) end - -- ⚠ OUR OWN SIBLING COUNTS TOO, on a contended surface across records. - -- PIH_ClashOn skips anything carrying a helper mark, because two signals on one - -- record are prevented outright rather than warned about. "Already infused" is - -- on its own record though, so it can genuinely lose a border or a text to one - -- of the other two -- a real contest that would otherwise go unwarned precisely - -- because it was ours. - local sibling = selfIn and P.PIH_SiblingContends - and P.PIH_SiblingContends(surface, key) or nil - if sibling then - clashes = clashes + 1 - who = who or pihLabel(sibling) - end - if clashes > 0 then - who = who or L["Another effect"] - -- More than one contender: naming only the first would read as "fix this - -- one and you are done", which would not be true. - if clashes > 1 then who = format(L["%s and %d more"], who, clashes - 1) end - -- A CAUTION BOX, the addon's own construct for a warning panel -- the same - -- one the click-casting dialog and the profiler use. It briefly became gold - -- text on the belief that the box was what broke the layout; it was not, and - -- a warning that looks like every other warning is worth the box. - -- The checkbox's own label key rides as a placeholder so a translator - -- renders it ONCE -- hardcoding the words here let the sentence and the - -- control it points at drift apart in any other language. - t.note(g, (surface == "border") - and format(L["%s already colours the border. Only one can show — tick '%s' on one of them, or move this signal somewhere else."], who, L["Give this aura its own border"]) - or format(L["%s already colours this text. Only one can show — raise this signal's priority, or move it somewhere else."], who), - "caution") - end - - -- Icons sit BESIDE the colour dropdown, equal weight: with "None" in the - -- menu, one row enumerates colour-only / icons-only / both. Every row has - -- the same flow -- tick, dropdown, icons -- which is what three earlier - -- shapes kept breaking by parking the trinkets control under the wrong - -- signal. ⚠ Strong's tick is labelled by what it SHOWS -- its - -- amplifier half -- because icons cannot make its cooldown-AND-amplifier - -- judgement; a bare "As icons" there would over-promise. The colour tint - -- stays the only display that judges. - -- Only the cooldown signal has an icon row. Infused draws as a placed Icon through - -- the dropdown above -- the same picture, chosen where every other surface is chosen, - -- rather than through a second control that means the same thing. - local which = PIH_ICON_OF[key] - local st = which and P.PIH_Settings() or nil - local anyIcons = which and (P.PIH_IconsShow(which) or st.trinkets == true - or st.potions == true or st.racials == true) or false - - if which then - -- ☠ FOUR LISTS, FOUR TICKS, AND A HEADER RATHER THAN A MASTER. The icon row - -- draws from four spell lists, and only three of them used to have a tick -- - -- cooldowns were implicit, because the control sat under the cooldown signal and - -- was assumed to mean it. That asymmetry is what made "include" the only word - -- available for the other three: they read as extras to something unnamed. - -- - -- ⚠ NO MASTER TICK, DELIBERATELY, and it was drawn both ways before this one - -- was chosen. A master would be DERIVED -- on when any child is on -- which is the - -- pattern removed from the signal rows for summarising its neighbours rather than - -- deciding anything. It also deadlocks the obvious reading: grey the children while - -- the master is off and no child can be ticked, so the only route back turns on all - -- four. A header names the set and costs nothing. - t.settingLabel(g, L["Icons"]) - t.subCheck(g, L["Cooldowns"], - function() return P.PIH_IconsShow(which) end, - function(v) P.PIH_SetIconsShow(which, v); Refresh() end) - -- ⭐ THE AMPLIFIERS: what makes a burst BIGGER, as against the cooldown list, which - -- says one is happening at all. Equal ticks now, so any of them can show alone. - local function amp(label, field) - t.subCheck(g, label, - function() return P.PIH_Settings()[field] == true end, - function(v) P.PIH_SetAmplifier(field, v); Refresh() end) - end - amp(L["Trinkets"], "trinkets") - amp(L["Potions"], "potions") - amp(L["Racials"], "racials") - - -- ☠ UNDER THE TICKS IT IS ABOUT. This used to be the LAST line in the box, - -- below the gate switch -- three controls away from the icons it explains, which is - -- the addon's convention read backwards: every CreateNote call site in the settings - -- puts its prose directly under the control it belongs to, one of them saying so - -- outright ("in the place the missing control would have occupied"). - -- Only while a group exists: with no icons there is nothing to move or size. - if pihAnyIconGroup() then - t.note(g, L["Move and size the icons under Layout Groups."]) - end - end - - -- ☠ THE NOTE EXISTS TO TEACH THE ICONS-ONLY SETUP, not to warn about "None". - -- Only a signal with an icons row gets one: there, "None" plus icons off is a dead end - -- someone can land in without realising the two controls are meant to be used - -- independently. On a signal whose menu is its ONLY control, "None" means nothing - -- shows and says so on its face -- a note there would explain a word to someone who - -- just chose it. - -- ⚠ Reachable only while ANOTHER signal is keeping the helper alive: on the last - -- one, this same state retires the helper and the row goes with it. Watched. - if which and surface == "none" and not anyIcons then - -- The tick's own label rides as the placeholder, the same way the border clash - -- warning names its remedy: a translator renders those words ONCE, so the - -- sentence and the control it points at cannot drift apart in any language. - t.note(g, format(L["Cooldowns are not showing. Add a display from the dropdown, or tick '%s'."], - L["Cooldowns"]), "caution") - end - end + -- ── t.signalRow: REMOVED, 2026-09-08 ── + -- ☠ It was the whole per-signal control: a surface dropdown, a clash warning, a colour + -- swatch and the icon lists. The Effects tab is the DESIGNER's add flow and effect + -- cards now (pihBuildEffectsTab), so the first three are drawn by the designer's own + -- widgets and cannot drift from them -- which is the whole point of the change. + -- ⚠ THE ICON LISTS SURVIVED THE REMOVAL, deliberately: they moved to pihAddIconLists and + -- are drawn under Triggers, where they belong -- each tick decides which category of buff + -- COUNTS, not how anything is drawn. Deleting this function without lifting them would + -- have quietly removed four of Maelareth's settings (#263) as a side effect of a + -- layout change. return t end @@ -6368,9 +7591,62 @@ local function pihAddGateAndNotes(g, t) -- single checkbox is more chrome than the setting is worth, and this label says -- what it does without a header to lean on -- which is the test for whether a -- control can live under a heading that does not quite describe it. - t.check(g, L["Hide the helper while your Power Infusion is on cooldown"], - function() return P.PIH_Settings().gateEnabled ~= false end, - function(v) P.PIH_SetGateEnabled(v) end) + -- ⚠ THAT TEST IS STILL MET BY A SHORTER LABEL. "Show even if your Power Infusion is on + -- cooldown" spelled the whole rule out on the row and wrapped doing it; Krathe, 2026-09-10: + -- "less verbose with tooltip and more clear." The rule moved to the tooltip; what stays on + -- the row is which cooldown is meant. + -- ⚠ KRATHE'S OWN WORDING, VERBATIM. My draft was "Show when I can't infuse" -- shorter, and + -- it identified the cooldown only by implication. Naming Power Infusion says it outright, + -- which matters in a box otherwise full of OTHER people's cooldowns: a label that does not + -- say which spell reads as the tracked one. (That is also why "Show while on cooldown", + -- shorter still, was never an option.) + -- ★ ASKED THE OTHER WAY ROUND (2026-09-08), for the same reason the roles were: every + -- other tick on this panel turns something ON when ticked, and this one turned a + -- SUPPRESSION on -- so the whole box read as a list of things you enable except for the + -- one that hid things. Krathe: "Hide the helper while on CD should be 'show even if PI is + -- on CD' off by default." + -- ⚠ THE STORE IS UNCHANGED AND THE DEFAULT ALREADY MATCHES. gateEnabled ships true, so + -- `not gateEnabled` reads as UNTICKED -- which is the off-by-default he asked for -- and + -- nobody's saved choice changes meaning. Inversion in the UI only, exactly like the roles. + local gateCb = t.check(g, L["Show when Power Infusion is on Cooldown"], + function() return P.PIH_Settings().gateEnabled == false end, + function(v) P.PIH_SetGateEnabled(not v) end) + -- ⚠ NO TITLE, WHICH MEANS THE LABEL. ResolveTooltipSpec fills a missing title from the + -- widget's label, and this label names its own setting -- unlike the sound tick, which + -- passes a title because "Enable" heads nothing. + -- ⚠ TWO LINES, ONE STATE EACH, and each one a plain sentence. What was here read + -- "markers appear only while your Power Infusion is ready, so you are never pointed at + -- someone you cannot infuse" -- a clause explaining a consequence of a rule it had not + -- finished stating, in a vocabulary this panel does not use. Krathe, 2026-09-10: + -- "markers? it should be effects and the wording itself is not very clear." + -- ⚠ "EFFECTS" IS THE PANEL'S OWN WORD -- what the Effects tab lists and what ACTIVE + -- INDICATORS holds. "Marker" belongs to the raid target icon and the dispel corner mark, + -- which are other features entirely. + -- ⚠ OFF FIRST. Off is the default, so the reader's first line is the behaviour they have. + -- ⚠ "off cooldown" / "on cooldown" ECHOES THE LABEL rather than reaching for a synonym: + -- the tick says on Cooldown, so the explanation says the same words back. + if gateCb then + gateCb.tooltip = { lines = { + L["Off: the helper's effects only appear while your Power Infusion is off cooldown."], + L["On: they appear even while it is on cooldown."], + } } + end + + -- ★ SHOW IN COMBAT ONLY (2026-09-10), asked for by Krathe. It sits with the cooldown + -- gate because it is the same kind of thing -- a condition on whether the helper has + -- anything to say at all -- and under the same heading for the same reason that one is: + -- a titled box around a single tick is more chrome than either setting is worth. + -- ⚠ THE TWO ARE INDEPENDENT, and the tooltip says so rather than leaving the reader to + -- work out how two conditions on one feature combine. Both must pass. + local combatCb = t.check(g, L["Show in combat only"], + function() return P.PIH_Settings().combatOnly == true end, + function(v) P.PIH_SetCombatOnly(v) end) + if combatCb then + combatCb.tooltip = { lines = { + L["Off: the helper works wherever you are."], + L["On: nothing shows until you are in combat. Independent of the cooldown setting above -- both have to pass."], + } } + end -- ☠ THE CONTENTION NOTES ARE GONE, AND THE ARGUMENT THAT KEPT THEM WAS THE @@ -6387,16 +7663,28 @@ local function pihAddGateAndNotes(g, t) -- icon group exists: position is not a question about icons that are not there. end +-- ★★ ASKED POSITIVELY: "WATCH THESE ROLES", NOT "NEVER SHOW ON THESE" (2026-09-08). +-- ☠ THE STORE IS STILL AN EXCLUSION SET AND THAT IS DELIBERATE. helperExcludedRoles is what +-- the container gate reads, and it fails open on a missing entry -- a group with no assigned +-- roles reads "no role" for everyone and nothing is hidden, which is the safe direction. +-- Rewriting the store to a positive set would flip that: an empty table would mean "watch +-- nobody" and the whole feature would go dark on a group without role assignments. +-- ⇒ THE INVERSION IS IN THE UI ONLY. Ticked means watched, which is `not excluded`. +-- ⚠ AND THE OLD DEFAULTS ALREADY ARE THE NEW ONES: the store ships { TANK, HEALER } +-- excluded, which reads through this inversion as DPS on, Tanks and Healers off -- exactly +-- what Krathe asked for. No migration, and nobody's saved choice changes meaning. +-- ⚠ DAMAGER is a real UnitGroupRolesAssigned token (Core.lua's GetUnitRole passes it +-- through), so unticking DPS excludes it the same way the other two do. It was simply never +-- offered before, which made "watch DPS" an invisible always-on rather than a choice. local function pihAddRoles(g, t) - -- ⚠ FAILS OPEN. A group with no assigned roles reads as "no role" for everyone - -- and nothing is excluded. Marking a tank you did not want is a smaller failure - -- than silently hiding the signal on the damage dealers you did. - t.check(g, L["Tanks"], - function() return (P.PIH_Settings().roles or {}).TANK == true end, - function(v) P.PIH_SetRole("TANK", v) end) - t.check(g, L["Healers"], - function() return (P.PIH_Settings().roles or {}).HEALER == true end, - function(v) P.PIH_SetRole("HEALER", v) end) + local function roleCheck(label, token) + t.check(g, label, + function() return (P.PIH_Settings().roles or {})[token] ~= true end, + function(v) P.PIH_SetRole(token, not v) end) + end + roleCheck(L["DPS"], "DAMAGER") + roleCheck(L["Tanks"], "TANK") + roleCheck(L["Healers"], "HEALER") t.note(g, L["Groups without assigned roles show everyone."]) end @@ -6405,6 +7693,29 @@ end -- button for single spells bolted on top, which is two different questions -- WHOSE cooldowns, -- and WHICH cooldowns -- under one name that only answered the first. Each half now carries a -- label at setting weight, and the box is named for both. +-- ⚠ DECLARED ABOVE ITS CALLERS, and it has two now: the source rows' pencils and the +-- Edit Cooldowns button inside pihAddClasses. Moving the button into that function put a +-- caller ABOVE this definition, which compiles as a nil GLOBAL read -- invisible to +-- luac -p and caught only by the _ENV globals diff. +-- ⭐ GUI:OpenFilterInDesigner, NOT a bare SelectTab. It switches the page AND scrolls to +-- the list, selects it and pulses it -- Krathe's "flash link". Its own comment records why +-- the difference matters: a hand-written jump "landed you on the page with nothing +-- indicated, which is indistinguishable from a broken link". +-- ⚠ TWICE, ONE FRAME APART, and that is a workaround rather than belt-and-braces: +-- _fdFocusFilter clamps its scroll against GetVerticalScrollRange, which is still 0 on the +-- target page's FIRST build -- so the row it selected sits below the fold. The second call +-- runs after layout. The proper fix is a deferred retry inside _fdFocusFilter; that file is +-- Danders' and it is on the list for him rather than edited from here. +local function pihOpenFilter(kind, key) + if not (key and GUI.OpenFilterInDesigner and GUI.Pages and GUI.Pages["auras_filterdesigner"]) then + return + end + GUI:OpenFilterInDesigner(kind, key) + if C_Timer and C_Timer.After then + C_Timer.After(0, function() GUI:OpenFilterInDesigner(kind, key) end) + end +end + local function pihAddClasses(g, t) local parent = t.parent t.settingLabel(g, L["Classes"]) @@ -6430,7 +7741,16 @@ local function pihAddClasses(g, t) and DF.FilterRegistry.ClassDisplayName(classFile)) or classFile local w = t.check(g, name, function() return P.PIH_ClassOn(classFile) end, - function(v) P.PIH_SetClassOn(classFile, v) end, + -- ⚠ t.Refresh AS WELL AS THE WRITE, because this tick changes the BOX HEADER. + -- The count beside "Classes and Cooldowns" is read at build time, and + -- P.PIH_SetClassOn ends at pihRefresh -- which redraws the FRAMES and the + -- preview, not the panel. So the number sat stale until the page was rebuilt + -- by something else: "the number does not update unless you go back to the + -- page as you tick off classes" (Krathe, 2026-09-09). + -- ⚠ THE ONLY TICK IN THIS BOX THAT NEEDS IT. The amplifier ticks show category + -- sizes, which are constants, and they add to the list rather than to the seed + -- set the header counts -- so nothing on screen moves when they change. + function(v) P.PIH_SetClassOn(classFile, v); t.Refresh() end, wrapW) -- ☠ CLASS-COLOURED, THROUGH THE SHARED HELPER. Thirteen identical grey rows is -- the one list on this panel nobody can scan -- and the addon already answers that @@ -6446,63 +7766,59 @@ local function pihAddClasses(g, t) end t.note(g, L["Untick a class to stop watching its cooldowns."]) - t.settingLabel(g, L["Cooldowns"]) - -- ☠ THE ESCAPE HATCH FOR WHAT THE LIST CANNOT DO -- single spells rather than whole - -- classes -- so it sits under its own label at the end of the box rather than opening it. - -- It was above the ticks on the argument that a button below a long list goes unscrolled; - -- naming the half it belongs to ("Cooldowns", as against "Classes") does that job without - -- putting a button before the list it is an escape from. - -- - -- ⭐ GUI:OpenFilterInDesigner, NOT a bare SelectTab. It switches the page AND - -- scrolls to this filter, selects it and pulses it. Its own comment records why: - -- the hand-written version "landed you on the page with nothing indicated, which - -- is indistinguishable from a broken link" -- which is exactly what was here. - local cfID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() - local fdBtn = GUI:CreateButton(parent, L["Filter Designer"], 140, 22, function() - GUI:OpenFilterInDesigner("custom", cfID) - -- ⚠ TWICE, ONE FRAME APART, AND THAT IS A WORKAROUND. _fdFocusFilter reads - -- GetVerticalScrollRange to clamp its scroll, and on the page's FIRST build - -- that range is still 0 -- so the clamp pins the scroll at the top and the - -- row it selected and pulsed is somewhere below the fold. The second call - -- runs after layout, when the range is real. The proper fix is a deferred - -- retry inside _fdFocusFilter itself; that file is Danders' and it is on the - -- list for him rather than edited from here. - if C_Timer and C_Timer.After then - C_Timer.After(0, function() GUI:OpenFilterInDesigner("custom", cfID) end) - end + -- ★ THE BUTTON IS BACK, IN THE BOX THAT OWNS THE LIST. Krathe, 2026-09-10: "Cooldowns + -- should be in with the Classes and maybe should still be a button Edit Cooldowns". + -- ☠ WHAT WENT WRONG BEFORE WAS NEVER THE BUTTON. It was a button captioned for one of four + -- lists standing in a box that held all four, with a NOTE underneath apologising that the + -- other three were not really editable. The three have their own rows and their own pencils + -- now, so this one is unambiguous: it belongs to the list the ticks above it narrow, and it + -- says which list that is. + -- ⚠ A BUTTON RATHER THAN A PENCIL, deliberately: the pencils sit on ROWS, beside the tick + -- that includes that source. This box has no source row -- the thirteen class ticks are the + -- control -- so there is nothing for a glyph to sit on, and a full-width button reads as + -- belonging to the box rather than to whichever row it happened to be nearest. + local cdID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() + local cdBtn = GUI:CreateButton(parent, L["Edit Cooldowns"], 140, 22, function() + pihOpenFilter("custom", cdID) end) - if not (cfID and GUI.Pages and GUI.Pages["auras_filterdesigner"]) then - -- ⚠ THE SHARED TREATMENT, not a hand-written grey. CreateButton routes - -- through StyleButton, which owns SetDisabled: dim backdrop, faint border, label - -- alpha, wash suppressed. Disable() plus a literal text colour rendered a NORMAL - -- backdrop with grey text, visibly unlike every other disabled button in the - -- addon. Caught in Danders' PR review. - if fdBtn.SetDisabled then fdBtn:SetDisabled(true) - else fdBtn:Disable(); fdBtn.Text:SetTextColor(0.4, 0.4, 0.4) end + if not (cdID and GUI.Pages and GUI.Pages["auras_filterdesigner"]) then + -- ⚠ THE SHARED TREATMENT, not a hand-written grey. CreateButton routes through + -- StyleButton, which owns SetDisabled: dim backdrop, faint border, label alpha, wash + -- suppressed. Disable() plus a literal text colour rendered a NORMAL backdrop with grey + -- text, visibly unlike every other disabled button in the addon. Caught in review. + if cdBtn.SetDisabled then cdBtn:SetDisabled(true) + else cdBtn:Disable(); cdBtn.Text:SetTextColor(0.4, 0.4, 0.4) end end -- Prose-width like the notes: only the class TICKS flow the popout's two tracks. - fdBtn.fullRow = true - g:AddWidget(fdBtn, 28) - -- ☠ UNDER THE CONTROL IT EXPLAINS. Every CreateNote in the settings sits below its - -- control -- one call site puts it "in the place the missing control would have occupied". - -- Both notes in this section used to open it instead, on the argument that a line under a - -- long list goes unread. That argument is about THIS list; the convention is about the - -- whole addon, and a panel a user can tell apart from every other page is the thing the - -- convention exists to prevent. - t.note(g, - L["To add or remove single cooldowns, edit the list in the Filter Designer."]) + cdBtn.fullRow = true + g:AddWidget(cdBtn, 28) + end + local function pihAddSound(g, t) local parent, Refresh = t.parent, t.Refresh -- ☠ TWO SETTINGS, NOT ONE. The key remembers WHICH sound, the switch remembers -- WHETHER -- so turning it off and back on does not make anyone hunt for their -- sound a second time. Silent until chosen, either way: a cue nobody asked for -- is the fastest route to the whole feature being switched off. - t.check(g, L["Play a sound when someone becomes worth infusing"], + -- ⚠ "Enable", NOT A SENTENCE. The box is already captioned Sound Alert, so a label + -- restating the whole feature says it twice and wraps to two lines doing it -- Krathe, + -- 2026-09-10: "too verbose, make it Enable with a tooltip explaining what it does in + -- better english". The explanation goes where an explanation goes. + -- ⚠ A TABLE SPEC, so the tooltip keeps the BOX's title. ResolveTooltipSpec defaults a + -- bare string's title to the LABEL, and "Enable" heading its own tooltip tells nobody + -- which setting they are reading about. + local soundCb = t.check(g, L["Enable"], function() return P.PIH_Settings().soundOn == true end, function(v) P.PIH_SetSoundOn(v); Refresh() end) + if soundCb then + soundCb.tooltip = { + title = L["Sound Alert"], + lines = { L["Plays your chosen sound when a group member's cooldown makes them worth infusing."] }, + } + end if P.PIH_Settings().soundOn then g:AddWidget(GUI:CreateSoundDropdown(parent, L["Sound"], P.PIH_Settings(), "soundLSMKey", @@ -6515,78 +7831,567 @@ local function pihAddSound(g, t) end end --- ── THE ROW PAGE'S SECTION LIST ── --- One entry per POPOUT ROW, in row order. `title` is the LOCALE KEY, resolved at --- mount time (a file-scope L[...] would freeze on enUS -- the locale-refresh rule). --- `gated` says whether the row EXISTS at all; the row page re-evaluates it on every --- page build, which is why a gate flip must go through page:Refresh rather than an --- in-place pane rebuild (Rows.lua owns that distinction). --- build(parent, o) -> yEnd; o = { startY, Refresh, indent, header } -- the same --- contract S.BuildPIHelperPane hands the bodies, minus the card. +-- ── THE ROW PAGE'S SECTION LIST: REMOVED, 2026-09-08 ── +-- ☠ It described a layout that no longer exists. S.PIHelperSections existed so the popout +-- page could mount each section behind its own row, and that band went when the helper got +-- its own page -- leaving a table nothing read and a paragraph of reasoning about pane +-- widths and row counts that would have gone on looking maintained. +-- ⚠ The BODIES it wrapped are all still here (pihAddRoles / pihAddClasses / pihAddSound / +-- pihAddGateAndNotes) and S.BuildPIHelperBody composes them per tab. Only the row-page +-- adapter went. If a second layout ever needs them again, wrap them again -- do not read +-- this comment as a reason not to. + +-- ★★ TWO TABS, THE SAME SHAPE THE DESIGNER'S RIGHT PANEL USES (2026-09-08). +-- ☠ THE SPLIT IS THE FEATURE'S OWN TWO QUESTIONS, and they are answered at different +-- times. TRIGGERS is "what counts as worth infusing" -- roles, the cooldown gate, which +-- classes and which spells -- and is set up once, carefully, probably while reading a spell +-- list. EFFECTS is "how do I want to be told" -- surface, colour, sound -- and gets fiddled +-- with. One column holding both meant scrolling past the long class list every time you +-- wanted to nudge a colour. Krathe: "we should also split on the right side Triggers and +-- Effects." +-- ⚠ THE KEYS ARE THE TAB IDS, and they are what the page's tab bar drives. Order matters: +-- triggers first, because you cannot sensibly choose how to be told about something you +-- have not yet said you care about. + -- ★★★ FOUR SOURCES, FOUR ROWS, EACH WITH THE WAY IN TO ITS OWN LIST (2026-09-09). + -- + -- ☠ WHAT THIS REPLACES, AND WHY PROSE COULD NOT SAVE IT. The box had one button captioned + -- for one of the four lists and a NOTE underneath explaining that the other three were not + -- really lists you could edit -- an implementation detail (the ticks COPY a preset's spells + -- rather than referencing it) leaking into the panel and being apologised for. Krathe: + -- "the note below the link to edit the cooldown list is silly, the additional filters can + -- also be edited, this really is an unclear mess." + -- ⇒ He is right that they can be edited. The bug was that editing them did nothing, so the + -- panel had to talk you out of trying. + -- ★ AND THE REAL FIX CAME TWO ROUNDS LATER (2026-09-10). The first attempt kept the copy + -- and made it honour each preset's ticks, re-taking it on every visit -- which made edits + -- reach the helper, and left the list reading 91 spells with the same ids in two places: + -- "so they are now twice on? this is very confusing." A copy that tracks its source is + -- still a copy, and the pencil still promises something the tick does not do. + -- ⇒ The ticks write `includes` now and nothing is copied at all (pihSyncTriggerExtras). + -- Each row links to the list it names, and that list is the one being read. + -- ⚠ NO NOTE. Four rows that each do the obvious thing need no paragraph underneath; a note + -- explaining why a control does not behave as it looks is a bug report in prose. + -- + -- ⚠ THE FIRST ROW HAS NO TICK, and that is not an oversight. The thirteen class ticks ARE + -- that source's switch -- unticking them all is turning class cooldowns off -- and a tick + -- here as well was removed for causing real harm: both read off the list, so toggling it + -- wiped and restored all forty cooldowns and silently undid whichever classes the user had + -- turned off. See P.PIH_CooldownCounts. + -- ⚠ A LABEL WITH THE NUMBER IN IT, rather than a second right-aligned region. The row + -- widget is a checkbox and the toolkit sizes it; a count anchored into it would be the one + -- hand-placed element in a column that lays itself out. Numbers need no translating. + -- ⚠ ONE NUMBER WHEN NOTHING IS TICKED OFF, TWO WHEN SOMETHING IS. "41/41" spends a + -- fraction on the fact that nothing has been changed; "38/41" is the whole point of + -- showing a fraction at all. Same rule the Classes and Cooldowns header follows, so the + -- two boxes read the same way. + local function pihCountLabel(text, a, b) + if b and b ~= a then return text .. " " .. a .. "/" .. b end + return text .. " " .. (b or a) + end + + local function pihSourceRow(g, t, label, on, total, get, set, link) + local w + if set then + w = t.subCheck(g, pihCountLabel(label, on, total), get, set) + else + w = t.settingLabel(g, pihCountLabel(label, on, total)) + end + -- ⚠ ANCHORED TO THE ROW, not placed at a y of its own. The group owns the layout and + -- these rows flow with it; a glyph positioned against the panel would be correct until + -- the first time a label wrapped. + if w and link and GUI.CreateGlyphButton then + local glyph = GUI:CreateGlyphButton(w, { + -- ☠☠ A BOX BIGGER THAN THE ART, AND THE LABEL'S TOOLTIP HIT IS WHY (2026-09-10). + -- Krathe: "trying to click the edit pencils is very hard like the hit detection + -- is wrong on them." + -- ⇒ GUI:CreateCheckbox ends with GUI:AttachTooltip(container, label, txt), + -- which builds a motion-only hit frame over the LABEL's rect at the container's + -- level + 5 -- and t.check re-anchors that label to the container's RIGHT edge + -- so it wraps. So the label's hover rect covers this whole row INCLUDING this + -- button: the pencil never got an OnEnter, never brightened, never showed its + -- own tooltip, and hovering it raised the ROW's tooltip instead. The clicks did + -- land (AttachTooltip's hit takes motion and explicitly not clicks -- see its + -- own note), but a control that gives no sign it is under the cursor is a + -- control you are guessing at, which is exactly what "hit detection is wrong" + -- feels like from the other side. + -- ⇒ Three parts, and all three are needed: a forgiving 26x22 box around a + -- 14px pencil, a frame level above that hit (below), and the hit itself pulled + -- back off the button (below) so the pencil owns its own corner of the row. + width = 26, height = 22, iconSize = 14, + -- ⚠ THE EDIT PENCIL, NOT THE FILTER GLYPH. The filter icon means "narrow + -- what is listed" everywhere else in this addon -- it is what the ACTIVE + -- INDICATORS caption uses to pick which kinds to show -- and this button + -- opens a list for editing. Two verbs, one picture, and the wrong one: + -- "you did not use the edit pencil you used a filter icon instead" + -- (Krathe, 2026-09-10). Media/Icons/edit is the pencil every other + -- edit-this affordance in the addon uses (Rename, the nickname rows). + -- ☠ DOUBLE BACKSLASHES -- Lua 5.1 passes an unrecognised escape through as + -- the bare character, so a single-backslash path draws nothing at all. + texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\edit", + color = C_TEXT_DIM, + tooltip = { title = L["Edit this list"], lines = { L["Open it in the Filter Designer."] } }, + onClick = link, + }) + glyph:SetPoint("RIGHT", w, "RIGHT", -2, 0) + + -- ☠ ABOVE THE LABEL'S HIT, AND MEASURED RATHER THAN GUESSED. The hit frame's + -- level is the container's + 5 AS IT WAS WHEN THE CHECKBOX WAS BUILT, and + -- g:AddWidget has run since -- so "+6" from here is arithmetic on a number that + -- may have moved. Reading both and adding one cannot be wrong. + local base = w:GetFrameLevel() or 0 + local hit = w.dfTooltipHit + if hit then base = max(base, hit:GetFrameLevel() or 0) end + glyph:SetFrameLevel(base + 1) + + -- ...and the hit stops before the button. Level alone gives the pencil the hover + -- back, but the label's rect would still be a hole the row's own tooltip fires + -- from on the way in and out. `dfTooltipHit` is exposed for exactly this -- see + -- UI:AttachTooltip, "a caller that needs to re-anchor it" -- and the two corners + -- below are its own, with the right edge pulled in by the button's width plus its + -- gap. Still anchored to the LABEL, so it keeps tracking a re-set or re-fonted one. + if hit and w.label then + hit:ClearAllPoints() + hit:SetPoint("TOPLEFT", w.label, "TOPLEFT", 0, 2) + hit:SetPoint("BOTTOMRIGHT", w.label, "BOTTOMRIGHT", -30, -2) + end + end + return w + end + + local function pihAddTriggerSources(g, t) + local st = P.PIH_Settings() + + -- ⚠ CLASS COOLDOWNS IS NOT A ROW HERE. It lives with the class ticks that narrow + -- it, under its own header and its own button -- Krathe, 2026-09-10: "Cooldowns + -- should be in with the Classes and maybe should still be a button Edit Cooldowns". + -- That box is the BASELINE (always watched, narrowed by class); these three are + -- additions you opt into, which is what makes them a box of their own. + -- ⚠ ENABLED / TOTAL, NOT THE CATEGORY'S SIZE, and shared with the count on the + -- Cooldown Icons card (P.PIH_WatchedCount) so the two screens cannot report the same + -- feature differently. See P.PIH_PresetCounts for why the raw size was wrong. + local trink, potion = PIH_SEED.amplifiers.trinkets, PIH_SEED.amplifiers.potions + local trinkOn, trinkAll = P.PIH_PresetCounts(trink) + pihSourceRow(g, t, L["Trinkets"], trinkOn, trinkAll, + function() return st.trinkets == true end, + function(v) P.PIH_SetAmplifier("trinkets", v) end, + function() pihOpenFilter("preset", trink) end) + local potOn, potAll = P.PIH_PresetCounts(potion) + pihSourceRow(g, t, L["Potions"], potOn, potAll, + function() return st.potions == true end, + function(v) P.PIH_SetAmplifier("potions", v) end, + function() pihOpenFilter("preset", potion) end) + -- ★ FOUR, NOT THIRTEEN -- AND NOW A LIST YOU CAN OPEN. `racials` is every racial + -- ability and nine of its thirteen (Shadowmeld, Darkflight, Stoneform) are the + -- opposite of worth infusing behind, so this row can never be that category. It used + -- to be four ids written out in this file instead, which left it the only source with + -- no pencil -- "racial show 4 and no edit pencil?" (Krathe, 2026-09-10). The four are + -- the SEED of a curated list of ours now, so the row links to something that is + -- genuinely what it means, and a racial we missed can be added to it. + local racOn, racAll = P.PIH_RacialCounts() + local racID = P.PIH_RacialFilterID and P.PIH_RacialFilterID() + pihSourceRow(g, t, L["Racials"], racOn, racAll, + function() return st.racials == true end, + function(v) P.PIH_SetAmplifier("racials", v) end, + racID and function() pihOpenFilter("custom", racID) end or nil) + end + + -- ★★ THE PLAYER PICKER, MOUNTED (2026-09-10). + -- ⚠ THE NOTE COMES FIRST, and it is the one sentence that makes an empty list readable: a + -- picker with nothing in it looks like a filter that has been switched off, when it is + -- actually the default and means the opposite. Everything else on this tab narrows by + -- ticking things ON; this one narrows by having anything in it at all. + -- ⚠ GUI:CreateCompactRosterWidget, not the pinned-frames widget. That one is 460 wide with + -- two 224px panes and this column is ~230 -- one of its columns alone is the whole + -- surface. The compact one is the same rows, the same role icons and class colours and the + -- same Add-by-name field, in one list whose button toggles both ways. Krathe: "it can just + -- be based around it... as long as it looks and functions in the same way, but is adjusted + -- for the more narrow width". + -- ⚠ SIZED FROM THE GROUP, NOT FROM THE PANEL. g.padding is 10 and AddWidget insets, so the + -- widget asks the group for its own width rather than deriving one from t.noteW and being + -- wrong the first time either number moves. + local function pihAddPlayers(g, t) + -- ★★ THE SWITCH, ABOVE THE LIST IT GOVERNS (2026-09-11). See P.PIH_PlayersOn for why + -- the list stopped deciding this for itself. + -- ⚠ THE LIST STAYS EDITABLE WHILE THIS IS OFF, deliberately -- greying it out would + -- defeat the whole request, which is to keep a raid team written down between raids and + -- edit it whenever. Off means "not applied", not "not available". + -- ⚠ REFRESHES THE TAB because the header carries the count, and the count now depends on + -- this tick. Same reason the roster widget's onChange does. + local onCb = t.check(g, L["Only watch these players"], + function() return P.PIH_PlayersOn() end, + function(v) P.PIH_SetPlayersOn(v); t.Refresh() end) + -- ⚠ OFF FIRST, because off is what a pug night wants and the sentence that matters is + -- the promise that the list survives it. + -- ⚠ THE EMPTY-LIST RULE LIVES ON THE ON LINE, where it applies. It used to be the note's + -- first sentence, back when emptiness WAS the switch. + if onCb then + onCb.tooltip = { lines = { + L["Off: the helper watches everyone. Your list is kept for next time."], + L["On: only the players listed below. An empty list still means everyone."], + } } + end + t.note(g, L["Add players here to watch only them."]) + if not GUI.CreateCompactRosterWidget then return end + local w = GUI:CreateCompactRosterWidget(t.parent, { + width = (t.noteW or 230), + rows = 6, + getPlayers = function() return P.PIH_Players() end, + setPlayers = function(list) P.PIH_SetPlayers(list) end, + -- ⚠ REBUILDS THE TAB, because the header carries the count. The widget refreshes + -- itself for the list; this is for the number above it. + onChange = function() t.Refresh() end, + }) + w.fullRow = true + -- The widget knows its own height (list + the add row); AddWidget wants it up front. + g:AddWidget(w, (w:GetHeight() or 160) + 6) + end + +-- ★ THE ICON ASKS WHICH PICTURE, WITH PICTURES (2026-09-09). +-- ⚠ IT WAS A TICK ON THE CARD, AFTER THE FACT. Krathe: "when you add an icon it should then +-- have a graphic like we do for the other types to then pick the type i.e an actual icon or a +-- PI icon?" -- right, because those two are as different from each other as an icon is from a +-- square, and every other such choice on this grid is made by looking at it. +-- ⚠ A SECOND STEP RATHER THAN THREE ICON TILES ON THE MAIN GRID, because a signal holds one +-- effect per surface: two of them would both be "icon" and the second could never be added. +-- The choice is about one effect, so it is asked once that effect has been chosen. +-- ⚠ The tick on the effect card stays -- it is how you change your mind later without +-- deleting and re-adding. -- --- ⚠ FINER THAN THE CLASSIC GROUPS ON PURPOSE. Classic's "What to Show" box (intro, --- three signal rows, the gate, the display notes) measures ~600px at the pane's --- 260px width -- taller than the whole page a popout pane must fit. So the popout --- splits it four ways: an overview row and one row per signal. The classic composer --- below does NOT iterate this list -- it folds the same bodies back into the same --- boxes as ever, which is what keeps that layout byte-comparable. -local function pihSection(bodyFn, gopts) - return function(parent, o) - o = o or {} - local t = pihMakeTools(parent, o) - local header = (o.header == false) and nil or (o.headerText or nil) - return t.group(header, bodyFn, o.startY or 0, gopts) - end -end - --- ☠ THE SIGNALS SHARE ONE ROW, and that is a consequence of there being two of them. --- With three signals plus an amplifiers box, one row each was the only way the popout pane --- could hold them -- classic's single "What to Show" box measures about 600px, taller than --- the page at the 260px pane width that split them up in the first place. Two signals and --- their nested ticks fit, and splitting them now would mean three rows to say what one says: --- the reader opens "What to show", and everything that answers that question is in front of --- them. Four rows instead of six. --- ⚠ If a third signal is ever added, measure before adding it here -- this row goes --- back to being too tall, and the per-signal rows are the shape that fixed that. -S.PIHelperSections = { - { key = "overview", title = "What to Show", - build = pihSection(function(g, t) - t.signalRow(g, "burst", L["Big cooldown"]) - t.signalRow(g, "infused", L["Already has active Power Infusion"]) - pihAddGateAndNotes(g, t) - end) }, - { key = "roles", title = "Never Show On", - build = pihSection(pihAddRoles) }, - { key = "classes", title = "Classes and Cooldowns", - build = function(parent, o) - o = o or {} - local t = pihMakeTools(parent, o) - t.classColumns = 2 -- see pihAddClasses; popout only - return t.group((o.header == false) and nil or (o.headerText or nil), - pihAddClasses, o.startY or 0, { innerColumns = 2 }) - end }, - { key = "sound", title = "Sound Alert", - build = pihSection(pihAddSound) }, -} +-- ★★ ...AND COOLDOWN ICONS IS THE THIRD ANSWER, NOT A FOURTH TILE (2026-09-10). It stood on +-- the main grid beside Border and Square, which put a CONTAINER among a row of EFFECTS -- and +-- that mismatch is the whole of what Krathe kept hitting: it disappeared from the list it was +-- added from, it turned up under a tab that grew a moment earlier, and the tile itself +-- vanished once one existed. "I think for icon it would be best to have a sub menu so you +-- click Icon then it has PI Icon, Cooldown Icons, and the other icon choices?" +-- ⇒ Three icon answers on two axes -- HOW MANY (one effect, or one per cooldown up) and WHAT +-- PICTURE (always Power Infusion, or the buff they used). The fourth cell is nonsense: four +-- identical Power Infusion icons in a row. So: three tiles, behind Icon, and the difference +-- between a container and an effect stops being the user's problem. +local pihAddPick = nil -- nil = the surface grid, "icon" = the art choice + +-- ── EXAMPLE ART FOR THE TWO TILES WHOSE PICTURE IS NOT KNOWN IN ADVANCE ── +-- ★ REAL COOLDOWNS, NOT QUESTION MARKS (2026-09-10). Krathe: "can we get the preview card +-- here to actually show some example icons instead of ??" +-- ☠ AND THE PLACEHOLDER WAS DEFENSIBLE RIGHT UP TO THE POINT IT WAS LOOKED AT. The designer's +-- add flow asks for a TYPE first and a SPELL second, so its tiles genuinely have no artwork to +-- show yet and the `?` is honest there -- I argued the same for these, since the helper's +-- picture really is unknown until a cooldown matches. But a tile is a picture of what the +-- thing LOOKS like, and three question marks in a row is a picture of an error. The unknown +-- is WHICH cooldown, never WHETHER there is art. +-- ⚠ FROM THE SEED, NOT FROM THE LIVE LIST, and sorted. The user's list moves with the class +-- ticks, so sampling it would make these tiles change picture when someone unticked Warrior -- +-- a tile is not a live readout. Sorting makes the choice the same on every client and every +-- build rather than whatever pairs() said first. +-- ⚠ ONLY IDS THE CLIENT CAN DRAW. GetSpellTexture returns nil for a spell whose data is not +-- cached, and one `?` standing among two real icons reads worse than three of them. +local function pihExampleSpellIDs(n) + local ids = pihSeedIDs() + table.sort(ids) + local out = {} + for _, sid in ipairs(ids) do + if C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(sid) then + out[#out + 1] = sid + if #out >= n then break end + end + end + return out +end + +-- ── A ROW OF ICONS, AS THE GROUP ACTUALLY DRAWS ONE ── +-- ☠ THE "HOW MANY" AXIS IS THE ONE PROSE KEEPS FAILING AT, which is why it is drawn. The +-- Cooldown Icons tile used to paint the single-icon thumbnail, so the tile that means "one per +-- cooldown" was a picture of one icon -- the two answers it had to be told apart from looked +-- identical to it. +-- ⚠ THE GROUP'S OWN GEOMETRY, not a decorative row: TOPRIGHT, growing LEFT, at the spacing +-- P.PIH_AddIconGroup creates it with. If those defaults change, this picture is wrong and +-- should be changed with them. +-- ⚠ `ids` IS A LIST OF EXAMPLE SPELLS (pihExampleSpellIDs), one per slot. Short or empty is +-- fine -- a slot with no id falls back to the designer's placeholder, which is what a client +-- that has not cached those spells yet will show, and it is still a row of the right length. +local function PaintIconRowOnThumb(pv, ids, n) + local mock = pv.mockFrame + if not mock then return end + local size = (TYPE_DEFAULTS and TYPE_DEFAULTS.icon and TYPE_DEFAULTS.icon.size) or 24 + local SPACING = 2 + for i = 1, (n or 3) do + local x = -((i - 1) * (size + SPACING)) + local ring = mock:CreateTexture(nil, "OVERLAY", nil, 1) + ring:SetColorTexture(0, 0, 0, 0.85) + ring:SetSize(size + 2, size + 2) + ring:SetPoint("TOPRIGHT", mock, "TOPRIGHT", x, 0) + local ico = mock:CreateTexture(nil, "OVERLAY", nil, 2) + ico:SetSize(size, size) + ico:SetPoint("CENTER", ring, "CENTER", 0, 0) + local sid = ids and ids[i] + local tex = sid and C_Spell and C_Spell.GetSpellTexture + and C_Spell.GetSpellTexture(sid) or nil + ico:SetTexture(tex or DEFAULT_TILE_ICON) + ico:SetTexCoord(0.08, 0.92, 0.08, 0.92) + end + -- ⚠ pv.spellIcon IS DELIBERATELY NOT PUBLISHED. It is the handle the designer's add pane + -- swaps a chosen spell's art through, and this tile has no single spell to swap in -- the + -- point of it is that the pictures are whatever they each turn out to be. +end + +local function pihBuildAddTiles(parent, yPos, Refresh) + local tc = GetThemeColor() + local CW = (parent:GetWidth() or 320) - 16 + local TILE_COLS, TILE_GAP = 3, 7 + local TILE_W = math.floor((CW - TILE_GAP * (TILE_COLS - 1)) / TILE_COLS) + + -- ── THE HEADING, AND ON STEP 2 THE WAY BACK OUT ── + -- ☠ AN ✕ ON THE HEADING ROW, NOT A "Back" BUTTON UNDER THE GRID. Krathe, 2026-09-10: + -- "no back use X like we do on the other AD effects." The designer's OWN picker is + -- directly above this function (S.BuildEffectsHeadArea's S.effectsPicker arm): a head + -- frame with the question on the left and GUI:CreateCloseButton on the right, captioned + -- there as "the only way out that does not commit to anything". This is the same + -- question in the same place, so it is the same control -- and a Back button was a + -- second vocabulary for leaving invented for one grid. + -- ⚠ TWO SHAPES, ONE PER STEP. Step 1 is a section CAPTION -- small-caps, dim, no way out + -- because there is nothing to leave -- and step 2 is a PICKER HEAD, in the picker's own + -- font and colour. Sharing one fontstring made step 2 quietly the wrong kind of object. + if pihAddPick then + local head = CreateFrame("Frame", nil, parent) + head:SetHeight(22) + head:SetPoint("TOPLEFT", 8, yPos) + head:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + + local headText = head:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + headText:SetPoint("LEFT", 0, 0) + headText:SetText(L["Which icon?"]) + headText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + local close = GUI:CreateCloseButton(head, { size = 18, iconSize = 11 }) + close:SetPoint("RIGHT", 0, 0) + close:SetScript("OnClick", function() + pihAddPick = nil + if Refresh then Refresh() end + end) + yPos = yPos - 26 + else + local head = parent:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(head, 9, "") + head:SetPoint("TOPLEFT", 8, yPos) + head:SetText(L["ADD AN INDICATOR"]) + head:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 18 + end + + -- ⚠ ONE LAYOUT FOR BOTH STEPS. The grid is the same shape whichever question is being + -- asked, so it is written once and fed a list -- the alternative is two flow blocks that + -- drift apart in tile size and spacing. + local function grid(items, y) + local rowTop, rowH = y, 0 + for i, it in ipairs(items) do + local col = (i - 1) % TILE_COLS + local tile = CreateFrameTile(parent, { + width = TILE_W, + label = it.label, + accent = it.accent or tc, + -- ⚠ THE REASON IT IS OFF GOES IN THE TOOLTIP, second line, so the tile still + -- answers the question a greyed control always raises. See `taken` below. + tooltip = { title = it.label, lines = { it.desc, it.taken } }, + Paint = it.Paint, + onClick = it.onClick, + }) + -- ☠ GREYED, NOT REMOVED, and the Cooldown Icons tile is why. It used to be + -- dropped from the grid the moment one existed, so adding it made the thing you + -- had just clicked disappear -- half of "it's confusing when you add Cooldown + -- Icons from effects". A tile that stays put and says why it is off tells you + -- where your thing went; a tile that vanishes tells you nothing. + if it.taken then tile:SetTileState("disabled") end + tile:SetPoint("TOPLEFT", 8 + col * (TILE_W + TILE_GAP), rowTop) + rowH = math.max(rowH, tile.layoutHeight or 72) + if col == TILE_COLS - 1 or i == #items then + rowTop = rowTop - (rowH + TILE_GAP) + rowH = 0 + end + end + return rowTop - 4 + end + + -- What the helper is already wearing. Read once and used by both steps: step 1 needs it to + -- decide whether Icon has anything left to offer, step 2 to grey the answers it has. + local held = {} + for _, s in ipairs(P.PIH_SurfacesOf("burst")) do held[s] = true end + local hasGroup = (P.PIH_IconGroup and P.PIH_IconGroup()) and true or false + + -- ── STEP 2: WHICH ICON ── + -- Three answers, and the two axes they differ on are in this function's header note. + if pihAddPick == "icon" then + local function add(showsAura) + local ok, why = P.PIH_AddSurface("burst", "icon", showsAura) + if not ok then DF:DebugWarn("AURADESIGNER", + "PIH: could not add the icon -- %s", tostring(why)) end + pihAddPick = nil + if Refresh then Refresh() end + end + local accent = BADGE_COLORS.icon or tc + -- Sampled ONCE for both tiles, so the single icon is the first of the row rather than + -- an unrelated fourth spell -- the tiles differ in HOW MANY, and picking different art + -- for each would put a second difference in the picture that means nothing. + local egIDs = pihExampleSpellIDs(3) + -- ★ ONE `taken` EACH, BECAUSE THEY ARE TWO EFFECTS NOW (2026-09-10). They used to share + -- one: the icon surface held a single record and which picture it wore was a tick on + -- its card, so adding either spent both. Krathe: "we can't add Power Infusion and Their + -- CD at the same time, we should allow this if we can?" We can -- placed instances are + -- per-id -- so each tile greys only when ITS OWN art is already on the frame. + -- ⚠ The tick on the effect card stays and still switches one icon's picture. It is how + -- you change your mind about an icon you have; these tiles are how you get a second. + local pinnedHeld, dynamicHeld = P.PIH_IconArtHeld("burst") + local alreadyAdded = L["Already added. Remove it from the list below to change it."] + yPos = grid({ + { label = L["Power Infusion"], accent = accent, + taken = pinnedHeld and alreadyAdded or nil, + desc = L["The same picture on everyone worth infusing."], + Paint = function(pv) PaintEffectOnThumb(pv, "icon", PIH_PI_SPELL_ID) end, + onClick = function() add(false) end }, + -- ⚠ AN EXAMPLE COOLDOWN, NOT A PINNED ONE. It goes through the same staticSpellID + -- parameter the tile above uses, and means something different: there the art IS + -- what you will get, here it is one of the things you might. The label and the + -- description carry that; a question mark carried nothing. See pihExampleSpellIDs. + { label = L["Their cooldown"], accent = accent, + taken = dynamicHeld and alreadyAdded or nil, + desc = L["The buff they actually used — one of them, if several are up at once."], + Paint = function(pv) PaintEffectOnThumb(pv, "icon", egIDs[1]) end, + onClick = function() add(true) end }, + -- ★ THE CONTAINER, AS THE THIRD ANSWER TO "WHICH ICON". It can stand beside the + -- other two here in a way it never could on the main grid: there the question was + -- "which kind of indicator", and a group is not one. + { label = L["Cooldown Icons"], accent = accent, + taken = hasGroup and L["Already added. Remove it from the list below to change it."] or nil, + desc = L["One icon per cooldown they have up, each showing its own."], + Paint = function(pv) PaintIconRowOnThumb(pv, egIDs, 3) end, + onClick = function() + local ok, why = P.PIH_AddIconGroup() + if not ok then DF:DebugWarn("AURADESIGNER", + "PIH: could not add the cooldown icons -- %s", tostring(why)) end + pihAddPick = nil + if Refresh then Refresh() end + end }, + }, yPos) + -- ⚠ NOTHING UNDER THE GRID. The way out is the ✕ on the heading above -- see the + -- note there for why this stopped being a Back button. + return yPos + end + + -- ── STEP 1: WHICH KIND OF INDICATOR ── + -- ⚠ FILTERED TO WHAT THE HELPER CAN DO. Sound is left out -- it is not a surface and has + -- its own box below -- and so is a type the signal already holds: an add button that + -- cannot add is the lying control this panel keeps being cleaned of. + local items = {} + for _, eff in ipairs(P.AddFlowEffects and P.AddFlowEffects() or {}) do + local isIcon = eff.type == "icon" + -- ☠ ICON SURVIVES ITS OWN SURFACE BEING TAKEN, and no other type does. Behind it are + -- THREE answers -- two arts and the group -- so hiding the tile the moment ONE icon + -- exists would hide the door to the other two. It goes when all three are spent, and + -- not before. + local exhausted + if isIcon then + local pinnedHeld, dynamicHeld = P.PIH_IconArtHeld("burst") + exhausted = pinnedHeld and dynamicHeld and hasGroup + else + exhausted = held[eff.type] + end + if eff.type ~= "sound" and not exhausted then + local capturedType = eff.type + items[#items + 1] = { + label = eff.label, + accent = BADGE_COLORS[eff.type] or tc, + -- The Icon tile opens a choice now rather than describing one behaviour. + desc = isIcon and L["Power Infusion, their cooldown, or one per cooldown they have up."] or eff.desc, + Paint = function(pv) + PaintEffectOnThumb(pv, capturedType, isIcon and PIH_PI_SPELL_ID or nil) + end, + onClick = function() + if isIcon then + pihAddPick = "icon" + if Refresh then Refresh() end + return + end + local ok, why = P.PIH_AddSurface("burst", capturedType) + if not ok then DF:DebugWarn("AURADESIGNER", + "PIH: could not add %s -- %s", tostring(capturedType), tostring(why)) end + if Refresh then Refresh() end + end, + } + end + end + + if #items == 0 then + -- Everything is in use. Not an error and not empty: say so rather than drawing a + -- caption over nothing. + local none = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + none:SetPoint("TOPLEFT", 8, yPos) + none:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + none:SetJustifyH("LEFT") + none:SetText(L["Every indicator is already in use. Remove one below to add it again."]) + none:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.8) + return yPos - (max(none:GetStringHeight(), 12) + 10) + end + + return grid(items, yPos) +end + +-- ── SOUND, WHICH IS NOT A SURFACE ── +-- ☠ IT HAS NO TILE AND NO EFFECT CARD, AND THAT IS NOT AN OVERSIGHT. The generic effects +-- list refuses to show `sound` on a filter-owned record -- the native path registers per +-- spell id, so one big filter would mean one registration per spell in it -- which means it +-- offers no row and no delete button for it either. So the helper owns the control outright. +-- ⚠ IT WAS UNREACHABLE FOR A DAY. It lived at the foot of pihBuildEffectsTab, and that +-- function stopped being called when the helper became a pool tab -- a setting removed from +-- the UI as a side effect of a layout change, with nothing saying so. Same fault the icon +-- ticks had, found the same way: by asking what USED to call the thing being deleted. +local function pihBuildSoundBox(parent, yPos, Refresh) + local t = pihMakeTools(parent, { Refresh = Refresh, indent = 8 }) + return t.group(L["Sound Alert"], pihAddSound, yPos) +end --- ── THE CLASSIC COMPOSITION -- every section, one column, unchanged ── -S.BuildPIHelperPane = function(parent, opts) +-- The two halves the designer's Effects tab mounts, in the order it mounts them. +-- One entry point rather than two exports, because the caller (S.BuildEffectsHeadArea) has +-- one place to put them and no business knowing the helper has two pieces. +S.BuildPIHelperAddArea = function(parent, yPos, Refresh) + yPos = pihBuildAddTiles(parent, yPos, Refresh) + return pihBuildSoundBox(parent, yPos, Refresh) +end + +-- ── THE TRIGGERS TAB ── +-- ☠ S.PIH_TABS LIVED HERE AND IS GONE. It named the helper's own two tabs back when the +-- helper had a tab bar of its own; the designer's sub-tab strip is that bar now -- Triggers +-- and Effects ARE its Global and Effects tabs, relabelled and reordered on this pool (see +-- P.SubTabDefs). A second list of the same two tabs could only ever drift from the strip +-- actually on screen. +-- ⚠ AND SO IS THE `tab` ARGUMENT. This function had an else-branch that built a private +-- copy of the Effects tab (pihBuildEffectsTab); the designer's own Effects tab does that job +-- now and the helper contributes S.BuildPIHelperAddArea to it. What is left here is one tab's +-- worth of settings -- the Triggers -- so it no longer has to be told which one. +-- ⚠ THE CARD IS NOT HERE EITHER. The enable banner turns the whole feature on, so it cannot +-- sit inside one of the things it governs; S.BuildPIHelperCard draws it above. +-- ⚠ Returns the running y, exactly as before, so the caller keeps owning the layout. +S.BuildPIHelperBody = function(parent, opts) opts = opts or {} - local yPos, open = S.BuildPIHelperCard(parent, opts) - - -- ── THE SETTINGS, FOLDED WITH THE CARD ── - -- ☠ GATED ON THE CARD'S OWN expanded, NOT ONLY ON THE HELPER EXISTING -- see - -- S.BuildPIHelperCard's return. - if open then - local t = pihMakeTools(parent, opts) - yPos = t.group(L["What to Show"], function(g) - t.signalRow(g, "burst", L["Big cooldown"]) - t.signalRow(g, "infused", L["Already has active Power Infusion"]) + local yPos = opts.startY or 0 + local t = pihMakeTools(parent, opts) + + do + -- ⚠ THE GATE LIVES HERE, not with the effects. "Hide the helper while your own Power + -- Infusion is on cooldown" is not a display choice -- it is a condition on whether + -- the helper has anything to say at all, which is what a trigger is. + -- ⚠ "Roles", NOT "Triggers" (Krathe, 2026-09-09). The box was named after the TAB it + -- sits on, so the Triggers tab opened with a box captioned Triggers -- a heading that + -- repeats its own parent tells you nothing, and the one thing it could have told you + -- (that this box is the ROLE filter) was the thing it left out. + -- ⚠ THE COOLDOWN GATE STAYS IN IT, and that was already argued: see the long note in + -- pihAddGateAndNotes -- a whole titled group around a single checkbox is more chrome + -- than the setting is worth, and "Show when Power Infusion is on Cooldown" says what + -- it does without a header to lean on. That note names this exact case as the test for + -- a control living under a heading that does not quite describe it, and records why + -- the label names the spell rather than implying it. + yPos = t.group(L["Roles"], function(g) + pihAddRoles(g, t) pihAddGateAndNotes(g, t) end, yPos) - yPos = t.group(L["Never Show On"], pihAddRoles, yPos) - -- ☠ COLLAPSIBLE, AND THIRTEEN ROWS IS WHY. Everything else in this panel is -- two or three ticks; a class list is as long as the game has classes, and -- most people will never open it. @@ -6594,11 +8399,59 @@ S.BuildPIHelperPane = function(parent, opts) -- which for thirteen classes and a two-line note is a wall of text rather -- than a summary. The header alone says what is folded away, which is what -- a summary was for. - yPos = t.group(L["Classes and Cooldowns"], pihAddClasses, yPos, - { collapsible = true, collapseKey = "pihelper:onlywatch" }) + -- ☠ NO SECOND FILTER LINK ANYWHERE. There is exactly one, at the foot of the second + -- box, and it uses GUI:OpenFilterInDesigner -- which switches the page AND scrolls to, + -- selects and pulses the filter. A bare SelectTab beside it once gave the page two + -- buttons to the same place, one of them the worse version: a hand-written jump "landed + -- you on the page with nothing indicated, which is indistinguishable from a broken + -- link". Krathe, 2026-09-08: "We seem to have two links to it? and confusing messaging." + + -- ★★ THE BASELINE FIRST, THEN WHAT YOU ADD TO IT. + -- ☠ THESE WERE ONE BOX AND THE ORDER SAID THE OPPOSITE OF THE TRUTH: thirteen class + -- ticks, then a list of four sources, when the classes reach only ONE of them -- trinkets + -- and potions are items with no class, and racials are tagged class = "ALL". Krathe read + -- the layout and asked exactly that: "the classes, they only effect the Cooldowns + -- correct?" + -- ⚠ SO THE CLASS COOLDOWNS LIVE WITH THEIR CLASSES. The box is the whole of that + -- source: the ticks that narrow it, the note, and the button that edits it. The three + -- sources nothing narrows are additions, in a box that says so. + -- ⚠ THE COUNT IS ON THIS HEADER because the source has no row of its own -- see + -- P.PIH_CooldownCounts for why a tick here was redundant AND harmful. Shown as a + -- fraction only when some are switched off, the same rule the Filter Designer follows. + -- ⚠ COLLAPSIBLE: thirteen rows is the one list on this panel nobody can scan, and most + -- people will never open it. + local cdOn, cdTotal = P.PIH_CooldownCounts() + local cdHead = L["Classes and Cooldowns"] .. " " + .. ((cdOn == cdTotal) and tostring(cdTotal) or (cdOn .. "/" .. cdTotal)) + yPos = t.group(cdHead, function(g) + pihAddClasses(g, t) + end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) + + yPos = t.group(L["Additional Filters"], function(g) + pihAddTriggerSources(g, t) + end, yPos) - yPos = t.group(L["Sound Alert"], pihAddSound, yPos) + -- ★★ NAMED PLAYERS, LAST, AND COLLAPSED (2026-09-10). Krathe: "in guild groups it + -- would be useful to only have the PI alert for the DPS you know who should be getting + -- PI instead of every DPS in the raid who uses a CD." + -- ⚠ LAST, BECAUSE IT IS THE NARROWEST THING ON THE TAB. Every box above answers "what + -- makes this fire"; this one answers "and for whom", which only means anything once + -- the rest is settled. It is also the only one most people will never touch. + -- ⚠ COLLAPSIBLE, like the class list and for the same reason: a roster is as long as + -- the raid, and an empty allowlist is the default. + -- ⚠ THE COUNT IS ON THE HEADER, so the box says whether it is doing anything while + -- shut -- which is the whole question about a folded filter. + -- ⚠ THE COUNT ANSWERS "HOW MANY IS THIS NARROWING TO", NOT "HOW MANY ARE SAVED", so a + -- switched-off list shows none -- it is narrowing to nobody, exactly like an empty one. + -- Printing 5 beside a switch that is off would be the header lying about the one thing + -- it exists to report. The names are one click away and the tick inside says why. + local pn = P.PIH_PlayersOn() and #P.PIH_Players() or 0 + local pHead = L["Players"] .. (pn > 0 and (" " .. pn) or "") + yPos = t.group(pHead, function(g) + pihAddPlayers(g, t) + end, yPos, { collapsible = true, collapseKey = "pihelper:players" }) end + return yPos end @@ -6659,7 +8512,12 @@ S.BuildEffectsHeadArea = function(parent, yPos, opts) -- rebuilds this tab, and the context check below drops a stale picker on -- that rebuild rather than leaving the player staring at options for a pool -- they have already left. - local pickerCtx = tostring(IsOtherTab()) .. "|" .. tostring(ResolveSpec()) + -- ⚠ THE POOL KEY, NOT IsOtherTab(). That predicate answers true for BOTH the Any Buff and + -- the Power Infusion Helper pools, so a picker opened on one survived a switch to the + -- other -- the designer's spell-picker column drawn over a pool that has no spell to pick. + -- The context has to change whenever the thing it was opened against changes, and the pool + -- key is that thing. + local pickerCtx = tostring(S.activeBuffTab) .. "|" .. tostring(ResolveSpec()) if S.effectsPicker and S.effectsPickerCtx ~= pickerCtx then S.effectsPicker = nil end @@ -6738,8 +8596,22 @@ S.BuildEffectsHeadArea = function(parent, yPos, opts) return yPos, true end + -- ── THE HELPER'S POOL: TILES, NOT SCOPE CARDS ── + -- ☠ THE THREE SCOPE CARDS ANSWER A QUESTION THIS POOL HAS ALREADY ANSWERED. Placed / + -- Frame-Level / From a Filter all end in "now pick a spell", and the helper's spell is the + -- cooldown list its Triggers tab owns. Offering the picker here would let someone hang a + -- helper effect off a spell of their own, which is not a helper effect at all -- it is an + -- Any Buff effect that happens to have been created from the wrong tab. + -- ⚠ EVERYTHING BELOW THIS BRANCH IS SHARED, and that is the point. The ACTIVE INDICATORS + -- caption, the type filter and the effect cards under it are the designer's own, so the + -- helper's list looks and behaves exactly like the designer's list -- which is what Krathe + -- asked for three times: "It should BE AD not a copy of it." + if not skipAdd and IsPIHelperTab() and S.BuildPIHelperAddArea then + yPos = S.BuildPIHelperAddArea(parent, yPos, function() S.SwitchTab("effects") end) + yPos = yPos - 4 + elseif not skipAdd then + -- ── NORMAL: three pinned scope cards ── - if not skipAdd then local addBlock = GUI:CreateChoiceCardGroup(parent, { title = L["ADD AN INDICATOR"], accent = tc, @@ -6799,34 +8671,21 @@ S.BuildEffectsHeadArea = function(parent, yPos, opts) yPos = yPos - (addBlock.layoutHeight + 10) end - -- ── POWER INFUSION HELPER (priest only) ── - -- The block itself is S.BuildPIHelperPane above -- one definition, two - -- layouts, the same bargain the add flow's scope cards struck. - -- ☠ OTHER BUFFS ONLY, AND THAT IS NOT TIDINESS -- IT IS THE ONLY TAB WHERE IT WORKS. - -- The pool a record lives in decides its caster filter before anything else: My Buffs means - -- "auras I cast", and poolFilter returns that before it ever consults othersOnly. The helper - -- watches OTHER people's cooldowns, so My Buffs is the one place it is guaranteed to match - -- nothing. It was addable there and silently did nothing, which is a lying control. + -- ── POWER INFUSION HELPER: MOVED OUT, 2026-09-08 ── + -- ☠ DO NOT MOUNT IT HERE AGAIN. The helper now has its own page beside the designer + -- (Auras > Power Infusion Helper -- see AuraDesigner/UI/PIHelperPage.lua and the + -- CreateSubTab in GUI/Pages/Auras.lua). Krathe's call, 2026-09-08: the settings were + -- "not very clear how to use it or even how to find it", and the behaviour panel and + -- the appearance of the records it creates were on opposite ends of one page. -- - -- ⚠ The recipe already writes into the Other Buffs pool wherever it is invoked from, so this - -- is no longer about correctness -- it is about not offering a button whose result lives - -- somewhere the user was not looking. Its indicators appear in that tab's list; the card - -- should be in the same place as the thing it creates. - -- ⭐ And a side benefit the user named: My Buffs is where most people work, and the helper's - -- rows would be clutter there for everyone who never uses it. - -- ☠ AND NOT IN THE ROW LAYOUT'S HEAD AREA. skipAdd is that layout's flag, - -- and it mounts the SAME builder behind its own "Power Infusion Helper" - -- popout row (AuraDesigner/UI/Rows.lua) -- drawn here too, the helper - -- would stand twice on one page. - if select(2, UnitClass("player")) == "PRIEST" and S.activeBuffTab == "other" - and not skipAdd then - yPos = S.BuildPIHelperPane(parent, { - startY = yPos, - -- The split panel's own redraw verb -- exactly what every callback - -- in the inline block used to run. - Refresh = function() S.SwitchTab("effects") end, - }) - end + -- ⚠ WHAT DID NOT CHANGE, so nobody re-derives it from an empty space: the helper's + -- records still live in THIS pool. The pool a record lives in decides its caster filter + -- before anything else -- My Buffs means "auras I cast", and poolFilter returns that + -- before it ever consults othersOnly -- and the helper watches OTHER people's + -- cooldowns, so Any Buff remains the only pool where it can match anything. That is + -- plumbing now; the user is never asked to know it. + -- ⚠ The builders are still HERE (S.BuildPIHelperCard / S.BuildPIHelperBody and the + -- section bodies, above). Only the MOUNT moved. The page composes them. -- ── ACTIVE INDICATORS heading ── local activeHeader = parent:CreateFontString(nil, "OVERLAY") @@ -6917,8 +8776,13 @@ S.BuildEffectsHeadArea = function(parent, yPos, opts) -- ⚠ ANCHORED UNDER THE CHIP ROW where there is one, not at a y the chips' -- first pass happened to produce. It is the one thing below a wrapping element -- in this area, so it is also the one thing a re-wrap would otherwise strand. + -- ⚠ NOT ON THE HELPER'S POOL. "These indicators trigger no matter who casts the buff" is + -- a true statement about the STORE and a misleading one about this tab: the helper's + -- caster rule is Others Only, set per effect by the recipe, and its Triggers tab is where + -- the user is told what makes it fire. A sentence about a rule the user did not choose + -- and cannot see reads as a rule they are being warned about. local obHint - if IsOtherTab() then + if IsOtherTab() and not IsPIHelperTab() then obHint = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") if chipsFrame then obHint:SetPoint("TOPLEFT", chipsFrame, "BOTTOMLEFT", 0, -10) @@ -6944,7 +8808,15 @@ S.BuildEffectsTab = function() if pickerOpen then return end -- ── EFFECTS LIST ── - local effects = CollectAllEffects() + -- ☠ includePIH ON THE HELPER'S POOL, AND WITHOUT IT THE TAB WAS EMPTY. CollectAllEffects + -- hides pihSignal-marked rows from the designer by default -- correct on My Buffs and Any + -- Buff, where a helper effect is somebody else's business -- but on the helper's own pool + -- they are the ONLY business, so the default filtered out every row the tab exists to + -- show. Krathe, 2026-09-09: "the trigger/effects are not showing." + -- ⚠ NO SECOND FILTER NEEDED. CurrentAuraPool is already S.PIH_PreviewPool on this tab -- + -- the helper's records and nothing else -- so "include ours" and "show only ours" are the + -- same instruction here. + local effects = CollectAllEffects({ includePIH = IsPIHelperTab() }) -- Apply filter local filtered = {} @@ -6954,7 +8826,26 @@ S.BuildEffectsTab = function() end end - if #filtered == 0 then + -- ★★ THE COOLDOWN-ICON GROUP IS A ROW IN THIS LIST (2026-09-10). Krathe: "It's confusing + -- when you add Cooldown Icons from effects and it appears as a layout group, it should + -- just show as a normal effect for PI helper." It is offered by a tile in THIS tab's add + -- grid, so this tab is where it has to come back -- a thing that vanishes from where you + -- made it and reappears behind a tab that grew a moment ago is two surprises, not one. + -- ⚠ THE DESIGNER'S OWN GROUP CARD (S.CreateLayoutGroupCard), told to rebuild "effects" + -- rather than "layout" and to drop its filter picker: what these icons watch is the + -- cooldown list on Triggers, and offering a second way to say it here would let the two + -- disagree. Everything else -- name, eye, delete, placement, growth, appearance -- is the + -- card every other group gets. + -- ⚠ IT OBEYS THE TYPE FILTER. The group draws icons, so "Showing: Icons" must keep it and + -- "Showing: Borders" must not: a row that ignores the filter reads as one the filter + -- failed to remove. + local pihGroup = nil + if IsPIHelperTab() and P.PIH_IconGroup and S.CreateLayoutGroupCard then + local af = S.activeFilter or "all" + if af == "all" or af == "icon" then pihGroup = P.PIH_IconGroup() end + end + + if #filtered == 0 and not pihGroup then local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") empty:SetPoint("TOP", parent, "TOP", 0, yPos - 30) empty:SetWidth(220) @@ -6977,6 +8868,19 @@ S.BuildEffectsTab = function() end end + if pihGroup then + -- ⚠ ITS OWN STACK, and one card in it is not a waste. The appearance sections inside + -- an expanded group card re-flow in place and call stack:Reflow(); without a stack + -- that call has nothing to reach and the card keeps the height it was built at, with + -- its own controls hanging out of the bottom. Nothing is drawn below it, so a stack + -- holding only this card re-anchors everything that can move. + local stack = P.CreateCardStack and P.CreateCardStack(parent, yPos) + yPos = S.CreateLayoutGroupCard(parent, yPos, pihGroup, stack, + { refreshTab = "effects", asEffect = true, + filtersSection = P.PIH_GroupSourceSection(pihGroup), + Summary = P.PIH_IconGroupSummary }) + end + parent:SetHeight(max(-yPos + 20, 200)) end @@ -6984,6 +8888,23 @@ end -- Wraps the existing BuildGlobalView into the tab content frame S.BuildGlobalTab = function() if not S.tabContentFrame then return end + -- ★★ ON THE HELPER'S POOL, "GLOBAL" IS ITS TRIGGERS. Every other pool's Global tab holds + -- the settings that apply to the whole POOL rather than to one effect -- which is exactly + -- what the helper's roles, class list, icon lists and cooldown gate are. Krathe's split: + -- "Triggers where people pick WHAT will show the effect... Then HOW it shows the + -- effects". WHAT lives here; HOW is the Effects tab, which is the designer's own and + -- needs nothing added to it at all. + -- ⚠ THE ENABLE TICK LEADS IT, because on this pool it governs everything below -- and it + -- has to be reachable when the helper is OFF, which is the state a new priest arrives in. + if P.IsPIHelperTab and P.IsPIHelperTab() and S.BuildPIHelperCard then + local parent = S.tabContentFrame + local Refresh = function() if S.SwitchTab then S.SwitchTab("global") end end + local yPos, open = S.BuildPIHelperCard(parent, { startY = -10, Refresh = Refresh }) + if open and S.BuildPIHelperBody then + S.BuildPIHelperBody(parent, { startY = yPos, Refresh = Refresh }) + end + return + end BuildGlobalView(S.tabContentFrame) end diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 06652bbd..d3151782 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -26,10 +26,17 @@ local CreateCardStack = P.CreateCardStack local ResolveSpec = P.ResolveSpec local CreateDebuffGroup = P.CreateDebuffGroup local IsOtherTab = P.IsOtherTab +-- ☠ NOT THE SAME QUESTION AS IsOtherTab. The helper's pool IS the other pool, but its caster +-- rule is a constant the recipe stamps rather than a setting -- so the tick and the header +-- suffix are drawn on Any Buff only. P.ShowsOthersOnly carries the whole argument. +local ShowsOthersOnly = P.ShowsOthersOnly local CurrentAuraPool = P.CurrentAuraPool local PoolKeyPrefix = P.PoolKeyPrefix local DebuffGroupsRead = P.DebuffGroupsRead local CurrentLayoutGroups = P.CurrentLayoutGroups +-- The DISPLAY half of that pair -- helper-owned groups filtered out. Its note in +-- AuraDesigner/UI/Options.lua says why the filter is there and not in the store accessor. +local VisibleLayoutGroups = P.VisibleLayoutGroups local OtherPoolDisplayName = P.OtherPoolDisplayName local RemoveIndicatorInstance = P.RemoveIndicatorInstance local GetAuraIcon = P.GetAuraIcon @@ -118,7 +125,12 @@ end -- Run a collected section list down a card body, captions and gaps included. -- Returns the cursor, so the caller carries on where the last section stopped. -local function RunCardSections(body, bodyWidth, by, sections) +-- ⚠ `tabKey` IS WHICH TAB A REBUILD REDRAWS, and it is a parameter because a group's card is +-- no longer only ever on the Layout Groups tab -- the helper's cooldown-icon group is a card +-- in ACTIVE INDICATORS now (S.CreateLayoutGroupCard). It defaults to "layout", which is what +-- every existing caller means. +local function RunCardSections(body, bodyWidth, by, sections, tabKey) + tabKey = tabKey or "layout" for _, sec in ipairs(sections) do by = by - (sec.gap or 0) local caption, captionY @@ -136,8 +148,8 @@ local function RunCardSections(body, bodyWidth, by, sections) place = CardPlace(body, bodyWidth, state), host = body, caption = caption, captionY = captionY, bodyWidth = bodyWidth, - Rebuild = function() S.SwitchTab("layout") end, - Redraw = function() S.SwitchTab("layout") end, + Rebuild = function() S.SwitchTab(tabKey) end, + Redraw = function() S.SwitchTab(tabKey) end, Header = function() end, }) by = state.by @@ -657,7 +669,10 @@ local function BuildGroupGrowth(env, group, kind, omitOthersOnly) -- ("HELPFUL|!PLAYER") binds at container build, so toggling is STRUCTURAL -- (folded into the fgroup struct sig → the factory Rebuilds), and the -- buff-row dedup union moves (an othersOnly group's spells keep their row icon). - if kind == "filter" and IsOtherTab() and not omitOthersOnly then + -- ⚠ ShowsOthersOnly, NOT IsOtherTab -- the helper's pool answers yes to the second and + -- must not draw this: its group is othersOnly by construction, and a tick offering to + -- turn that off would offer to make the helper watch the priest's own cooldowns. + if kind == "filter" and ShowsOthersOnly() and not omitOthersOnly then local ooCb = GUI:CreateCheckbox(host, L["Others Only"], group, "othersOnly", function() env.Rebuild() RefreshPlacedIndicators() @@ -962,7 +977,7 @@ S.BuildLayoutGroupsHeadArea = function(parent, yPos, opts) -- what gets created depends on the count. A card runs ~2.5x a button's -- height, which this ~260px column can only spare while there is no list -- underneath it -- hence cards or buttons, never both. - local hasGroups = #CurrentLayoutGroups() > 0 + local hasGroups = #VisibleLayoutGroups() > 0 -- Teaching prose, first visit only. The CARDS below are pinned permanently -- -- they are the create action, so they have to be -- but this sentence is read @@ -1024,8 +1039,10 @@ S.LayoutGroupSummary = function(group) for _ in pairs(fsel.customs or {}) do linkCount = linkCount + 1 end end local info = linkCount .. (linkCount ~= 1 and L[" filters"] or L[" filter"]) - -- Collapsed-state Others Only suffix — mirror the effect-card header - if IsOtherTab() and group.othersOnly then + -- Collapsed-state Others Only suffix — mirror the effect-card header, ShowsOthersOnly + -- included: on the helper's pool it is true of every group and every effect, and a + -- summary spent on a constant says nothing (see P.ShowsOthersOnly). + if ShowsOthersOnly() and group.othersOnly then info = info .. " - " .. L["Others Only"] end return info @@ -1034,13 +1051,239 @@ S.LayoutGroupSummary = function(group) return memberCount .. (memberCount ~= 1 and L[" indicators"] or L[" indicator"]) end +-- ============================================================ +-- ONE LAYOUT GROUP, AS A CARD +-- ------------------------------------------------------------ +-- ★★ EXTRACTED SO A GROUP CAN BE LISTED SOMEWHERE OTHER THAN THE LAYOUT GROUPS TAB +-- (2026-09-10). Krathe, on the helper's cooldown-icon group: "It's confusing when you add +-- Cooldown Icons from effects and it appears as a layout group, it should just show as a +-- normal effect for PI helper." He is right, and the only thing that made it a tab of its own +-- was this card: every structural edit in it named "layout" by hand, so the card could only +-- be drawn by the tab whose name it hardcoded. Now the tab it rebuilds is an argument, and the +-- helper lists its group in ACTIVE INDICATORS beside the effects it was added alongside. +-- +-- ⚠ ONE CARD, NOT A SECOND ONE THAT LOOKS LIKE IT. A copy specialised for the helper is how +-- the three duplicated FRAME_ITEMS lists in Cards.lua came about, and a group's card is a +-- large object -- name, eye, delete, three sections and a reflowing appearance stack. +-- +-- opts (all optional): +-- refreshTab the sub-tab every structural edit rebuilds. Defaults to "layout" -- the +-- Layout Groups tab's own behaviour, unchanged. +-- omitFilters drop the LINKED FILTERS section. The helper's group is bound to OUR cooldown +-- list, which its Triggers tab owns and edits; a filter picker here would be a +-- second, contradictory way to say what the icons watch. +-- asEffect wear the EFFECT card's chrome instead of the Layout Groups amber. Krathe's +-- sentence is "it should just show as a normal effect", and a card sitting in +-- ACTIVE INDICATORS in the one colour this panel uses to mean "layout group" +-- would still be saying the thing he asked it to stop saying. +-- Summary what the collapsed header says after the name, replacing the filter count. +-- Paired with omitFilters: a card with no Linked Filters block and the default +-- "1 filter" summary says nothing at all about its own contents. +-- ============================================================ +S.CreateLayoutGroupCard = function(parent, yPos, group, stack, opts) + opts = opts or {} + -- ⚠ READ ONCE, HERE. The refresh closures below outlive this call, and a tab key read + -- later would be whichever tab the user had moved to by then. + local refreshTab = opts.refreshTab or "layout" + local function Redraw() S.SwitchTab(refreshTab) end + local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab color + -- The two chromes, side by side, because they are two lines of the same recipe: an effect + -- card takes the plain border and no chevron tint (S.CreateEffectCard), a group card takes + -- the amber at the two weights the tab has always drawn it at. + local shellBorder = opts.asEffect + and {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.5} + or {r = gc.r * 0.35, g = gc.g * 0.35, b = gc.b * 0.35, a = 0.5} + local bodyBorder = opts.asEffect + and {r = C_BORDER.r, g = C_BORDER.g, b = C_BORDER.b, a = 0.3} + or {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3} + + -- Expansion keys are pool-scoped — raw id on My Buffs, "othergroup:" on + -- Other; the id counters overlap. + local expandKey = GroupExpandKey(group.id) + local isExpanded = expandedGroups[expandKey] or false + + -- ── CARD + HEADER ── + local card, header, chevron = CreateCardShell(parent, { + yPos = yPos, + expanded = isExpanded, + borderColor = shellBorder, + chevronColor = (not opts.asEffect) and gc or nil, + }) + if stack then stack:Add(card) end + + -- Group name + local nameText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameText:SetPoint("LEFT", chevron, "RIGHT", 6, 0) + nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) + nameText:SetMaxLines(1) + local isFilterGroup = (group.kind == "filter") + nameText:SetText(group.name .. " - " + .. ((opts.Summary and opts.Summary(group)) or S.LayoutGroupSummary(group))) + nameText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + + -- Delete button + local capturedGroupID = group.id + local delBtn = GUI:CreateCloseButton(header, { + size = 22, + onClick = function() + DeleteLayoutGroup(capturedGroupID) + Redraw() + RefreshPlacedIndicators() + -- Deleting a group deletes its member indicators — same + -- structural refresh as the effect-card delete / eye toggle. + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end, + }) + delBtn:SetPoint("RIGHT", -4, 0) + delBtn:SetFrameLevel(header:GetFrameLevel() + 2) + + -- Eye icon (visibility toggle) — filter groups only; same asset + toggle + -- idiom as the effect-card eye (A3). enabled == false is hidden; nil/true + -- = shown. Toggling is STRUCTURAL: the factory tears down / stands up the + -- group container and the buff-row dedup union changes. + if isFilterGroup then + local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" + local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) + eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) + local function shown() return group.enabled ~= false end + -- SetGlyph makes the state colour the new REST colour, so OnLeave + -- restores the state; hover is suppressed while hidden. + local function updateEyeIcon() + if shown() then + eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) + else + eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) + end + eyeBtn:SetGlyphHover(shown()) + end + updateEyeIcon() + eyeBtn:RegisterForClicks("LeftButtonUp") + eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) + eyeBtn:SetScript("OnClick", function() + group.enabled = (group.enabled == false) and true or false + updateEyeIcon() + Redraw() + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + end) + if not shown() then + nameText:SetAlpha(0.5) + end + end + + -- Header click → toggle expansion + header:SetScript("OnClick", function() + expandedGroups[expandKey] = not expandedGroups[expandKey] + Redraw() + end) + header:SetScript("OnEnter", function(self) + self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) + end) + header:SetScript("OnLeave", function(self) + self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) + end) + + local totalCardH = 30 + local cardHeaderH = totalCardH -- captured before the body is folded in + + -- ── BODY (when expanded) ── + if isExpanded then + local body = CreateFrame("Frame", nil, card, "BackdropTemplate") + body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) + body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) + ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, bodyBorder) + + local by = -10 + local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 + if bodyWidth < 100 then bodyWidth = 240 end + + -- Group Name (editable) + local nameLabel = body:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(nameLabel, 8, "") + nameLabel:SetPoint("TOPLEFT", 8, by) + nameLabel:SetText(L["GROUP NAME"]) + nameLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + by = by - 16 + + local nameEdit = CreateFrame("EditBox", nil, body, "BackdropTemplate") + nameEdit:SetHeight(22) + nameEdit:SetPoint("TOPLEFT", 8, by) + nameEdit:SetPoint("RIGHT", body, "RIGHT", -8, 0) + nameEdit:SetAutoFocus(false) + nameEdit:SetText(group.name) + nameEdit:SetMaxLetters(30) + GUI:StyleEditBox(nameEdit, {}) + nameEdit:SetScript("OnEnterPressed", function(self) + local val = self:GetText() + if val and val ~= "" then + group.name = val + end + self:ClearFocus() + Redraw() + end) + nameEdit:SetScript("OnEscapePressed", function(self) + self:SetText(group.name) + self:ClearFocus() + end) + by = by - 32 + + -- Members / Linked Filters, then Placement, then Growth -- the + -- SAME list the row layout mounts, run down the card's cursor. + local sections = CollectLayoutGroupSections(group) + -- ⚠ REPLACED BY POSITION, and CollectLayoutGroupSections is why that is safe: Linked + -- Filters is its FIRST entry on a filter group and Members is the first on the other + -- kind, so index 1 is "what fills this group" in both cases and nothing else can be. + -- ⚠ opts.filtersSection SUBSTITUTES rather than removing. The helper's group had that + -- slot emptied when the generic filter picker was dropped from it -- correct, and it + -- left the card silent about its own contents. It now holds the four sources the + -- feature actually has, by name; see P.PIH_GroupSourceSection. + if isFilterGroup and (opts.omitFilters or opts.filtersSection) then + if opts.filtersSection then sections[1] = opts.filtersSection + else tremove(sections, 1) end + end + by = RunCardSections(body, bodyWidth, by, sections, refreshTab) + + if isFilterGroup then + -- ── APPEARANCE (collapsible — the effect-card section idiom) ── + by = by - 10 + by = AddGroupAppearanceSection(body, group, bodyWidth, by, expandKey) + + -- The appearance sections reflow in place; when they do, the + -- body and card must re-size and the cards below must slide. + -- `newBy` is the section stack's new tail, i.e. what + -- AddGroupAppearanceSection would have returned this time — + -- so the body height formula is the build-time one verbatim. + body.dfAD_ReflowCard = function(newBy) + local h = -newBy + 12 + body:SetHeight(h) + card:SetHeight(cardHeaderH + h) + if stack then stack:Reflow() end + end + end + + local bodyH = -by + 12 + body:SetHeight(bodyH) + totalCardH = totalCardH + bodyH + end + + card:SetHeight(totalCardH) + return yPos - totalCardH - 5 +end + S.BuildLayoutGroupsTab = function() if not S.tabContentFrame then return end local parent = S.tabContentFrame local yPos = S.BuildLayoutGroupsHeadArea(parent, -10) - local gc = { r = 0.91, g = 0.66, b = 0.25 } -- Layout Groups tab color - local groups = CurrentLayoutGroups() + local groups = VisibleLayoutGroups() if #groups > 0 then -- ── LAYOUT GROUPS heading — mirrors the Effects tab's ACTIVE INDICATORS @@ -1059,174 +1302,8 @@ S.BuildLayoutGroupsTab = function() -- re-anchor pass so those edits don't have to rebuild the tab. local stack = CreateCardStack(parent, yPos) - -- Render group cards (expansion keys are pool-scoped — raw id on My - -- Buffs, "othergroup:" on Other; the id counters overlap) for _, group in ipairs(groups) do - local expandKey = GroupExpandKey(group.id) - local isExpanded = expandedGroups[expandKey] or false - - -- ── CARD + HEADER ── - local card, header, chevron = CreateCardShell(parent, { - yPos = yPos, - expanded = isExpanded, - borderColor = {r = gc.r * 0.35, g = gc.g * 0.35, b = gc.b * 0.35, a = 0.5}, - chevronColor = gc, - }) - stack:Add(card) - - -- Group name - local nameText = header:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - nameText:SetPoint("LEFT", chevron, "RIGHT", 6, 0) - nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) - nameText:SetMaxLines(1) - local isFilterGroup = (group.kind == "filter") - nameText:SetText(group.name .. " - " .. S.LayoutGroupSummary(group)) - nameText:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - - -- Delete button - local capturedGroupID = group.id - local delBtn = GUI:CreateCloseButton(header, { - size = 22, - onClick = function() - DeleteLayoutGroup(capturedGroupID) - S.SwitchTab("layout") - RefreshPlacedIndicators() - -- Deleting a group deletes its member indicators — same - -- structural refresh as the effect-card delete / eye toggle. - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end, - }) - delBtn:SetPoint("RIGHT", -4, 0) - delBtn:SetFrameLevel(header:GetFrameLevel() + 2) - - -- Eye icon (visibility toggle) — filter groups only; same asset + toggle - -- idiom as the effect-card eye (A3). enabled == false is hidden; nil/true - -- = shown. Toggling is STRUCTURAL: the factory tears down / stands up the - -- group container and the buff-row dedup union changes. - if isFilterGroup then - local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - local eyeBtn = DF.GUI:CreateGlyphButton(header, { size = 18 }) - eyeBtn:SetPoint("RIGHT", delBtn, "LEFT", -4, 0) - local function shown() return group.enabled ~= false end - -- SetGlyph makes the state colour the new REST colour, so OnLeave - -- restores the state; hover is suppressed while hidden. - local function updateEyeIcon() - if shown() then - eyeBtn:SetGlyph(mediaPath .. "visibility", { 0.95, 0.95, 0.95 }) - else - eyeBtn:SetGlyph(mediaPath .. "visibility_off", { 0.45, 0.45, 0.45 }) - end - eyeBtn:SetGlyphHover(shown()) - end - updateEyeIcon() - eyeBtn:RegisterForClicks("LeftButtonUp") - eyeBtn:SetFrameLevel(header:GetFrameLevel() + 2) - eyeBtn:SetScript("OnClick", function() - group.enabled = (group.enabled == false) and true or false - updateEyeIcon() - S.SwitchTab("layout") - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - if DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then - DF.AuraDesigner.Engine:ForceRefreshAllFrames() - end - end) - if not shown() then - nameText:SetAlpha(0.5) - end - end - - -- Header click → toggle expansion - header:SetScript("OnClick", function() - expandedGroups[expandKey] = not expandedGroups[expandKey] - S.SwitchTab("layout") - end) - header:SetScript("OnEnter", function(self) - self:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 1) - end) - header:SetScript("OnLeave", function(self) - self:SetBackdropColor(C_ELEMENT.r, C_ELEMENT.g, C_ELEMENT.b, 1) - end) - - local totalCardH = 30 - local cardHeaderH = totalCardH -- captured before the body is folded in - - -- ── BODY (when expanded) ── - if isExpanded then - local body = CreateFrame("Frame", nil, card, "BackdropTemplate") - body:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) - body:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) - ApplyBackdrop(body, {r = 0.09, g = 0.09, b = 0.09, a = 1}, - {r = gc.r * 0.20, g = gc.g * 0.20, b = gc.b * 0.20, a = 0.3}) - - local by = -10 - local bodyWidth = (S.tabContentFrame and S.tabContentFrame:GetWidth() or 260) - 24 - if bodyWidth < 100 then bodyWidth = 240 end - - -- Group Name (editable) - local nameLabel = body:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(nameLabel, 8, "") - nameLabel:SetPoint("TOPLEFT", 8, by) - nameLabel:SetText(L["GROUP NAME"]) - nameLabel:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - by = by - 16 - - local nameEdit = CreateFrame("EditBox", nil, body, "BackdropTemplate") - nameEdit:SetHeight(22) - nameEdit:SetPoint("TOPLEFT", 8, by) - nameEdit:SetPoint("RIGHT", body, "RIGHT", -8, 0) - nameEdit:SetAutoFocus(false) - nameEdit:SetText(group.name) - nameEdit:SetMaxLetters(30) - GUI:StyleEditBox(nameEdit, {}) - nameEdit:SetScript("OnEnterPressed", function(self) - local val = self:GetText() - if val and val ~= "" then - group.name = val - end - self:ClearFocus() - S.SwitchTab("layout") - end) - nameEdit:SetScript("OnEscapePressed", function(self) - self:SetText(group.name) - self:ClearFocus() - end) - by = by - 32 - - -- Members / Linked Filters, then Placement, then Growth -- the - -- SAME list the row layout mounts, run down the card's cursor. - by = RunCardSections(body, bodyWidth, by, CollectLayoutGroupSections(group)) - - if isFilterGroup then - -- ── APPEARANCE (collapsible — the effect-card section idiom) ── - by = by - 10 - by = AddGroupAppearanceSection(body, group, bodyWidth, by, expandKey) - - -- The appearance sections reflow in place; when they do, the - -- body and card must re-size and the cards below must slide. - -- `newBy` is the section stack's new tail, i.e. what - -- AddGroupAppearanceSection would have returned this time — - -- so the body height formula is the build-time one verbatim. - body.dfAD_ReflowCard = function(newBy) - local h = -newBy + 12 - body:SetHeight(h) - card:SetHeight(cardHeaderH + h) - stack:Reflow() - end - end - - local bodyH = -by + 12 - body:SetHeight(bodyH) - totalCardH = totalCardH + bodyH - end - - card:SetHeight(totalCardH) - yPos = yPos - totalCardH - 5 + yPos = S.CreateLayoutGroupCard(parent, yPos, group, stack) end end @@ -1821,11 +1898,34 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) -- object (editingProfile == activeRuntimeProfile), so _adLayout alone -- misses the transition and the editing-banner offset is never applied. local _adEditing = (DF.AutoProfilesUI and DF.AutoProfilesUI.IsEditing and DF.AutoProfilesUI:IsEditing()) or false - if S.mainFrame and prevDB == dbRef + -- ☠☠ THE ISLAND BELONGS TO ONE PAGE, AND TWO NAV ENTRIES NOW REACH THIS BUILDER. The + -- Aura Designer page and the Power Infusion Helper page both call it, and the reuse path + -- below REPARENTS S.mainFrame to whoever asked last -- so bouncing between them handed + -- one island back and forth while each page's own harness still believed it owned the + -- widgets it had Add'd around it. That is the overlapping strip in Krathe's screenshot. + -- ⇒ A different page is a different build, exactly like a mode switch. The teardown below + -- hides and unparents the old island first, so nothing is left stranded on the page we + -- came from. + -- ⚠ Compared by IDENTITY, not by name: the harness can rebuild a page object, and a + -- stale reference must read as "different" rather than matching a dead frame. + local sameOwner = (S.mainFrameOwner == pageRef) + if S.mainFrame and sameOwner and prevDB == dbRef and S.mainFrame.dfBuiltFrameW == _adW and S.mainFrame.dfBuiltFrameH == _adH and S.mainFrame.dfBuiltLayout == _adLayout and S.mainFrame.dfBuiltPreset == _adPreset and S.mainFrame.dfBuiltEditing == _adEditing then + -- ☠ CONSUMED ON THIS PATH TOO, and it was not. S.pendingBuffTab is a one-shot from + -- whoever navigated here, and only the FULL build read it -- so the Power Infusion + -- Helper's nav row, which almost always lands on this reuse path (its own Invalidate + -- drops the harness cache, not the island), left the request standing. It would then + -- be picked up by the next unrelated full build and pin the designer to the helper's + -- pool for a visit nobody asked it of. + -- ⚠ The direct write it also makes is what actually moved the pool here; this is about + -- clearing the request, and about the two paths reading the same field. + if S.pendingBuffTab then + S.activeBuffTab = S.pendingBuffTab + S.pendingBuffTab = nil + end S.mainFrame:SetParent(parent) S.mainFrame:SetAllPoints() S.mainFrame:Show() @@ -1842,8 +1942,24 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) wipe(expandedCards) wipe(effectCardPool) + -- Retire whatever an older Power Infusion Helper schema left running -- above all the + -- cooldown-icon group, which draws with no control left that can reach it. Priests only, + -- schema-stamped, so this is one comparison on every build after the first. + if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() and P.PIH_Sweep then P.PIH_Sweep() end + S.activeTab = "effects" - S.activeBuffTab = "my" + -- ☠☠ A FULL BUILD USED TO CLOBBER THE POOL UNCONDITIONALLY, AND THAT BROKE THE ONE + -- CALLER THAT ASKS FOR A SPECIFIC ONE. The Power Infusion Helper's nav entry sets the + -- pool and then builds this page; the line below then reset it to My Buffs, so the + -- helper's own entry landed on somebody else's pool with none of its controls on screen + -- -- and only on a FULL build, so it behaved differently on a revisit (which takes the + -- reuse path above and leaves the pool alone). Krathe, 2026-09-08: "selecting the PI + -- helper in the menu is totally fucked up and the trigger/effects are not showing." + -- ⚠ CONSUMED, NOT READ. A standing preference would pin the designer to that pool + -- forever; this is a one-shot request from whoever navigated here, and clearing it means + -- the next plain visit to the designer opens on My Buffs exactly as it always has. + S.activeBuffTab = S.pendingBuffTab or "my" + S.pendingBuffTab = nil S.activeFilter = "all" -- A shared picker left open on the OLD S.rightPanel dies with it (its -- close hook may already have run via the ancestor hide); drop the @@ -1868,6 +1984,10 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) S.mainFrame.dfBuiltLayout = _adLayout S.mainFrame.dfBuiltPreset = _adPreset S.mainFrame.dfBuiltEditing = _adEditing + -- ...and WHICH PAGE it was built for, which the reuse guard now checks. Two nav entries + -- reach this builder (the designer's own and the Power Infusion Helper's), and an island + -- reparented between them leaves the page it left holding widgets anchored to nothing. + S.mainFrameOwner = pageRef -- Closing the settings window (or leaving this S.page) hides S.mainFrame with -- no refresh pass, which would leave the rendered preview pool's border -- animations ticking on the external driver (it ticks hidden secretRect @@ -2055,6 +2175,14 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) tabBaseline:SetPoint("BOTTOMRIGHT", 0, 0) tabBaseline:SetColorTexture(C_BORDER.r, C_BORDER.g, C_BORDER.b, 0.5) + -- ☠ ALL THREE ARE BUILT, WHATEVER THE POOL SHOWS. The strip is created ONCE inside + -- S.mainFrame and a pool switch does not rebuild it (SetMainTab redraws the tab CONTENT + -- and leaves the panel standing), so a button not built here could never appear later. + -- P.ApplySubTabStrip then decides per pool which of them are anchored, in what order and + -- under which label -- two on the Power Infusion Helper's pool, three everywhere else. + -- ⚠ THE LABELS BELOW ARE THE DEFAULT-POOL ONES and are overwritten on the first + -- ApplySubTabStrip pass; they are still worth passing, because StyleButton sizes its + -- label region from the text it is given. local TAB_GAP = 4 local TAB_DEFS = { { key = "effects", label = L["Effects"], accent = nil }, -- theme-tracking @@ -2105,13 +2233,11 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) UpdateLayoutTabState() -- Equal-width tabs (accounting for the gaps) on parent resize. - S.tabBar:SetScript("OnSizeChanged", function(self, w, h) - local n = #TAB_DEFS - local tabW = (w - (n - 1) * TAB_GAP) / n - for _, def in ipairs(TAB_DEFS) do - local btn = tabButtons[def.key] - if btn then btn:SetWidth(tabW) end - end + -- ⚠ THROUGH ApplySubTabStrip, NOT A LOCAL DIVISION. #TAB_DEFS is 3 and the strip on the + -- helper's pool has 2 buttons -- dividing by the built count would leave a third of the + -- band empty there, and widen the two hidden buttons that are not on screen anyway. + S.tabBar:SetScript("OnSizeChanged", function() + if P.ApplySubTabStrip then P.ApplySubTabStrip() end end) -- ── TAB CONTENT (scrollable) ── @@ -2170,15 +2296,16 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) S.tabContentFrame:SetWidth(initW) end - S.SwitchTab("effects") + -- ☠ THE POOL DECIDES WHERE THE STRIP LANDS. The helper's pool has no Effects tab in + -- first position -- Triggers is -- so a hardcoded "effects" would open its own nav entry + -- on the wrong one of its two tabs. P.CoerceTabForPool answers for every pool, and the + -- activeBuffTab it reads was settled a few dozen lines above (S.pendingBuffTab). + S.SwitchTab((P.CoerceTabForPool and P.CoerceTabForPool("effects")) or "effects") C_Timer.After(0, function() - if S.tabBar and S.tabBar:IsVisible() and S.tabBar:GetWidth() > 10 then - local tabW = (S.tabBar:GetWidth() - (#TAB_DEFS - 1) * TAB_GAP) / #TAB_DEFS - for _, def in ipairs(TAB_DEFS) do - if tabButtons[def.key] then - tabButtons[def.key]:SetWidth(tabW) - end - end + -- The strip's real width only exists after the first layout pass; ApplySubTabStrip + -- divides whatever it finds, and early-outs on a bar too narrow to be real. + if S.tabBar and S.tabBar:IsVisible() and P.ApplySubTabStrip then + P.ApplySubTabStrip() end end) RefreshPlacedIndicators() @@ -2283,6 +2410,18 @@ function DF:AuraDesigner_RefreshPage() end end + -- ☠☠ THE POOL CAN HAVE MOVED WITHOUT THE PANEL BEING REBUILT, and both strips were left + -- describing the pool the panel was BUILT for. The Power Infusion Helper's nav row asks + -- for its pool and reopens this page, which takes the island's REUSE path -- so the pool + -- tab stayed lit on Any Buff and the sub-tab strip still read Effects / Layout Groups / + -- Global while the content below it was the helper's. Krathe, 2026-09-09: "it shows + -- global/layout group and is not highlighting Power Infusion Helper tab up top." + -- ⚠ BEFORE SwitchTab, because ApplySubTabStrip decides which sub-tab buttons EXIST for + -- this pool and SwitchTab decides which of them is active. The other order lights a button + -- that is about to be hidden. + if P.SyncPoolTabs then P.SyncPoolTabs() end + if P.UpdateLayoutTabState then P.UpdateLayoutTabState() end + -- Rebuild the current tab to reflect data changes if S.activeTab and S.SwitchTab then S.SwitchTab(S.activeTab) diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index ffcad57d..ead0783e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -22,6 +22,7 @@ local GetOtherAuras = P.GetOtherAuras local NextGroupName = P.NextGroupName local GetSpecLayoutGroups = P.GetSpecLayoutGroups local IsOtherTab = P.IsOtherTab +local IsPIHelperTab = P.IsPIHelperTab local IsDebuffTab = P.IsDebuffTab local EMPTY_POOL = P.EMPTY_POOL local CurrentAuraPool = P.CurrentAuraPool @@ -29,6 +30,10 @@ local PoolKeyPrefix = P.PoolKeyPrefix local DebuffGroupsRead = P.DebuffGroupsRead local GetOtherLayoutGroups = P.GetOtherLayoutGroups local CurrentLayoutGroups = P.CurrentLayoutGroups +-- ☠ THE DISPLAY HALF OF THAT PAIR. CurrentLayoutGroups is the STORE and is what logic reads; +-- this is what a surface SHOWS. The preview canvas used the store and painted the helper's +-- group on every pool -- see PIHShowsMark for the whole account. +local VisibleLayoutGroups = P.VisibleLayoutGroups local OtherPoolDisplayName = P.OtherPoolDisplayName local EnsureAuraConfig = P.EnsureAuraConfig local EnsureTypeConfig = P.EnsureTypeConfig @@ -536,14 +541,22 @@ P.GetAuraWarningKey = GetAuraWarningKey -- offsetX/Y -- default 3, 3 -- size -- default 16 -- color -- { r, g, b } default red { 1.0, 0.25, 0.25 } +-- ⚠ opts.text: THE SAME BADGE, WITH THE TEXT SUPPLIED RATHER THAN LOOKED UP. Every caller +-- until now had a config warning KEY, so the text came from GetWarningText. The Power Infusion +-- Helper's clash warning is composed at render time -- it names the offending effect and how +-- many others contend -- so there is no key it could be filed under. +-- ⚠ A key still wins when both are given: a tracking limitation is a fact about the SPELL and +-- outranks a fact about this configuration. In practice they never collide (a helper record is +-- filter-owned, so it has no spec entry and no warning key). local function AttachWarningBadge(host, warnKey, opts) if not host then return end local badge = host.dfWarningBadge - if not warnKey then + local supplied = opts and opts.text + if not warnKey and not supplied then if badge then badge:Hide() end return end - local tooltipText = GetWarningText(warnKey) + local tooltipText = warnKey and GetWarningText(warnKey) or supplied if not tooltipText then if badge then badge:Hide() end return @@ -1111,30 +1124,28 @@ P.GetIndicatorLayoutGroup = GetIndicatorLayoutGroup -- (GetUngroupedIndicators removed — uncalled since the group picker moved to -- the full spell-picker "group" mode; reclaimed for the 200-locals ceiling.) --- Create a new layout group. kind: nil/"members" = classic member arranger --- (legacy records carry no kind); "filter" = a container-backed group linked to --- registry filters (stable preset keys / custom ids in filterSelection) with --- uniform per-group styling (iconSize / maxIcons on top of the shared layout). -local function CreateLayoutGroup(name, kind) - local adDB = GetAuraDesignerDB() - if not adDB then return nil end - -- Pool-routed: the Other Buffs tab creates into the flat spec-independent - -- store (born lazily HERE — the first add) with its own id counter. - local groups, id - if IsOtherTab() then - groups = GetOtherLayoutGroups(true) -- the first add creates the store - if not adDB.nextOtherLayoutGroupID then adDB.nextOtherLayoutGroupID = 1 end - id = adDB.nextOtherLayoutGroupID - adDB.nextOtherLayoutGroupID = id + 1 - else - groups = GetSpecLayoutGroups() - if not adDB.nextLayoutGroupID then adDB.nextLayoutGroupID = 1 end - id = adDB.nextLayoutGroupID - adDB.nextLayoutGroupID = id + 1 - end +-- ★★ WHAT A NEW LAYOUT GROUP *IS*, IN ONE PLACE (2026-09-10). +-- +-- ☠ EXTRACTED BECAUSE A SECOND CREATOR DRIFTED FROM IT AND SHIPPED. The Power Infusion +-- Helper's cooldown-icon group is built directly into adDB.otherLayoutGroups rather than +-- through CreateLayoutGroup below -- deliberately, and for a good reason: that function picks +-- its store from the OPEN TAB, which is the one line that put eight stray groups in Krathe's +-- spec store and took three attempts to clean up. What it also did was hand-write the record, +-- and a hand-written record omitted `iconSize` and `maxIcons`. Krathe: "Max icons should +-- default to 4 it's showing blank but seems to look like 8? Icon size is also showing blank on +-- the slider." Both sliders bind the field directly, so nil draws blank -- and the factory +-- falls back to 8 for a filter group's max, which is exactly what he was seeing. +-- ⇒ The two callers now share the RECORD and differ only in the STORE. Copying a field list +-- is how they drifted; there is no longer a field list to copy. +-- +-- kind: nil/"members" = classic member arranger (legacy records carry no kind); "filter" = a +-- container-backed group linked to registry filters (stable preset keys / custom ids in +-- filterSelection) with uniform per-group styling (iconSize / maxIcons on top of the shared +-- layout). The caller owns `id` and `name` -- both come from the store it is inserting into. +local function NewLayoutGroupRecord(id, name, kind) local group = { id = id, - name = name or NextGroupName(groups, (kind == "filter") and "Filter Group" or "Group"), + name = name, anchor = "TOPLEFT", offsetX = 0, offsetY = 0, @@ -1153,6 +1164,31 @@ local function CreateLayoutGroup(name, kind) else group.members = {} end + return group +end +P.NewLayoutGroupRecord = NewLayoutGroupRecord + +-- Create a new layout group in the ACTIVE TAB's store. See NewLayoutGroupRecord for the +-- record itself, and for why a second creator exists that does not come through here. +local function CreateLayoutGroup(name, kind) + local adDB = GetAuraDesignerDB() + if not adDB then return nil end + -- Pool-routed: the Other Buffs tab creates into the flat spec-independent + -- store (born lazily HERE — the first add) with its own id counter. + local groups, id + if IsOtherTab() then + groups = GetOtherLayoutGroups(true) -- the first add creates the store + if not adDB.nextOtherLayoutGroupID then adDB.nextOtherLayoutGroupID = 1 end + id = adDB.nextOtherLayoutGroupID + adDB.nextOtherLayoutGroupID = id + 1 + else + groups = GetSpecLayoutGroups() + if not adDB.nextLayoutGroupID then adDB.nextLayoutGroupID = 1 end + id = adDB.nextLayoutGroupID + adDB.nextLayoutGroupID = id + 1 + end + local group = NewLayoutGroupRecord(id, + name or NextGroupName(groups, (kind == "filter") and "Filter Group" or "Group"), kind) tinsert(groups, group) return group end @@ -1268,6 +1304,15 @@ local function DebuffSelectionView(sel) end P.DebuffSelectionView = DebuffSelectionView +-- Drop a group's remembered fold state. Exported for the one kind of caller that removes a +-- group WITHOUT going through DeleteLayoutGroup: a store-wide sweep, which walks the raw +-- arrays because the group it is hunting may be in any of them (see pihPurgeStrayMarks in +-- Cards.lua). The expand table is a file local, so the removal cannot clear it itself. +local function ForgetGroupExpandState(groupID) + expandedGroups[GroupExpandKey(groupID)] = nil +end +P.ForgetGroupExpandState = ForgetGroupExpandState + -- Delete a layout group by ID (from the ACTIVE tab's store; the member- -- indicator cascade removes from the active pool via RemoveIndicatorInstance's -- CurrentAuraPool routing — members always live in their group's pool) @@ -1483,8 +1528,69 @@ P.BADGE_COLORS = BADGE_COLORS -- Collect all configured effects into a flat, sorted list -- Returns: { { source="placed"|"frame", auraName, typeKey, ... }, ... } -local function CollectAllEffects() +-- ☠ HELPER-OWNED RECORDS ARE EXCLUDED BY DEFAULT (2026-09-08). A record carrying a +-- `pihSignal` mark belongs to the Power Infusion Helper, which now has its own page +-- (Auras > Power Infusion Helper). Krathe's requirement when it moved: "Anything added +-- should show just on the PI helper page and not in AD itself." Left in this list they +-- read as stray indicators the user does not remember making, and deleting one there +-- silently half-dismantles a feature configured somewhere else. +-- +-- ⚠ AN OPTION, NOT A HARD SKIP, and the difference matters. The helper's own page wants +-- exactly these rows -- it is the one surface where they ARE the subject -- so the filter +-- is a caller's choice and the display-name derivation below stays live rather than +-- becoming unreachable code that looks maintained. +-- ⚠ Callers that want the designer's behaviour pass nothing: every existing call site +-- (Cards.lua's Active Indicators list, Rows.lua's) is a designer list and wants them gone. +-- ☠☠ THE SAME RULE, FOR THE SURFACE THAT NEVER LEARNED IT (2026-09-10). +-- CollectAllEffects hides helper-owned records from every pool but the helper's; the PREVIEW +-- CANVAS was written before that rule existed and kept painting them. Krathe: "any buff tab on +-- AD is showing our PI helper indicators, it should not." +-- ⚠ THREE LEAKS, ONE CAUSE, and all three are display sites reading a STORE accessor: +-- · the filter-group placeholder loop read CurrentLayoutGroups (the store) instead of +-- VisibleLayoutGroups (the display filter) -- the exact split that accessor's own note +-- describes, applied everywhere except here; +-- · the placed-instance loop iterated CurrentAuraPool without testing the mark; +-- · RefreshPreviewEffects did the same for frame-level effects, so a helper BORDER painted +-- itself over the Any Buff preview. +-- ⚠ READ IN BOTH DIRECTIONS, exactly like VisibleLayoutGroups: on the helper's own tab the +-- marked records are the ONLY ones that belong, and the user's unrelated Any Buff work is +-- what does not. One rule -- "show what this tab is about" -- not two lists of exceptions. +local function PIHShowsMark(marked) + return ((marked and true or false) == IsPIHelperTab()) +end +P.PIHShowsMark = PIHShowsMark + +-- The record this tab may paint, with the frame-level effects it may not removed. +-- ⚠ PER TYPE KEY, NOT PER RECORD. A helper record is keyed by its filter reference, and +-- nothing stops the user adding an effect of their own to that same filter from the Any Buff +-- tab -- so "this record is the helper's" would hide their work along with ours. Same +-- granularity CollectAllEffects uses. +-- ⚠ NO COPY IN THE COMMON CASE: a record with nothing to hide is handed straight back, which +-- is every record in every profile that has never opened the helper. +-- ☠ A REAL COPY, NOT AN __index PROXY. The painters read auraCfg.border, auraCfg.healthbar and +-- so on directly, and a metatable would answer every one of those from the original -- hiding +-- nothing while looking like it did. +local function PIHVisibleRecord(auraCfg) + local hide + for _, typeKey in ipairs(FRAME_LEVEL_TYPE_KEYS) do + local cfg = auraCfg[typeKey] + if type(cfg) == "table" and not PIHShowsMark(cfg.pihSignal) then + hide = hide or {} + hide[typeKey] = true + end + end + if not hide then return auraCfg end + local out = {} + for k, v in pairs(auraCfg) do + if not hide[k] then out[k] = v end + end + return out +end +P.PIHVisibleRecord = PIHVisibleRecord + +local function CollectAllEffects(opts) local effects = {} + local includePIH = opts and opts.includePIH and true or false local spec = ResolveSpec() local trackable = spec and Adapter and Adapter:GetTrackableAuras(spec) @@ -1542,18 +1648,22 @@ local function CollectAllEffects() -- Placed indicators if auraCfg.indicators then for _, indicator in ipairs(auraCfg.indicators) do + if includePIH or not indicator.pihSignal then tinsert(effects, { source = "placed", auraName = auraName, -- Same derivation as the frame-level rows below: a marked indicator -- (a helper Icon or Square) names itself, from the mark. + -- ⚠ THE RECORD GOES IN TOO, not just the signal: the two helper ICONS + -- differ only in their art, and the type badge says "Icon" for both. displayName = (indicator.pihSignal and P.PIH_SignalLabel - and P.PIH_SignalLabel(indicator.pihSignal)) or displayName, + and P.PIH_SignalLabel(indicator.pihSignal, indicator)) or displayName, indicatorID = indicator.id, typeKey = indicator.type, config = indicator, anchor = indicator.anchor or "CENTER", }) + end end end @@ -1562,7 +1672,8 @@ local function CollectAllEffects() -- 600-spell filter would mean 600 registrations. local isFilterOwned = DF.ParseADFilterRef and DF:ParseADFilterRef(auraName) ~= nil for _, typeKey in ipairs(FRAME_LEVEL_TYPE_KEYS) do - if auraCfg[typeKey] and not (isFilterOwned and typeKey == "sound") then + if auraCfg[typeKey] and not (isFilterOwned and typeKey == "sound") + and (includePIH or not auraCfg[typeKey].pihSignal) then tinsert(effects, { source = "frame", auraName = auraName, @@ -1573,7 +1684,7 @@ local function CollectAllEffects() -- is a translated string frozen into the profile, so it would keep the -- locale it was created in while every other name followed the client. displayName = (auraCfg[typeKey].pihSignal and P.PIH_SignalLabel - and P.PIH_SignalLabel(auraCfg[typeKey].pihSignal)) or displayName, + and P.PIH_SignalLabel(auraCfg[typeKey].pihSignal, auraCfg[typeKey])) or displayName, typeKey = typeKey, config = auraCfg[typeKey], }) @@ -2666,7 +2777,11 @@ local function RefreshPlacedIndicators() local fgPoolKey = isOther and "dfADOtherFilterGroupSlots" or "dfADFilterGroupSlots" local fgPool = mockFrame[fgPoolKey] if not fgPool then fgPool = {}; mockFrame[fgPoolKey] = fgPool end - for _, group in ipairs(specGroups) do + -- ⚠ VisibleLayoutGroups, NOT `specGroups`. This is a DISPLAY loop and specGroups is the + -- STORE -- it is kept raw above because the placement pass before it is LOGIC (it + -- resolves an indicator's owning group and must find one wherever it lives). Reading + -- the store here drew the helper's Cooldown Icons group on the Any Buff preview. + for _, group in ipairs(VisibleLayoutGroups()) do if group.kind == "filter" and group.enabled ~= false then tinsert(placedIndicators, DrawGroupPlaceholderSlot(mockFrame, fgPool, group, 8, 8, @@ -2701,12 +2816,18 @@ local function RefreshPlacedIndicators() -- "other:" prefix so the two pools' slots can't collide in the store. -- Hidden indicators (eye toggle, enabled == false) don't render — same as live. -- (keyPrefix hoisted above the group-position pass — same value.) - local idSpec = isOther and nil or spec + -- ☠ NOT `isOther and nil or spec` -- that always yields spec (nil never wins an + -- and/or), so the Other pool was previewing with the spec it must not use. + local idSpec + if not isOther then idSpec = spec end for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do local info = infoLookup[auraName] if type(auraCfg) == "table" and (isOther or info or AdHocSpellID(auraName)) and auraCfg.indicators then for _, indicator in ipairs(auraCfg.indicators) do - if indicator.enabled ~= false then + -- ⚠ AND THE MARK, which this loop never tested: the Any Buff pool holds the + -- helper's records too, so every helper icon and square painted itself on the + -- designer's own canvas. See PIHShowsMark. + if indicator.enabled ~= false and PIHShowsMark(indicator.pihSignal) then local instanceKey = keyPrefix .. auraName .. "#" .. indicator.id local capturedAura = auraName local capturedID = indicator.id @@ -2756,7 +2877,7 @@ local function GetOrCreatePreviewCustomBorder(mockFrame, key) return pool[key] end -local function RefreshPreviewEffects() +local function RefreshPreviewEffects(opts) if not S.framePreview then return end local mockFrame = S.framePreview.mockFrame if not mockFrame then return end @@ -2800,10 +2921,23 @@ local function RefreshPreviewEffects() -- Indicators:Apply's `if state.X then return end`). Mirror that here so the -- preview is deterministic instead of pairs()-order-dependent: iterate auras -- in descending-priority order (tiebreak by name) and apply first-wins per type. + -- ⚠ THE POOL IS AN ARGUMENT NOW (2026-09-08), defaulting to exactly what it always was. + -- The Power Infusion Helper's page shows this same canvas but must paint ONLY the + -- helper's own records -- the Any Buff pool it shares holds the user's unrelated work + -- too, and a preview on a page about one feature that quietly renders another feature's + -- effects is worse than no preview. It passes a table holding the SAME cfg tables, so + -- every painter below is unchanged and cannot drift from the designer's rendering. local sortedAuras = {} - for auraName, auraCfg in pairs(CurrentAuraPool()) do + for auraName, auraCfg in pairs((opts and opts.pool) or CurrentAuraPool()) do if type(auraCfg) == "table" then -- skip corrupted entries - sortedAuras[#sortedAuras + 1] = { name = auraName, cfg = auraCfg, priority = auraCfg.priority or 5 } + -- ⚠ THE HELPER'S FRAME-LEVEL EFFECTS ARE STRIPPED FOR THE DESIGNER, and ONLY the + -- helper's -- PIHVisibleRecord hides per type key, so a user's own effect on the + -- same filter record still paints. Without it a helper BORDER drew itself over the + -- Any Buff preview, which is the half of Krathe's report that had no card to + -- explain it: the effects list already hid the row, so the colour on the mock frame + -- came from nowhere the panel would admit to. + local cfg = PIHVisibleRecord(auraCfg) + sortedAuras[#sortedAuras + 1] = { name = auraName, cfg = cfg, priority = cfg.priority or 5 } end end sort(sortedAuras, function(a, b) @@ -2931,7 +3065,10 @@ S.RefreshPreviewLightweight = function() -- Re-apply placed indicator instances using current settings -- (hidden indicators skipped — RenderPreviewIndicator would resurrect their -- slot; keyPrefix hoisted above the group-position pass — same value) - local idSpec = isOther and nil or spec + -- ☠ NOT `isOther and nil or spec` -- that always yields spec (nil never wins an + -- and/or), so the Other pool was previewing with the spec it must not use. + local idSpec + if not isOther then idSpec = spec end for auraName, auraCfg in pairs(CurrentAuraPool(spec)) do if type(auraCfg) == "table" and auraCfg.indicators then for _, indicator in ipairs(auraCfg.indicators) do diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index 9a77733d..9efc9102 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua @@ -183,6 +183,23 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff end end + -- ☠ A HELPER EFFECT HAS NOTHING TO COUNT. Krathe, 2026-09-09: "the PI 'icon' has stacks + -- but PI does not have stacks, you only get 1 charge so that setting should at least be + -- off by default if not hidden." + -- ⚠ AND IT IS WORSE THAN A DEAD SETTING ON THE ICON: that icon's art is PINNED to Power + -- Infusion while the aura it matched is somebody's cooldown, so a stack count there would + -- be a number describing a spell the picture is not of. + -- ⚠ THE WHOLE GROUP GOES, not just the tick. Stack Font, Scale, Outline, Anchor and Offset + -- are six controls for a number that is never drawn -- leaving them and unticking one is + -- how a panel fills up with settings that do nothing. + -- ⚠ HIDDEN, NOT GREYED, and that is the opposite call from GateSWM above -- deliberately. + -- Show When Missing is greyed WITH A REASON because ticking it would silently break the + -- cooldown gate, and the user needs to know why they cannot. Stacks are not broken, they + -- are irrelevant, and a greyed control invites a "why?" that has no interesting answer. + -- ⚠ The stored value is forced false as well (pihCreateSignal stamps it, pihSweep step 7 + -- backfills), so what renders and what is stored agree rather than relying on this. + local pihNoStacks = (proxy and proxy.pihSignal) and true or false + local function AddWidget(widget, height) -- Collect mode: the card has no stack, so a loose widget belongs to -- whichever pane's body is running. Sized by the group, not by hand. @@ -641,6 +658,73 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff -- -- ★ Frame Level and Alpha are the canaries: they belong to Appearance on every type, -- without exception. If a review finds either anywhere else, the card has drifted. + -- ★★★ THE HELPER'S OWN TWO QUESTIONS, AND THEY LEAD FOR THE REASON SHOW WHEN MISSING + -- USUALLY DOES: they are prior to everything below. "Which picture is this" and "what + -- is it allowed to show" decide what the indicator IS; the rest decide how it looks. + -- ⚠ ONE GROUP, HELPER ONLY. They are meaningless on an ordinary icon (whose picture is + -- its spell's by definition) and would be two dead rows on every other card. + -- ⚠ THE ICON ONLY, of all the helper's surfaces. A border or a square looks the same + -- whichever trigger fired, so "which one is this" has no answer to give there -- the + -- icon is the only surface that carries the information, and therefore the only one + -- where choosing between several matters. If it ever generalises, both verbs already + -- take a record rather than assuming one. + if pihNoStacks then -- the same "this is a helper effect" test; see its note + local pihRec + do + local pool = CurrentAuraPool() + local auraCfg = pool and pool[auraName] + for _, x in ipairs((type(auraCfg) == "table" and auraCfg.indicators) or {}) do + if x.id == indicatorID then pihRec = x; break end + end + end + if pihRec then + AddGroup(L["Power Infusion Helper"], function(g) + -- ⚠ customGet/customSet, not a db key: the stored value is a SPELL ID or + -- nil rather than a boolean, and the field that does the work is the field + -- the control reads. Same shape as the border card's "own border" tick. + local artCb = GUI:CreateCheckbox(parent, + L["Show the triggering cooldown's icon"], nil, nil, + function() if RPL then RPL() end end, + function() return pihRec.staticSpellID == nil end, + function(v) P.PIH_SetIconShowsAura(pihRec, v) end) + artCb.tooltip = L["Off: the Power Infusion icon, on everyone worth infusing. On: the buff they actually used — one of them, if several are up at once."] + g:AddWidget(artCb, 28) + + -- ★★ THE SAME FOUR SOURCES THE COOLDOWN ICONS GROUP HAS (2026-09-10). + -- Krathe: "yes build the icon block the same". This was ONE tick -- + -- "Ignore trinkets, potions and racials" -- over all three amplifiers at + -- once, which is the same mechanism (per-record mutes) at a coarser + -- grain. Nothing to migrate: a record saved by that tick carries exactly + -- the mutes these read, so "all three ignored" reads back as three off. + -- ⚠ SUBTRACTIVE, and the footer says so. A placed effect is keyed by one + -- filter reference and can only narrow what that resolves to -- showing + -- something Triggers is NOT watching needs a filter of its own, which is + -- what the group is for. So a source Triggers has off is GREYED here + -- rather than hidden: hiding it would make the two cards disagree about + -- how many sources this feature has. + for _, d in ipairs({ + { key = "cooldowns", label = L["Class cooldowns"] }, + { key = "trinkets", label = L["Trinkets"] }, + { key = "potions", label = L["Potions"] }, + { key = "racials", label = L["Racials"] }, + }) do + local key = d.key + local cb = GUI:CreateCheckbox(parent, d.label, nil, nil, + function() if RPL then RPL() end end, + function() return P.PIH_IconSourceOn(pihRec, key) end, + function(v) P.PIH_SetIconSourceOn(pihRec, key, v) end) + if not P.PIH_IconSourceAvailable(key) then + if cb.SetEnabled then cb:SetEnabled(false) end + cb.tooltip = L["Switch this on under Triggers first — the helper is not watching it."] + end + g:AddWidget(cb, 28) + end + g:AddWidget(GUI:CreateNote(parent, + L["This icon only. It can show less than the Triggers tab watches, never more."]), 34) + end) + end + end + AddGroup(L["Show When Missing"], function(g) local desatCb local function UpdateDesatState() @@ -814,6 +898,8 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff end) -- Stack Count sits with Duration Text: they are the two TEXT elements on an icon, -- and tuning either means reading them as a pair. + -- ⚠ ...and neither exists on a helper effect. See pihNoStacks. + if not pihNoStacks then AddGroup(L["Stack Count"], function(g) g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], proxy, "showStacks"), 28) g:AddWidget(GUI:CreateFontDropdown(parent, L["Stack Font"], proxy, "stackFont"), 54) @@ -825,6 +911,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "stackY"), 54) g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) end) + end -- pihNoStacks -- Duration Bar (native SetDurationBar strip — shared with the square card). Closes -- the run of things drawn ON the icon, and keeps all three duration/count elements -- together rather than stranding the bar below the conditional reveals. @@ -955,6 +1042,8 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff UpdateHideAboveState() end) -- Stack Count sits with Duration Text — see the icon card for why. + -- ⚠ ...and is skipped on a helper effect. See pihNoStacks. + if not pihNoStacks then AddGroup(L["Stack Count"], function(g) g:AddWidget(GUI:CreateCheckbox(parent, L["Show Stacks"], proxy, "showStacks"), 28) g:AddWidget(GUI:CreateFontDropdown(parent, L["Stack Font"], proxy, "stackFont"), 54) @@ -966,6 +1055,7 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(GUI:CreateSlider(parent, L["Offset Y"], -150, 150, 1, proxy, "stackY"), 54) g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) end) + end -- pihNoStacks -- Duration Bar (native SetDurationBar strip — shared with the icon card) AddDurationBarGroup() -- The two conditional reveals, adjacent, in the same order and last, as on every diff --git a/DandersFrames_Options/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua index 6c51baf7..96f15233 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Options.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Options.lua @@ -578,13 +578,52 @@ P.GetSpecLayoutGroups = GetSpecLayoutGroups -- groups, migrations, the spec dropdown itself) deliberately do not. -- ============================================================ -S.activeBuffTab = "my" -- "my" | "debuffs" | "other" - +S.activeBuffTab = "my" -- "my" | "debuffs" | "other" | "pihelper" + +-- ★★★ THE POWER INFUSION HELPER IS A POOL TAB (2026-09-08), NOT A PAGE OF ITS OWN. +-- ☠ IT WAS A SEPARATE PAGE FOR A DAY AND EVERY VERSION OF IT WAS A WORSE AURA DESIGNER. +-- I rebuilt the add flow, then the effect list, then both again -- each time a lookalike of +-- something that already existed twenty lines away. Krathe: "This should function EXACTLY as +-- AD but with the triggers / effects. It should BE AD not a copy of it." +-- ⇒ So it is a fourth pool, beside My Buffs / Debuffs / Any Buff. Every surface the designer +-- already has -- the preview, the add flow, the effect cards, layout groups -- works on it +-- unchanged, because all of them route through the four functions below. Nothing is +-- reimplemented, so nothing can drift. +local function IsPIHelperTab() + return S.activeBuffTab == "pihelper" +end +P.IsPIHelperTab = IsPIHelperTab + +-- ⚠ THE HELPER TAB *IS* THE OTHER POOL, filtered -- so everything that asks "is this the +-- other pool" must say yes for it. That question is really "does this pool hold any caster's +-- buffs, shared across specs" (as against My Buffs, which means your own casts on your own +-- spec), and the helper's records are exactly that: they watch OTHER people's cooldowns. +-- Answering no would give them My Buffs' caster filter, which is the one place they are +-- guaranteed to match nothing -- the trap the old add button fell into. local function IsOtherTab() - return S.activeBuffTab == "other" + return S.activeBuffTab == "other" or S.activeBuffTab == "pihelper" end P.IsOtherTab = IsOtherTab +-- ☠ ...AND THE ONE PLACE THE TWO POOLS PART COMPANY: DOES THE USER GET TO DECIDE THE CASTER +-- RULE? On Any Buff, yes -- that pool means "any caster including you", so Others Only is the +-- switch that narrows it and the choice is the whole point. +-- On the Power Infusion Helper it is not a choice at all. Krathe, 2026-09-09: "we don't need +-- the others only setting or even note that on the Active indicators either, we only care +-- about using it on others anyway, it's a pointless option." He is right, and the recipe +-- already agreed with him: pihCreateSignal STAMPS othersOnly on every effect it builds, +-- because a helper watching your own cooldowns would be telling you to infuse yourself. +-- ⇒ The STORED VALUE is unchanged -- it is what makes the effect correct, and the engine +-- still reads it. What goes is the control that pretends it is up for discussion, and the +-- suffix on the row that reports a constant as though it were a setting. +-- ⚠ THE SUFFIX MATTERS AS MUCH AS THE TICK. "PI Helper - Center - Others Only" spends the +-- row's summary on something true of every helper effect ever created -- the same +-- constant-on-every-line fault the "Big cooldown" label had. +local function ShowsOthersOnly() + return IsOtherTab() and not IsPIHelperTab() +end +P.ShowsOthersOnly = ShowsOthersOnly + -- C2: the Debuffs tab hosts debuff CATEGORY groups (spec-independent, no -- spell pool, no placed indicators). Its Effects sub-tab frosts, the spec -- dropdown greys, and CurrentAuraPool reads empty. @@ -602,6 +641,17 @@ P.EMPTY_POOL = EMPTY_POOL -- READ access to the active tab's pool. Never creates adDB.otherAuras. -- `spec` is forwarded to GetSpecAuras on the My Buffs tab only. local function CurrentAuraPool(spec) + -- ☠ A FILTERED VIEW OF THE OTHER POOL, AND IT IS SAFE BECAUSE THE VALUES ARE THE LIVE + -- TABLES. Every consumer of this either iterates it or does `CurrentAuraPool()[name]` and + -- mutates the record it gets back -- both of which reach the real cfg through the shared + -- reference. The only thing a copy would break is adding a NEW record, and that goes + -- through CurrentAuraPoolWrite below, which hands back the genuine pool. + -- ⚠ So the helper tab shows the designer's own surfaces holding ONLY the helper's + -- records: the user's unrelated Any Buff work is not on this tab, and the helper's + -- records are hidden from every other one (see CollectAllEffects' includePIH). + if S.activeBuffTab == "pihelper" then + return (S.PIH_PreviewPool and S.PIH_PreviewPool()) or EMPTY_POOL + end if S.activeBuffTab == "other" then local adDB = GetAuraDesignerDB() if adDB and adDB.otherAuras then return GetOtherAuras() end @@ -618,7 +668,11 @@ P.CurrentAuraPool = CurrentAuraPool -- WRITE access: creates the pool table (the other pool is born lazily on -- the first add — drag-drop, picker click, or add-by-ID). local function CurrentAuraPoolWrite() - if S.activeBuffTab == "other" then return GetOtherAuras() end + -- ⚠ THE HELPER WRITES INTO THE REAL OTHER POOL. Its READ view is filtered (see + -- CurrentAuraPool), but a new record has to land in the actual store or it would be + -- created into a temporary table and vanish on the next redraw -- the one thing the + -- filtered view cannot carry. + if S.activeBuffTab == "other" or S.activeBuffTab == "pihelper" then return GetOtherAuras() end return GetSpecAuras() end @@ -626,8 +680,12 @@ end -- the other-pool record embeds "other:" .. auraName in the name segment -- (expandedCards "placed:other:#" / "frame::other:", -- preview slot keys). The auraName itself never carries the prefix. +-- ⚠ THE HELPER SHARES "other:" DELIBERATELY. The prefix identifies which POOL a record's +-- name belongs to, and the helper's records are in the other pool -- a prefix of its own +-- would key the same record two ways, so an effect card expanded on one tab would read as +-- collapsed on the other. local function PoolKeyPrefix() - return (S.activeBuffTab == "other") and "other:" or "" + return IsOtherTab() and "other:" or "" end P.PoolKeyPrefix = PoolKeyPrefix @@ -682,11 +740,40 @@ P.GetOtherLayoutGroups = GetOtherLayoutGroups -- (Debuffs never reaches these — its Layout Groups tab builds debuff -- category groups instead.) local function CurrentLayoutGroups() - if S.activeBuffTab == "other" then return GetOtherLayoutGroups(false) end + -- ⚠ THE STORE IS SHARED WITH THE OTHER POOL and stays whole for both -- this is the + -- accessor two LOGIC callers use (GetIndicatorLayoutGroup resolves an indicator's owning + -- group, DeleteLayoutGroup removes one by id), and hiding a group from those would make + -- the helper's own group unreachable and undeletable. The DISPLAY filter is + -- VisibleLayoutGroups below; that is where a tab decides what it shows. + if IsOtherTab() then return GetOtherLayoutGroups(false) end return GetSpecLayoutGroups() end P.CurrentLayoutGroups = CurrentLayoutGroups +-- ☠ THE LIST THE DESIGNER SHOWS, WHICH IS NOT THE LIST IT OPERATES ON. The Power Infusion +-- Helper's icon group carries a `pihSignal` mark and belongs to its own page now +-- (Auras > Power Infusion Helper), so the designer must not list it: shown there it reads +-- as a stray group, and deleting it half-dismantles a feature configured elsewhere. +-- ⚠ FILTERED HERE AND NOT IN CurrentLayoutGroups, deliberately. Two of that function's +-- callers are LOGIC, not display -- GetIndicatorLayoutGroup resolves an indicator's owning +-- group and DeleteLayoutGroup removes one by id -- and hiding a group from those would +-- make the helper's own group unreachable and undeletable by its own remove path. Display +-- filters belong at the display site; the store stays whole. +-- ⚠ THE TEST INVERTS ON THE HELPER'S OWN TAB. Everywhere else a helper group is somebody +-- else's business and is hidden; on the helper tab it is the ONLY business, and the user's +-- unrelated Any Buff groups are the ones that do not belong. One rule -- "show the groups +-- this tab is about" -- read in both directions. +local function VisibleLayoutGroups() + local want = IsPIHelperTab() + local out = {} + for _, g in ipairs(CurrentLayoutGroups()) do + local mine = (type(g) == "table" and g.pihSignal) and true or false + if mine == want then out[#out + 1] = g end + end + return out +end +P.VisibleLayoutGroups = VisibleLayoutGroups + -- Display name for an OTHER-pool aura key: ad-hoc "#" resolves live, -- SpellDB names resolve through GetSpellDisplay (localized), else the raw key. local function OtherPoolDisplayName(auraName) diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua new file mode 100644 index 00000000..81fca68a --- /dev/null +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -0,0 +1,134 @@ +-- Power Infusion Helper — the nav entry, and nothing else. +-- +-- ☠ Companion addon: `...` yields THIS addon's private table, not the parent's, so every +-- DF.* read here would be nil. Take the parent's table from the global it publishes at +-- DandersFrames/Core.lua:9 (`_G[addonName] = DF`). Same preamble as the other AD parts. +local DF = DandersFrames +local S = DF.AuraDesigner._uiState +local L = DF.L + +-- ============================================================ +-- ☠☠ THIS FILE USED TO BE A WHOLE PAGE. IT SHOULD NEVER BE ONE AGAIN. +-- ============================================================ +-- Over one afternoon it grew a split layout, a frame preview, a tab bar, an add flow, an +-- effect list and a settings column -- roughly 290 lines, every one of them a WORSE COPY of +-- something the Aura Designer already had twenty lines away. Krathe, after the third +-- screenshot: "what have you done... It should basically function EXACTLY as AD. It should +-- BE AD not a copy of it." +-- +-- ★ AND THE ANSWER WAS HIS: "maybe we put it as a tab at the top of AD next to Any Buff... +-- and then the Power Infusion Helper in the menu just instant links to that." +-- +-- So the helper is a fourth POOL -- My Buffs / Debuffs / Any Buff / Power Infusion Helper -- +-- and every surface the designer owns works on it unchanged, because they all route through +-- the pool accessors in AuraDesigner/UI/Options.lua (CurrentAuraPool, CurrentAuraPoolWrite, +-- PoolKeyPrefix, IsOtherTab, VisibleLayoutGroups). The preview, the add tiles, the effect +-- cards: one implementation, four pools. + +-- ============================================================ +-- ☠☠ IT IS A LINK NOW, NOT A SECOND PAGE. AND THE SECOND PAGE IS WHAT WENT BLANK. +-- ============================================================ +-- The first version of "the door" was a real page whose builder called the designer's +-- builder. Both pages then wanted the same island -- S.mainFrame, the one frame the classic +-- layout builds everything into -- and the designer's builder can only ever parent it to ONE +-- of them. Giving each page its own build (the S.mainFrameOwner guard) fixed the overlapping +-- widgets and traded them for something worse: +-- +-- · open the helper's entry -> full build, island parented to the HELPER's page +-- · open the Aura Designer -> full build, island reparented to the DESIGNER's page +-- · open the helper's entry -> the harness's cache is still valid, so its builder is +-- NOT re-run (GUI/Panel.lua RefreshCached: a valid cache +-- calls RefreshStates and returns) -- and RefreshStates +-- on this page is AuraDesigner_RefreshPage, which redraws +-- the island WHEREVER IT IS. It is on the other page. +-- ⇒ A BLANK PAGE, until something invalidates the cache. +-- +-- Krathe, 2026-09-09: "the PI helper is blank sometimes when you select it in the side menu +-- vs via AD." Sometimes, because it is whichever of the two pages did not build last. +-- +-- ⚠ THE LESSON IS THE SAME ONE AS THE COMMIT BEFORE IT, and I did not learn it the first +-- time: a page-cache hit does not re-run the builder, so ANY state a builder sets up for its +-- page has to survive without it. An island that can only be parented to one page at a time +-- cannot be shared by two pages under a cache that skips the reparenting. +-- +-- ⇒ ONE PAGE OWNS THE ISLAND -- the Aura Designer's, which is the page the island is FOR -- +-- and this entry is a LINK to it, exactly as Krathe described the design. Clicking it asks +-- for the helper's pool and selects the designer's own nav row; nothing here builds anything. +-- ============================================================ + +-- What the nav row does INSTEAD of opening its own page. Also what the fallback button on +-- the stub page below calls, so there is one definition of "go to the helper". +-- +-- ⚠ INVALIDATE BEFORE SELECT, AND THAT ORDER IS THE WHOLE FUNCTION. SelectTab takes the +-- cache path on a page that has already been built -- which is exactly the case here, since +-- the designer is where the user just was -- and the cache path never re-reads the pool. So +-- the request is made (S.pendingBuffTab), the cache is dropped, and only then is the page +-- selected: the rebuild that follows consumes the request. Without the invalidate this is +-- the blank-page bug again with the pool wrong instead of the parent. +function DF.OpenPIHelperInDesigner() + if not (DF.IsPIHelperAvailable and DF.IsPIHelperAvailable()) then return false end + local GUI = DF.GUI + if not (GUI and GUI.SelectTab and GUI.Pages and GUI.Pages["auras_auradesigner"]) then + return false + end + -- ⚠ BOTH, for the reason the designer's own build records: the PENDING one survives a + -- full build (which resets the pool as part of its teardown), and the DIRECT one is what + -- any path that skips the full build reads. + S.pendingBuffTab = "pihelper" + S.activeBuffTab = "pihelper" + -- Triggers is the helper's first tab, and someone who asked for the helper by name wants + -- the helper's first tab -- not whichever sub-tab the designer was left on. The build + -- would coerce a Layout Groups anyway (P.CoerceTabForPool), but coercion is a safety net + -- and this is a choice: the entry means "show me the Power Infusion Helper". + -- ⚠ THE KEY IS "global". On this pool that button is labelled Triggers and drawn first + -- -- see P.SubTabDefs for why it is a relabel rather than a tab of its own. + S.activeTab = "global" + local page = GUI.Pages["auras_auradesigner"] + if page and page.Invalidate then page:Invalidate() end + GUI.SelectTab("auras_auradesigner") + -- ⚠ AND THE RAIL GOES ON THE ROW THEY CLICKED, not on the row that owns the page. + -- SelectTab has just lit the Aura Designer, which is true about the page and wrong about + -- the click. Krathe: "clicking power infusion helper on the menu should highlight it." + -- ⚠ AFTER SelectTab, never before -- its own tail clears every row and lights one, so a + -- highlight set first would simply be undone. + if GUI.SetNavHighlight then GUI.SetNavHighlight("auras_pihelper") end + return true +end + +-- ============================================================ +-- THE STUB PAGE -- REACHED BY SEARCH, NOT BY THE NAV ROW +-- ============================================================ +-- ⚠ IT EXISTS BECAUSE CreateSubTab BUILDS A PAGE WHETHER OR NOT ANYTHING SHOWS IT, and the +-- settings search can select any tab by name. The nav row is re-pointed at the designer +-- (GUI/Pages/Auras.lua), so in normal use nobody ever lands here -- but "nobody ever" is not +-- "nothing can", and the failure mode of an unhandled arrival is the blank page this whole +-- rework exists to remove. One banner and one button is cheap insurance. +-- ⚠ IT DOES NOT REDIRECT ITSELF. Calling SelectTab from inside a page's own builder is a +-- re-entrant page switch during a build; the button makes the same trip as an ordinary click. +function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) + if not Add then return end + local GUI = guiRef or DF.GUI + local banner = GUI:CreateInfoBanner(pageRef.child, { tone = "info" }) + banner:SetText(L["The Power Infusion Helper is a tab inside the Aura Designer."]) + Add(banner, nil, "both") + -- ⚠ NO EXTENSION ON THE ICON NAME. CreateIconButton concatenates the Media\Icons path and + -- nothing else, and every other call site passes a bare name ("delete", "refresh"). + local btn = GUI:CreateIconButton(pageRef.child, "chevron_right", + L["Power Infusion Helper"], 220, 22, function() DF.OpenPIHelperInDesigner() end) + Add(btn, 22, "both") +end + +-- ============================================================ +-- IS THE HELPER OFFERED AT ALL? +-- ============================================================ +-- ☠ CLASS, NOT SPEC, AND CONSTANT FOR THE LOGIN. Power Infusion is a priest ability and a +-- character cannot change class in session -- which is what all three callers want: +-- CreateSubTab's `hidden` flag decides whether the nav entry is BUILT, PoolDefs decides +-- whether the pool tab exists at all, and OpenPIHelperInDesigner refuses to send anyone to a +-- pool that is not there. +-- ⚠ Deliberately NOT gated on the helper existing. A page that appears only once you have +-- already added the helper is a page you cannot use to add it. +function DF.IsPIHelperAvailable() + local _, class = UnitClass("player") + return class == "PRIEST" +end diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index a478d444..43a59c7a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -21,6 +21,8 @@ local C_TEXT_DIM = GUI.Colors.textDim local OPTS = P.OPTS local ResolveSpec = P.ResolveSpec local IsOtherTab = P.IsOtherTab +local IsPIHelperTab = P.IsPIHelperTab +local ShowsOthersOnly = P.ShowsOthersOnly local IsDebuffTab = P.IsDebuffTab local CurrentAuraPool = P.CurrentAuraPool local CollectAllEffects = P.CollectAllEffects @@ -46,6 +48,9 @@ local expandedCards = P.expandedCards local mainTabButtons = P.mainTabButtons -- ...and the Layout Groups / Global halves, which phase 3 brought over. local CurrentLayoutGroups = P.CurrentLayoutGroups +-- The DISPLAY half -- helper-owned groups filtered out, same as the classic layout's list. +-- Its note in AuraDesigner/UI/Options.lua says why the filter is not in the store accessor. +local VisibleLayoutGroups = P.VisibleLayoutGroups local DebuffGroupsRead = P.DebuffGroupsRead local GroupExpandKey = P.GroupExpandKey local expandedGroups = P.expandedGroups @@ -63,6 +68,14 @@ local BuildGlobalView = P.BuildGlobalView -- P.CollectLayoutGroupSections P.CollectDebuffGroupSections -- P.EnsureDebuffSelection +-- ⚠ FORWARD-DECLARED, not moved. The Effects tab mounts the helper's cooldown-icon group +-- (see BuildEffectsTabRows) and is written above the group builder; hoisting a 300-line +-- function to satisfy the reading order would be a diff nobody can check. The upvalue is +-- filled at load and every call happens long after. +-- ☠ AND THEY MUST STAY DECLARED. `function MountGroup(...)` below assigns the LOCAL only +-- while this line exists; delete it and both become globals that happen to work. +local MountGroup, MountLayoutGroup + -- The SPLIT PANEL's pool tab strip. The band layout has its own strip now (see -- S.BuildPoolTabs) and it is the same height, so the two layouts spend the same -- 30px on the same three words. @@ -106,8 +119,11 @@ local function PopoutWidth() return GUI.PopoutContentWidth or 260 end -- L[...] lookup, and a table built at load freezes whatever locale was live then -- -- the trap DF:RegisterLocaleRefresh exists for. Two callers now read it: the -- split panel's strip below and the scope row's picker. +-- ⚠ A VERB, NOT A TABLE, and the fourth entry is why that matters twice over: every label is +-- an L[...] lookup that must resolve at the live locale, AND the helper's tab is class-gated, +-- so the list genuinely differs between characters. Built at load, it would freeze both. local function PoolDefs() - return { + local defs = { { key = "my", label = L["My Buffs"], tooltip = { L["Buffs from your own class, and only when you cast them."], L["Set up separately for each specialization."], @@ -122,6 +138,19 @@ local function PoolDefs() L["Shared across all your specializations."], } }, } + -- ⚠ PRIEST ONLY, AND APPENDED RATHER THAN DECLARED ABOVE. Power Infusion is a priest + -- ability, so on anyone else this tab would be a fourth of the strip's width spent on a + -- pool that can never hold anything. Appending keeps the other three in their existing + -- order and positions -- the strip divides its width by #defs, so a conditional entry + -- anywhere but the end would move tabs people already know the position of. + if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() then + defs[#defs + 1] = { key = "pihelper", label = L["Power Infusion Helper"], tooltip = { + L["Who is worth casting Power Infusion on, and how that shows on the frame."], + L["Set up its Triggers, then add effects the same way as any other pool."], + L["Shared across all your specializations."], + } } + end + return defs end S.BuildPoolStrip = function(buffTabBar) @@ -359,7 +388,9 @@ local function EffectTag(effect, indicatorGroup) parts[#parts + 1] = format(L["+%d triggers"], #triggers - 1) end end - if IsOtherTab() and effect.config and effect.config.othersOnly then + -- ⚠ NOT ON THE HELPER'S POOL, where it is a constant rather than a state -- see + -- P.ShowsOthersOnly. + if ShowsOthersOnly() and effect.config and effect.config.othersOnly then parts[#parts + 1] = L["Others Only"] end return table.concat(parts, " \194\183 ") @@ -427,10 +458,14 @@ local function MountEffect(ctx, effect, shell) -- The aura's own tracking warning, where the card put it: after the identity, -- before the actions. + -- ...and the helper's clash warning on the same badge, exactly as the card mounts it. + -- See P.PIH_ClashText. AttachWarningBadge(section, GetAuraWarningKey( (not IsOtherTab()) and ResolveSpec() or nil, effect.auraName), { point = "RIGHT", relativeTo = section, relativePoint = "RIGHT", offsetX = -76, offsetY = 0, size = 16, + text = (effect.config and effect.config.pihSignal and P.PIH_ClashText) + and P.PIH_ClashText(effect.config, effect.typeKey) or nil, }) -- ── THE HEADER'S TWO ACTIONS ── @@ -441,6 +476,8 @@ local function MountEffect(ctx, effect, shell) delBtn = GUI:CreateCloseButton(section, { size = 22, onClick = function() + -- Asked BEFORE the removal; see P.PIH_ReDerive and the card layout's twin. + local wasPIH = effect.config and effect.config.pihSignal if isPlaced then RemoveIndicatorInstance(effect.auraName, effect.indicatorID) else @@ -448,6 +485,7 @@ local function MountEffect(ctx, effect, shell) if auraCfg then auraCfg[effect.typeKey] = nil end S.CleanupAdHocAura(effect.auraName) end + if wasPIH and P.PIH_ReDerive then P.PIH_ReDerive() end expandedCards[cardKey] = nil S.SwitchTab("effects") RefreshPlacedIndicators() @@ -618,7 +656,9 @@ local function MountEffect(ctx, effect, shell) -- column already IS that checkbox -- the Modules page's rule for a group with -- nothing in it but the switch. The trade is the group's two footer verbs, -- which for one boolean the modified dot on the control itself covers. - if IsOtherTab() and effect.typeKey ~= "sound" then + -- ⚠ ...AND NOT ON THE HELPER'S POOL: there the caster rule is stamped by the recipe and + -- is not the user's to change. P.ShowsOthersOnly carries the reasoning. + if ShowsOthersOnly() and effect.typeKey ~= "sound" then -- The page's STATE pass, never a rebuild: a rebuild here would retire the -- row the click landed on. Named once so the row and the search entry it -- registers run the same thing. @@ -678,6 +718,21 @@ local function BuildEffectsTabRows(ctx, shell) -- where (Cards.lua's S.BuildAddIndicatorPane). It holds no settings, so it -- takes neither a modified tick nor a footer -- the same rule the Members and -- Linked Filters rows follow. + -- + -- ☠☠ ...AND THE HELPER'S POOL HAS NO SPELL TO PICK, SO IT HAS NO ROW. That panel is the + -- three-step spell-first flow, and the helper's aura is the cooldown list its Triggers tab + -- owns -- so the only step left is the tile grid, which is small enough to sit on the page + -- rather than behind a door. + -- ⚠ THE SAME BUILDER THE SPLIT PANEL USES (S.BuildPIHelperAddArea), mounted as one band. + -- Two layouts, one add flow: a second copy of it is what produced every Power Infusion + -- Helper bug of the last two days. + if IsPIHelperTab() then + GUI:AddDesignerLegacyTab(shell, function(host) + host:SetWidth(tools.BandWidth()) + local y = S.BuildPIHelperAddArea(host, -4, function() S.SwitchTab("effects") end) + host:SetHeight(max(-(y or 0) + 4, 1)) + end) + else local addBand = GUI:CreateSettingsGroup(page.child, tools.BandWidth(), { chromeless = true }) local addRow -- ⚠ A LIST, NOT ONE HANDLE. PopoutContent is a FACTORY: pin a panel and click @@ -743,224 +798,22 @@ local function BuildEffectsTabRows(ctx, shell) end if not ctx.adEnabled then addRow.disableOn = function() return true end end Add(addBand, nil, "both") - - -- ── POWER INFUSION HELPER (priest only, Any Buff pool only) ── - -- The classic layout draws this block inline in its Effects head area; here - -- it is a BAND OF POPOUT ROWS mounting the same shared parts (Cards.lua's - -- S.BuildPIHelperCard and S.PIHelperSections): the card row is always - -- present and is the family's collapsible HEADER, and while a helper - -- exists (and the header is unfolded) each section stands behind an - -- INDENTED sibling row of its own -- one pane per section, because the - -- whole panel in one pane outgrew the page ("extends off the page"). The pool gate is the - -- classic one's: the helper watches OTHER people's cooldowns, so Any Buff - -- is the only pool where its records can match anything (the head area's - -- gate comment says why at length). The panes hold whole-feature verbs and - -- per-signal state the builders redraw themselves, so like the add rows - -- they take neither a modified tick nor a footer. - if select(2, UnitClass("player")) == "PRIEST" and IsOtherTab() then - local pihBand = GUI:CreateSettingsGroup(page.child, tools.BandWidth(), - { chromeless = true }) - -- ☠ A TICK IN A PANE MUST NOT REBUILD THE PAGE. The first cut routed - -- the builder's Refresh to page:Refresh(), and a page rebuild retires - -- the row the panel is docked to -- so every checkbox slammed the panel - -- shut ("it should stay until ur done editing"). A pane redraws ITSELF - -- in place, and the page -- whose Active Indicators list the helper's - -- signals feed, and whose sibling panes read state this one just moved - -- -- refreshes ONCE, from the row's onClose, and only when something in - -- here actually changed. ONE dirty flag across the whole band: a change - -- made in any pane is caught up on whichever helper panel closes. - local pihDirty = false - -- ⭐ ...AND ONE OPEN-PANEL COUNT beside it. The catch-up refresh - -- rebuilds the page, which retires every row -- including one whose - -- panel the user JUST opened. Edit in pane A, open pane B: A's close - -- fired the refresh and B died in the user's hand. So the deferred - -- refresh yields while ANY helper panel is still open (re-arming the - -- dirty flag), and the LAST close is the one that pays it. - local pihOpenPanes = 0 - -- ☠ ...EXCEPT A CHANGE THAT DECIDES WHICH ROWS EXIST. Adding or - -- removing the helper gates every section row. An in-place rebuild - -- cannot add or retire a ROW, so that goes straight to the deferred - -- page:Refresh -- the panel closing at that moment is correct: the - -- surface being edited is being restructured. - -- ⚠ The amplifier ticks used to belong here too, when they gated a - -- Trinkets and Potions row of their own. They are nested inside the - -- cooldown row now and add no row, so they rebuild the pane in place. - -- ⚠ THE FAMILY FOLDS, AND THE FOLD IS ACCOUNT STATE UNDER A LITERAL KEY. - -- Four section rows under one card is still a lot of column; the card row is - -- the family's header and carries an expander, and the section rows are - -- only BUILT while it is open -- the same "a collapsed thing builds no - -- rows" rule the effect sections above follow. The store is the shared - -- collapsed-groups map (DandersFramesDB_v2.collapsedGroups -- account - -- level, not the profile, like every other remembered fold), and the key - -- is a LITERAL: no spell name or user-typed text may reach that store - -- (see MountEffect's expandedCards note). Absent key = expanded, which - -- is the default a fresh helper gets. - local pihSaved = GUI:GetCollapsedGroups() - local PIH_FOLD_KEY = "ad_pihelper" - local function PIHCollapsed() return pihSaved[PIH_FOLD_KEY] and true or false end - local function PIHRowSet() - return tostring(P.PIH_Exists()) .. "|" .. tostring(PIHCollapsed()) - end - local function PIHDeferredPageRefresh() - -- The page rebuild is the catch-up, so the close that follows it - -- must not schedule a second one. - pihDirty = false - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - if page:IsShown() and page.Refresh then page:Refresh() end - end) - end - end - -- The shared mount: every helper row's pane goes through here -- - -- buildPane(pane, Refresh) -> height. Generalised from the single-pane - -- version so the rebuild machinery exists ONCE, not once per row. - local function PIHMount(buildPane) - return tools.PopoutContent(function(g, holder) - local pane = CreateFrame("Frame", nil, holder) - pane:SetWidth(PopoutWidth()) - -- ⚠ NO ready/wantH DANCE, unlike the add panes above: the - -- shared builders are synchronous and RETURN their y cursor - -- rather than reporting through a SetHeight callback, so the - -- height exists before AddWidget needs it. - local builtRowSet - local BuildContent - local function RebuildPane() - -- Retire the previous build the way the classic arm's - -- ClearTabContent does: hide and unanchor. The retired - -- frames stay PARKED on the pane -- WoW never releases - -- frames -- which is the same cost profile as the classic - -- arm's SwitchTab rebuild (that arm re-runs its builder on - -- every tick). - for _, child in ipairs({ pane:GetChildren() }) do - child:Hide() - child:ClearAllPoints() - end - for _, region in ipairs({ pane:GetRegions() }) do - region:Hide() - end - local h = BuildContent() - -- The panel re-measures the pane's slot -- the same verb - -- the Add Indicator pane's SetHeight reports through above. - GUI:RelayoutHost(pane, h) - end - local function Refresh() - if PIHRowSet() ~= builtRowSet then - PIHDeferredPageRefresh() - return - end - pihDirty = true - RebuildPane() - end - BuildContent = function() - builtRowSet = PIHRowSet() - local h = buildPane(pane, Refresh) - pane:SetHeight(h) - return h - end - pihOpenPanes = pihOpenPanes + 1 - g:AddWidget(pane, BuildContent()) - end) - end - local function AddPIHRow(band, label, mount) - local row = band:AddWidget(GUI:CreatePopoutRow(page.child, { - label = label, - title = label, - window = DF.GUIFrame, - clipTo = page, - build = mount, - -- See the effect rows' note: the dependent grey is a real gate. - gateWhenDisabled = true, - -- "Done editing" is the panel closing, and that is when the - -- page catches up on what the pane changed. Deferred a frame: - -- this fires inside the popout's own close path (a teardown's - -- CloseAllPopoutRows included), and a synchronous rebuild would - -- retire frames mid-close. Skipped when nothing changed, and - -- when the page has left the screen (a window close or layout - -- flip runs its own rebuild). - onClose = function() - if pihOpenPanes > 0 then pihOpenPanes = pihOpenPanes - 1 end - if not pihDirty then return end - pihDirty = false - if C_Timer and C_Timer.After then - C_Timer.After(0, function() - -- A sibling helper panel is open (or opened in this - -- same click): stand down and re-arm -- its own - -- close will pay the catch-up. Checked in the - -- deferred frame so both click orders (close-then- - -- open, open-then-close) resolve the same way. - if pihOpenPanes > 0 then pihDirty = true return end - if page:IsShown() and page.Refresh then page:Refresh() end - end) - end - end, - })) - if not ctx.adEnabled then row.disableOn = function() return true end end - return row - end - -- The card row -- ONLY the add/remove card, and the family's HEADER. - -- Its Refresh always crosses a row-set boundary (the card's one verb - -- creates or deletes the helper), so it lands in PIHDeferredPageRefresh; - -- the card's own fold/unfold arrives with an unchanged row set and - -- redraws in place. - local cardRow = AddPIHRow(pihBand, L["POWER INFUSION HELPER"], - PIHMount(function(pane, Refresh) - local yEnd = S.BuildPIHelperCard(pane, { startY = -4, Refresh = Refresh }) - return max(-(yEnd or 0) + 4, 1) - end)) - Add(pihBand, nil, "both") - -- The expander, only while there are section rows to fold. A glyph - -- BUTTON rather than the section factory's surface-click toggle, - -- because this row's surface already has a verb -- it opens the card - -- pane -- and the two gestures must stay separate (CreateRowToggle's - -- rule). Same glyph pair as every fold in the kit. The toggle is - -- structural -- it decides which rows exist -- so it goes through - -- PIHDeferredPageRefresh like every other row-set change. - if P.PIH_Exists() then - local foldBtn = GUI:CreateGlyphButton(cardRow.plate or cardRow, { - size = 18, iconSize = 12, - texture = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - .. (PIHCollapsed() and "chevron_right" or "expand_more"), - }) - foldBtn:SetPoint("RIGHT", cardRow.gear, "LEFT", -8, 0) - foldBtn:SetScript("OnClick", function() - -- Only-store-true, the collapsed-groups convention: expanded - -- rows leave no key behind. - pihSaved[PIH_FOLD_KEY] = not PIHCollapsed() or nil - PIHDeferredPageRefresh() - end) - if not ctx.adEnabled then foldBtn:SetGlyphEnabled(false) end - end - -- ...and one row per section while the helper exists AND the family is - -- unfolded -- a collapsed family builds no section rows at all, exactly - -- as a collapsed effect section builds none. Titles are the sections' - -- own locale keys; `gated` is each row's existence test, re-run on - -- every page build. indent 8 (the pane's content inset) and no in-pane - -- header -- the row already says the name. - -- - -- ⚠ A BAND OF THEIR OWN, INDENTED. `indent` is the page engine's flag - -- (Panel.lua's layout pass: x + 20 per level, width narrowed to match) - -- -- the addon's one indent mechanism, the same step the classic tab's - -- PIH_INDENT borrows -- so the sub-rows read as belonging to the header - -- row above them. - if P.PIH_Exists() and not PIHCollapsed() then - local pihSecBand = GUI:CreateSettingsGroup(page.child, tools.BandWidth() - 20, - { chromeless = true }) - pihSecBand.indent = true - for _, sec in ipairs(S.PIHelperSections) do - if not sec.gated or sec.gated() then - local build = sec.build - AddPIHRow(pihSecBand, L[sec.title], PIHMount(function(pane, Refresh) - local yEnd = build(pane, { - startY = -4, Refresh = Refresh, - indent = 8, header = false, - }) - return max(-(yEnd or 0) + 4, 1) - end)) - end - end - Add(pihSecBand, nil, "both") - end - end + end -- the helper's tile band / the designer's Add Indicator row + + -- ── POWER INFUSION HELPER: MOVED OUT, 2026-09-08 ── + -- ☠ DO NOT MOUNT IT HERE AGAIN. This layout used to carry the helper as a band of + -- popout rows -- a card row acting as the family header, then one indented row per + -- section. It now has its own page beside the designer (Auras > Power Infusion + -- Helper -- AuraDesigner/UI/PIHelperPage.lua), which composes the same shared parts + -- from Cards.lua and needs no band here. + -- + -- ⚠ WHAT WENT WITH IT: the fold key "ad_pihelper", the per-band dirty flag and the + -- deferred page refresh all existed to keep a panel open while its own page rebuilt + -- underneath it. On a page whose only subject IS the helper that problem does not + -- arise, so none of it was carried across rather than being lost. + -- ⚠ WHAT DID NOT CHANGE: the records still live in the Any Buff pool, because the + -- pool decides a record's caster filter and the helper watches OTHER people's + -- cooldowns. That is plumbing now; the user is never asked to know it. -- ── THE ACTIVE INDICATORS HEADING, AND THE FILTER ON IT ── -- The same furniture the card layout puts above its list, mounted as one @@ -995,7 +848,10 @@ local function BuildEffectsTabRows(ctx, shell) end) if pickerOpen then return end - local effects = CollectAllEffects() + -- includePIH on the helper's pool -- see the note on the split panel's own list + -- (S.BuildEffectsTab): the collector hides helper rows from the designer by default, and + -- on the helper's pool they are the only rows there are. + local effects = CollectAllEffects({ includePIH = IsPIHelperTab() }) local filtered = {} for _, effect in ipairs(effects) do if S.activeFilter == "all" or effect.typeKey == S.activeFilter then @@ -1003,7 +859,17 @@ local function BuildEffectsTabRows(ctx, shell) end end - if #filtered == 0 then + -- The helper's cooldown-icon group is one of this tab's rows -- see the note on the split + -- panel's own list (S.BuildEffectsTab) for why it is here and not behind a tab, and + -- MountLayoutGroup for what it is mounted with. It draws icons, so it obeys the type + -- filter the same way an icon effect does. + local pihGroup = nil + if IsPIHelperTab() and P.PIH_IconGroup then + local af = S.activeFilter or "all" + if af == "all" or af == "icon" then pihGroup = P.PIH_IconGroup() end + end + + if #filtered == 0 and not pihGroup then -- ⚠ THE EMPTY STATE IS A BANNER, NOT A CENTRED FONTSTRING. A column of -- bands has no half-panel to centre anything in, and CreateInfoBanner is -- the shared shape for "nothing here yet, and here is why". @@ -1033,6 +899,12 @@ local function BuildEffectsTabRows(ctx, shell) for _, effect in ipairs(filtered) do MountEffect(ctx, effect, shell) end + + if pihGroup then + MountLayoutGroup(ctx, pihGroup, { refreshTab = "effects", + filtersSection = P.PIH_GroupSourceSection(pihGroup), + Summary = P.PIH_IconGroupSummary }) + end end -- ============================================================ @@ -1072,7 +944,7 @@ end -- Everything a group's rows are built from, for both group stores. The two -- differ in which sections they collect, which record their rows measure against -- and what their headers say; the machinery below is one copy. -local function MountGroup(ctx, group, spec) +function MountGroup(ctx, group, spec) local page, tools, Add = ctx.page, ctx.tools, ctx.Add local bandW = tools.BandWidth() local cardKey = spec.cardKey @@ -1212,7 +1084,9 @@ local function MountGroup(ctx, group, spec) local verbs = { -- A LIST moved. Both layouts redraw the page; the panel goes with it, -- which is honest -- what was being edited is gone. - Rebuild = function() S.SwitchTab("layout") end, + -- ⚠ WHICH TAB IS THE CALLER'S. A group is not always on Layout Groups any more: the + -- helper's cooldown-icon group is a row on its Effects tab (MountLayoutGroup). + Rebuild = function() S.SwitchTab(spec.refreshTab or "layout") end, -- A VALUE moved and the widgets around it must re-read their greying. A -- rebuild here would retire the tick the user just clicked, so the page's -- STATE pass runs instead and the panes re-flow themselves. @@ -1276,6 +1150,73 @@ local function MountGroup(ctx, group, spec) Add(band, nil, "both") end +-- ── ONE LAYOUT GROUP, MOUNTED ── +-- ★★ EXTRACTED SO TWO TABS CAN MOUNT ONE (2026-09-10), the band layout's half of the split +-- panel's S.CreateLayoutGroupCard. The helper's cooldown-icon group is added from the Effects +-- tab and now lives there -- Krathe: "It's confusing when you add Cooldown Icons from effects +-- and it appears as a layout group, it should just show as a normal effect for PI helper" -- +-- and everything that made this block Layout-Groups-only was the tab key it typed out twice. +-- +-- opts (all optional): +-- refreshTab the sub-tab a delete or an eye rebuilds. Defaults to "layout". +-- omitFilters drop the LINKED FILTERS row -- the helper's group watches OUR cooldown list, +-- which its Triggers tab owns. See S.CreateLayoutGroupCard for the argument. +-- Summary what the collapsed header says after the name, replacing the filter count. +-- Paired with omitFilters, which would otherwise leave the group silent about +-- its own contents. +function MountLayoutGroup(ctx, group, opts) + opts = opts or {} + local refreshTab = opts.refreshTab or "layout" + local isFilterGroup = (group.kind == "filter") + + -- `true`: the row layout draws Others Only itself, as a control + -- row, so the Growth section must not draw it as well. + local sections = P.CollectLayoutGroupSections(group, true) + -- Index 1 is "what fills this group" for both kinds -- Linked Filters on a filter group, + -- Members on the other. Substituted rather than removed when the caller has something + -- better to put there; see the same handling in S.CreateLayoutGroupCard. + if isFilterGroup and (opts.omitFilters or opts.filtersSection) then + if opts.filtersSection then sections[1] = opts.filtersSection + else tremove(sections, 1) end + end + + local function Structural() + S.SwitchTab(refreshTab) + RefreshPlacedIndicators() + DF:InvalidateAuraLayout() + DF:UpdateAllFrames() + local E = DF.AuraDesigner and DF.AuraDesigner.Engine + if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end + end + + MountGroup(ctx, group, { + cardKey = GroupExpandKey(group.id), + record = GroupRecordView(group), + refreshTab = refreshTab, + sections = sections, + appearance = isFilterGroup, + showEye = isFilterGroup, + -- ⚠ ShowsOthersOnly, NOT IsOtherTab: the helper's pool answers yes to the second and + -- its groups are othersOnly by construction. P.ShowsOthersOnly carries the argument. + othersOnly = isFilterGroup and ShowsOthersOnly(), + Summary = function() + return (opts.Summary and opts.Summary(group)) or S.LayoutGroupSummary(group) + end, + Apply = function() + RefreshPlacedIndicators() + local E = DF.AuraDesigner and DF.AuraDesigner.Engine + if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end + end, + onDelete = function() + DeleteLayoutGroup(group.id) + -- Deleting a group deletes its member indicators -- the same + -- structural refresh as the effect row's delete. + Structural() + end, + onEye = Structural, + }) +end + -- ── THE LAYOUT GROUPS TAB ── local function BuildLayoutTabRows(ctx, shell) local page, tools, Add = ctx.page, ctx.tools, ctx.Add @@ -1359,7 +1300,7 @@ local function BuildLayoutTabRows(ctx, shell) host:SetHeight(max(-(yPos or 0) + 4, 1)) end) - local groups = isDebuffs and DebuffGroupsRead() or CurrentLayoutGroups() + local groups = isDebuffs and DebuffGroupsRead() or VisibleLayoutGroups() -- ⚠ NO SEPARATE EMPTY STATE. The head area above already IS one when the -- list is empty: it swaps in the teaching sentence that says what this tab @@ -1406,42 +1347,7 @@ local function BuildLayoutTabRows(ctx, shell) onEye = StructuralDebuffGroupRefresh, }) else - local isFilterGroup = (group.kind == "filter") - MountGroup(ctx, group, { - cardKey = GroupExpandKey(group.id), - record = GroupRecordView(group), - -- `true`: the row layout draws Others Only itself, as a control - -- row, so the Growth section must not draw it as well. - sections = P.CollectLayoutGroupSections(group, true), - appearance = isFilterGroup, - showEye = isFilterGroup, - othersOnly = isFilterGroup and IsOtherTab(), - Summary = function() return S.LayoutGroupSummary(group) end, - Apply = function() - RefreshPlacedIndicators() - local E = DF.AuraDesigner and DF.AuraDesigner.Engine - if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end - end, - onDelete = function() - DeleteLayoutGroup(group.id) - S.SwitchTab("layout") - RefreshPlacedIndicators() - -- Deleting a group deletes its member indicators -- the same - -- structural refresh as the effect row's delete. - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - local E = DF.AuraDesigner and DF.AuraDesigner.Engine - if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end - end, - onEye = function() - S.SwitchTab("layout") - RefreshPlacedIndicators() - DF:InvalidateAuraLayout() - DF:UpdateAllFrames() - local E = DF.AuraDesigner and DF.AuraDesigner.Engine - if E and E.ForceRefreshAllFrames then E:ForceRefreshAllFrames() end - end, - }) + MountLayoutGroup(ctx, group) end end end @@ -1453,6 +1359,30 @@ end local function BuildGlobalTabRows(ctx, shell) local page, tools, Add = ctx.page, ctx.tools, ctx.Add + -- ★★ ON THE HELPER'S POOL, "GLOBAL" IS ITS TRIGGERS -- the same branch the split panel's + -- S.BuildGlobalTab makes, and it has to be made here too or the popout layout would draw + -- the DESIGNER's global settings under a tab labelled Triggers. Every other pool's Global + -- tab holds what applies to the whole POOL rather than to one effect, which is exactly + -- what the helper's roles, class list and cooldown gate are. + -- ⚠ THE ENABLE TICK LEADS IT, because on this pool it governs everything below -- and it + -- has to be reachable when the helper is OFF, which is the state a new priest arrives in. + -- ⚠ ONE BAND, NOT POPOUT ROWS. The helper's sections are the card builder's own column + -- layout (S.BuildPIHelperCard / S.BuildPIHelperBody run a y cursor and anchor into their + -- parent); re-expressing them as rows is the second copy that this feature has already + -- paid for twice. + if IsPIHelperTab() and S.BuildPIHelperCard then + GUI:AddDesignerLegacyTab(shell, function(host) + host:SetWidth(tools.BandWidth()) + local Refresh = function() S.SwitchTab("global") end + local y, open = S.BuildPIHelperCard(host, { startY = -4, Refresh = Refresh }) + if open and S.BuildPIHelperBody then + y = S.BuildPIHelperBody(host, { startY = y, Refresh = Refresh, indent = 8 }) + end + host:SetHeight(max(-(y or 0) + 4, 1)) + end) + return + end + -- ☠ page.child AS THE HOST, AND IT IS NEVER TOUCHED. Collect mode builds -- nothing, sizes nothing and stamps nothing onto the host it is handed; the -- argument exists only because the section bodies read it, and each of them @@ -1526,13 +1456,25 @@ P.BuildAuraDesignerRowsPage = function(page, db, Add, AddSpace) S.leftPanel, S.rightPanel = nil, nil S.tabBar, S.tabScrollFrame, S.tabContentFrame = nil, nil, nil S.activeTab = S.activeTab or "effects" - S.activeBuffTab = S.activeBuffTab or "my" + -- ☠ THE ONE-SHOT THE NAV ENTRY LEAVES BEHIND, CONSUMED HERE TOO. The Power Infusion + -- Helper's nav row asks for its pool and then opens this page; the split panel's builder + -- consumes the request in its own full-build path, and this arm has to do the same or the + -- popout layout would open that entry on whatever pool was last used. + S.activeBuffTab = S.pendingBuffTab or S.activeBuffTab or "my" + S.pendingBuffTab = nil S.activeFilter = S.activeFilter or "all" - if S.activeTab == "effects" and IsDebuffTab() then S.activeTab = "layout" end + -- Every pool's coercion in one call -- Effects frosts on Debuffs, Layout Groups does not + -- exist on the helper's pool. See P.CoerceTabForPool. + S.activeTab = (P.CoerceTabForPool and P.CoerceTabForPool(S.activeTab)) or S.activeTab local tools = GUI:CreatePopoutPageTools(page) if not tools then return end -- classic; the caller took the island arm + -- Retire whatever an older Power Infusion Helper schema left running -- above all the + -- cooldown-icon group, which draws with no control left that can reach it. Priests only, + -- schema-stamped, so this is one comparison on every build after the first. + if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() and P.PIH_Sweep then P.PIH_Sweep() end + -- ☠ FROM THE MODE, NOT THE PRESET. The enable switch writes the MODE's own -- key; reading it off the preset is what made the tick un-stick once already. local adEnabled = DF.IsAuraDesignerEnabledForMode @@ -1649,16 +1591,30 @@ P.BuildAuraDesignerRowsPage = function(page, db, Add, AddSpace) { height = SCOPEROW_H, build = function(host) S.BuildScopeRow(host) end }, }, - tabs = { - { key = "effects", label = L["Effects"], accent = nil, - -- Effects is buff-pool-only: category groups have no per-spell - -- placed indicators, so it frosts on the Debuffs pool. - disabled = function() return IsDebuffTab() end, - tooltip = { title = L["Effects"], onlyWhenDisabled = true, - lines = { L["Not available for Debuffs. Use Layout Groups instead."] } } }, - { key = "layout", label = L["Layout Groups"], accent = { r = 0.91, g = 0.66, b = 0.25 } }, - { key = "global", label = L["Global"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, - }, + -- ⚠ ONE DEFINITION, SHARED WITH THE SPLIT PANEL. P.SubTabDefs decides which sub-tabs + -- a pool has, in what order and under what label -- Triggers then Effects on the + -- Power Infusion Helper's pool, the usual three everywhere else. This layout rebuilds + -- the whole page on a pool switch, so simply reading it here is enough; the split + -- panel keeps its strip standing and re-lays it (P.ApplySubTabStrip). + -- ⚠ THE FROSTED-EFFECTS ARM IS STAMPED ON HERE rather than carried in the shared + -- defs, because `disabled` and `tooltip` are this shell's vocabulary and the split + -- panel expresses the same fact through SetDisabled and a HookScript. + tabs = (function() + local out = {} + for _, def in ipairs(P.SubTabDefs()) do + if def.key == "effects" then + -- Effects is buff-pool-only: category groups have no per-spell + -- placed indicators, so it frosts on the Debuffs pool. + out[#out + 1] = { key = "effects", label = def.label, accent = def.accent, + disabled = function() return IsDebuffTab() end, + tooltip = { title = L["Effects"], onlyWhenDisabled = true, + lines = { L["Not available for Debuffs. Use Layout Groups instead."] } } } + else + out[#out + 1] = { key = def.key, label = def.label, accent = def.accent } + end + end + return out + end)(), activeTab = S.activeTab, onTab = function(key) S.SwitchTab(key) end, diff --git a/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua index 7228109c..caaa0eb0 100755 --- a/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua +++ b/DandersFrames_Options/ClickCasting/UI/BindingEditor.lua @@ -215,7 +215,10 @@ function CC:CreateBindingRow(parent, binding, index) local actionType = binding.actionType or "" local isMacro = (actionType == "macro") or (binding.macroId ~= nil) local fallback = binding.fallback or {} - local fallbackText = isMacro and nil or GetFallbackDisplayText(fallback) + -- Explicit if: `isMacro and nil or ...` always evaluated the text, so macros showed + -- the fallback line the comment above says to hide. + local fallbackText + if not isMacro then fallbackText = GetFallbackDisplayText(fallback) end -- Fall back to the legacy loadCombat field for freshly-added bindings (the -- loadCombat -> combat migration only runs at profile load); map its vocabulary. local combatSetting = binding.combat diff --git a/DandersFrames_Options/DandersFrames_Options.toc b/DandersFrames_Options/DandersFrames_Options.toc index a7011cbc..7f52710d 100644 --- a/DandersFrames_Options/DandersFrames_Options.toc +++ b/DandersFrames_Options/DandersFrames_Options.toc @@ -100,6 +100,10 @@ AuraDesigner\UI\Cards.lua # The popout layout's page (bands and rows). AFTER Cards.lua, whose helpers it # aliases at load; BEFORE Editor.lua, which calls S.BuildPoolStrip from here. AuraDesigner\UI\Rows.lua +# The Power Infusion Helper's own page. AFTER Cards.lua, which owns the card and the +# section builders it composes (S.BuildPIHelperPane / S.PIHelperSections); it adds a +# page host and takes nothing else from the designer. +AuraDesigner\UI\PIHelperPage.lua AuraDesigner\UI\Editor.lua # Text Designer editor diff --git a/DandersFrames_Options/FilterRegistry/UI/Options.lua b/DandersFrames_Options/FilterRegistry/UI/Options.lua index 64a3e607..2a78ef47 100644 --- a/DandersFrames_Options/FilterRegistry/UI/Options.lua +++ b/DandersFrames_Options/FilterRegistry/UI/Options.lua @@ -700,12 +700,10 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) return ids end - local function CustomSpellCount(f) - local n = 0 - for _ in pairs(f.spells) do n = n + 1 end - for _ in pairs(f.rawIDs) do n = n + 1 end - return n - end + -- ⚠ CustomSpellCount WENT TO THE REGISTRY as R:CustomFilterCounts, because it was one + -- of THREE places counting the same thing and none of them honoured a curated list's + -- per-spell ticks. One counter, three consumers -- this file's left list and header, + -- and R:ListFilters, which the Buff Bar's picker reads. -- Display name of the current selection (duplicate-prompt prefill) local function CurrentDisplayName() @@ -1697,11 +1695,22 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) end) resetBtn:HookScript("OnLeave", function() GUI:HideTooltip() end) resetBtn:Hide() - -- Presets only. It used to branch for the Optional Debuffs list; that list and - -- its reset moved to the Debuff Bar page together. + -- Presets, and CURATED CUSTOM LISTS -- the ones we seeded, which have a default to go + -- back to (R:IsCuratedFilter). It used to branch for the Optional Debuffs list; that + -- list and its reset moved to the Debuff Bar page together. + -- ⚠ THE TWO RESETS DIFFER IN WHAT THEY UNDO, and both match what the button says. + -- A preset's is an overrides layer, so clearing it restores every tick. A curated + -- list's ALSO restores any seeded spell that went missing -- but never prunes what + -- the user added to it themselves, which is theirs (R:ResetCuratedFilter). resetBtn:SetScript("OnClick", function() - if selKind ~= "preset" or not selKey then return end - R:ResetPreset(selKey) + if not selKey then return end + if selKind == "preset" then + R:ResetPreset(selKey) + elseif selKind == "custom" and R.ResetCuratedFilter then + if not R:ResetCuratedFilter(selKey) then return end + else + return + end DirectFilterChangedProxy() RefreshAll() end) @@ -2302,7 +2311,16 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) addBtn:Show() -- Reset (header row 1, red danger tone): shown when a preset differs from its -- shipped defaults. - resetBtn:SetShown((selKind == "preset" and selKey ~= nil and R:IsPresetModified(selKey)) or false) + -- ⚠ SHOWN ONLY WHEN THERE IS SOMETHING TO UNDO, for both kinds. A curated list + -- counts as modified once anything is ticked off -- `disabled` is exactly the + -- overrides table's role, so the two tests are the same question. + local curatedModified = false + if selKind == "custom" and selKey and R.IsCuratedFilter and R:IsCuratedFilter(selKey) then + local cf = R:GetCustomFilter(selKey) + curatedModified = (cf and cf.disabled and next(cf.disabled)) and true or false + end + resetBtn:SetShown((selKind == "preset" and selKey ~= nil and R:IsPresetModified(selKey)) + or curatedModified or false) end -- ========== LEFT ROW POOL ========== @@ -2702,7 +2720,13 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) -- a preset and a custom filter. Indented from the left so the nesting is -- structural rather than a colour cue. local isChild = item.child and true or false - local showCheck = isPreset or isChild + -- ★ ...AND A CURATED CUSTOM LIST TICKS TOO. A filter WE seeded has a default to + -- go back to (R:IsCuratedFilter), so unticking a spell is reversible and the + -- destructive ✕ is the wrong verb for it. A list the USER built keeps the ✕ -- + -- there, membership IS the truth and removing what they added is exactly right. + local isCurated = (not isPreset) and selKind == "custom" and selKey + and R.IsCuratedFilter and R:IsCuratedFilter(selKey) or false + local showCheck = isPreset or isChild or isCurated row:ClearAllPoints() row:SetPoint("TOPLEFT", isChild and 18 or 0, -y) row:SetPoint("TOPRIGHT", 0, -y) @@ -2823,6 +2847,16 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) DirectFilterChangedProxy() RefreshAll() end + elseif isCurated then + -- Our own seeded list: the tick writes the filter's own disabled set, + -- which ResolveSelection honours. Reset to Default clears it wholesale. + local cfKey, sid = selKey, item.id + row._onAction = function() + R:SetCustomSpellEnabled(cfKey, sid, + not R:IsCustomSpellEnabled(cfKey, sid)) + DirectFilterChangedProxy() + RefreshAll() + end else -- One toggle shape left: a preset's spell in or out of that preset. The -- inverse-polarity branch belonged to the Optional Debuffs list, which @@ -2962,8 +2996,14 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) used = used + 1 local row = AcquireLeftRow(used) local id = cfId + -- ★ A CURATED LIST READS LIKE A PRESET: "34/39" once something is ticked off, + -- and the modified dot beside it. A hand-built one answers enabled == total + -- (nothing can be off), so it keeps the single number it always had -- no + -- branch on the kind, just the shared counter. + local onN, totalN = R:CustomFilterCounts(id) BindLeftRow(row, y, "custom", id, f.name or id, - tostring(CustomSpellCount(f)), false, + (onN == totalN) and tostring(totalN) or (onN .. "/" .. totalN), + R.IsCuratedFilterModified and R:IsCuratedFilterModified(id) or false, selKind == "custom" and selKey == id) y = y + LEFT_ROW_H end @@ -3057,7 +3097,11 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) eyebrowText:SetText(L["Editing custom filter"]) local f = R:GetCustomFilter(selKey) titleText:SetText(f and (f.name or selKey) or "") - countText:SetText(format(L["%d spells"], f and CustomSpellCount(f) or 0)) + -- Same shape as the left row: the fraction only appears once something is + -- actually off, so an ordinary custom filter's header is unchanged. + local onN, totalN = R:CustomFilterCounts(selKey) + countText:SetText((onN == totalN) and format(L["%d spells"], totalN) + or format(L["%d of %d spells"], onN, totalN)) end -- Both texts are now set, so the name can be capped against what the count -- actually takes up on this pass. @@ -3225,6 +3269,9 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) put("ALL", { id = id, name = nm, icon = icon or FALLBACK_ICON, chip = name and L["not in database"] or L["unknown ID"], + -- Curated lists tick their raw ids too; see the spells arm. + enabled = (selKind == "custom" and selKey) + and R:IsCustomSpellEnabled(selKey, id) or nil, raw = true, tooltipID = id, }) end @@ -3257,6 +3304,10 @@ function DF.BuildFilterDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) put(rec.class, { rec = rec, id = sid, name = name, icon = icon, chip = RecordChip(rec), + -- On a curated list the row shows a tick, so it needs + -- the state to draw. Absent on a hand-built filter, + -- whose rows show the ✕ and never read this. + enabled = R:IsCustomSpellEnabled(selKey, sid), tooltipID = rec.id, }) putRecordChildren(rec.class, rec, name) diff --git a/DandersFrames_Options/GUI/Controls.lua b/DandersFrames_Options/GUI/Controls.lua index c37101a2..00a8a108 100644 --- a/DandersFrames_Options/GUI/Controls.lua +++ b/DandersFrames_Options/GUI/Controls.lua @@ -2892,6 +2892,84 @@ function GUI:CreateGroupOrderList(parent, dbTable, dbKey, callback, playerGroupF return container end +-- ============================================================ +-- ROSTER WIDGETS -- SHARED LOOK +-- ------------------------------------------------------------ +-- ⚠ FILE SCOPE, BECAUSE THERE ARE TWO WIDGETS NOW. These were locals inside the dual-column +-- widget; the compact one below has to look identical to it, and the fastest way for two +-- lists to stop looking alike is two copies of the paths their icons come from. +-- ============================================================ +local ROSTER_ROLE_ICONS = { + TANK = "Interface\\AddOns\\DandersFrames\\Media\\DF_Tank", + HEALER = "Interface\\AddOns\\DandersFrames\\Media\\DF_Healer", + DAMAGER = "Interface\\AddOns\\DandersFrames\\Media\\DF_DPS", +} +local ROSTER_ROLE_COLORS = { + TANK = {0.35, 0.56, 0.82}, + HEALER = {0.29, 0.62, 0.29}, + DAMAGER = {0.70, 0.35, 0.35}, +} +-- ☠ DOUBLE BACKSLASHES. Lua passes an unrecognised escape through as the bare character, so +-- the single-backslash form is a path to nothing and the client draws an empty square. +local ROSTER_ICON_ARROW = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right" +local ROSTER_ICON_CHECK = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\check" +local ROSTER_ICON_CLOSE = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close" + +-- ★ THE GROUP, AS A SORTED LIST OF { name, fullName, class, role, group }. +-- ⚠ ONE READER FOR BOTH WIDGETS. It was a local inside the dual-column one, and the compact +-- one needs exactly the same answer -- including the `-Realm` suffix, which is what makes a +-- name written by one list mean the same thing to the other. +-- ☠ NO `+ 1` ON UnitInRaid. It already returns an index GetRaidRosterInfo takes directly -- +-- Blizzard passes it straight through in both CompactUnitFrame and CompactRaidFrameManager. +-- The +1 read the NEXT member's subgroup, so every unit reported its neighbour's group and +-- the last member in the raid got nil and silently fell back to group 1. +local function RosterSnapshot() + local roster = {} + local numMembers = GetNumGroupMembers() + if numMembers == 0 then + local name = UnitName("player") + local _, class = UnitClass("player") + roster[1] = { name = name, fullName = name .. "-" .. GetRealmName(), + class = class or "WARRIOR", role = "DAMAGER", group = 1 } + return roster + end + local isRaid = IsInRaid() + for i = 1, numMembers do + local unit = isRaid and ("raid" .. i) or (i == 1 and "player" or "party" .. (i - 1)) + local name, realm = UnitName(unit) + if name then + -- ☠ EMPTY STRING AS WELL AS NIL. UnitName returns the realm only when it differs + -- from yours, and which of nil / "" it returns for a same-realm unit is not + -- something to bet a key on: `realm or GetRealmName()` keeps an empty string, and + -- "Bob-" would then be a name that matches nothing and can never be removed. + if realm == "" then realm = nil end + realm = realm or GetRealmName() + local _, class = UnitClass(unit) + local role = UnitGroupRolesAssigned(unit) + if role == "NONE" then role = "DAMAGER" end + local group = 1 + if isRaid then + local raidIndex = UnitInRaid(unit) + if raidIndex then + local _, _, subgroup = GetRaidRosterInfo(raidIndex) + group = subgroup or 1 + end + end + roster[#roster + 1] = { name = name, fullName = name .. "-" .. realm, + class = class or "WARRIOR", role = role, group = group } + end + end + table.sort(roster, function(a, b) + if a.group ~= b.group then return a.group < b.group end + local order = { TANK = 1, HEALER = 2, DAMAGER = 3 } + local ar, br = order[a.role] or 3, order[b.role] or 3 + if ar ~= br then return ar < br end + return a.name < b.name + end) + return roster +end +GUI.RosterSnapshot = RosterSnapshot + -- ============================================================ -- HIGHLIGHT FRAMES ROSTER WIDGET -- ============================================================ @@ -2916,23 +2994,11 @@ function GUI:CreateHighlightRosterWidget(parent, getPlayersFunc, setPlayersFunc, local draggingItem = nil local dragOffsetY = 0 - -- Custom role icons - local ROLE_ICONS = { - TANK = "Interface\\AddOns\\DandersFrames\\Media\\DF_Tank", - HEALER = "Interface\\AddOns\\DandersFrames\\Media\\DF_Healer", - DAMAGER = "Interface\\AddOns\\DandersFrames\\Media\\DF_DPS", - } - local ROLE_COLORS = { - TANK = {0.35, 0.56, 0.82}, - HEALER = {0.29, 0.62, 0.29}, - DAMAGER = {0.70, 0.35, 0.35}, - } - - -- Icon paths - local ICON_ARROW = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\chevron_right" - local ICON_CHECK = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\check" - local ICON_CLOSE = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\close" - + -- Custom role icons (see the file-scope tables above: one copy, two widgets) + local ROLE_ICONS, ROLE_COLORS = ROSTER_ROLE_ICONS, ROSTER_ROLE_COLORS + local ICON_ARROW, ICON_CHECK, ICON_CLOSE = + ROSTER_ICON_ARROW, ROSTER_ICON_CHECK, ROSTER_ICON_CLOSE + -- ========== LEFT COLUMN: Group Roster ========== local leftHeader = container:CreateFontString(nil, "OVERLAY", "DFFontNormal") leftHeader:SetPoint("TOPLEFT", 0, 0) @@ -3629,7 +3695,234 @@ function GUI:CreateHighlightRosterWidget(parent, getPlayersFunc, setPlayersFunc, -- Initial refresh container:Refresh() - + + return container +end + +-- ============================================================ +-- COMPACT ROSTER WIDGET -- ONE COLUMN, THE SAME LANGUAGE +-- ------------------------------------------------------------ +-- ★ THE DUAL-COLUMN WIDGET AT HALF THE WIDTH. Krathe wants the Power Infusion Helper to fire +-- for named players only -- "in guild groups... only have the PI alert for the DPS you know +-- who should be getting PI instead of every DPS in the raid who uses a CD" -- built "around" +-- the pinned-frames list rather than reusing it outright: "as long as it looks and functions +-- in the same way, but is adjusted for the more narrow width". +-- +-- ☠ TWO COLUMNS DO NOT FIT AND CANNOT BE MADE TO. The helper lives in the Aura Designer's +-- right panel: pihMakeTools derives a group width of roughly 254 and its inner content +-- roughly 230. The dual widget is 460 wide with two 224px panes -- one of its columns alone +-- is the whole surface. So the two panes become ONE list, and the right-hand button becomes a +-- TOGGLE rather than an add: click to include, click again to drop. +-- +-- ⚠ WHAT IS DELIBERATELY NOT HERE: +-- · The role bulk-add buttons. "No need for an auto add function as you can pick class/role +-- etc anyway" -- the helper already narrows by role and by class on the same tab, so a +-- button that adds every DPS would be a third control saying the same thing. +-- · Drag to reorder. Pinned frames needs an order because the order is the LAYOUT; an +-- allowlist is a set, and a set with a hand-sorted order invites the reader to think the +-- order means something. +-- · The group number. It is the first thing that stops fitting, and the list is sorted by +-- group anyway, so the grouping is still visible -- just not labelled. +-- +-- ⚠ CHOSEN-BUT-ABSENT PLAYERS LEAD THE LIST. They are the whole point of typing a name in +-- (someone not in the group yet), and a list that only ever shows who is present would give +-- you no way to see -- or remove -- what you had typed. They keep the toggle, and are drawn +-- dim with no role icon because neither their role nor their class is knowable from here. +-- +-- opts: +-- width the list's width. Defaults to the parent's, less nothing -- the caller knows +-- its own insets and this widget should not guess them. +-- rows visible rows before it scrolls (default 6) +-- getPlayers -> array of "Name-Realm" setPlayers(array) +-- onChange called after any edit, for the consumer's own apply +-- ============================================================ +function GUI:CreateCompactRosterWidget(parent, opts) + opts = opts or {} + local getPlayers = opts.getPlayers or function() return {} end + local setPlayers = opts.setPlayers or function() end + local onChange = opts.onChange + + local W = opts.width or (parent:GetWidth() or 230) + local ROW_H = SnapLen(parent, 22) or 22 + local ROWS = opts.rows or 6 + local LIST_H = ROW_H * ROWS + 8 + + local container = CreateFrame("Frame", nil, parent) + container:SetSize(W, LIST_H + 30) + + local listBg = CreateFrame("Frame", nil, container, "BackdropTemplate") + listBg:SetPoint("TOPLEFT", 0, 0) + listBg:SetPoint("TOPRIGHT", 0, 0) + listBg:SetHeight(LIST_H) + GUI:CreateElementBackdrop(listBg, { bgColor = GUI.Colors.background }) + + local scroll = CreateFrame("ScrollFrame", nil, listBg, "ScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", 4, -4) + scroll:SetPoint("BOTTOMRIGHT", -22, 4) + local content = CreateFrame("Frame", nil, scroll) + content:SetSize(W - 30, 1) + scroll:SetScrollChild(content) + StyleScrollBar(scroll) + + local rows = {} + + local function IsChosen(fullName) + for _, p in ipairs(getPlayers()) do + if p == fullName then return true end + end + return false + end + + -- ⚠ A NEW ARRAY EVERY TIME, never a mutation of what the getter returned. That table is + -- the consumer's stored list; editing it in place would write the profile behind the + -- setter's back and skip whatever the setter does about override tracking. + local function Toggle(fullName) + local out, found = {}, false + for _, p in ipairs(getPlayers()) do + if p == fullName then found = true else out[#out + 1] = p end + end + if not found then out[#out + 1] = fullName end + setPlayers(out) + if onChange then onChange() end + container:Refresh() + end + + -- One row. `data` is a roster entry, or { fullName = ..., absent = true }. + local function BuildRow(data, index) + local row = CreateFrame("Frame", nil, content, "BackdropTemplate") + row:SetHeight(ROW_H - 2) + row:SetPoint("TOPLEFT", 0, -((index - 1) * ROW_H)) + row:SetPoint("TOPRIGHT", 0, -((index - 1) * ROW_H)) + GUI:CreateElementBackdrop(row, { outline = false, bgColor = { 0, 0, 0, 0 } }) + + local x = 4 + if not data.absent then + local icon = row:CreateTexture(nil, "OVERLAY") + icon:SetSize(14, 14) + icon:SetPoint("LEFT", 4, 0) + icon:SetTexture(ROSTER_ROLE_ICONS[data.role] or ROSTER_ROLE_ICONS.DAMAGER) + local rc = ROSTER_ROLE_COLORS[data.role] + if rc then icon:SetVertexColor(rc[1], rc[2], rc[3]) end + row.icon = icon + x = 22 + end + + local nameText = row:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + nameText:SetPoint("LEFT", x, 0) + nameText:SetPoint("RIGHT", -26, 0) + nameText:SetJustifyH("LEFT") + nameText:SetMaxLines(1) + -- ⚠ THE STORED NAME IS "Name-Realm" AND THE ROW SHOWS ONLY THE NAME. The realm is what + -- makes the entry unambiguous and it is what a cross-realm raid needs; it is also + -- twenty characters this column does not have. An absent entry keeps whatever was + -- typed, since there is no roster row to take a short name from. + nameText:SetText(data.name or data.fullName) + local cc = (not data.absent) and DF:GetClassColor(data.class) or nil + if cc then nameText:SetTextColor(cc.r, cc.g, cc.b) + else nameText:SetTextColor(0.62, 0.62, 0.62) end + + local chosen = IsChosen(data.fullName) + local btn = CreateFrame("Button", nil, row, "BackdropTemplate") + btn:SetSize(20, 18) + btn:SetPoint("RIGHT", -2, 0) + GUI:CreateElementBackdrop(btn) + btn.icon = btn:CreateTexture(nil, "OVERLAY") + btn.icon:SetSize(11, 11) + btn.icon:SetPoint("CENTER", 0, 0) + + local tc = GetThemeColor() + local function Paint() + chosen = IsChosen(data.fullName) + if chosen then + btn:SetBackdropColor(tc.r * 0.25, tc.g * 0.25, tc.b * 0.25, 0.9) + btn:SetBackdropBorderColor(tc.r * 0.6, tc.g * 0.6, tc.b * 0.6, 0.9) + btn.icon:SetTexture(ROSTER_ICON_CHECK) + btn.icon:SetVertexColor(tc.r, tc.g, tc.b) + row:SetBackdropColor(tc.r * 0.12, tc.g * 0.12, tc.b * 0.12, 0.5) + nameText:SetAlpha(1) + if row.icon then row.icon:SetAlpha(1) end + else + btn:SetBackdropColor(0.15, 0.15, 0.15, 0.8) + btn:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.6) + btn.icon:SetTexture(ROSTER_ICON_ARROW) + btn.icon:SetVertexColor(0.5, 0.5, 0.5) + row:SetBackdropColor(0, 0, 0, 0) + nameText:SetAlpha(data.absent and 0.55 or 0.85) + if row.icon then row.icon:SetAlpha(0.85) end + end + end + Paint() + + btn:SetScript("OnClick", function() Toggle(data.fullName) end) + btn:SetScript("OnEnter", function(self) + self:SetBackdropBorderColor(tc.r, tc.g, tc.b, 1) + -- The full stored name, realm included -- the row could only show half of it. + GUI:ShowTooltip(self, { + title = data.fullName, + lines = { chosen and L["Click to stop watching this player."] + or L["Click to watch this player."] }, + }) + end) + btn:SetScript("OnLeave", function() Paint(); GUI:HideTooltip() end) + + row.Paint = Paint + return row + end + + -- ── ADD BY NAME ── + -- ⚠ ONE ROW, because two would cost a line this panel has not got: the field stretches and + -- the button is fixed. Enter and the button do the same thing, and the same thing the + -- dual-column widget's does -- including appending the player's own realm when none is + -- typed, so "Bob" and "Bob-YourRealm" cannot become two entries for one person. + local input = CreateFrame("EditBox", nil, container, "BackdropTemplate") + input:SetPoint("TOPLEFT", listBg, "BOTTOMLEFT", 0, -6) + input:SetPoint("RIGHT", container, "RIGHT", -48, 0) + input:SetHeight(22) + GUI:StyleEditBox(input, { skipFont = true }) + input:SetFontObject(DFFontHighlight) + input:SetTextInsets(6, 6, 0, 0) + input:SetAutoFocus(false) + input:SetMaxLetters(50) + + local function Commit() + local text = input:GetText() + text = text and text:trim() or "" + if text ~= "" then + if not text:find("-") then text = text .. "-" .. GetRealmName() end + if not IsChosen(text) then Toggle(text) else container:Refresh() end + input:SetText("") + end + input:ClearFocus() + end + input:SetScript("OnEnterPressed", Commit) + input:SetScript("OnEscapePressed", function(self) self:SetText(""); self:ClearFocus() end) + + local addBtn = CreateFrame("Button", nil, container, "BackdropTemplate") + addBtn:SetPoint("LEFT", input, "RIGHT", 4, 0) + GUI:StyleButton(addBtn, { width = 42, height = 22, tinted = true, text = L["Add"] }) + addBtn:SetScript("OnClick", Commit) + + function container:Refresh() + for _, r in ipairs(rows) do r:Hide(); r:SetParent(nil) end + wipe(rows) + + local roster = RosterSnapshot() + local inGroup = {} + for _, e in ipairs(roster) do inGroup[e.fullName] = true end + + local list = {} + -- Chosen but not here, first -- see the note at the top for why they are shown at all. + for _, p in ipairs(getPlayers()) do + if not inGroup[p] then list[#list + 1] = { fullName = p, absent = true } end + end + for _, e in ipairs(roster) do list[#list + 1] = e end + + for i, data in ipairs(list) do rows[i] = BuildRow(data, i) end + content:SetHeight(math.max(#list * ROW_H, 1)) + end + + container:SetScript("OnShow", function(self) self:Refresh() end) + container:Refresh() return container end diff --git a/DandersFrames_Options/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua index 07530ad5..e5e730db 100644 --- a/DandersFrames_Options/GUI/Pages/Auras.lua +++ b/DandersFrames_Options/GUI/Pages/Auras.lua @@ -4768,6 +4768,19 @@ function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, -- Filters" implied it covered debuffs too. local pageFilterDesigner = CreateSubTab("auras", "auras_filterdesigner", L["Filter Designer"]) BuildPage(pageFilterDesigner, function(self, db, Add, AddSpace, AddSyncPoint) + -- ☠ THE HELPER'S SWEEP RUNS HERE TOO, AND THIS PAGE IS WHY IT HAD TO. The sweep marks + -- the Power Infusion Helper's seeded lists as CURATED (dfDefaults), which is what gives + -- their rows the on/off tick instead of the destructive ✕ and puts Reset on screen. It + -- ran only on the Aura Designer's page build -- so opening the Filter Designer FIRST, + -- which is exactly what someone inspecting that list does, showed the unmarked version. + -- Krathe, 2026-09-09, one round after the mark shipped. + -- ⚠ A MIGRATION HOOK, not a dependency: schema-stamped, so it is one comparison after + -- the first run, and priest-gated so it costs nothing for anyone else. + if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() + and DF.AuraDesigner and DF.AuraDesigner._priv + and DF.AuraDesigner._priv.PIH_Sweep then + DF.AuraDesigner._priv.PIH_Sweep() + end -- ⚠ MIRRORED IN DF.SECTION_PREFIXES.auras_filterdesigner (GUI.lua) — change both. -- ☠ THIS PAGE OWNS NO PER-MODE KEYS ANY MORE, and its Copy/Sync/Reset list is -- deliberately EMPTY. It used to carry buffFilterSelection, debuffFilter*, @@ -4837,6 +4850,50 @@ function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, end end) + -- Auras > Power Infusion Helper (priest only) + -- ☠☠ A NAV ROW THAT LINKS, NOT A PAGE THAT BUILDS. The helper's records must live in the + -- Any Buff pool -- the pool decides a record's caster filter and the helper watches OTHER + -- people's cooldowns -- and it is now a POOL TAB of the designer, beside My Buffs / + -- Debuffs / Any Buff. This entry exists because that tab is four levels deep and a priest + -- should not have to know the helper lives inside the designer, which was the original + -- complaint ("it's not very clear how to use it or even how to find it"). + -- ⚠ IT WAS A REAL PAGE FOR A DAY AND IT WENT BLANK. Two pages calling the designer's + -- builder both wanted the one island it builds into, and the page-cache path does not + -- re-run a builder -- so whichever page did not build last showed nothing. The whole + -- diagnosis is in AuraDesigner/UI/PIHelperPage.lua; the fix is that only ONE page owns + -- the island now, and it is this row that goes there rather than a page of its own. + -- ⚠ `hidden` is CreateSubTab's own fourth argument (GUI/Panel.lua:3077), so a + -- non-priest never has the entry built rather than seeing a greyed one for an ability + -- they cannot cast. + local pagePIHelper = CreateSubTab("auras", "auras_pihelper", L["Power Infusion Helper"], + not (DF.IsPIHelperAvailable and DF.IsPIHelperAvailable())) + -- The stub the settings SEARCH can still land on -- a banner and a button to the real + -- thing. The nav row below never reaches it; see the file for why it exists anyway. + BuildPage(pagePIHelper, function(self, db, Add, AddSpace, AddSyncPoint) + if DF.BuildPIHelperPage then + DF.BuildPIHelperPage(GUI, self, db, Add, AddSpace) + end + end) + -- ☠ THE ROW'S OWN CLICK, REPLACED. CreateSubTab wires OnClick to SelectTab(its own name); + -- there is no "link" kind of nav row, and inventing one for a single caller is a change to + -- the shared factory for a case only this feature has. Replacing the script keeps the row + -- identical in every other way -- hover plate, New badge, hidden gating, the lot. + -- ⚠ GUI.Tabs IS THE REGISTRY (Panel.lua stamps GUI.Tabs[name] = btn), so the button is + -- reachable without CreateSubTab having to return it. + -- ⚠ FALLS BACK TO THE PAGE. If the designer's page is missing for any reason, + -- OpenPIHelperInDesigner answers false and the row does what it always did rather than + -- doing nothing -- a nav row that swallows its own click is the worst of both. + local piBtn = GUI.Tabs and GUI.Tabs["auras_pihelper"] + if piBtn then + piBtn:SetScript("OnClick", function(self) + if self.disabled then return end + if not (DF.OpenPIHelperInDesigner and DF.OpenPIHelperInDesigner()) then + GUI.SelectTab("auras_pihelper") + end + PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON) + end) + end + -- Auras > Aura Blacklist: RETIRED as a standalone page. The debuff blacklist -- now lives inside the Filter Designer (Debuffs > Blacklist) — one home for -- all per-spell aura control. Backend unchanged (AuraBlacklist/Config.lua + diff --git a/DandersFrames_Options/GUI/Panel.lua b/DandersFrames_Options/GUI/Panel.lua index 4d20a4e2..abbed9c3 100644 --- a/DandersFrames_Options/GUI/Panel.lua +++ b/DandersFrames_Options/GUI/Panel.lua @@ -2944,7 +2944,41 @@ function DF:CreateGUI() UpdateThemeColors() end GUI.SelectTab = SelectTab - + + -- ★ LIGHT A NAV ROW THAT IS NOT THE ROW OWNING THE PAGE ON SCREEN. + -- ☠ ONE CALLER, AND IT IS NOT A HACK FOR IT. The Power Infusion Helper's row opens the + -- Aura Designer's page on the helper's pool -- one page owns that island, and two pages + -- sharing it is what blanked both (AuraDesigner/UI/PIHelperPage.lua has the diagnosis). + -- So the page is the designer's and the ROW the user clicked is the helper's, and the rail + -- has to say which row they clicked. Krathe, 2026-09-09: "clicking power infusion helper + -- on the menu should highlight it." + -- ⚠ THE SAME THREE WRITES SelectTab'S OWN TAIL MAKES -- clear every row, then light one -- + -- lifted into a verb rather than reproduced at the call site, because `navMarker` and + -- `C_TEXT` are panel locals and a caller reaching for them would be reaching into this + -- file's private state. + -- ⚠ SAFE TO BE OVERRIDDEN BY THE NEXT CLICK: SelectTab has no same-name early-out, so + -- clicking any row -- including the designer's own -- re-runs the clear and re-lights the + -- right one. This changes no page state, only what the nav looks like. + GUI.SetNavHighlight = function(name) + local btn = GUI.Tabs[name] + if not btn then return false end + for _, other in pairs(GUI.Tabs) do + if other ~= btn then + other.isActive = false + other.Text:SetTextColor(other.disabled and 0.4 or C_TEXT.r, + other.disabled and 0.4 or C_TEXT.g, + other.disabled and 0.4 or C_TEXT.b) + other:SetBackdropColor(0, 0, 0, 0) + end + end + local nc = GetThemeColor() + navMarker:SetTo(btn, nc, not frame:IsShown()) + btn.Text:SetTextColor(nc.r, nc.g, nc.b) + btn.isActive = true + return true + end + + GUI.RefreshCurrentPage = function() -- ☠ THE SEARCH RESULTS RE-FLOW HERE TOO, and this is the only place they can. -- The results panel is not a page, so the page loop below never reaches it — yet diff --git a/DandersFrames_Options/GUI/SettingsWidgets.lua b/DandersFrames_Options/GUI/SettingsWidgets.lua index 76bdd0e1..f592b12b 100644 --- a/DandersFrames_Options/GUI/SettingsWidgets.lua +++ b/DandersFrames_Options/GUI/SettingsWidgets.lua @@ -3103,6 +3103,10 @@ function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) local hasScale = { DF_ORBIT=1, DF_PROC=1, DF_FLASH=1 } -- Length slider = bar length (DF Pixel's chasing bars). local hasLength = { DF_PIXEL=1 } + -- Blend mode = how the effect's own textures mix with what is behind them. Every effect + -- that draws textures of its own; DF_PULSATE is absent because it has none -- it modulates + -- the BORDER's edges, whose blend mode is the border's own control. + local hasBlendMode = { DF_DASH=1, BLINK=1, DF_ORBIT=1, DF_PROC=1, DF_FLASH=1, DF_PIXEL=1 } local cornersOnly = { CORNERS_ONLY=1 } local function hideUnless(set) return function() @@ -3235,6 +3239,33 @@ function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) fullUpdate, lightUpdate, true), 55) w.animationOffsetY.hideOn = hideUnless(hasPositioning) + -- ★★ ANIMATION BLEND MODE (2026-09-10). Krathe: "if I've set it to red it will show orange + -- when over a yellow border... I'm sure we used to offer up a blend mode for animation?" + -- We never did -- what exists is Border Blend Mode, which governs the border's own EDGES + -- and not the effect drawn over them. The effects had a mode each, chosen by hand when + -- c4b4e5eb replaced LibCustomGlow: DF Chase and DF Proc additive, the rest not. Same colour + -- picker, two different meanings, and nothing anywhere saying which you had. + -- ⚠ NO DEFAULT VALUE IN THE DB. An unset key means "this effect's own default", so every + -- existing profile keeps exactly the look it has -- see ANIM_BLEND_DEFAULT in Border.lua. + -- That is why this dropdown is not seeded and why its first entry is not Blend. + -- ⚠ EVERY EFFECT BUT DF PULSATE, which owns no textures: it modulates the border's own + -- edges, so Border Blend Mode already IS its blend mode. Two controls over one texture + -- would leave the user watching whichever ran last. + w.animationBlendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Animation Blend Mode"], + -- _order, because pairs() order is not an order -- Default leads (it is the state + -- everyone is in), then the two anyone will actually pick, then the two nobody will. + { DEFAULT = L["Default"], BLEND = L["Blend"], ADD = L["Add"], + MOD = L["Modulate"], DISABLE = L["Disable"], + _order = { "DEFAULT", "BLEND", "ADD", "MOD", "DISABLE" } }, + dbTable, aKey("BlendMode"), fullUpdate, + -- ⚠ nil <-> "DEFAULT" IN THE VIEW ONLY. The stored shape stays "absent means the + -- effect decides", so nothing has to migrate and a profile exported before today + -- imports unchanged; the dropdown just needs a row to show for that state. + function() return dbTable[aKey("BlendMode")] or "DEFAULT" end, + function(v) dbTable[aKey("BlendMode")] = (v ~= "DEFAULT") and v or nil end), 55) + w.animationBlendMode.hideOn = hideUnless(hasBlendMode) + w.animationBlendMode.tooltip = L["How the effect's colour mixes with what is behind it. Add brightens whatever it crosses, so a red effect reads orange over a yellow border — it is what makes a glow glow. Blend draws the colour exactly as picked. Default keeps this effect's original look."] + -- DF Flash / DF Proc: skip the one-shot intro burst (glow-only). -- ☠ introInert SHOWS THE FORCED VALUE, NOT THE STORED ONE. The runtime pins -- procStart = true (intro suppressed) on every row-mode button, so binding the