From c1303bbd6ce8fc9ca009c67a6723c6c1e9c68c4e Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 20:57:30 +0100 Subject: [PATCH 01/66] Power Infusion Helper: give it its own page, out of the Aura Designer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe, 2026-09-08: "it's not very clear how to use it or even how to find it. It's under AD any buff and it's settings are confusing and don't merge well… Even the main settings and the added indicator are split." Both complaints came from the same decision. The helper's records must live in the Any Buff pool -- the pool decides a record's caster filter before anything else, and the helper watches OTHER people's cooldowns -- and the card was put next to the records so the button and its result sat together. Reasonable in code, and it also decided where the user had to LOOK: priest-only, four levels deep, behind a tab that is not the default, with the behaviour in one panel and the appearance of what it created in a different card further down the page. ★ THE CARD DOES NOT HAVE TO LIVE WHERE THE RECORDS LIVE. The pool constraint is real and unchanged; the location was never required by it. So the feature moves and the plumbing stays exactly where it was. * New page: Auras > Power Infusion Helper, a SIBLING of the Aura Designer rather than a mode of it -- this is a feature with its own rules, and sitting in the same category is also what gives it the designer's chrome for nothing. AuraDesigner/UI/PIHelperPage.lua composes the parts Cards.lua already owns (S.BuildPIHelperCard / S.PIHelperSections / S.BuildPIHelperPane); not one builder was rewritten. * Priest-gated through CreateSubTab's own fourth argument, so a non-priest never has the entry built rather than seeing it greyed. * NOT gated on the helper existing. A page that appears only once you have added the helper is a page you cannot use to add it -- the exact trap this move exists to undo. * Both old mounts removed: the classic pane in the Effects head area (Cards.lua) and the popout row band (Rows.lua, 215 lines). Each site keeps a DO-NOT-REMOUNT note saying where it went and, more importantly, what did NOT change -- so nobody re-derives the pool rule from an empty space. ⚠ THE WIDTH DANCE IS THE ONLY GENUINELY NEW MACHINERY, and it is worth knowing about. GUI/Panel.lua's Add records layoutHeight/layoutCol and nothing else; the actual SetWidth happens later, in PageRefreshStates' layout pass. But S.BuildPIHelperPane needs a real width AT BUILD TIME -- the card measures its wrapped description against it and falls back to 320 without one, which is right for the 260px popout pane and wrong by half on a full-width page. So the page builds lazily on the first size it is given, re-measures only when the width actually moves, then publishes the measured height and asks for one more layout pass. That cannot loop: the rebuild is gated on WIDTH and the second pass changes only HEIGHT, with a re-entrancy flag as a belt. Teardown goes through GUI._trashFrame, the same way BuildPage retires its own children -- a rebuild that left the old column parented here would stack two sets of controls writing the same settings. Verification: 170 files parse. No new globals anywhere -- Cards.lua and Rows.lua each LOST one (UnitClass, select) as the class checks left them, and the new file reads only CreateFrame/DandersFrames/UIParent/UnitClass/math/ pcall. Every identifier defined inside the deleted 215-line band was checked for use after it: none escaped. Load order is safe because the CreateSubTab call runs inside DF._SetupGUIPagesPart3 at GUI-build time, long after PIHelperPage.lua has loaded. All files CRLF, no BOM, blob-to-blob. ⚠ Not verified in game. Two increments still to come, both agreed: hiding the helper's records from the designer's own lists, and per-signal appearance plus a preview on the new page. (cherry picked from commit 0fa0e84ae7d184ddff427b6f9acd4fc41cc35130) --- DandersFrames/Locales/enUS.lua | 6 + .../AuraDesigner/UI/Cards.lua | 41 ++-- .../AuraDesigner/UI/PIHelperPage.lua | 154 ++++++++++++ .../AuraDesigner/UI/Rows.lua | 231 ++---------------- .../DandersFrames_Options.toc | 4 + DandersFrames_Options/GUI/Pages/Auras.lua | 22 ++ 6 files changed, 214 insertions(+), 244 deletions(-) create mode 100644 DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index cb4312ee..dda06b4b 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2939,6 +2939,12 @@ L["Right-Click:"] = true -- 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 +-- ⚠ TITLE CASE, AND A SEPARATE STRING FROM THE ALL-CAPS ONE ABOVE. This is the NAV ENTRY +-- (Auras > Power Infusion Helper); the caps version is the card's own heading inside the +-- page. Nav labels are title case everywhere in this addon and card headings are not, so +-- casing one from the other would be wrong in one of the two places -- and in a locale +-- where case is not a presentation choice, it would be wrong in both. +L["Power Infusion Helper"] = true L["Add the helper"] = true L["Remove the helper"] = true L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."] = true diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 3ff26100..9915b4e0 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6799,34 +6799,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.PIHelperSections / + -- S.BuildPIHelperPane, above). Only the MOUNT moved. The page composes them. -- ── ACTIVE INDICATORS heading ── local activeHeader = parent:CreateFontString(nil, "OVERLAY") diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua new file mode 100644 index 00000000..86348b23 --- /dev/null +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -0,0 +1,154 @@ +-- Power Infusion Helper — its own settings page, beside the Aura Designer. +-- +-- ☠ 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 L = DF.L +local GUI = DF.GUI +local S = DF.AuraDesigner._uiState +local P = DF.AuraDesigner._priv + +local max, abs = math.max, math.abs + +-- ============================================================ +-- WHY THIS PAGE EXISTS +-- ============================================================ +-- ☠ THE HELPER WAS A FEATURE WEARING AN AURA DESIGNER RECIPE'S CLOTHES. It behaves like a +-- feature — one switch, its own rules, its own class lists, its own sound — but it was +-- implemented as records in the Any Buff pool, so it inherited the designer's LOCATION as +-- well as its plumbing: priest-only, behind a non-default tab, four levels deep, with its +-- behaviour in one panel and the appearance of the things it created in a different card +-- further down the same page. Krathe, 2026-09-08: "it's not very clear how to use it or +-- even how to find it… the main settings and the added indicator are split." +-- +-- ★ THE DECISION THAT UNLOCKED IT: the CARD does not have to live where the RECORDS live. +-- The pool still decides a record's caster filter — that constraint is real and unchanged, +-- and the helper still writes into Any Buff — but nothing required the user to go there to +-- find the switch. So the feature moves and the plumbing stays. +-- +-- ⚠ WHAT THIS PAGE IS NOT: a second rendering path. Under 12.1 every aura-driven visual +-- goes through the container engine, and the helper's signals are ordinary AD records +-- carrying a `pihSignal` mark that the Factory reads to stamp `dfGate` +-- (AuraDesigner/Factory.lua:2236, :2681, :3446). Moving the UI changes none of that. +-- +-- ⚠ AND IT IS NOT AN ISLAND. DF.BuildAuraDesignerPage builds a whole anchored sub-window +-- (S.mainFrame) because the designer needs one; this page is a column of settings, so it +-- goes through the page harness like any other page and inherits its chrome for free. +-- That is also what makes it LOOK like the designer without copying anything. + +-- ============================================================ +-- THE WIDTH DANCE +-- ============================================================ +-- ☠ THE HARNESS SIZES A WIDGET *AFTER* Add, NOT BEFORE. GUI/Panel.lua's Add only records +-- layoutHeight/layoutCol; the actual SetWidth happens in PageRefreshStates' layout pass +-- (the `layoutCol == "both"` arm). S.BuildPIHelperPane needs a real width AT BUILD TIME — +-- the card measures its wrapped description against it, and falls back to 320 without one, +-- which is right for the 260px popout pane and wrong by half on a full-width page. +-- +-- ⇒ Build LAZILY, on the first size we are given, and re-measure only when the width +-- actually moves. The height then flows the other way: the pane returns its measured +-- bottom, we publish that as layoutHeight and ask for ONE more layout pass. +-- +-- ⚠ THAT FEEDBACK CANNOT LOOP, and the guard is deliberate rather than lucky: the rebuild +-- is gated on WIDTH changing, and the second pass changes only HEIGHT. `rebuilding` is a +-- belt on top of that, because a nested layout pass during a build would re-enter here +-- with the host half-populated. +local WIDTH_EPSILON = 2 -- sub-pixel jitter from the snapping pass is not a resize + +-- ============================================================ +-- THE PAGE +-- ============================================================ +-- Signature mirrors DF.BuildAuraDesignerPage so the two register identically in +-- GUI/Pages/Auras.lua and neither is a special case at the call site. +function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) + if not (Add and pageRef and pageRef.child) then return end + if not (S and S.BuildPIHelperPane) then return end + + local host = CreateFrame("Frame", nil, pageRef.child) + host:SetHeight(1) + host.layoutHeight = 1 + host.layoutCol = "both" + + local content, lastWidth, rebuilding = nil, 0, false + + local function relayout() + -- PageRefreshStates IS the layout pass (GUI/Panel.lua:186) — it re-anchors every + -- child from its layoutHeight and refreshes states. It does NOT rebuild widgets, + -- so calling it here re-flows the column around our new height without + -- re-entering the builder. + if pageRef.RefreshStates then pcall(pageRef.RefreshStates, pageRef) end + end + + local function rebuild() + if rebuilding then return end + local w = host:GetWidth() or 0 + -- Nothing sensible can be measured against a collapsed width, and the harness + -- hands us one on the very first pass. Wait for the real number. + if w < 50 then return end + rebuilding = true + + -- ☠ TEAR DOWN THROUGH THE TRASH FRAME, the same way BuildPage retires its own + -- children. WoW cannot GC frames, but a hidden, unanchored, reparented subtree is + -- not walked during layout — and leaving the old column parented here would stack + -- two sets of controls on one page, each writing the same settings. + if content then + content:Hide() + content:ClearAllPoints() + content:SetParent(GUI._trashFrame or UIParent) + end + + content = CreateFrame("Frame", nil, host) + content:SetPoint("TOPLEFT", 0, 0) + content:SetPoint("TOPRIGHT", 0, 0) + content:SetHeight(1) + + -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane + -- calls opts.Refresh when it changes something structural; inside the designer + -- that meant "rebuild the tab", and here it means "rebuild this page". Routing it + -- at S.SwitchTab would redraw a designer that may not even be open. + local yEnd = S.BuildPIHelperPane(content, { + startY = 0, + Refresh = function() + if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end + end, + }) + + local h = max(1, -(yEnd or 0)) + content:SetHeight(h) + rebuilding = false + + if abs((host.layoutHeight or 0) - h) > 0.5 then + host.layoutHeight = h + host:SetHeight(h) + relayout() + else + host:SetHeight(h) + end + end + + host:SetScript("OnSizeChanged", function(self, w) + w = w or 0 + if abs(w - lastWidth) <= WIDTH_EPSILON then return end + lastWidth = w + rebuild() + end) + + Add(host, 1, "both") + return host +end + +-- ============================================================ +-- IS THE PAGE OFFERED AT ALL? +-- ============================================================ +-- ☠ CLASS, NOT SPEC, AND READ ONCE AT REGISTRATION. Power Infusion is a priest ability and +-- a character cannot change class in session, so this is a constant for the life of the +-- login — which is what CreateSubTab's `hidden` flag wants (it decides whether the entry is +-- built, not whether it is greyed). +-- ⚠ 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 — the exact discoverability +-- trap this move exists to undo. +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..862d3945 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -744,223 +744,20 @@ local function BuildEffectsTabRows(ctx, shell) 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 + -- ── 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 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/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua index 07530ad5..55819b85 100644 --- a/DandersFrames_Options/GUI/Pages/Auras.lua +++ b/DandersFrames_Options/GUI/Pages/Auras.lua @@ -4837,6 +4837,28 @@ function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, end end) + -- Auras > Power Infusion Helper (priest only) + -- ☠ ITS OWN PAGE, AND THAT IS THE WHOLE POINT. The helper used to live inside the + -- designer's Any Buff tab, because that is the pool its records must go in -- the pool + -- decides a record's caster filter and the helper watches OTHER people's cooldowns. + -- That constraint is real and unchanged. What was wrong is that it also decided where + -- the user had to LOOK: four levels deep, behind a tab that is not the default, with + -- the behaviour in one panel and the appearance of what it created in a different card + -- further down the same page. The CARD does not have to live where the RECORDS live. + -- ⚠ A SIBLING OF THE DESIGNER, not a sub-page of it: this is a feature with its own + -- rules, not a mode of the editor -- and sitting here is also what gives it the + -- designer's chrome for nothing, since it inherits the same category and page harness. + -- ⚠ `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())) + BuildPage(pagePIHelper, function(self, db, Add, AddSpace, AddSyncPoint) + if DF.BuildPIHelperPage then + DF.BuildPIHelperPage(GUI, self, db, Add, AddSpace) + 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 + From 253251d8ea13ab7efb25c82383f6076e8fccfd28 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 21:05:37 +0100 Subject: [PATCH 02/66] PI Helper: its records leave the designer's lists, and the colour joins the signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same complaint, both from 2026-09-08: "Anything added should show just on the PI helper page and not in AD itself" and "the main settings and the added indicator are split". THE RECORDS LEAVE THE DESIGNER'S LISTS A record carrying a `pihSignal` mark belongs to the helper. Left in the designer's own lists it reads as a stray indicator the user does not remember making, and deleting one there silently half-dismantles a feature configured somewhere else entirely. * CollectAllEffects (Active Indicators, both layouts) now takes an opts table and drops helper-owned rows unless asked for them. ☠ AN OPTION, NOT A HARD SKIP: the helper's own page wants exactly these rows -- it is the one surface where they ARE the subject -- so the filter is the caller's choice, and the PIH_SignalLabel derivation below it stays live rather than becoming unreachable code that still looks maintained. * Layout Groups: a new P.VisibleLayoutGroups, used by the classic list, its hasGroups gate, and the popout layout's list. ☠ FILTERED AT THE DISPLAY SITES AND NOT IN CurrentLayoutGroups, and that distinction is the whole reason the helper still works: two of that function's callers are LOGIC, not display -- GetIndicatorLayoutGroup resolves an indicator's owning group and DeleteLayoutGroup removes one by id. Filtering at the source would have made the helper's own icon group unreachable and undeletable by its own Remove path. The store stays whole. THE COLOUR JOINS THE SIGNAL A signal's behaviour was set on the helper panel and its colour on the generated indicator's card further down the page, with nothing saying the two belonged together. That division was consistency with the designer, where appearance lives on effect rows -- on a page whose only subject IS the helper it has nothing left to justify it. Each signal row now carries its own colour. * Bound straight to the record, so no accessor pair was needed: the colour already lives at cfg[pihColorKey(surface)] and CreateColorPicker writes through a table+key like every other colour in the addon. * Re-fetched per build rather than captured. A surface swap changes both the record and the key -- a border keeps its colour under BorderColor -- so a captured binding would write to the record the signal had just left. * Offered ONLY where a colour exists. An Icon has none (pihPlace says so when it declines to carry one) and "None" has no record at all; a swatch on either is a control that writes where nothing reads, the same class of lying control this feature was moved away from. * Writes end at P.PIH_Apply + pihRefresh, the chokepoint every other helper mutation already uses -- the table alone would leave the frames on the old colour until something unrelated repainted them. Verification: 170 files parse, no global changes in any of the five files, all CRLF, no BOM, blob-to-blob. ⚠ Not verified in game. ⚠ THE PREVIEW IS NOT DONE, and deliberately not guessed at. CreateFramePreview is reusable standalone (the scope-card tiles already do it, with thumb + placement=false), so the plumbing is not the obstacle -- opts.Paint is, because what a helper preview should PAINT is a design question I would be inventing an answer to: one frame per signal, or one frame showing whichever signal wins the contested surface. Worth asking rather than building twice. (cherry picked from commit a70c4dc993f2543eba0c24c96daf9b992e392451) --- .../AuraDesigner/UI/Cards.lua | 31 +++++++++++++++++++ .../AuraDesigner/UI/Editor.lua | 7 +++-- .../AuraDesigner/UI/Groups.lua | 21 +++++++++++-- .../AuraDesigner/UI/Options.lua | 18 +++++++++++ .../AuraDesigner/UI/Rows.lua | 5 ++- 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 9915b4e0..76543f43 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6250,6 +6250,37 @@ local function pihMakeTools(parent, opts) "caution") end + -- ── THE COLOUR, ON THE SIGNAL'S OWN ROW (2026-09-08) ── + -- ☠ THIS IS THE SPLIT THE USER NAMED. A signal's BEHAVIOUR was set here and its + -- COLOUR on the generated indicator's card further down the same page, so setting + -- up one signal end to end meant two places and no sign that they belonged + -- together. That division was consistency with the designer -- appearance lives on + -- effect rows there -- and on a page whose only subject IS the helper it has + -- nothing left to justify it. + -- ⚠ BOUND STRAIGHT TO THE RECORD, which is why this needs no accessor pair: the + -- colour already lives at cfg[pihColorKey(surface)] and CreateColorPicker writes + -- through a table+key exactly like every other colour in the addon. Re-fetched per + -- build rather than captured, so a surface swap (which changes both the record and + -- the key -- a border keeps its colour under BorderColor) rebinds instead of + -- writing to the record the signal just left. + -- ⚠ OFFERED ONLY WHERE A COLOUR EXISTS. An Icon has none -- pihPlace says so when + -- it declines to carry one -- and "None" has no record at all. A swatch on either + -- would be a control that writes somewhere nothing reads, which is the same class + -- of lying control as the add button this feature was moved away from. + local pihHit = pihFound()[key] + local pihCKey = pihHit and pihColorKey(pihHit.typeKey) or nil + if pihHit and pihCKey and type(pihHit.cfg[pihCKey]) == "table" then + g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], pihHit.cfg, pihCKey, false, + function() + -- Same chokepoint every other helper mutation ends at: push the + -- settings to the engine, then let pihRefresh re-derive the resident + -- half. Writing the table alone would leave the frames on the old + -- colour until something unrelated repainted them. + P.PIH_Apply() + pihRefresh() + end), GUI.RowHeight.colorpicker) + 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 06652bbd..237521f2 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -30,6 +30,9 @@ 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 @@ -962,7 +965,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 @@ -1040,7 +1043,7 @@ S.BuildLayoutGroupsTab = function() 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index ffcad57d..3b740330 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1483,8 +1483,22 @@ 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. +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,6 +1556,7 @@ 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, @@ -1554,6 +1569,7 @@ local function CollectAllEffects() config = indicator, anchor = indicator.anchor or "CENTER", }) + end end end @@ -1562,7 +1578,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, diff --git a/DandersFrames_Options/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua index 6c51baf7..978ac117 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Options.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Options.lua @@ -687,6 +687,24 @@ local function CurrentLayoutGroups() 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. +local function VisibleLayoutGroups() + local out = {} + for _, g in ipairs(CurrentLayoutGroups()) do + if not (type(g) == "table" and g.pihSignal) 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/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 862d3945..8156f01e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -46,6 +46,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 @@ -1156,7 +1159,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 From b8f961ac6246d7b9dc6074d7ec7dc6466cb4e724 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 21:26:36 +0100 Subject: [PATCH 03/66] PI Helper: an enable tick, a real preview, and Square retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three asks from 2026-09-08, after checking what the standalone PIHelper addon actually does: its entire visual vocabulary is a glow on the raid frame plus optional duration text. Nothing placed. That is the shape this was drifting towards anyway. SQUARE IS RETIRED AS A HELPER SURFACE (schema 3) The helper answers "who should I infuse", and that is a STATE OF THE FRAME, not a thing placed on it. Border, health bar, background and the two texts are all the frame saying something about the unit; a Square is a coloured block at a coordinate, which is a PLACEMENT decision -- and placement is the Aura Designer's job, not this feature's. ⚠ MIGRATED, NOT DELETED. Someone running a Square helper would otherwise open the panel to a signal reading "None" with no explanation, which is indistinguishable from their settings having been lost. pihSweep step 4 converts it to Border, carrying the colour across, because Border is the nearest honest equivalent -- a contended surface, like the Square was competing for attention. ⚠ The square HANDLING code stays (pihPlace, pihCreateSignal, pihDeleteSignal all still know the type). It is what the migration runs through, and a half-removed surface is how a legacy record becomes unreachable. ⭐ ICON SURVIVES, NARROWED TO ONE MEANING, and is relabelled to say it: "Power Infusion icon". It earns its place because it answers something no colour can -- on the already-infused signal it is the buff you would be about to waste, on the cooldown signal it is the spell you are being told to cast. A picture of Power Infusion means Power Infusion; a gold border means whatever the user decided gold means today. "Icon" was accurate and told them nothing -- it read as a sibling of Square, i.e. another placement, which is the exact reading this cut removes. AN ENABLE TICK, NOT AN ADD/REMOVE CARD "Add" and "Remove" were the implementation talking. Literally true -- the helper creates and deletes AD 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. It now reads like the designer's own enable: same banner, same styled check button, same left-aligned label. ⚠ Settings still survive the round trip, as they already did: unticking routes to PIH_Remove, which STASHES every customisation, and PIH_Create lays the stash back over fresh defaults. The tick just stops making it look like a destructive act. ⚠ The handler reads the POOL, not the box. A double click or a profile switch landing mid-build would otherwise create a second helper or remove one that is already gone. The explaining sentence moved onto the banner and stays visible while the tick is OFF -- which is exactly when someone needs to read it. A REAL PREVIEW CreateFramePreview, the same factory the designer mounts in both its layouts and the scope-card tiles already use standalone. Reusing it is the only way this page can promise the picture matches the live frame; a second renderer is a second thing to drift. ☠ PAINTED FROM THE HELPER'S OWN RECORDS. RefreshPreviewEffects now takes an optional pool, defaulting to exactly what it always used, and the page hands it S.PIH_PreviewPool -- the SAME cfg tables, filtered to those carrying a helper mark. The Any Buff pool also holds the user's unrelated work, and a preview on a page about one feature that quietly renders another's effects is worse than no preview. ⚠ S.framePreview is a singleton and this takes it. Safe for the same reason the designer's two layouts can both assign it: one options page renders at a time, and whichever builds next claims it back. Four now-orphaned locale strings removed rather than left for translators to work on text nobody will ever see. Verification: 170 files parse, no global changes in any file, all CRLF, no BOM. ⚠ Not verified in game. (cherry picked from commit e7738df9c6eff7467016222fd8d4099983868ece) --- DandersFrames/Locales/enUS.lua | 31 +-- .../AuraDesigner/UI/Cards.lua | 187 +++++++++++++----- .../AuraDesigner/UI/Groups.lua | 10 +- .../AuraDesigner/UI/PIHelperPage.lua | 32 ++- 4 files changed, 200 insertions(+), 60 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index dda06b4b..ba45852d 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2935,20 +2935,27 @@ 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 --- ⚠ TITLE CASE, AND A SEPARATE STRING FROM THE ALL-CAPS ONE ABOVE. This is the NAV ENTRY --- (Auras > Power Infusion Helper); the caps version is the card's own heading inside the --- page. Nav labels are title case everywhere in this addon and card headings are not, so --- casing one from the other would be wrong in one of the two places -- and in a locale --- where case is not a presentation choice, it would be wrong in both. +-- 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 -L["Add the helper"] = true -L["Remove the 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 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 +-- ⚠ NAMED FOR WHAT IT SHOWS, not for its shape. This replaced a plain "Icon" in the +-- helper's surface menu, which read as a sibling of "Square" -- i.e. as another placement +-- choice -- when it is the one entry whose picture carries the meaning: Power Infusion's +-- own artwork, saying either "already has it" or "worth casting it on". +L["Power Infusion icon"] = 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 76543f43..0c7e9038 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -411,6 +411,39 @@ local function pihAnyIconGroup() return nil end +-- ★ 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 + -- 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. @@ -917,14 +950,22 @@ function P.PIH_SurfaceOptions(key) -- 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"] + -- ⭐ THE ONE PLACED SURFACE LEFT, AND IT IS NOT A PLACEMENT CONTROL. Everything above is + -- the FRAME saying something about the unit; this is the Power Infusion artwork itself, + -- and it earns its place because it answers a question no colour can: on the "already + -- has it" signal it is the buff you would otherwise be about to waste, and on the + -- cooldown signal it is the spell you are being told to cast. A picture of Power + -- Infusion means Power Infusion; a gold border means whatever the user decided gold + -- means today. + -- ☠ SQUARE IS GONE (schema 3, 2026-09-08) -- a flat colour block at a coordinate is a + -- PLACEMENT decision, and placement is the Aura Designer's job. Existing ones migrate to + -- Border rather than vanishing; see step 4 in pihSweep. Do not re-add it here without + -- also reviving that migration's opposite, or a downgrade strands the record. + -- ⚠ Named for what it SHOWS, not for its shape. "Icon" was accurate and told the user + -- nothing -- it read as a sibling of Square, i.e. another placement, which is exactly + -- the reading this cut exists to remove. + opts.icon = L["Power Infusion icon"] opts._order[#opts._order + 1] = "icon" - opts._order[#opts._order + 1] = "square" return opts end @@ -5847,7 +5888,8 @@ 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 as a helper surface, migrated to Border (see step 4 in pihSweep). +local PIH_SCHEMA = 3 local function pihSweep() local s = P.PIH_Settings() @@ -5903,6 +5945,40 @@ local function pihSweep() local ig = pihIconGroup("infused") if ig and P.DeleteLayoutGroup then P.DeleteLayoutGroup(ig.id) end + -- 4. SQUARE IS RETIRED AS A HELPER SURFACE (schema 3, 2026-09-08). + -- ☠ THE HELPER ANSWERS "WHO SHOULD I INFUSE", AND THAT IS A STATE OF THE FRAME, NOT A + -- THING PLACED ON IT. Border, health bar, background and the two texts are all the + -- frame saying something about the unit; a Square is a coloured block at a coordinate, + -- which is a PLACEMENT decision -- and placement is the Aura Designer's job, not this + -- feature's. Krathe, 2026-09-08, after we checked the addon that does only this: + -- PIHelper's entire vocabulary is a glow on the frame plus optional duration text. + -- Nothing placed. ⭐ Icon SURVIVES, but narrowed to one meaning -- see + -- PIH_SurfaceOptions: it is the Power Infusion artwork saying "this one already has it" + -- or "this one is worth it", not a free placement. + -- + -- ⚠ MIGRATED, NOT DELETED. Someone running a Square helper today would otherwise open + -- the panel to a signal reading "None" and no explanation -- indistinguishable from + -- their settings having been lost. Border is the nearest honest equivalent: it is a + -- contended surface like the Square was competing for attention, and pihCapture carries + -- the colour across so the signal keeps the shade they chose. + 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 == "square" 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. + local carried = { colour = inst.color, conditions = inst.conditions } + table.remove(auraCfg.indicators, i) + pihPlace(sig, auraName, "border", carried) + end + end + end + end + end + s.schema = PIH_SCHEMA if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end end @@ -5914,44 +5990,65 @@ S.BuildPIHelperCard = function(parent, opts) pihSweep() 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 + + -- ── 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") + banner:SetHeight(52) + 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(exists) + cb:SetScript("OnClick", function(self) + -- ⚠ READ THE WORLD, NOT THE BOX. The tick's own state is what the user just did; + -- whether a helper exists is what the pool says. A double click, a profile switch + -- landing mid-build or a stale page would otherwise create a second helper or try + -- to remove one that is already gone. + if P.PIH_Exists() then P.PIH_Remove() else P.PIH_Create() end + self:SetChecked(P.PIH_Exists()) + 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 one sentence that says what it does. Kept from the old card's face, because it is + -- the only place the feature explains itself -- and it stays visible when the tick is + -- OFF, which is exactly when someone needs to read it. + local desc = banner:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(desc, 10, "") + desc:SetPoint("TOPLEFT", banner, "TOPLEFT", 10, -30) + desc:SetPoint("TOPRIGHT", banner, "TOPRIGHT", -10, -30) + desc:SetJustifyH("LEFT") + desc:SetText(L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."]) + desc:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + + banner.checkbox = cb + yPos = yPos - (banner:GetHeight() + GUI.Space.section) + -- ☠ THE SECOND RETURN STILL GATES THE SECTIONS, but on existence alone now. 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, exists and true or false end -- ── THE SECTION TOOLKIT ── diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 3b740330..0dc6e8ea 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -2773,7 +2773,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 @@ -2817,8 +2817,14 @@ 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 } end diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index 86348b23..7f8daa5c 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -103,12 +103,42 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) content:SetPoint("TOPRIGHT", 0, 0) content:SetHeight(1) + -- ── THE PREVIEW, THE DESIGNER'S OWN CANVAS ── + -- ⚠ THE SAME FACTORY, NOT A LOOKALIKE. CreateFramePreview is what the designer + -- mounts in both its layouts, and the scope-card tiles already prove it stands + -- alone with no right panel. Reusing it is the only way this page can promise the + -- picture matches the live frame -- a second renderer is a second thing to drift. + -- ☠ S.framePreview IS A SINGLETON AND WE TAKE IT, which is safe for the same + -- reason the designer's two layouts can both assign it: only one options page + -- renders at a time, and whichever page builds next re-assigns it. Navigating away + -- leaves it pointing at a retired frame until the designer rebuilds and claims it + -- back -- exactly what already happens when the settings layout is flipped. + -- ⚠ PAINTED FROM THE HELPER'S OWN RECORDS, never the shared pool: S.PIH_PreviewPool + -- hands over the same cfg tables the designer would paint, filtered to the ones + -- carrying a helper mark. The Any Buff pool also holds the user's unrelated work, + -- and rendering that here would show effects this page does not control. + local pv + if P and S.PIH_PreviewPool and P.CreateFramePreview then + pv = P.CreateFramePreview(content, 0, nil, { compact = true, hideLabel = true }) + if pv then + S.framePreview = pv + if P.RefreshPreviewEffects then + pcall(P.RefreshPreviewEffects, { pool = S.PIH_PreviewPool() }) + end + end + end + -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane -- calls opts.Refresh when it changes something structural; inside the designer -- that meant "rebuild the tab", and here it means "rebuild this page". Routing it -- at S.SwitchTab would redraw a designer that may not even be open. + -- The pane starts below whatever the preview took. Read back rather than assumed: + -- CreateFramePreview sizes itself from the frame settings and the preview scale, so + -- the only honest number is the one it ended up with. + local previewH = pv and ((pv:GetHeight() or 0) + 10) or 0 + local yEnd = S.BuildPIHelperPane(content, { - startY = 0, + startY = -previewH, Refresh = function() if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end end, From 3f9eb2dab4c492797092ec6852cb021aec2c0ce9 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 21:36:57 +0100 Subject: [PATCH 04/66] PI Helper: side-by-side like the designer, Triggers before Indicators, Icon out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe, 2026-09-08, with the designer's page beside it for comparison: "the preview should be the same with a side by side, settings on right side preview on left, the page should look almost exactly like AD… right side should be the PI helper options with triggers then indicators". THE SPLIT A 50/50 column: preview left, settings right, mirroring the designer's own splitContainer -- including its 6px gutter, borrowed rather than picked so three split panels in this addon do not drift to three different gaps. ⚠ MEASURED, NOT ANCHORED TO CENTER. The designer can anchor its halves to a CENTER point because they live inside an island of known size; this column is measured by the page harness after the fact, so the halves take an explicit width off the host's real width -- which is why nothing builds until the first size arrives. ⚠ The column is as tall as its TALLER half. Sizing to the settings alone would clip the preview whenever the helper is off and the panel is two rows high; sizing to the preview alone would clip the settings in every other case. ⚠ No border on the left panel -- the preview container draws the visible dim border, and one on both stacks into a brighter doubled line. The designer's own note, and the reason it says it. TRIGGERS, THEN INDICATORS The old grouping opened with "What to Show" -- the answer -- and buried the question two boxes down, which is why the panel read as a pile of settings rather than one decision followed by another. Now: Triggers (roles + the cooldown gate), Classes and Cooldowns, Indicators (the signal rows), Sound. ⚠ THE GATE MOVED. "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. ⚠ Classes and Cooldowns is a SIBLING of Triggers, not a child: t.group draws a box, and a box inside a box for a thirteen-row list reads as a nested pane rather than a fold. ICON OUT, SQUARE BACK (schema 4) ☠ THIS REVERSES THE PREVIOUS COMMIT, AND THE REVERSAL IS THE POINT. I cut Square as "a placement, which is the designer's job" and kept Icon as "the one picture that carries meaning". That was SHAPE reasoning. Krathe's is SCOPE reasoning: "we don't need to track each buff just the fact someone has popped a CD and we highlight in some form." An icon shows a specific buff's artwork, so offering one implies the helper tracks WHICH cooldown each player popped -- detail the feature never delivers. Scope wins over shape, and the swap is recorded in both files so it does not later read as drift. ⚠ Schema 3 (Square -> Border) is deliberately NOT preserved and its number is burned rather than reused. It existed for part of one afternoon on one developer's client, so nothing in the wild ran it; 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 -- a colour on a different surface, not lost work. ⚠ Schema 4 migrates Icon -> Square: the nearest thing that still marks the same unit in the same place. An Icon carries no colour, so the square falls back to the signal's default, which is what pihPlace already does with a nil carry. S.PIHelperSections is removed -- it adapted the bodies for the popout row band, and that band went when the helper got its own page. The per-section split is KEPT: it is what let the page reorder Triggers before Indicators without touching a single control. Three locale strings retired with the shapes that used them. Verification: 170 files parse, no global changes, every L[] key resolved, no orphaned key still referenced, all CRLF, no BOM. ⚠ Not verified in game. ⚠ Still open: "indicators you can add like you would on AD" is implemented as the existing one-surface-per-signal dropdown offering exactly that list. Whether you meant MULTIPLE indicators per signal is a data-model question -- pihFound is keyed by signal and assumes one record each -- and I have not guessed at it. (cherry picked from commit 6fa0fe9bd9ab154969595074c51033cae7c7c77a) --- DandersFrames/Locales/enUS.lua | 27 ++- .../AuraDesigner/UI/Cards.lua | 186 ++++++++---------- .../AuraDesigner/UI/PIHelperPage.lua | 55 ++++-- 3 files changed, 142 insertions(+), 126 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index ba45852d..4218c038 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2951,14 +2951,20 @@ L["Enable Power Infusion Helper"] = 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 --- ⚠ NAMED FOR WHAT IT SHOWS, not for its shape. This replaced a plain "Icon" in the --- helper's surface menu, which read as a sibling of "Square" -- i.e. as another placement --- choice -- when it is the one entry whose picture carries the meaning: Power Infusion's --- own artwork, saying either "already has it" or "worth casting it on". -L["Power Infusion icon"] = 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 +-- ⚠ 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 signals. Each label has to stand alone with just the line beneath it for context. 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. @@ -2979,8 +2985,9 @@ 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 inside Triggers +-- now -- 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. L["Groups without assigned roles show everyone."] = true L["Hide the helper while your Power Infusion is on cooldown"] = true -- Only watch. Classes rather than specs because the spell data records a class and nothing diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 0c7e9038..fdf41212 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -950,22 +950,22 @@ function P.PIH_SurfaceOptions(key) -- setup is first-class. L["None"] is the addon's existing key, reused. opts.none = L["None"] table.insert(opts._order, 1, "none") - -- ⭐ THE ONE PLACED SURFACE LEFT, AND IT IS NOT A PLACEMENT CONTROL. Everything above is - -- the FRAME saying something about the unit; this is the Power Infusion artwork itself, - -- and it earns its place because it answers a question no colour can: on the "already - -- has it" signal it is the buff you would otherwise be about to waste, and on the - -- cooldown signal it is the spell you are being told to cast. A picture of Power - -- Infusion means Power Infusion; a gold border means whatever the user decided gold - -- means today. - -- ☠ SQUARE IS GONE (schema 3, 2026-09-08) -- a flat colour block at a coordinate is a - -- PLACEMENT decision, and placement is the Aura Designer's job. Existing ones migrate to - -- Border rather than vanishing; see step 4 in pihSweep. Do not re-add it here without - -- also reviving that migration's opposite, or a downgrade strands the record. - -- ⚠ Named for what it SHOWS, not for its shape. "Icon" was accurate and told the user - -- nothing -- it read as a sibling of Square, i.e. another placement, which is exactly - -- the reading this cut exists to remove. - opts.icon = L["Power Infusion icon"] - opts._order[#opts._order + 1] = "icon" + -- ⭐ SQUARE STAYS. A flat colour block is a HIGHLIGHT, which is all this feature ever + -- needs to say: someone popped a cooldown, mark them. It costs one colour and reads at + -- a glance, which is the whole job. + -- ☠☠ ICON IS GONE (schema 4, 2026-09-08), AND THE REASON IS THE FEATURE'S SCOPE, NOT ITS + -- SHAPE. An icon shows a SPECIFIC BUFF'S ARTWORK, so offering one implies the helper + -- tracks which cooldown each player popped -- and it 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." An icon promises per-buff detail the feature does not + -- deliver, which is a control that lies about its own scope. + -- ⚠ I ARGUED THE OPPOSITE ONE COMMIT AGO -- cut Square as "a placement", kept Icon as + -- "the one picture that carries meaning". That was shape reasoning; this is scope + -- reasoning, and scope wins. Recorded so the swap does not look like drift. + -- ⚠ Existing Icon surfaces migrate to Square (pihSweep step 4) -- the nearest thing + -- that still marks the same unit in the same place. + opts.square = L["Square"] + opts._order[#opts._order + 1] = "square" return opts end @@ -5868,12 +5868,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 @@ -5888,8 +5890,11 @@ 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. --- 3: Square retired as a helper surface, migrated to Border (see step 4 in pihSweep). -local PIH_SCHEMA = 3 +-- 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). +local PIH_SCHEMA = 4 local function pihSweep() local s = P.PIH_Settings() @@ -5945,34 +5950,40 @@ local function pihSweep() local ig = pihIconGroup("infused") if ig and P.DeleteLayoutGroup then P.DeleteLayoutGroup(ig.id) end - -- 4. SQUARE IS RETIRED AS A HELPER SURFACE (schema 3, 2026-09-08). - -- ☠ THE HELPER ANSWERS "WHO SHOULD I INFUSE", AND THAT IS A STATE OF THE FRAME, NOT A - -- THING PLACED ON IT. Border, health bar, background and the two texts are all the - -- frame saying something about the unit; a Square is a coloured block at a coordinate, - -- which is a PLACEMENT decision -- and placement is the Aura Designer's job, not this - -- feature's. Krathe, 2026-09-08, after we checked the addon that does only this: - -- PIHelper's entire vocabulary is a glow on the frame plus optional duration text. - -- Nothing placed. ⭐ Icon SURVIVES, but narrowed to one meaning -- see - -- PIH_SurfaceOptions: it is the Power Infusion artwork saying "this one already has it" - -- or "this one is worth it", not a free placement. + -- 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. -- - -- ⚠ MIGRATED, NOT DELETED. Someone running a Square helper today would otherwise open - -- the panel to a signal reading "None" and no explanation -- indistinguishable from - -- their settings having been lost. Border is the nearest honest equivalent: it is a - -- contended surface like the Square was competing for attention, and pihCapture carries - -- the colour across so the signal keeps the shade they chose. + -- ⚠☠ 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 == "square" then + 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. - local carried = { colour = inst.color, conditions = inst.conditions } + -- 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, "border", carried) + pihPlace(sig, auraName, "square", carried) end end end @@ -6643,59 +6654,15 @@ 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. --- --- ⚠ 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) }, -} +-- ── 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.BuildPIHelperPane composes them directly. 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. -- ── THE CLASSIC COMPOSITION -- every section, one column, unchanged ── S.BuildPIHelperPane = function(parent, opts) @@ -6707,14 +6674,21 @@ S.BuildPIHelperPane = function(parent, opts) -- 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"]) + + -- ★★ TRIGGERS FIRST, THEN INDICATORS (2026-09-08). Krathe's ordering, and it is the + -- order the feature is actually reasoned about: you decide WHAT COUNTS as worth + -- infusing, then you decide HOW that gets shown. The old grouping opened with "What + -- to Show" -- the answer -- and buried the question two boxes down, which is why the + -- panel read as a pile of settings rather than one decision followed by another. + -- + -- ⚠ THE GATE MOVED HERE, out of the old "What to Show". "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. + yPos = t.group(L["Triggers"], 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. @@ -6722,9 +6696,17 @@ 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. + -- ⚠ A SIBLING OF Triggers RATHER THAN A CHILD OF IT: t.group draws a box, and a box + -- inside a box for a list this long reads as a nested pane rather than a fold. It + -- sits directly under Triggers, which is the grouping doing the work. yPos = t.group(L["Classes and Cooldowns"], pihAddClasses, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) + yPos = t.group(L["Indicators"], function(g) + t.signalRow(g, "burst", L["Big cooldown"]) + t.signalRow(g, "infused", L["Already has active Power Infusion"]) + end, yPos) + yPos = t.group(L["Sound Alert"], pihAddSound, yPos) end return yPos @@ -6940,8 +6922,8 @@ S.BuildEffectsHeadArea = function(parent, yPos, opts) -- 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.PIHelperSections / - -- S.BuildPIHelperPane, above). Only the MOUNT moved. The page composes them. + -- ⚠ The builders are still HERE (S.BuildPIHelperCard / S.BuildPIHelperPane and the + -- section bodies, above). Only the MOUNT moved. The page composes them. -- ── ACTIVE INDICATORS heading ── local activeHeader = parent:CreateFontString(nil, "OVERLAY") diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index 7f8daa5c..fdfa4f2f 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -103,6 +103,30 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) content:SetPoint("TOPRIGHT", 0, 0) content:SetHeight(1) + -- ── THE 50/50 SPLIT, THE DESIGNER'S OWN SHAPE ── + -- ☠ MEASURED, NOT ANCHORED TO CENTER. The designer's splitContainer can anchor its + -- halves to a CENTER point because it lives inside an island of known size; this + -- column is measured by the page harness AFTER the fact, so the halves take an + -- explicit width off the one number we do have. `w` is the host's real width, which + -- is why nothing here runs until the first size arrives. + -- ⚠ The 6px gutter and the CENTER-3 / CENTER+3 pair it comes from are the + -- designer's, and the Text Designer's before that -- borrowed rather than picked, so + -- three split panels in this addon do not drift to three different gaps. + local SPLIT_GAP = 6 + local halfW = math.max(80, (w - SPLIT_GAP) / 2) + + local leftPanel = CreateFrame("Frame", nil, content, "BackdropTemplate") + leftPanel:SetPoint("TOPLEFT", 0, 0) + leftPanel:SetWidth(halfW) + -- ⚠ NO BORDER, and the designer's own note says why: the inner preview container + -- draws the visible dim border, and one on both stacks into a brighter doubled line. + GUI:CreatePanelBackdrop(leftPanel, { border = false }) + + local rightPanel = CreateFrame("Frame", nil, content, "BackdropTemplate") + rightPanel:SetPoint("TOPRIGHT", 0, 0) + rightPanel:SetWidth(halfW) + GUI:CreatePanelBackdrop(rightPanel, { border = false }) + -- ── THE PREVIEW, THE DESIGNER'S OWN CANVAS ── -- ⚠ THE SAME FACTORY, NOT A LOOKALIKE. CreateFramePreview is what the designer -- mounts in both its layouts, and the scope-card tiles already prove it stands @@ -119,7 +143,7 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) -- and rendering that here would show effects this page does not control. local pv if P and S.PIH_PreviewPool and P.CreateFramePreview then - pv = P.CreateFramePreview(content, 0, nil, { compact = true, hideLabel = true }) + pv = P.CreateFramePreview(leftPanel, 0, nil, { compact = true, hideLabel = true }) if pv then S.framePreview = pv if P.RefreshPreviewEffects then @@ -127,24 +151,27 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) end end end - - -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane - -- calls opts.Refresh when it changes something structural; inside the designer - -- that meant "rebuild the tab", and here it means "rebuild this page". Routing it - -- at S.SwitchTab would redraw a designer that may not even be open. - -- The pane starts below whatever the preview took. Read back rather than assumed: - -- CreateFramePreview sizes itself from the frame settings and the preview scale, so - -- the only honest number is the one it ended up with. - local previewH = pv and ((pv:GetHeight() or 0) + 10) or 0 - - local yEnd = S.BuildPIHelperPane(content, { - startY = -previewH, + leftPanel:SetHeight(max(1, (pv and pv:GetHeight() or 0))) + + -- ── THE SETTINGS, IN THE RIGHT HALF ── + -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane calls + -- opts.Refresh when it changes something structural; inside the designer that meant + -- "rebuild the tab", and here it means "rebuild this page". Routing it at + -- S.SwitchTab would redraw a designer that may not even be open. + local yEnd = S.BuildPIHelperPane(rightPanel, { + startY = 0, Refresh = function() if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end end, }) - local h = max(1, -(yEnd or 0)) + local rightH = max(1, -(yEnd or 0)) + rightPanel:SetHeight(rightH) + + -- ☠ THE COLUMN IS AS TALL AS ITS TALLER HALF. Sizing to the settings alone would + -- clip the preview whenever the helper is switched off and the panel is two rows + -- high; sizing to the preview alone would clip the settings in every other case. + local h = max(rightH, leftPanel:GetHeight() or 1) content:SetHeight(h) rebuilding = false From 76790f69eb7e373530ca2d9cd68e4080a84312f6 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 21:40:05 +0100 Subject: [PATCH 05/66] PI Helper: size the preview panel before building into it, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left half rendered as a transparent gap with the game world showing through (Krathe, screenshot 2026-09-08). My bug, and a circular one. ☠ WITHOUT opts.thumb, CreateFramePreview ANCHORS ITS CONTAINER TO ALL FOUR CORNERS OF THE PARENT -- so the canvas takes its height FROM the panel it is built into. I built the preview into a 1px panel and then set that panel's height FROM the preview. The two resolved against each other at 1px, so nothing was drawn and the empty panel showed the world behind it. The designer never hits this: its leftPanel is anchored TOPLEFT+BOTTOMLEFT inside an island that already has a size, so the height exists before the canvas asks for it. ⇒ Decide the height, THEN build into it. The settings half is measured first, the column takes the larger of that and the canvas's own wanted height, and the preview is created last into a panel that already has a size. ⚠ AND THE FLOOR IS THE CANVAS'S OWN NUMBER, not a constant I picked. P.CanvasWantedHeight exists for precisely this case -- its comment says "the host must size the band BEFORE calling the builder that creates it" -- and it derives from the frame height and the live preview scale, so the canvas keeps fitting when either moves. My first patch used a flat 260, which would have been wrong the moment anyone touched the scale slider. It matters most in the state a new user sees first: with the helper switched off the settings are one banner tall, and a canvas sized to that cannot show a unit frame at all. Verification: 170 files parse, no global changes, CRLF, no BOM. ⚠ Not verified in game. (cherry picked from commit b6a77aab30ac452f55c322e75100c2bad32cecab) --- .../AuraDesigner/UI/PIHelperPage.lua | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index fdfa4f2f..80e852a2 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -127,6 +127,41 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) rightPanel:SetWidth(halfW) GUI:CreatePanelBackdrop(rightPanel, { border = false }) + -- ── THE SETTINGS FIRST, BECAUSE THEY DECIDE THE HEIGHT ── + -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane calls + -- opts.Refresh when it changes something structural; inside the designer that meant + -- "rebuild the tab", and here it means "rebuild this page". Routing it at + -- S.SwitchTab would redraw a designer that may not even be open. + local yEnd = S.BuildPIHelperPane(rightPanel, { + startY = 0, + Refresh = function() + if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end + end, + }) + + local rightH = max(1, -(yEnd or 0)) + rightPanel:SetHeight(rightH) + + -- ☠☠ THE PANEL IS SIZED BEFORE THE PREVIEW IS BUILT, AND THAT ORDER IS THE WHOLE + -- FIX. Without opts.thumb, CreateFramePreview anchors its container to ALL FOUR + -- CORNERS of the parent -- so the canvas takes its height FROM the panel. The first + -- cut built the preview into a 1px panel and then set the panel's height from the + -- preview: circular, and it resolved to 1px of nothing. The left half rendered as a + -- transparent gap with the game world showing through (Krathe, 2026-09-08). + -- ⇒ Decide the height, THEN build into it. The designer never hits this because its + -- leftPanel is anchored TOPLEFT+BOTTOMLEFT inside an island that already has a size. + -- ⚠ THE FLOOR IS THE CANVAS'S OWN NUMBER, NOT A GUESS. P.CanvasWantedHeight exists + -- for exactly this: "the host must size the band BEFORE calling the builder that + -- creates it". It derives from the frame height and the live preview scale, so the + -- canvas keeps fitting when either moves -- which a constant would not. + -- ⚠ It matters most in the state a new user sees FIRST: with the helper switched off + -- the settings are one banner tall, and a canvas sized to that cannot show a unit + -- frame at all. The page would look broken before it had done anything. + local canvasH = (P.CanvasWantedHeight and P.CanvasWantedHeight(true, nil)) or 260 + local h = max(rightH, canvasH) + leftPanel:SetHeight(h) + content:SetHeight(h) + -- ── THE PREVIEW, THE DESIGNER'S OWN CANVAS ── -- ⚠ THE SAME FACTORY, NOT A LOOKALIKE. CreateFramePreview is what the designer -- mounts in both its layouts, and the scope-card tiles already prove it stands @@ -141,9 +176,8 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) -- hands over the same cfg tables the designer would paint, filtered to the ones -- carrying a helper mark. The Any Buff pool also holds the user's unrelated work, -- and rendering that here would show effects this page does not control. - local pv if P and S.PIH_PreviewPool and P.CreateFramePreview then - pv = P.CreateFramePreview(leftPanel, 0, nil, { compact = true, hideLabel = true }) + local pv = P.CreateFramePreview(leftPanel, 0, nil, { compact = true, hideLabel = true }) if pv then S.framePreview = pv if P.RefreshPreviewEffects then @@ -151,28 +185,6 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) end end end - leftPanel:SetHeight(max(1, (pv and pv:GetHeight() or 0))) - - -- ── THE SETTINGS, IN THE RIGHT HALF ── - -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane calls - -- opts.Refresh when it changes something structural; inside the designer that meant - -- "rebuild the tab", and here it means "rebuild this page". Routing it at - -- S.SwitchTab would redraw a designer that may not even be open. - local yEnd = S.BuildPIHelperPane(rightPanel, { - startY = 0, - Refresh = function() - if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end - end, - }) - - local rightH = max(1, -(yEnd or 0)) - rightPanel:SetHeight(rightH) - - -- ☠ THE COLUMN IS AS TALL AS ITS TALLER HALF. Sizing to the settings alone would - -- clip the preview whenever the helper is switched off and the panel is two rows - -- high; sizing to the preview alone would clip the settings in every other case. - local h = max(rightH, leftPanel:GetHeight() or 1) - content:SetHeight(h) rebuilding = false if abs((host.layoutHeight or 0) - h) > 0.5 then From 63cd60822973a89fcf650949c0c3676bd329336b Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 21:50:19 +0100 Subject: [PATCH 06/66] PI Helper: preview at the top with its slider, and Triggers / Effects as tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from the screenshot, 2026-09-08. THE PREVIEW WAS STRANDED IN THE MIDDLE ☠ The mock frame is SetPoint("CENTER", container, "CENTER") and the container fills its parent -- so a left panel stretched to match a long settings column centres the frame halfway down THAT column, which is why it sat near the bottom with a screen of empty space above it. The designer never shows this because its two halves are the same height BY CONSTRUCTION: they fill an island sized to the viewport and the settings side scrolls inside it. This page is a measured column in a scrolling page, so the halves are independent -- only the CONTENT height is shared, and the left panel takes the CANVAS's height. ⚠ That height is the canvas's own number, not one I picked: the non-compact floor, or the height that fits the frame at the live preview scale plus the 30px the band form saves by putting the scale behind a glyph -- the file's own accounting, quoted at the call site. Whichever is larger. THE MISSING HEADER AND SLIDER It was built with compact = true, which is the BAND form: label hidden, and the scale behind a glyph in the corner rather than a slider row. That is right for a popout band and wrong for a page asked to look like the designer. Now non-compact, with placement = false -- the one thing suppressed, because that flag gates the nine anchor dots, the drag hint and the three instruction rows, all of which are about POSITIONING an indicator. Nothing here can be dragged. It does not gate the slider. TRIGGERS AND EFFECTS ARE TABS NOW ☠ The split is the feature's two questions, and they are answered at different times. TRIGGERS -- roles, the cooldown gate, which classes, which spells -- is set up once, carefully. EFFECTS -- surface, colour, sound -- gets fiddled with. One column holding both meant scrolling past the thirteen-row class list every time you wanted to nudge a colour. * GUI:StyleButton's `tab` mode, the same the designer's right panel uses, so this reads as the same kind of page rather than a lookalike. * ☠ The active tab lives on S, not in a local: this builder re-runs on every rebuild, and a local would drop the user back to Triggers every time they changed anything on Effects. * The enable banner sits ABOVE the tabs -- it turns the whole feature on, so it cannot live inside one of the two things it governs. * Sound moved to Effects: it is another way of being told the same thing. * S.BuildPIHelperPane becomes S.BuildPIHelperBody(parent, {tab=...}) -- one tab's worth of settings, card no longer included. AND THE WAY IN TO THE SPELL LIST The class ticks say WHICH classes are watched; the filter behind them holds the individual cooldowns, and nothing on the page said that list existed. Triggers now carries a line and a button through to the Filter Designer. A LINK, not a second copy of the picker -- one place edits these, and a rebuilt picker here would be a second answer to "is this spell watched". ⚠ CAUGHT BY THE _ENV DIFF, NOT BY luac: reworking the height flow left `h` as a nil GLOBAL in the layout feedback block, which parses clean and would have thrown on the first rebuild. It is `colH` now. This is the third time that check has earned its place. Verification: 170 files parse; the only new global anywhere is `ipairs` for the tab loop; every L[] key resolves; no stale BuildPIHelperPane reference outside one historical note; all CRLF, no BOM. ⚠ Not verified in game. (cherry picked from commit 16601288ceadb577bed63276c5573ee5f4ce3ae3) --- DandersFrames/Locales/enUS.lua | 5 + .../AuraDesigner/UI/Cards.lua | 82 ++++++++---- .../AuraDesigner/UI/PIHelperPage.lua | 119 ++++++++++++++---- 3 files changed, 154 insertions(+), 52 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 4218c038..245af02b 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2964,6 +2964,11 @@ L["Shows who is worth infusing, and goes dark while your Power Infusion is on co -- ⚠ "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 way in to the actual spell list. The class ticks say WHICH classes are watched; the +-- filter behind them holds the individual cooldowns, and nothing on the page said that list +-- existed. A LINK rather than a second copy of the picker -- one place edits these. +L["Individual cooldowns live in the filter this list drives."] = true +L["Edit the cooldown list"] = true -- The signals. Each label has to stand alone with just the line beneath it for context. L["Big cooldown"] = true -- How the surface pickers behave. What the controls cannot show on their own: which diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index fdf41212..c19ef02e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6660,30 +6660,42 @@ end -- 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.BuildPIHelperPane composes them directly. Only the row-page +-- 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. --- ── THE CLASSIC COMPOSITION -- every section, one column, unchanged ── -S.BuildPIHelperPane = function(parent, opts) +-- ★★ 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. +S.PIH_TABS = { + { key = "triggers", label = L["Triggers"] }, + { key = "effects", label = L["Effects"] }, +} + +-- ── ONE TAB'S WORTH OF SETTINGS ── +-- ⚠ THE CARD IS NOT HERE. It used to be, when this was S.BuildPIHelperPane and the whole +-- panel was one column; now the page draws the enable banner, then the tab bar, then this. +-- The banner has to sit ABOVE the tabs -- it turns the whole feature on, so it cannot be +-- inside one of the two things it governs. +-- ⚠ 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) - - -- ★★ TRIGGERS FIRST, THEN INDICATORS (2026-09-08). Krathe's ordering, and it is the - -- order the feature is actually reasoned about: you decide WHAT COUNTS as worth - -- infusing, then you decide HOW that gets shown. The old grouping opened with "What - -- to Show" -- the answer -- and buried the question two boxes down, which is why the - -- panel read as a pile of settings rather than one decision followed by another. - -- - -- ⚠ THE GATE MOVED HERE, out of the old "What to Show". "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. + local yPos = opts.startY or 0 + local t = pihMakeTools(parent, opts) + local tab = opts.tab or "triggers" + + if tab == "triggers" then + -- ⚠ 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. yPos = t.group(L["Triggers"], function(g) pihAddRoles(g, t) pihAddGateAndNotes(g, t) @@ -6696,19 +6708,35 @@ 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. - -- ⚠ A SIBLING OF Triggers RATHER THAN A CHILD OF IT: t.group draws a box, and a box - -- inside a box for a list this long reads as a nested pane rather than a fold. It - -- sits directly under Triggers, which is the grouping doing the work. - yPos = t.group(L["Classes and Cooldowns"], pihAddClasses, yPos, - { collapsible = true, collapseKey = "pihelper:onlywatch" }) - + yPos = t.group(L["Classes and Cooldowns"], function(g) + pihAddClasses(g, t) + -- ★ THE WAY IN TO THE ACTUAL SPELL LIST. The class ticks say WHICH classes are + -- watched; the filter behind them holds the individual cooldowns, and until now + -- nothing on this page said that list existed or where to find it. Krathe: + -- "the link to edit the filter itself to tick on/off individual CD's as needed." + -- ⚠ A LINK, NOT A COPY OF THE LIST. The Filter Designer already edits these -- + -- rebuilding a spell picker here would be a second place to maintain and a + -- second answer to "is this spell watched". + t.note(g, L["Individual cooldowns live in the filter this list drives."]) + -- ⚠ PARENTED TO `parent`, NOT TO THE GROUP -- the same thing t.group does with + -- its own header widget. A SettingsGroup positions what it is given; it is not + -- the frame they hang off. + local btn = GUI:CreateButton(parent, L["Edit the cooldown list"], 170, 22, function() + if GUI.SelectTab then GUI.SelectTab("auras_filterdesigner") end + end) + g:AddWidget(btn, 26) + end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) + else yPos = t.group(L["Indicators"], function(g) t.signalRow(g, "burst", L["Big cooldown"]) t.signalRow(g, "infused", L["Already has active Power Infusion"]) end, yPos) + -- ⚠ SOUND IS AN EFFECT. It is another way of being told the same thing, so it + -- belongs beside the surfaces rather than with the conditions that fire them. yPos = t.group(L["Sound Alert"], pihAddSound, yPos) end + return yPos end @@ -6922,7 +6950,7 @@ S.BuildEffectsHeadArea = function(parent, yPos, opts) -- 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.BuildPIHelperPane and the + -- ⚠ 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 ── diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index 80e852a2..c535d105 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -42,9 +42,9 @@ local max, abs = math.max, math.abs -- ============================================================ -- ☠ THE HARNESS SIZES A WIDGET *AFTER* Add, NOT BEFORE. GUI/Panel.lua's Add only records -- layoutHeight/layoutCol; the actual SetWidth happens in PageRefreshStates' layout pass --- (the `layoutCol == "both"` arm). S.BuildPIHelperPane needs a real width AT BUILD TIME — --- the card measures its wrapped description against it, and falls back to 320 without one, --- which is right for the 260px popout pane and wrong by half on a full-width page. +-- (the `layoutCol == "both"` arm). The builders need a real width AT BUILD TIME — the +-- settings groups size themselves against their parent, and the split's two halves are +-- computed from it — so nothing here can run until the first real size arrives. -- -- ⇒ Build LAZILY, on the first size we are given, and re-measure only when the width -- actually moves. The height then flows the other way: the pane returns its measured @@ -63,7 +63,7 @@ local WIDTH_EPSILON = 2 -- sub-pixel jitter from the snapping pass is not a re -- GUI/Pages/Auras.lua and neither is a special case at the call site. function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) if not (Add and pageRef and pageRef.child) then return end - if not (S and S.BuildPIHelperPane) then return end + if not (S and S.BuildPIHelperCard and S.BuildPIHelperBody) then return end local host = CreateFrame("Frame", nil, pageRef.child) host:SetHeight(1) @@ -132,13 +132,57 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) -- opts.Refresh when it changes something structural; inside the designer that meant -- "rebuild the tab", and here it means "rebuild this page". Routing it at -- S.SwitchTab would redraw a designer that may not even be open. - local yEnd = S.BuildPIHelperPane(rightPanel, { - startY = 0, - Refresh = function() - if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end - end, + local function pageRefresh() + if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end + end + + -- The enable banner sits ABOVE the tabs: it turns the whole feature on, so it + -- cannot live inside one of the two things it governs. + local yPos, open = S.BuildPIHelperCard(rightPanel, { + startY = 0, Refresh = pageRefresh, }) + -- ── THE TAB BAR, THE DESIGNER'S OWN STYLING ── + -- ⚠ GUI:StyleButton's `tab` mode is what the designer's right panel uses -- faint + -- cell when inactive, accent fill and underline when active -- so borrowing it is + -- what makes this read as the same kind of page rather than a lookalike. + -- ☠ THE ACTIVE TAB LIVES ON S, NOT IN A LOCAL. This whole builder re-runs on every + -- rebuild (a tick, a colour, a width change), and a local would reset the user to + -- Triggers every time they changed anything on Effects. + if open and S.PIH_TABS then + S.pihTab = S.pihTab or "triggers" + local TAB_GAP, TAB_H = 4, 26 + local tabW = math.max(60, (halfW - 16 - TAB_GAP) / 2) + local prev + for _, def in ipairs(S.PIH_TABS) do + local btn = CreateFrame("Button", nil, rightPanel, "BackdropTemplate") + btn:SetSize(tabW, TAB_H) + if prev then + btn:SetPoint("TOPLEFT", prev, "TOPRIGHT", TAB_GAP, 0) + else + btn:SetPoint("TOPLEFT", 8, yPos) + end + GUI:StyleButton(btn, { tab = true, text = def.label, font = "DFFontHighlight" }) + if btn.SetActive then btn:SetActive(S.pihTab == def.key) end + btn:SetScript("OnClick", function() + if S.pihTab == def.key then return end + S.pihTab = def.key + pageRefresh() + end) + prev = btn + end + yPos = yPos - (TAB_H + 8) + end + + local yEnd = yPos + if open then + yEnd = S.BuildPIHelperBody(rightPanel, { + startY = yPos, + tab = S.pihTab or "triggers", + Refresh = pageRefresh, + }) + end + local rightH = max(1, -(yEnd or 0)) rightPanel:SetHeight(rightH) @@ -150,17 +194,33 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) -- transparent gap with the game world showing through (Krathe, 2026-09-08). -- ⇒ Decide the height, THEN build into it. The designer never hits this because its -- leftPanel is anchored TOPLEFT+BOTTOMLEFT inside an island that already has a size. - -- ⚠ THE FLOOR IS THE CANVAS'S OWN NUMBER, NOT A GUESS. P.CanvasWantedHeight exists - -- for exactly this: "the host must size the band BEFORE calling the builder that - -- creates it". It derives from the frame height and the live preview scale, so the - -- canvas keeps fitting when either moves -- which a constant would not. - -- ⚠ It matters most in the state a new user sees FIRST: with the helper switched off - -- the settings are one banner tall, and a canvas sized to that cannot show a unit - -- frame at all. The page would look broken before it had done anything. - local canvasH = (P.CanvasWantedHeight and P.CanvasWantedHeight(true, nil)) or 260 - local h = max(rightH, canvasH) - leftPanel:SetHeight(h) - content:SetHeight(h) + -- ☠☠ THE LEFT PANEL TAKES THE CANVAS'S HEIGHT, NEVER THE COLUMN'S. The mock frame is + -- anchored SetPoint("CENTER", container, "CENTER") and the container fills its + -- parent -- so a left panel stretched to match a long settings column centres the + -- frame halfway down that column, which is why it appeared stranded near the bottom + -- with empty space above it (Krathe, 2026-09-08). The designer does not show this + -- because its two halves are the same height BY CONSTRUCTION: they fill an island + -- sized to the viewport, and the settings side scrolls INSIDE it. This page is a + -- measured column in a scrolling page, so the halves are independent and only the + -- CONTENT height is shared. + -- + -- ⚠ THE NUMBER IS THE CANVAS'S OWN, not one I picked. CanvasWantedHeight(false) is + -- the non-compact form's floor; CanvasWantedHeight(true) is the height that actually + -- fits the frame at the live preview scale, and the +30 is the file's own accounting + -- for what the band form saves by putting the scale behind a glyph instead of a + -- slider row ("that 30px goes straight into CANVAS_FURNITURE and back to the + -- frame"). Taking the larger keeps the frame fitting when the scale slider moves. + local canvasH = 260 + if P.CanvasWantedHeight then + canvasH = max(P.CanvasWantedHeight(false, nil), + P.CanvasWantedHeight(true, nil) + 30) + end + -- The column is as tall as its taller half; only the CONTENT height is shared, and + -- the two panels keep their own (see the note above -- a left panel stretched to + -- match the settings would centre the mock frame halfway down the page). + local colH = max(rightH, canvasH) + leftPanel:SetHeight(canvasH) + content:SetHeight(colH) -- ── THE PREVIEW, THE DESIGNER'S OWN CANVAS ── -- ⚠ THE SAME FACTORY, NOT A LOOKALIKE. CreateFramePreview is what the designer @@ -177,7 +237,16 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) -- carrying a helper mark. The Any Buff pool also holds the user's unrelated work, -- and rendering that here would show effects this page does not control. if P and S.PIH_PreviewPool and P.CreateFramePreview then - local pv = P.CreateFramePreview(leftPanel, 0, nil, { compact = true, hideLabel = true }) + -- ⚠ THE DESIGNER'S OWN FORM, not the band's. Non-compact is what gives this the + -- "FRAME PREVIEW" caption and the Preview Scale SLIDER rather than the band's + -- glyph in the corner -- Krathe asked for the designer's treatment and that is + -- the difference between the two. + -- ⚠ placement = false is the one thing suppressed: it gates the nine anchor + -- dots, the drag hint and the three instruction rows, all of which are about + -- POSITIONING an indicator. Nothing on this page can be dragged, so offering the + -- furniture for it would be three lines of instructions for a gesture that does + -- nothing. It does NOT gate the scale slider. + local pv = P.CreateFramePreview(leftPanel, 0, nil, { placement = false }) if pv then S.framePreview = pv if P.RefreshPreviewEffects then @@ -187,12 +256,12 @@ function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) end rebuilding = false - if abs((host.layoutHeight or 0) - h) > 0.5 then - host.layoutHeight = h - host:SetHeight(h) + if abs((host.layoutHeight or 0) - colH) > 0.5 then + host.layoutHeight = colH + host:SetHeight(colH) relayout() else - host:SetHeight(h) + host:SetHeight(colH) end end From ec1f152bb3317426165617ceb9d30426a0e7bb34 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 22:02:31 +0100 Subject: [PATCH 07/66] PI Helper: Roles asked positively, one duplicate link removed, banner to a tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four from 2026-09-08, all of them the panel saying things the wrong way round. ROLES, NOT "NEVER SHOW ON" DPS / Tanks / Healers, ticked to WATCH. Tanks and Healers off by default. ☠ THE STORE STAYS AN EXCLUSION SET, and that is deliberate rather than lazy. helperExcludedRoles 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. A positive store would invert that: an empty table would mean "watch nobody" and the feature would go dark on any group without role assignments. So the inversion is in the UI only. ⭐ AND THE OLD DEFAULTS ALREADY ARE THE NEW ONES. The store ships { TANK, HEALER } excluded, which through this inversion reads as DPS on, Tanks and Healers off -- exactly what was asked for. No migration, and nobody's saved choice changes meaning. ⚠ DPS was never offered before, which made "watch damage dealers" an invisible always-on rather than a choice. DAMAGER is a real UnitGroupRolesAssigned token (Core.lua's GetUnitRole passes it through), so unticking it excludes the same way the other two do. THE GATE, ALSO POSITIVELY "Show even if your Power Infusion is on cooldown", off by default. It was the one control in a box of enables that turned a SUPPRESSION on, which made the box read inconsistently. Same trick as the roles: stored value untouched, still defaults to gating, so it ships unticked and means what it always meant. TWO LINKS TO THE FILTER DESIGNER -- ONE OF THEM MINE, AND THE WORSE ONE pihAddClasses ALREADY ended with a Filter Designer button, and it uses GUI:OpenFilterInDesigner, which switches the page AND scrolls to, selects and pulses the cooldown filter. I added a bare SelectTab beside it without checking. The comment on the existing one says exactly why mine was wrong: a hand-written jump "landed you on the page with nothing indicated, which is indistinguishable from a broken link". Mine is gone. ☠ Check for the affordance before adding it. The section was already complete; I read its first half and built against the gap I assumed was there. THE BANNER'S SENTENCE IS A TOOLTIP It is read once, by someone deciding whether to switch the feature on, and after that it is a line of prose in front of the settings on every visit. The hover is the whole banner row, not the 16px box -- a tooltip on a checkbox the size of a full stop is a tooltip nobody finds. Verification: 170 files parse, no global changes, no orphaned key still referenced, all CRLF, no BOM. ⚠ Not verified in game. ⚠ Still open, and the larger half of the request: Effects adding like the designer's tiles with several indicators per signal (border + health + square at once), and a generic square/bar that marks "needs PI" without naming the cooldown. That is the data-model change -- pihFound is keyed by signal and assumes ONE record each -- so it is its own commit rather than tacked onto this. (cherry picked from commit d3f17bffa2a6f4df8ff65ddccf1de45b2af312f2) --- DandersFrames/Locales/enUS.lua | 6 +- .../AuraDesigner/UI/Cards.lua | 98 +++++++++++-------- 2 files changed, 62 insertions(+), 42 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 245af02b..adc8c1ef 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2994,7 +2994,11 @@ L["Another effect"] = true -- now -- 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. L["Groups without assigned roles show everyone."] = true -L["Hide the helper while your Power Infusion is on cooldown"] = 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. +L["Show even if your Power Infusion is on cooldown"] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index c19ef02e..2bdef443 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6019,7 +6019,12 @@ S.BuildPIHelperCard = function(parent, opts) -- 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") - banner:SetHeight(52) + -- ⚠ 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 } }) @@ -6043,16 +6048,16 @@ S.BuildPIHelperCard = function(parent, opts) cbLabel:SetText(L["Enable Power Infusion Helper"]) cbLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) - -- The one sentence that says what it does. Kept from the old card's face, because it is - -- the only place the feature explains itself -- and it stays visible when the tick is - -- OFF, which is exactly when someone needs to read it. - local desc = banner:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(desc, 10, "") - desc:SetPoint("TOPLEFT", banner, "TOPLEFT", 10, -30) - desc:SetPoint("TOPRIGHT", banner, "TOPRIGHT", -10, -30) - desc:SetJustifyH("LEFT") - desc:SetText(L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."]) - desc:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.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) @@ -6507,9 +6512,17 @@ 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) + -- ★ 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. + t.check(g, L["Show even if your Power Infusion is on cooldown"], + function() return P.PIH_Settings().gateEnabled == false end, + function(v) P.PIH_SetGateEnabled(not v) end) -- ☠ THE CONTENTION NOTES ARE GONE, AND THE ARGUMENT THAT KEPT THEM WAS THE @@ -6526,16 +6539,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 @@ -6708,24 +6733,15 @@ S.BuildPIHelperBody = 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"], function(g) - pihAddClasses(g, t) - -- ★ THE WAY IN TO THE ACTUAL SPELL LIST. The class ticks say WHICH classes are - -- watched; the filter behind them holds the individual cooldowns, and until now - -- nothing on this page said that list existed or where to find it. Krathe: - -- "the link to edit the filter itself to tick on/off individual CD's as needed." - -- ⚠ A LINK, NOT A COPY OF THE LIST. The Filter Designer already edits these -- - -- rebuilding a spell picker here would be a second place to maintain and a - -- second answer to "is this spell watched". - t.note(g, L["Individual cooldowns live in the filter this list drives."]) - -- ⚠ PARENTED TO `parent`, NOT TO THE GROUP -- the same thing t.group does with - -- its own header widget. A SettingsGroup positions what it is given; it is not - -- the frame they hang off. - local btn = GUI:CreateButton(parent, L["Edit the cooldown list"], 170, 22, function() - if GUI.SelectTab then GUI.SelectTab("auras_filterdesigner") end - end) - g:AddWidget(btn, 26) - end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) + -- ☠ NO SECOND FILTER LINK HERE. pihAddClasses ALREADY ends with one, and its button + -- uses GUI:OpenFilterInDesigner -- which switches the page AND scrolls to, selects + -- and pulses the cooldown filter. I added a bare SelectTab beside it without + -- checking, which gave the page two buttons to the same place, one of them the worse + -- version: the existing comment there records that 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." + yPos = t.group(L["Classes and Cooldowns"], pihAddClasses, yPos, + { collapsible = true, collapseKey = "pihelper:onlywatch" }) else yPos = t.group(L["Indicators"], function(g) t.signalRow(g, "burst", L["Big cooldown"]) From fe384c4b085a52b2ee4eba689616c9c17244004a Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 22:13:17 +0100 Subject: [PATCH 08/66] PI Helper: several effects per signal, added and removed one at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe, 2026-09-08: "the effects should add with the same button's as we use on AD... we should be able to add multiple like border + health + square/bar etc. Just like AD." ☠ THE STORE ALREADY ALLOWED THIS. A pool record holds many frame-level effects and many placed instances, so "border AND health bar AND a square" was always expressible. Two things said otherwise: pihFound wrote each hit over the last into out[signal], and pihCreateSignal refused any second add with "already on". Both were one-surface assumptions layered over a store that never had one. * pihFoundAll returns EVERY hit per signal, sorted by surface. pihFound keeps its old one-per-signal shape over the top, so the eleven existing consumers are untouched. ⚠ SORTED, because the pool walk is hash order -- "the primary hit" used to be whichever the iterator reached last. Harmless with one; three effects reshuffling on every redraw is not. ⚠ Its own rank table rather than PIH_SURFACE_ORDER: that local is declared ~400 lines below, so naming it would compile as a nil GLOBAL -- the "declared below its first caller" trap this file documents elsewhere. * The create gate is per SURFACE. It still refuses a duplicate of the same one: two borders on a record cannot both exist, and two identical squares would be an invisible double only the store can see. * pihDeleteSignal takes an optional surface. Without it, unchanged behaviour. * New: PIH_AddSurface / PIH_RemoveSurface / PIH_SurfacesOf, all ending at PIH_Apply + pihRefresh like every other helper mutation. THE ROW BECOMES A LIST Each signal now shows what it displays -- one entry per surface, with that surface's colour swatch where it has one and a "Remove Border" / "Remove Health Bar" button naming what it acts on -- then a row of buttons for what it could also show. No "None" entry: removing the last effect IS what none meant, and an empty list says so without a word for it. ⚠ THE CLASH WARNING IS PER EFFECT NOW. It read the signal's single surface; with several it has to ask about the row it is drawn under, or a border clash would be reported beside the health bar. ⚠ Two nil GLOBALS caught by the _ENV diff, neither visible to luac: `surface` (the icons-only note still read the dropdown's variable) and, earlier, the rank table. Fourth and fifth this week. ☠ AND I PARAPHRASED TWO LOCALE STRINGS WHILE MOVING THE CLASH BLOCK, inventing L["Stack borders"] and a shortened warning -- keys that do not exist, which compile fine and render BLANK. The originals are restored verbatim. Copy locale lines; do not retype them. ⚠ PIH_SurfaceOptions and PIH_SurfaceOf are now unreferenced, and PIH_SetSurface keeps only its own definition. Left in place rather than removed in the same commit: PIH_SetSurface owns the swap machinery (pihSurfaceTakenBy, pihCapture, pihPlace) and pihPlace is what schema 4's migration runs through. Untangling that is its own job, not a tail-end cleanup. Verification: 170 files parse, no new globals, every L[] key in the new block checked against the locale file, all CRLF, no BOM. ⚠ Not verified in game. ⚠ Still to come, and asked for: the PI icon showing Power Infusion's own artwork rather than the tracked buff's, and a generic square/bar with optional "PI Me" text. Those change what the surfaces DRAW; this commit changes how many of them a signal can have. (cherry picked from commit b1b42390ef83dc3d302a283a9290d2a26dafa669) --- DandersFrames/Locales/enUS.lua | 7 + .../AuraDesigner/UI/Cards.lua | 310 ++++++++++++------ 2 files changed, 216 insertions(+), 101 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index adc8c1ef..c4655586 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2964,6 +2964,13 @@ L["Shows who is worth infusing, and goes dark while your Power Infusion is on co -- ⚠ "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. +L["Add an effect"] = true +L["Remove %s"] = true -- ⚠ The way in to the actual spell list. The class ticks say WHICH classes are watched; the -- filter behind them holds the individual cooldowns, and nothing on the page said that list -- existed. A LINK rather than a second copy of the picker -- one place edits these. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 2bdef443..4c456b44 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -346,6 +346,60 @@ 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 = pihFoundAll + local function pihFound() local out = {} local pool = pihOtherPoolRead() @@ -642,7 +696,25 @@ end local function pihCreateSignal(key, surfaceOverride) 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. + -- ⚠ 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 return true, "already on" 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 @@ -743,8 +815,19 @@ local function pihCreateSignal(key, surfaceOverride) return true end -local function pihDeleteSignal(key) - local hit = pihFound()[key] +-- ⚠ `surface` IS OPTIONAL, and its absence means what it always meant: remove the signal's +-- PRIMARY representation. Named, it removes exactly that one and leaves the signal's other +-- surfaces alone -- which is what the per-effect remove button on the Effects tab needs now +-- that a signal can hold several. +local function pihDeleteSignal(key, surface) + local hit + if surface then + for _, h in ipairs(pihFoundAll()[key] or {}) do + if h.typeKey == surface then hit = h break end + end + else + hit = pihFound()[key] + end 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) @@ -1095,6 +1178,43 @@ function P.PIH_SetSurface(key, surface) return true end +-- ★★★ 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. +function P.PIH_AddSurface(key, surface) + if not PIH_SIGNALS[key] then return false, "no such signal" end + if not surface or surface == "none" then return false, "no surface" end + local ok, why = pihCreateSignal(key, surface) + if ok then P.PIH_Apply() end + pihRefresh() + return ok, why +end + +function P.PIH_RemoveSurface(key, surface) + if not PIH_SIGNALS[key] then return false, "no such signal" end + if not surface then return false, "no surface" end + local removed = pihDeleteSignal(key, surface) + if removed then P.PIH_Apply() end + pihRefresh() + return removed +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 -- ───────────────────────────────────────────────────────────── @@ -6298,100 +6418,87 @@ local function pihMakeTools(parent, opts) -- 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" + -- ★★★ ONE SIGNAL, MANY SURFACES (2026-09-08). A dropdown asked "which ONE surface is + -- this on", so choosing an occupied one had to SWAP two signals -- there was nowhere + -- for both to live. Krathe wants the designer's behaviour: add several, border AND + -- health bar AND a square, each removable on its own. + -- ⇒ The row is now a LIST of what this signal shows plus a row of things it could + -- also show. No "None" entry either: removing the last effect is what "none" meant, + -- and a list that is empty says it without a word for it. + t.settingLabel(g, label) + + local held, heldSet = P.PIH_SurfacesOf(key), {} + for _, s in ipairs(held) do heldSet[s] = true end + + local FL = S.FRAME_LEVEL_LABELS or {} + local PL = S.PLACED_TYPE_LABELS or {} + local function surfaceLabel(s) return PL[s] or FL[s] or s end + + for _, hit in ipairs(pihFoundAll()[key] or {}) do + local s = hit.typeKey + + -- ⚠ THE CLASH WARNING IS PER EFFECT NOW. It used to read the signal's single + -- surface; with several it has to ask about the one it is drawn under, or a + -- border clash would be reported on the health bar row beside it. + if P.PIH_SelfContends and P.PIH_SelfContends(s, key) then + local clashes, who = P.PIH_ClashOn(s) + local sibling = P.PIH_SiblingContends and P.PIH_SiblingContends(s, 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"] + if clashes > 1 then who = format(L["%s and %d more"], who, clashes - 1) end + -- ⚠ THE EXACT STRINGS THE DROPDOWN USED, keys and placeholders unchanged. + -- I paraphrased them while moving the block and invented two keys that do + -- not exist -- which compiles, and renders blank. Copy locale lines, do + -- not retype them. + t.note(g, (s == "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 + end - -- 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") + -- The colour, bound straight to the record. Offered only where one exists: an + -- Icon shows Power Infusion's own artwork and has no colour to set. + local ck = pihColorKey(s) + if type(hit.cfg[ck]) == "table" then + g:AddWidget(GUI:CreateColorPicker(parent, surfaceLabel(s), hit.cfg, ck, false, + function() P.PIH_Apply(); pihRefresh() end), GUI.RowHeight.colorpicker) + else + t.settingLabel(g, surfaceLabel(s)) + end + + -- ⚠ REMOVE NAMES ITS SURFACE. PIH_RemoveSurface(key) with no surface would take + -- the signal's PRIMARY effect, which on a row about the health bar would delete + -- the border instead -- the classic "the button did something, just not this". + local rm = GUI:CreateButton(parent, format(L["Remove %s"], surfaceLabel(s)), 150, 22, + function() P.PIH_RemoveSurface(key, s); Refresh() end) + g:AddWidget(rm, 26) end - -- ── THE COLOUR, ON THE SIGNAL'S OWN ROW (2026-09-08) ── - -- ☠ THIS IS THE SPLIT THE USER NAMED. A signal's BEHAVIOUR was set here and its - -- COLOUR on the generated indicator's card further down the same page, so setting - -- up one signal end to end meant two places and no sign that they belonged - -- together. That division was consistency with the designer -- appearance lives on - -- effect rows there -- and on a page whose only subject IS the helper it has - -- nothing left to justify it. - -- ⚠ BOUND STRAIGHT TO THE RECORD, which is why this needs no accessor pair: the - -- colour already lives at cfg[pihColorKey(surface)] and CreateColorPicker writes - -- through a table+key exactly like every other colour in the addon. Re-fetched per - -- build rather than captured, so a surface swap (which changes both the record and - -- the key -- a border keeps its colour under BorderColor) rebinds instead of - -- writing to the record the signal just left. - -- ⚠ OFFERED ONLY WHERE A COLOUR EXISTS. An Icon has none -- pihPlace says so when - -- it declines to carry one -- and "None" has no record at all. A swatch on either - -- would be a control that writes somewhere nothing reads, which is the same class - -- of lying control as the add button this feature was moved away from. - local pihHit = pihFound()[key] - local pihCKey = pihHit and pihColorKey(pihHit.typeKey) or nil - if pihHit and pihCKey and type(pihHit.cfg[pihCKey]) == "table" then - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], pihHit.cfg, pihCKey, false, - function() - -- Same chokepoint every other helper mutation ends at: push the - -- settings to the engine, then let pihRefresh re-derive the resident - -- half. Writing the table alone would leave the frames on the old - -- colour until something unrelated repainted them. - P.PIH_Apply() - pihRefresh() - end), GUI.RowHeight.colorpicker) + -- ── WHAT ELSE THIS SIGNAL COULD SHOW ── + -- ⚠ ONE BUTTON PER SURFACE IT DOES NOT ALREADY HAVE. Listing the held ones again + -- would be an add button that cannot add, which is the lying control this panel keeps + -- being cleaned of. The order is the menu's, so the list does not reshuffle. + local addable = { "border", "healthbar", "background", "nametext", "healthtext", + "icon", "square", "bar" } + local anyAddable = false + for _, s in ipairs(addable) do + if not heldSet[s] then anyAddable = true break end + end + if anyAddable then + t.settingLabel(g, L["Add an effect"]) + for _, s in ipairs(addable) do + if not heldSet[s] then + local btn = GUI:CreateButton(parent, surfaceLabel(s), 150, 22, function() + local ok, why = P.PIH_AddSurface(key, s) + if not ok then DF:DebugWarn("AURADESIGNER", + "PIH: could not add %s -- %s", tostring(s), tostring(why)) end + Refresh() + end) + g:AddWidget(btn, 26) + end + end end -- Icons sit BESIDE the colour dropdown, equal weight: with "None" in the @@ -6449,15 +6556,16 @@ local function pihMakeTools(parent, opts) 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. + -- ☠ THE NOTE EXISTS TO TEACH THE ICONS-ONLY SETUP, not to warn about an empty list. + -- Only a signal with an icons row gets one: there, no effects 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 effect list is its ONLY control, an empty + -- list says on its face that nothing shows. + -- ⚠ "NO EFFECTS" IS #held == 0 NOW, not a "None" menu entry -- the dropdown that had + -- one is gone with the multi-surface list above. Same question, asked of the new shape. -- ⚠ 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 + if which and #held == 0 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. From 00755adb309d834ee212aa5560238dfb660df0df Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 22:22:28 +0100 Subject: [PATCH 09/66] PI Helper: the Effects tab IS the designer's, scoped to the helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe, 2026-09-08, with three screenshots side by side: "what have you done... It should basically function exactly as AD. Just without the My buffs / Debuffs / Any Buff tabs and AD only stuff." ☠ I BUILT A LOOKALIKE TWICE AND IT WAS WRONG BOTH TIMES -- first a surface dropdown, then a stack of plain grey buttons -- while the real thing sat one call away. The helper's records ARE Aura Designer records; there was never a reason to draw a second, worse version of the designer's own list. The whole of AD's Effects tab is three lines: local yPos = S.BuildEffectsHeadArea(parent, -10) local effects = CollectAllEffects() for _, e in ipairs(filtered) do yPos = S.CreateEffectCard(parent, yPos, e) end So the helper now uses those parts directly: * ADD AN EFFECT: GUI:CreateChoiceCardGroup for the route -- the designer asks route -> spell -> type, and there is no spell to choose here (the cooldown filter IS the spell), so it asks signal -> type. Two cards, then the designer's own tile grid: CreateFrameTile + PaintEffectOnThumb, the same miniature pictures and tooltips, three to a row like the add flow. * ACTIVE INDICATORS: S.CreateEffectCard per effect -- the same expandable shell, type badge, eye toggle and delete the designer draws, because it IS that list. CollectAllEffects({ includePIH = true }) is its own collector asked for the rows it normally hides from itself. * Types the signal already holds are left out of the grid, and sound has no tile: it is a property of the helper, not a surface. ⚠ S.activeBuffTab IS SET TO "other" AROUND THE BUILD AND RESTORED AFTER, and that is not a hack. S.CreateEffectCard reads PoolKeyPrefix() and IsOtherTab() off it, to key its expand state and decide whether a spec applies. The helper's records genuinely live in the Any Buff pool, so pointing it there while we build is telling the shared builder the truth. Restored immediately, because the designer's own page reads the same field. ☠ AND THE ICON LISTS WOULD HAVE VANISHED. Cooldowns / Trinkets / Potions / Racials lived inside t.signalRow, and the Effects tab stopped calling it the moment the designer's tiles took over -- which would have removed four of Maelareth's settings (#263) from the UI as a silent side effect of a layout change. They are lifted into pihAddIconLists and drawn under TRIGGERS, which is where they belonged anyway: each tick decides which category of buff COUNTS, not how anything is drawn. t.signalRow is removed, with a note at its site saying where each of its four jobs went. Verification: 170 files parse, no new globals against the session baseline, every L[] key in the new code checked against the locale file, all CRLF, no BOM. ⚠ Not verified in game. ⚠ Still not done, and it is the part that needs the surfaces themselves to change rather than the panel around them: the Icon showing Power Infusion's own artwork instead of the tracked buff's, and a generic square/bar with optional "PI Me" text. (cherry picked from commit 745d180e66e0d444bbb4d247696c95ad30c43b96) --- DandersFrames/Locales/enUS.lua | 10 + .../AuraDesigner/UI/Cards.lua | 369 +++++++++--------- 2 files changed, 201 insertions(+), 178 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index c4655586..e2c7074f 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2969,6 +2969,16 @@ L["Triggers"] = true -- ⚠ "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 Effects tab is the DESIGNER'S add flow, scoped to the helper: two route cards (which +-- signal) and then the designer's own effect tiles. Its heading is the designer's +-- "ADD AN INDICATOR" one verb across -- an EFFECT, because on this page you are not adding a +-- thing to track, only a way of showing what the helper already watches. +L["ADD AN EFFECT"] = true +L["Someone has popped a cooldown worth infusing"] = true +L["Someone already has it, so do not double up"] = true +-- ⚠ Names the NEXT STEP, not the absence. "Nothing here" tells someone what they can already +-- see; this tells them the two clicks that fix it. +L["Nothing yet. Pick a signal above, then choose how it should show."] = true L["Add an effect"] = true L["Remove %s"] = true -- ⚠ The way in to the actual spell list. The class ticks say WHICH classes are watched; the diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 4c456b44..eca026c9 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6405,174 +6405,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. - -- ★★★ ONE SIGNAL, MANY SURFACES (2026-09-08). A dropdown asked "which ONE surface is - -- this on", so choosing an occupied one had to SWAP two signals -- there was nowhere - -- for both to live. Krathe wants the designer's behaviour: add several, border AND - -- health bar AND a square, each removable on its own. - -- ⇒ The row is now a LIST of what this signal shows plus a row of things it could - -- also show. No "None" entry either: removing the last effect is what "none" meant, - -- and a list that is empty says it without a word for it. - t.settingLabel(g, label) - - local held, heldSet = P.PIH_SurfacesOf(key), {} - for _, s in ipairs(held) do heldSet[s] = true end - - local FL = S.FRAME_LEVEL_LABELS or {} - local PL = S.PLACED_TYPE_LABELS or {} - local function surfaceLabel(s) return PL[s] or FL[s] or s end - - for _, hit in ipairs(pihFoundAll()[key] or {}) do - local s = hit.typeKey - - -- ⚠ THE CLASH WARNING IS PER EFFECT NOW. It used to read the signal's single - -- surface; with several it has to ask about the one it is drawn under, or a - -- border clash would be reported on the health bar row beside it. - if P.PIH_SelfContends and P.PIH_SelfContends(s, key) then - local clashes, who = P.PIH_ClashOn(s) - local sibling = P.PIH_SiblingContends and P.PIH_SiblingContends(s, 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"] - if clashes > 1 then who = format(L["%s and %d more"], who, clashes - 1) end - -- ⚠ THE EXACT STRINGS THE DROPDOWN USED, keys and placeholders unchanged. - -- I paraphrased them while moving the block and invented two keys that do - -- not exist -- which compiles, and renders blank. Copy locale lines, do - -- not retype them. - t.note(g, (s == "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 - end - - -- The colour, bound straight to the record. Offered only where one exists: an - -- Icon shows Power Infusion's own artwork and has no colour to set. - local ck = pihColorKey(s) - if type(hit.cfg[ck]) == "table" then - g:AddWidget(GUI:CreateColorPicker(parent, surfaceLabel(s), hit.cfg, ck, false, - function() P.PIH_Apply(); pihRefresh() end), GUI.RowHeight.colorpicker) - else - t.settingLabel(g, surfaceLabel(s)) - end - - -- ⚠ REMOVE NAMES ITS SURFACE. PIH_RemoveSurface(key) with no surface would take - -- the signal's PRIMARY effect, which on a row about the health bar would delete - -- the border instead -- the classic "the button did something, just not this". - local rm = GUI:CreateButton(parent, format(L["Remove %s"], surfaceLabel(s)), 150, 22, - function() P.PIH_RemoveSurface(key, s); Refresh() end) - g:AddWidget(rm, 26) - end - - -- ── WHAT ELSE THIS SIGNAL COULD SHOW ── - -- ⚠ ONE BUTTON PER SURFACE IT DOES NOT ALREADY HAVE. Listing the held ones again - -- would be an add button that cannot add, which is the lying control this panel keeps - -- being cleaned of. The order is the menu's, so the list does not reshuffle. - local addable = { "border", "healthbar", "background", "nametext", "healthtext", - "icon", "square", "bar" } - local anyAddable = false - for _, s in ipairs(addable) do - if not heldSet[s] then anyAddable = true break end - end - if anyAddable then - t.settingLabel(g, L["Add an effect"]) - for _, s in ipairs(addable) do - if not heldSet[s] then - local btn = GUI:CreateButton(parent, surfaceLabel(s), 150, 22, function() - local ok, why = P.PIH_AddSurface(key, s) - if not ok then DF:DebugWarn("AURADESIGNER", - "PIH: could not add %s -- %s", tostring(s), tostring(why)) end - Refresh() - end) - g:AddWidget(btn, 26) - end - end - 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 an empty list. - -- Only a signal with an icons row gets one: there, no effects 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 effect list is its ONLY control, an empty - -- list says on its face that nothing shows. - -- ⚠ "NO EFFECTS" IS #held == 0 NOW, not a "None" menu entry -- the dropdown that had - -- one is gone with the multi-surface list above. Same question, asked of the new shape. - -- ⚠ 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 #held == 0 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 @@ -6808,6 +6650,172 @@ end -- ⚠ 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. + -- ★★ THE ICON LISTS, LIFTED OUT OF THE OLD SIGNAL ROW (2026-09-08). + -- ☠ THEY WOULD OTHERWISE HAVE VANISHED. They lived inside t.signalRow, and the Effects + -- tab stopped calling it when the designer's own tiles and effect cards took over -- + -- which would have removed four settings from the UI as a side effect of a layout + -- change, with nothing saying so. They are Maelareth's (#263). + -- ⚠ AND THEY BELONG UNDER TRIGGERS, not Effects. Each tick decides which category of + -- buff COUNTS as worth infusing -- cooldowns, trinkets, potions, racials -- which is the + -- same question the class list answers, not a question about how it is drawn. + -- ⚠ THE SIGNAL IS FIXED HERE. PIH_ICON_OF only ever answered for the cooldown signal; + -- the old code read it per row because it was inside a per-signal builder. + local function pihAddIconLists(g, t) + local which = PIH_ICON_OF["burst"] + if not which then return end + t.settingLabel(g, L["Icons"]) + t.subCheck(g, L["Cooldowns"], + function() return P.PIH_IconsShow(which) end, + function(v) P.PIH_SetIconsShow(which, v) end) + local st = P.PIH_Settings() + t.subCheck(g, L["Trinkets"], + function() return st.trinkets == true end, + function(v) P.PIH_SetAmplifier("trinkets", v) end) + t.subCheck(g, L["Potions"], + function() return st.potions == true end, + function(v) P.PIH_SetAmplifier("potions", v) end) + t.subCheck(g, L["Racials"], + function() return st.racials == true end, + function(v) P.PIH_SetAmplifier("racials", v) end) + end + +-- ★★★ THE HELPER'S EFFECTS TAB — the designer's own furniture, scoped to the helper. +-- +-- ☠ THE POOL TAB IS SET AROUND THE BUILD, AND THAT IS NOT A HACK. S.CreateEffectCard reads +-- PoolKeyPrefix() and IsOtherTab() to key its expand state and to decide whether a spec +-- applies -- both routed off S.activeBuffTab. The helper's records genuinely live in the +-- Other/Any Buff pool, so pointing that at "other" while we build is telling the shared +-- builder the truth, not lying to it. Restored immediately after, because the designer's own +-- page reads the same field and a page switch must not inherit ours. +-- +-- ⚠ WHICH SIGNAL AN EFFECT BELONGS TO IS THE HELPER'S VERSION OF "WHICH SPELL". The designer +-- asks route -> spell -> type; there is no spell to choose here (the cooldown filter IS the +-- spell), so it asks signal -> type. Two cards instead of three, then the same tile grid. +local pihAddSignal = nil -- which signal's tile grid is open, nil = none +local function pihBuildEffectsTab(parent, yPos, t) + local tc = GetThemeColor() + local prevTab = S.activeBuffTab + S.activeBuffTab = "other" + + -- ── ADD AN EFFECT ── + -- The route cards, one per signal. Same widget the designer opens its add flow with. + local routes = {} + for _, def in ipairs({ + { key = "burst", label = L["Big cooldown"], + desc = L["Someone has popped a cooldown worth infusing"] }, + { key = "infused", label = L["Already has active Power Infusion"], + desc = L["Someone already has it, so do not double up"] }, + }) do + local capturedKey = def.key + routes[#routes + 1] = { + title = def.label, + desc = def.desc, + art = { kind = "border", color = { 1.00, 0.82, 0.25 } }, + onClick = function() + pihAddSignal = (pihAddSignal == capturedKey) and nil or capturedKey + if t.Refresh then t.Refresh() end + end, + } + end + local addBlock = GUI:CreateChoiceCardGroup(parent, { + title = L["ADD AN EFFECT"], + accent = tc, + width = (parent:GetWidth() or 320) - 16, + cards = routes, + }) + addBlock:SetPoint("TOPLEFT", 8, yPos) + addBlock:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + yPos = yPos - (addBlock.layoutHeight + GUI.Space.section) + + -- ── THE TILE GRID, once a signal is chosen ── + -- ⚠ THE SAME TILES THE DESIGNER DRAWS: CreateFrameTile with PaintEffectOnThumb, so the + -- picture on each one is the real miniature of that effect rather than a label on a box. + -- ⚠ FILTERED TO WHAT THE HELPER CAN DO. Sound has its own section (it is not a surface), + -- and a type the signal already holds is left out -- an add button that cannot add is the + -- lying control this panel keeps being cleaned of. + if pihAddSignal then + local held = {} + for _, s in ipairs(P.PIH_SurfacesOf(pihAddSignal)) do held[s] = true end + + local avail = {} + for _, eff in ipairs(P.AddFlowEffects and P.AddFlowEffects() or {}) do + if eff.type ~= "sound" and not held[eff.type] then avail[#avail + 1] = eff end + end + + if #avail > 0 then + 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) + local rowTop, rowH = yPos, 0 + for i, eff in ipairs(avail) do + local col = (i - 1) % TILE_COLS + local capturedType = eff.type + local tile = CreateFrameTile(parent, { + width = TILE_W, + label = eff.label, + accent = BADGE_COLORS[eff.type] or tc, + tooltip = { title = eff.label, lines = { eff.desc } }, + Paint = function(pv) PaintEffectOnThumb(pv, capturedType) end, + onClick = function() + local ok, why = P.PIH_AddSurface(pihAddSignal, capturedType) + if not ok then DF:DebugWarn("AURADESIGNER", + "PIH: could not add %s -- %s", tostring(capturedType), tostring(why)) end + -- Fold the grid away on success, the way the designer's add flow + -- closes once it has what it asked for. + if ok then pihAddSignal = nil end + if t.Refresh then t.Refresh() end + 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 == #avail then + rowTop = rowTop - (rowH + TILE_GAP) + rowH = 0 + end + end + yPos = rowTop - 4 + end + end + + -- ── ACTIVE EFFECTS ── + -- ⚠ CollectAllEffects({ includePIH = true }) is the designer's own collector asked for + -- the rows it normally hides from itself -- see its note. Then S.CreateEffectCard draws + -- each one: the same shell, badge, eye toggle, delete and expandable settings the + -- designer's Active Indicators list uses, because it IS that list. + local header = parent:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(header, 9, "") + header:SetPoint("TOPLEFT", 8, yPos) + header:SetText(L["ACTIVE INDICATORS"]) + header:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 18 + + local mine = {} + for _, effect in ipairs(P.CollectAllEffects({ includePIH = true }) or {}) do + local cfg = effect.config + if type(cfg) == "table" and cfg.pihSignal then mine[#mine + 1] = effect end + end + + if #mine == 0 then + local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + empty:SetPoint("TOPLEFT", 8, yPos) + empty:SetText(L["Nothing yet. Pick a signal above, then choose how it should show."]) + empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 30 + else + for _, effect in ipairs(mine) do + yPos = S.CreateEffectCard(parent, yPos, effect) + end + end + + -- ── SOUND ── + -- Not a surface, so it has no tile and no effect card: it is a property of the helper + -- rather than something drawn on a frame. Its own box, as before. + yPos = t.group(L["Sound Alert"], pihAddSound, yPos) + + S.activeBuffTab = prevTab + return yPos +end + S.PIH_TABS = { { key = "triggers", label = L["Triggers"] }, { key = "effects", label = L["Effects"] }, @@ -6848,17 +6856,22 @@ S.BuildPIHelperBody = function(parent, opts) -- version: the existing comment there records that 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." - yPos = t.group(L["Classes and Cooldowns"], pihAddClasses, yPos, - { collapsible = true, collapseKey = "pihelper:onlywatch" }) + yPos = t.group(L["Classes and Cooldowns"], function(g) + pihAddClasses(g, t) + pihAddIconLists(g, t) + end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) else - yPos = t.group(L["Indicators"], function(g) - t.signalRow(g, "burst", L["Big cooldown"]) - t.signalRow(g, "infused", L["Already has active Power Infusion"]) - end, yPos) - - -- ⚠ SOUND IS AN EFFECT. It is another way of being told the same thing, so it - -- belongs beside the surfaces rather than with the conditions that fire them. - yPos = t.group(L["Sound Alert"], pihAddSound, yPos) + -- ★★★ THE DESIGNER'S OWN EFFECTS TAB, SCOPED TO THE HELPER (2026-09-08). + -- ☠ I BUILT A LOOKALIKE TWICE AND IT WAS WRONG BOTH TIMES -- a dropdown, then a + -- stack of plain buttons -- while the real thing sat one call away. Krathe: "It + -- should basically function exactly as AD. Just without the My buffs / Debuffs / + -- Any Buff tabs and AD only stuff." + -- ⇒ The helper's records ARE Aura Designer records. So this uses the designer's own + -- parts: CreateFrameTile + PaintEffectOnThumb for the add tiles (the same pictures, + -- the same tooltips) and S.CreateEffectCard for the list (the same expandable card, + -- badge, eye toggle and delete the designer draws). Nothing is reimplemented, so + -- nothing can drift from it. + yPos = pihBuildEffectsTab(parent, yPos, t) end return yPos From 42027dc02625bb8d63036bfa605d372ddff8e1bd Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 22:37:21 +0100 Subject: [PATCH 10/66] PI Helper: a fourth pool tab in the designer, not a page beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe's own answer, and the right one: "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." ☠ I BUILT A COPY OF THE AURA DESIGNER THREE TIMES. A surface dropdown, then a stack of grey buttons, then choice cards and a tile grid -- each one a worse version of something sitting twenty lines away, and each one corrected in review. The mistake was never a widget choice: it was treating the helper as a thing BESIDE the designer when it is a POOL INSIDE it. My Buffs | Debuffs | Any Buff | Power Infusion Helper Every surface the designer owns now works on it unchanged, because they all route through four accessors in AuraDesigner/UI/Options.lua: * CurrentAuraPool -> a filtered view of the other pool, helper records only. ☠ SAFE BECAUSE THE VALUES ARE THE LIVE TABLES: consumers either iterate it or take CurrentAuraPool()[name] and mutate the record they get back, and both reach the real cfg through the shared reference. * CurrentAuraPoolWrite -> the REAL pool. A new record must land in the store, not in the temporary view -- the one thing a filtered read cannot carry. * IsOtherTab -> true for it. That question is really "any caster's buffs, shared across specs", which is exactly what the helper watches; answering no would give it My Buffs' caster filter, the one pool where its records are guaranteed to match nothing. * PoolKeyPrefix -> shares "other:", because it is the same record. A prefix of its own would key one card two ways, so expanding it on one tab would read as collapsed on the other. * VisibleLayoutGroups -> the test INVERTS on this tab. Elsewhere a helper group is somebody else's business; here it is the only business. One rule, read both ways. The pool tab itself is one appended entry in PoolDefs -- priest-gated, and appended rather than inserted so the other three keep the positions people already know. Both layouts get it: they share S.BuildPoolStrip. TRIGGERS ARE THE GLOBAL SUB-TAB Every pool's Global tab holds what applies to the whole pool rather than to one effect, which is precisely what roles, classes, icon lists and the cooldown gate are. Krathe: "Triggers where people pick WHAT will show the effect... Then HOW it shows the effects." WHAT lives there; HOW is the Effects tab, which is the designer's own and needed nothing added to it. The enable tick leads it, because it governs everything below and has to be reachable while the helper is OFF -- the state a new priest arrives in. PIHelperPage.lua: 292 lines -> 69 It is the DOOR now and nothing else: set the pool, call DF.BuildAuraDesignerPage. The nav entry survives because the tab is four levels deep and a priest should not have to know the helper lives inside the designer -- which was the original complaint. Everything else it had grown is deleted, with a note at the top saying it must never grow a page again, and why. ⚠ The tab is set BEFORE the build and deliberately NOT restored: the user is now looking at that pool, and putting it back would leave the strip showing Power Infusion Helper as active while the surfaces below rendered My Buffs. Verification: 170 files parse, no global changes in any file, all CRLF, no BOM. ⚠ Not verified in game. ⚠ The two signals (burst / infused) still exist underneath. Collapsing them to one -- "someone is worth infusing", with "already has it" as a trigger rather than a second signal -- is the next thing, and it is a data change rather than a layout one. (cherry picked from commit 59f531f55705c7f0bd90f7555c0e010b04ff8f40) --- DandersFrames/Locales/enUS.lua | 4 + .../AuraDesigner/UI/Cards.lua | 17 + .../AuraDesigner/UI/Options.lua | 64 +++- .../AuraDesigner/UI/PIHelperPage.lua | 301 +++--------------- .../AuraDesigner/UI/Rows.lua | 18 +- 5 files changed, 134 insertions(+), 270 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index e2c7074f..bbb3b72d 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2948,6 +2948,10 @@ 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index eca026c9..92de2e72 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7246,6 +7246,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, tab = "triggers", Refresh = Refresh }) + end + return + end BuildGlobalView(S.tabContentFrame) end diff --git a/DandersFrames_Options/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua index 978ac117..435fcf43 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Options.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Options.lua @@ -578,10 +578,30 @@ 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 @@ -602,6 +622,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 +649,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 +661,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,7 +721,12 @@ 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 @@ -696,10 +740,16 @@ P.CurrentLayoutGroups = CurrentLayoutGroups -- 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 - if not (type(g) == "table" and g.pihSignal) then out[#out + 1] = g end + 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index c535d105..81a9214f 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -1,291 +1,68 @@ --- Power Infusion Helper — its own settings page, beside the Aura Designer. +-- 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 L = DF.L -local GUI = DF.GUI local S = DF.AuraDesigner._uiState -local P = DF.AuraDesigner._priv - -local max, abs = math.max, math.abs -- ============================================================ --- WHY THIS PAGE EXISTS +-- ☠☠ THIS FILE USED TO BE A WHOLE PAGE. IT SHOULD NEVER BE ONE AGAIN. -- ============================================================ --- ☠ THE HELPER WAS A FEATURE WEARING AN AURA DESIGNER RECIPE'S CLOTHES. It behaves like a --- feature — one switch, its own rules, its own class lists, its own sound — but it was --- implemented as records in the Any Buff pool, so it inherited the designer's LOCATION as --- well as its plumbing: priest-only, behind a non-default tab, four levels deep, with its --- behaviour in one panel and the appearance of the things it created in a different card --- further down the same page. Krathe, 2026-09-08: "it's not very clear how to use it or --- even how to find it… the main settings and the added indicator are split." --- --- ★ THE DECISION THAT UNLOCKED IT: the CARD does not have to live where the RECORDS live. --- The pool still decides a record's caster filter — that constraint is real and unchanged, --- and the helper still writes into Any Buff — but nothing required the user to go there to --- find the switch. So the feature moves and the plumbing stays. +-- 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." -- --- ⚠ WHAT THIS PAGE IS NOT: a second rendering path. Under 12.1 every aura-driven visual --- goes through the container engine, and the helper's signals are ordinary AD records --- carrying a `pihSignal` mark that the Factory reads to stamp `dfGate` --- (AuraDesigner/Factory.lua:2236, :2681, :3446). Moving the UI changes none of that. +-- ★ 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." -- --- ⚠ AND IT IS NOT AN ISLAND. DF.BuildAuraDesignerPage builds a whole anchored sub-window --- (S.mainFrame) because the designer needs one; this page is a column of settings, so it --- goes through the page harness like any other page and inherits its chrome for free. --- That is also what makes it LOOK like the designer without copying anything. - --- ============================================================ --- THE WIDTH DANCE --- ============================================================ --- ☠ THE HARNESS SIZES A WIDGET *AFTER* Add, NOT BEFORE. GUI/Panel.lua's Add only records --- layoutHeight/layoutCol; the actual SetWidth happens in PageRefreshStates' layout pass --- (the `layoutCol == "both"` arm). The builders need a real width AT BUILD TIME — the --- settings groups size themselves against their parent, and the split's two halves are --- computed from it — so nothing here can run until the first real size arrives. +-- 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, the layout groups: one implementation, four pools. -- --- ⇒ Build LAZILY, on the first size we are given, and re-measure only when the width --- actually moves. The height then flows the other way: the pane returns its measured --- bottom, we publish that as layoutHeight and ask for ONE more layout pass. +-- ⚠ WHAT IS LEFT HERE IS THE DOOR. The nav entry exists because the tab is four levels deep +-- and a priest should not have to know that 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 +-- opens the designer with that pool selected. That is the whole file. -- --- ⚠ THAT FEEDBACK CANNOT LOOP, and the guard is deliberate rather than lucky: the rebuild --- is gated on WIDTH changing, and the second pass changes only HEIGHT. `rebuilding` is a --- belt on top of that, because a nested layout pass during a build would re-enter here --- with the host half-populated. -local WIDTH_EPSILON = 2 -- sub-pixel jitter from the snapping pass is not a resize +-- ⚠ IF THIS EVER NEEDS A CONTROL THE DESIGNER DOES NOT HAVE, add it to the designer for that +-- pool -- do not grow a page here again. The three rebuilds are in the git history if the +-- argument needs re-making. -- ============================================================ --- THE PAGE +-- THE PAGE: OPEN THE DESIGNER ON THE HELPER'S POOL -- ============================================================ -- Signature mirrors DF.BuildAuraDesignerPage so the two register identically in -- GUI/Pages/Auras.lua and neither is a special case at the call site. function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) - if not (Add and pageRef and pageRef.child) then return end - if not (S and S.BuildPIHelperCard and S.BuildPIHelperBody) then return end - - local host = CreateFrame("Frame", nil, pageRef.child) - host:SetHeight(1) - host.layoutHeight = 1 - host.layoutCol = "both" - - local content, lastWidth, rebuilding = nil, 0, false - - local function relayout() - -- PageRefreshStates IS the layout pass (GUI/Panel.lua:186) — it re-anchors every - -- child from its layoutHeight and refreshes states. It does NOT rebuild widgets, - -- so calling it here re-flows the column around our new height without - -- re-entering the builder. - if pageRef.RefreshStates then pcall(pageRef.RefreshStates, pageRef) end + -- ⚠ SET BEFORE THE BUILD, NOT AFTER. Every pool-routed accessor is read DURING + -- BuildAuraDesignerPage -- the preview paints from CurrentAuraPool, the effect list keys + -- itself off PoolKeyPrefix -- so a tab set afterwards would render one pool and label it + -- another for a frame. + -- ⚠ NOT RESTORED, deliberately, and this is the difference from a scoped swap: the user + -- is now LOOKING at the helper pool. Putting the tab back would leave the strip showing + -- Power Infusion Helper as active while every surface below it rendered My Buffs. + if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() then + S.activeBuffTab = "pihelper" end - - local function rebuild() - if rebuilding then return end - local w = host:GetWidth() or 0 - -- Nothing sensible can be measured against a collapsed width, and the harness - -- hands us one on the very first pass. Wait for the real number. - if w < 50 then return end - rebuilding = true - - -- ☠ TEAR DOWN THROUGH THE TRASH FRAME, the same way BuildPage retires its own - -- children. WoW cannot GC frames, but a hidden, unanchored, reparented subtree is - -- not walked during layout — and leaving the old column parented here would stack - -- two sets of controls on one page, each writing the same settings. - if content then - content:Hide() - content:ClearAllPoints() - content:SetParent(GUI._trashFrame or UIParent) - end - - content = CreateFrame("Frame", nil, host) - content:SetPoint("TOPLEFT", 0, 0) - content:SetPoint("TOPRIGHT", 0, 0) - content:SetHeight(1) - - -- ── THE 50/50 SPLIT, THE DESIGNER'S OWN SHAPE ── - -- ☠ MEASURED, NOT ANCHORED TO CENTER. The designer's splitContainer can anchor its - -- halves to a CENTER point because it lives inside an island of known size; this - -- column is measured by the page harness AFTER the fact, so the halves take an - -- explicit width off the one number we do have. `w` is the host's real width, which - -- is why nothing here runs until the first size arrives. - -- ⚠ The 6px gutter and the CENTER-3 / CENTER+3 pair it comes from are the - -- designer's, and the Text Designer's before that -- borrowed rather than picked, so - -- three split panels in this addon do not drift to three different gaps. - local SPLIT_GAP = 6 - local halfW = math.max(80, (w - SPLIT_GAP) / 2) - - local leftPanel = CreateFrame("Frame", nil, content, "BackdropTemplate") - leftPanel:SetPoint("TOPLEFT", 0, 0) - leftPanel:SetWidth(halfW) - -- ⚠ NO BORDER, and the designer's own note says why: the inner preview container - -- draws the visible dim border, and one on both stacks into a brighter doubled line. - GUI:CreatePanelBackdrop(leftPanel, { border = false }) - - local rightPanel = CreateFrame("Frame", nil, content, "BackdropTemplate") - rightPanel:SetPoint("TOPRIGHT", 0, 0) - rightPanel:SetWidth(halfW) - GUI:CreatePanelBackdrop(rightPanel, { border = false }) - - -- ── THE SETTINGS FIRST, BECAUSE THEY DECIDE THE HEIGHT ── - -- ⚠ Refresh is THIS page's redraw, not S.SwitchTab. Every control in the pane calls - -- opts.Refresh when it changes something structural; inside the designer that meant - -- "rebuild the tab", and here it means "rebuild this page". Routing it at - -- S.SwitchTab would redraw a designer that may not even be open. - local function pageRefresh() - if pageRef.Refresh then pcall(pageRef.Refresh, pageRef) end - end - - -- The enable banner sits ABOVE the tabs: it turns the whole feature on, so it - -- cannot live inside one of the two things it governs. - local yPos, open = S.BuildPIHelperCard(rightPanel, { - startY = 0, Refresh = pageRefresh, - }) - - -- ── THE TAB BAR, THE DESIGNER'S OWN STYLING ── - -- ⚠ GUI:StyleButton's `tab` mode is what the designer's right panel uses -- faint - -- cell when inactive, accent fill and underline when active -- so borrowing it is - -- what makes this read as the same kind of page rather than a lookalike. - -- ☠ THE ACTIVE TAB LIVES ON S, NOT IN A LOCAL. This whole builder re-runs on every - -- rebuild (a tick, a colour, a width change), and a local would reset the user to - -- Triggers every time they changed anything on Effects. - if open and S.PIH_TABS then - S.pihTab = S.pihTab or "triggers" - local TAB_GAP, TAB_H = 4, 26 - local tabW = math.max(60, (halfW - 16 - TAB_GAP) / 2) - local prev - for _, def in ipairs(S.PIH_TABS) do - local btn = CreateFrame("Button", nil, rightPanel, "BackdropTemplate") - btn:SetSize(tabW, TAB_H) - if prev then - btn:SetPoint("TOPLEFT", prev, "TOPRIGHT", TAB_GAP, 0) - else - btn:SetPoint("TOPLEFT", 8, yPos) - end - GUI:StyleButton(btn, { tab = true, text = def.label, font = "DFFontHighlight" }) - if btn.SetActive then btn:SetActive(S.pihTab == def.key) end - btn:SetScript("OnClick", function() - if S.pihTab == def.key then return end - S.pihTab = def.key - pageRefresh() - end) - prev = btn - end - yPos = yPos - (TAB_H + 8) - end - - local yEnd = yPos - if open then - yEnd = S.BuildPIHelperBody(rightPanel, { - startY = yPos, - tab = S.pihTab or "triggers", - Refresh = pageRefresh, - }) - end - - local rightH = max(1, -(yEnd or 0)) - rightPanel:SetHeight(rightH) - - -- ☠☠ THE PANEL IS SIZED BEFORE THE PREVIEW IS BUILT, AND THAT ORDER IS THE WHOLE - -- FIX. Without opts.thumb, CreateFramePreview anchors its container to ALL FOUR - -- CORNERS of the parent -- so the canvas takes its height FROM the panel. The first - -- cut built the preview into a 1px panel and then set the panel's height from the - -- preview: circular, and it resolved to 1px of nothing. The left half rendered as a - -- transparent gap with the game world showing through (Krathe, 2026-09-08). - -- ⇒ Decide the height, THEN build into it. The designer never hits this because its - -- leftPanel is anchored TOPLEFT+BOTTOMLEFT inside an island that already has a size. - -- ☠☠ THE LEFT PANEL TAKES THE CANVAS'S HEIGHT, NEVER THE COLUMN'S. The mock frame is - -- anchored SetPoint("CENTER", container, "CENTER") and the container fills its - -- parent -- so a left panel stretched to match a long settings column centres the - -- frame halfway down that column, which is why it appeared stranded near the bottom - -- with empty space above it (Krathe, 2026-09-08). The designer does not show this - -- because its two halves are the same height BY CONSTRUCTION: they fill an island - -- sized to the viewport, and the settings side scrolls INSIDE it. This page is a - -- measured column in a scrolling page, so the halves are independent and only the - -- CONTENT height is shared. - -- - -- ⚠ THE NUMBER IS THE CANVAS'S OWN, not one I picked. CanvasWantedHeight(false) is - -- the non-compact form's floor; CanvasWantedHeight(true) is the height that actually - -- fits the frame at the live preview scale, and the +30 is the file's own accounting - -- for what the band form saves by putting the scale behind a glyph instead of a - -- slider row ("that 30px goes straight into CANVAS_FURNITURE and back to the - -- frame"). Taking the larger keeps the frame fitting when the scale slider moves. - local canvasH = 260 - if P.CanvasWantedHeight then - canvasH = max(P.CanvasWantedHeight(false, nil), - P.CanvasWantedHeight(true, nil) + 30) - end - -- The column is as tall as its taller half; only the CONTENT height is shared, and - -- the two panels keep their own (see the note above -- a left panel stretched to - -- match the settings would centre the mock frame halfway down the page). - local colH = max(rightH, canvasH) - leftPanel:SetHeight(canvasH) - content:SetHeight(colH) - - -- ── THE PREVIEW, THE DESIGNER'S OWN CANVAS ── - -- ⚠ THE SAME FACTORY, NOT A LOOKALIKE. CreateFramePreview is what the designer - -- mounts in both its layouts, and the scope-card tiles already prove it stands - -- alone with no right panel. Reusing it is the only way this page can promise the - -- picture matches the live frame -- a second renderer is a second thing to drift. - -- ☠ S.framePreview IS A SINGLETON AND WE TAKE IT, which is safe for the same - -- reason the designer's two layouts can both assign it: only one options page - -- renders at a time, and whichever page builds next re-assigns it. Navigating away - -- leaves it pointing at a retired frame until the designer rebuilds and claims it - -- back -- exactly what already happens when the settings layout is flipped. - -- ⚠ PAINTED FROM THE HELPER'S OWN RECORDS, never the shared pool: S.PIH_PreviewPool - -- hands over the same cfg tables the designer would paint, filtered to the ones - -- carrying a helper mark. The Any Buff pool also holds the user's unrelated work, - -- and rendering that here would show effects this page does not control. - if P and S.PIH_PreviewPool and P.CreateFramePreview then - -- ⚠ THE DESIGNER'S OWN FORM, not the band's. Non-compact is what gives this the - -- "FRAME PREVIEW" caption and the Preview Scale SLIDER rather than the band's - -- glyph in the corner -- Krathe asked for the designer's treatment and that is - -- the difference between the two. - -- ⚠ placement = false is the one thing suppressed: it gates the nine anchor - -- dots, the drag hint and the three instruction rows, all of which are about - -- POSITIONING an indicator. Nothing on this page can be dragged, so offering the - -- furniture for it would be three lines of instructions for a gesture that does - -- nothing. It does NOT gate the scale slider. - local pv = P.CreateFramePreview(leftPanel, 0, nil, { placement = false }) - if pv then - S.framePreview = pv - if P.RefreshPreviewEffects then - pcall(P.RefreshPreviewEffects, { pool = S.PIH_PreviewPool() }) - end - end - end - rebuilding = false - - if abs((host.layoutHeight or 0) - colH) > 0.5 then - host.layoutHeight = colH - host:SetHeight(colH) - relayout() - else - host:SetHeight(colH) - end + if DF.BuildAuraDesignerPage then + DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) end - - host:SetScript("OnSizeChanged", function(self, w) - w = w or 0 - if abs(w - lastWidth) <= WIDTH_EPSILON then return end - lastWidth = w - rebuild() - end) - - Add(host, 1, "both") - return host end -- ============================================================ --- IS THE PAGE OFFERED AT ALL? +-- IS THE HELPER OFFERED AT ALL? -- ============================================================ --- ☠ CLASS, NOT SPEC, AND READ ONCE AT REGISTRATION. Power Infusion is a priest ability and --- a character cannot change class in session, so this is a constant for the life of the --- login — which is what CreateSubTab's `hidden` flag wants (it decides whether the entry is --- built, not whether it is greyed). +-- ☠ 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 both callers want: CreateSubTab's +-- `hidden` flag decides whether the nav entry is BUILT, and PoolDefs decides whether the pool +-- tab exists at all. -- ⚠ 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 — the exact discoverability --- trap this move exists to undo. +-- already added the helper is a page you cannot use to add it. function DF.IsPIHelperAvailable() local _, class = UnitClass("player") return class == "PRIEST" diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 8156f01e..4ff0e259 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -109,8 +109,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."], @@ -125,6 +128,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) From f9ada49175512b26c119f6ee8fb41ce937d04bf8 Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 8 Sep 2026 22:41:14 +0100 Subject: [PATCH 11/66] PI Helper: fix the two bugs the fourth-tab change introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "selecting the PI helper in the menu is totally fucked up and the trigger/effects are not showing." Both mine, both from pointing a second nav entry at a builder that assumed it had only one. 1. THE FULL BUILD CLOBBERED THE POOL BuildAuraDesignerPage's teardown sets S.activeBuffTab = "my" unconditionally. The helper's page set the pool and then called that builder -- so the pool was reset before anything rendered, and the helper's entry opened on My Buffs with none of its controls on screen. ☠ AND IT WAS INTERMITTENT, WHICH IS WHY IT READ AS "TOTALLY BROKEN" RATHER THAN "WRONG TAB": a REVISIT takes the reuse path, which leaves the pool alone. Same click, two behaviours, depending on whether the island already existed. ⇒ S.pendingBuffTab: a one-shot the builder CONSUMES at the point it would otherwise have defaulted. Requested, not assigned. Cleared on use, so a plain visit to the designer still opens on My Buffs exactly as before. 2. TWO PAGES WERE SHARING ONE ISLAND S.mainFrame is built into whichever page asked for it, and the reuse guard REPARENTS it to whoever asks next. With the designer's page and the helper's page both calling the builder, one island got handed back and forth while each page's harness still believed it owned the widgets it had Add'd around it -- which is the sub-tab strip drawn over the Template row in the screenshot. ⇒ S.mainFrameOwner. A different page is a different build, exactly like a mode switch, so it takes the full-build path -- which hides and unparents the old island first and leaves nothing stranded on the page we came from. Compared by identity, not name: the harness can rebuild a page object, and a stale reference must read as "different" rather than matching a dead frame. ☠ WHAT I SHOULD HAVE DONE: read what BuildAuraDesignerPage does to shared state BEFORE pointing a second caller at it. I checked that the pool accessors routed correctly and stopped there -- the builder's own teardown, twenty lines further down the same function, resets three of the fields I was setting. Verification: 170 files parse, no new globals, all CRLF. ⚠ Not verified in game. (cherry picked from commit e3b99f290b5ea3a0242a8ad8e1aa84d4045a86eb) --- .../AuraDesigner/UI/Editor.lua | 30 +++++++++++++++++-- .../AuraDesigner/UI/PIHelperPage.lua | 18 ++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 237521f2..8f2440aa 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1824,7 +1824,18 @@ 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 @@ -1846,7 +1857,18 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) wipe(effectCardPool) 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 @@ -1871,6 +1893,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 diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index 81a9214f..bc7d6a21 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -39,15 +39,17 @@ local S = DF.AuraDesigner._uiState -- Signature mirrors DF.BuildAuraDesignerPage so the two register identically in -- GUI/Pages/Auras.lua and neither is a special case at the call site. function DF.BuildPIHelperPage(guiRef, pageRef, dbRef, Add, AddSpace) - -- ⚠ SET BEFORE THE BUILD, NOT AFTER. Every pool-routed accessor is read DURING - -- BuildAuraDesignerPage -- the preview paints from CurrentAuraPool, the effect list keys - -- itself off PoolKeyPrefix -- so a tab set afterwards would render one pool and label it - -- another for a frame. - -- ⚠ NOT RESTORED, deliberately, and this is the difference from a scoped swap: the user - -- is now LOOKING at the helper pool. Putting the tab back would leave the strip showing - -- Power Infusion Helper as active while every surface below it rendered My Buffs. + -- ☠☠ REQUESTED, NOT ASSIGNED, AND THE DIFFERENCE COST A BROKEN PAGE. Writing + -- S.activeBuffTab here does nothing on a FULL build: BuildAuraDesignerPage resets the + -- pool as part of its teardown, so the helper's own nav entry landed on My Buffs with + -- none of its controls on screen -- and only sometimes, because a REVISIT takes the + -- reuse path, which leaves the pool alone. Two behaviours from one click. + -- ⇒ S.pendingBuffTab is a one-shot the builder CONSUMES at the point it would otherwise + -- have defaulted. Set both: the pending one survives a full build, and the direct one is + -- what the reuse path (which never touches pending) reads. if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() then - S.activeBuffTab = "pihelper" + S.pendingBuffTab = "pihelper" + S.activeBuffTab = "pihelper" end if DF.BuildAuraDesignerPage then DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) From 8c4981c511c65323aac514e26f037bd8515b7e38 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 01:18:41 +0100 Subject: [PATCH 12/66] PI Helper: one page, two sub-tabs, and the stuck icon group deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reports, one root cause each. 1. THE BLANK PAGE. Two nav entries both called the designer's builder, and that builder has ONE island (S.mainFrame) to parent. Giving each page its own build stopped them overlapping and started them blanking instead: the page harness does not re-run a builder on a cache hit (Panel.lua RefreshCached), so whichever page did not build last showed nothing at all. ⇒ Only the Aura Designer's page owns the island now. The Power Infusion Helper's nav row is a LINK -- it asks for the pool, drops the designer's cache and selects it -- which is what the design said from the start: "the Power Infusion helper in the menu just instant links to that." The page object survives as a stub with a banner and a button, because the settings search can still select any tab by name. WHAT I SHOULD HAVE DONE: the previous commit fixed one consequence of two pages sharing an island without asking whether they could share one at all. 2. THE SUB-TABS. On the helper's pool the strip is Triggers, then Effects, and no Layout Groups. "Global" IS "Triggers" -- relabelled, not a new tab, so SwitchTab and sixty call sites need no special case. Layout Groups goes because a layout group is a container of live aura icons and the helper has no per-spell display to arrange; leaving the tab would be a door back to the bug in (3). One definition (P.SubTabDefs) drives both layouts. 3. THE STUCK "PI Helper - Cooldown icons". A Filter Group of live cooldown icons, switched on by a tick captioned "Icons" filed under "Classes and Cooldowns" -- moved, renamed and finally removed, while its output kept drawing with nothing left that could reach it. ⇒ Retired outright (schema 5). pihSweep deletes every pihSignal group and the stash behind it, and both designer builders run the sweep for a priest so it does not wait for the right tab to be opened. The three amplifier ticks stay -- they are genuine triggers -- and now write into the ONE list the effects match on, so ticking Trinkets makes a trinket proc fire them. AND THE EFFECTS TAB IS THE DESIGNER'S. It lists helper rows (includePIH on this pool -- without it the collector filtered out every row the tab exists to show) and adds through the designer's own tiles, with the spell step removed because the cooldown list IS the spell. Icon comes back pinned to Power Infusion's own artwork rather than the trigger's, which answers the scope objection that cut it in schema 4: iconSpec.staticSpellID reaches a placed indicator, and bindNative skips Blizzard's SetIcon when one is set so the engine cannot repaint our art with the matched aura's. Bar joined the placed branch, where it always belonged -- it was writing a frame-level key nothing reads and could never have drawn. Verified: 170 files parse, no new globals in any edited file except PlaySound and SOUNDKIT in Auras.lua (both real), all CRLF, blob-to-blob diffs targeted. NOT verified in game. (cherry picked from commit 6cdbd9c115b3beeb217290510842c94b2563db9b) --- DandersFrames/AuraDesigner/Factory.lua | 27 +- DandersFrames/Frames/AuraContainer.lua | 16 +- DandersFrames/Locales/enUS.lua | 22 +- .../AuraDesigner/UI/Cards.lua | 855 ++++++++++-------- .../AuraDesigner/UI/Editor.lua | 42 +- .../AuraDesigner/UI/PIHelperPage.lua | 115 ++- .../AuraDesigner/UI/Rows.lua | 96 +- DandersFrames_Options/GUI/Pages/Auras.lua | 42 +- 8 files changed, 778 insertions(+), 437 deletions(-) diff --git a/DandersFrames/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua index d392905c..f2b437b0 100644 --- a/DandersFrames/AuraDesigner/Factory.lua +++ b/DandersFrames/AuraDesigner/Factory.lua @@ -1994,7 +1994,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 +2288,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 "") diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 845dac33..2174bfef 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -2930,7 +2930,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 diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index bbb3b72d..4d469a59 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3034,13 +3034,27 @@ 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. +-- The three extra trigger ticks, under the class list. They used to feed a separate spell +-- list that only the cooldown-icon group ever read; that group is retired, so they write into +-- the ONE list the helper matches on -- ticking Trinkets makes a trinket proc fire whatever +-- effects have been added on the Effects tab. "Also count" is the caption above them. +-- ⚠ L["Cooldowns"] is kept: the tick that used it is gone, but the string is a plain noun +-- other surfaces use. +L["Also count"] = true L["Cooldowns"] = true 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: a helper icon is PINNED to Power Infusion, so +-- it says "infuse this player" rather than reporting which cooldown they pressed. +L["Shows the Power Infusion icon."] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 92de2e72..3dd8dd00 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -28,6 +28,7 @@ local CreateCardShell = P.CreateCardShell local ShowBuffCoexistPopup = P.ShowBuffCoexistPopup local ResolveSpec = P.ResolveSpec local IsOtherTab = P.IsOtherTab +local IsPIHelperTab = P.IsPIHelperTab local IsDebuffTab = P.IsDebuffTab local CurrentAuraPool = P.CurrentAuraPool local PoolKeyPrefix = P.PoolKeyPrefix @@ -428,8 +429,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' @@ -498,21 +515,14 @@ function S.PIH_PreviewPool() 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. +-- ☠ 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 or pihAnyIconGroup() ~= nil + return next(pihFound()) ~= nil 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" } - -- ───────────────────────────────────────────────────────────── -- SHARED SETTINGS -- ───────────────────────────────────────────────────────────── @@ -614,31 +624,14 @@ local function pihRestoreInto(cfg, key, surface) 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 GROUP STASH IS GONE WITH THE GROUPS (schema 5, 2026-09-09). PIH_GROUP_OWNED, +-- pihStashGroup and pihRestoreGroup kept a deleted cooldown-icon group's position, size and +-- appearance so a re-tick brought it back where the user had dragged it. There is no re-tick +-- and no group any more -- pihSweep step 5 deletes both the groups and the stash -- so a +-- writer with no reader is all that would be left, which is the dead-mechanism half of the +-- lying-control problem this file keeps auditing itself for. The EFFECT stash above is +-- untouched and still does its job: unticking Enable really does delete records, and +-- pihRestoreInto is what makes that behave like a switch. -- ───────────────────────────────────────────────────────────── -- BUILDING AND UNBUILDING ONE SIGNAL @@ -668,29 +661,59 @@ 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. +local function pihAmplifierIDs(s) + local R = DF.FilterRegistry + local out = {} + local function addCat(catKey) + for _, rec in ipairs((R and R.ByCategory and R.ByCategory[catKey]) or {}) do + if rec.id 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 + for _, id in ipairs(PIH_RACIAL_IDS) do out[#out + 1] = id end + end + return out +end + +local PIH_ALL_AMPLIFIERS = { potions = true, trinkets = true, racials = true } + +local function pihSyncTriggerExtras(s) + local R = DF.FilterRegistry + if not R then return end + -- ⚠ 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. With no list + -- there is nothing to add to and nothing to take out of. + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + if not id then return end + local want = {} + for _, sid in ipairs(pihAmplifierIDs(s)) do want[sid] = true end + for _, sid in ipairs(pihAmplifierIDs(PIH_ALL_AMPLIFIERS)) do + if want[sid] then + if R.AddSpellToCustom then R:AddSpellToCustom(id, sid) end + elseif R.RemoveSpellFromCustom then + R:RemoveSpellFromCustom(id, sid) + end end - return pihEnsureFilter(PIH_FILTERS.amplifiers, presets, ids, true) end local function pihCreateSignal(key, surfaceOverride) @@ -731,6 +754,13 @@ local function pihCreateSignal(key, surfaceOverride) -- 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 @@ -748,7 +778,14 @@ 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 local inst = CreateIndicatorInstance and CreateIndicatorInstance(ref, tgt) if not inst then return false, "could not create the indicator" end inst.pihSignal = key @@ -757,10 +794,23 @@ 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. + if tgt == "icon" then inst.staticSpellID = PIH_PI_SPELL_ID end -- ⚠ 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 @@ -997,9 +1047,8 @@ 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 + -- ⚠ THE "icons-only" ANSWER WENT WITH THE ICON GROUP (schema 5). With no group there is + -- no state where a signal is on and holds no surface, so an absent mark is simply absent. return nil end @@ -1068,7 +1117,9 @@ local function pihCapture(hit) end 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 @@ -1076,7 +1127,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 @@ -1142,7 +1194,7 @@ function P.PIH_SetSurface(key, surface) -- 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 + if hit.indicatorID or surface == "icon" or surface == "square" or surface == "bar" then local carried = pihCapture(hit) pihDeleteSignal(key) if not pihPlace(key, hit.auraName, surface, carried) then @@ -1317,94 +1369,15 @@ 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 - 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 - pihRefresh() - return true -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. function P.PIH_Create() local ok, why = pihCreateSignal("burst") @@ -1484,13 +1457,16 @@ function P.PIH_Remove() 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. + -- ⚠ A BELT-AND-BRACES SWEEP, NOT A LIVE PATH ANY MORE. Nothing creates a cooldown-icon + -- group since schema 5 and pihSweep deletes the ones older builds made -- but a profile + -- can arrive here unswept (a preset switched into after this panel was last opened), and + -- a helper being removed must not leave icons running whatever made them. local ig = pihAnyIconGroup() while ig and P.DeleteLayoutGroup do - pihStashGroup(ig) -- position, size, appearance survive a remove P.DeleteLayoutGroup(ig.id) - ig = pihAnyIconGroup() + local nextG = pihAnyIconGroup() + if nextG == ig then break end -- see pihSweep step 5: never spin on a failed delete + ig = nextG end -- ☠ SOUND IS NOT A CONTAINER, so nothing above reaches it. Removing the helper has to @@ -1523,27 +1499,17 @@ 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. 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 @@ -3233,14 +3199,65 @@ 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 + 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 + return (tabKey == "effects") and "effects" or "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 @@ -3310,7 +3327,64 @@ 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 + local function UpdateLayoutTabState() + ApplySubTabStrip() local layoutBtn = tabButtons and tabButtons.layout if layoutBtn and layoutBtn.SetDisabled then layoutBtn:SetDisabled(false) @@ -3358,12 +3432,11 @@ local function SetMainTab(tabKey) end 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. @@ -6014,7 +6087,12 @@ P.OpenFilterPopout = OpenFilterPopout -- 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). -local PIH_SCHEMA = 4 +-- 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. +local PIH_SCHEMA = 5 local function pihSweep() local s = P.PIH_Settings() @@ -6058,12 +6136,10 @@ 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 - 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: the Icon surface replaced it, and nothing represents it now. @@ -6110,9 +6186,58 @@ local function pihSweep() 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. + do + local ig = pihAnyIconGroup() + while ig and P.DeleteLayoutGroup do + P.DeleteLayoutGroup(ig.id) + local nextG = pihAnyIconGroup() + -- Refuse to spin: a delete that did not remove the group would loop forever, and + -- a settings panel that hangs the client is worse than a stale group. + if nextG == ig then break end + ig = nextG + 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 + s.schema = PIH_SCHEMA 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 {} @@ -6650,24 +6775,26 @@ end -- ⚠ 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. - -- ★★ THE ICON LISTS, LIFTED OUT OF THE OLD SIGNAL ROW (2026-09-08). - -- ☠ THEY WOULD OTHERWISE HAVE VANISHED. They lived inside t.signalRow, and the Effects - -- tab stopped calling it when the designer's own tiles and effect cards took over -- - -- which would have removed four settings from the UI as a side effect of a layout - -- change, with nothing saying so. They are Maelareth's (#263). - -- ⚠ AND THEY BELONG UNDER TRIGGERS, not Effects. Each tick decides which category of - -- buff COUNTS as worth infusing -- cooldowns, trinkets, potions, racials -- which is the - -- same question the class list answers, not a question about how it is drawn. - -- ⚠ THE SIGNAL IS FIXED HERE. PIH_ICON_OF only ever answered for the cooldown signal; - -- the old code read it per row because it was inside a per-signal builder. - local function pihAddIconLists(g, t) - local which = PIH_ICON_OF["burst"] - if not which then return end - t.settingLabel(g, L["Icons"]) - t.subCheck(g, L["Cooldowns"], - function() return P.PIH_IconsShow(which) end, - function(v) P.PIH_SetIconsShow(which, v) end) + -- ★★ WHAT ELSE COUNTS AS WORTH INFUSING -- three ticks under the class list. + -- ☠ THIS WAS "Icons", AND THE HEADING WAS THE BUG. Four ticks sat under it: a + -- "Cooldowns" one that created a Filter Group of live cooldown icons, and these three, + -- which fed a second spell list that only that group ever read. So a control captioned + -- Icons, filed under Classes and Cooldowns, was the only way to switch a whole display on + -- -- and once the display was on, nothing on the page said where it had come from. + -- Krathe found the result rather than the control: "I have stuck PI Helper Cooldown - + -- Icons on my AD despite that not even being an option now for PI helper." + -- ⇒ The icons tick is gone with the group (pihSweep step 5). These three stay, because + -- they are genuinely triggers -- "this CD/trinket being used" is Krathe's own definition + -- of one -- and they now write into the ONE list the effects match on, so ticking + -- Trinkets makes a trinket proc light whatever the user has added on the Effects tab. + -- ⚠ THEY ARE STILL Maelareth's (#263). The setting survives; only its consumer moved. + -- ⚠ READ OFF THE SETTING, not off the list. The class ticks read the list because a + -- class OWNS its spells and hand edits there are meaningful; these three are categories + -- that overlap nothing and whose ids are pulled from a shared database, so the stored + -- choice is the only stable record of what was asked for. + local function pihAddTriggerExtras(g, t) local st = P.PIH_Settings() + t.settingLabel(g, L["Also count"]) t.subCheck(g, L["Trinkets"], function() return st.trinkets == true end, function(v) P.PIH_SetAmplifier("trinkets", v) end) @@ -6679,161 +6806,141 @@ end function(v) P.PIH_SetAmplifier("racials", v) end) end --- ★★★ THE HELPER'S EFFECTS TAB — the designer's own furniture, scoped to the helper. +-- ★★★ THE HELPER'S ADD BLOCK — the designer's own tiles, with the spell question removed. +-- +-- ☠☠ THIS REPLACES A WHOLE SECOND EFFECTS TAB, AND THAT TAB'S EXISTENCE WAS THE MISTAKE. +-- pihBuildEffectsTab drew route cards ("Big cooldown" / "Already has active Power Infusion"), +-- then a tile grid, then its own ACTIVE INDICATORS caption, then its own effect-card loop, +-- then the sound box -- a private copy of the designer's Effects tab standing beside the real +-- one. Krathe, 2026-09-09: "It should BE AD not a copy of it." And on the route cards, one +-- round earlier: "we don't need 'already has active power infusion' or 'big cooldowns?'" +-- ⇒ So there is ONE Effects tab -- the designer's -- and the helper contributes exactly one +-- thing to it: this block, in place of the three scope cards. Everything below it (the +-- caption, the type filter, the effect cards, the eye and the delete) is the designer's own, +-- unchanged, because the helper's records ARE Aura Designer records. -- --- ☠ THE POOL TAB IS SET AROUND THE BUILD, AND THAT IS NOT A HACK. S.CreateEffectCard reads --- PoolKeyPrefix() and IsOtherTab() to key its expand state and to decide whether a spec --- applies -- both routed off S.activeBuffTab. The helper's records genuinely live in the --- Other/Any Buff pool, so pointing that at "other" while we build is telling the shared --- builder the truth, not lying to it. Restored immediately after, because the designer's own --- page reads the same field and a page switch must not inherit ours. +-- ⚠ NO SPELL QUESTION, AND THAT IS THE ONLY REAL DIFFERENCE. The designer asks route -> +-- spell -> type because a user's effect hangs off a spell they choose. The helper's hangs off +-- the cooldown list, which the Triggers tab owns -- so the spell is already answered and the +-- tile grid is the whole flow. One click adds. -- --- ⚠ WHICH SIGNAL AN EFFECT BELONGS TO IS THE HELPER'S VERSION OF "WHICH SPELL". The designer --- asks route -> spell -> type; there is no spell to choose here (the cooldown filter IS the --- spell), so it asks signal -> type. Two cards instead of three, then the same tile grid. -local pihAddSignal = nil -- which signal's tile grid is open, nil = none -local function pihBuildEffectsTab(parent, yPos, t) +-- ⚠ ONE SIGNAL. Everything added here is "worth infusing" (the `burst` mark). The second +-- signal survives in the DATA -- an existing "already has Power Infusion" effect still +-- renders, still lists itself and still deletes -- but nothing creates a new one, because +-- asking which of two things an effect is about was the question Krathe cut. +-- +-- Returns the y to carry on at. +local function pihBuildAddTiles(parent, yPos, Refresh) local tc = GetThemeColor() - local prevTab = S.activeBuffTab - S.activeBuffTab = "other" - - -- ── ADD AN EFFECT ── - -- The route cards, one per signal. Same widget the designer opens its add flow with. - local routes = {} - for _, def in ipairs({ - { key = "burst", label = L["Big cooldown"], - desc = L["Someone has popped a cooldown worth infusing"] }, - { key = "infused", label = L["Already has active Power Infusion"], - desc = L["Someone already has it, so do not double up"] }, - }) do - local capturedKey = def.key - routes[#routes + 1] = { - title = def.label, - desc = def.desc, - art = { kind = "border", color = { 1.00, 0.82, 0.25 } }, - onClick = function() - pihAddSignal = (pihAddSignal == capturedKey) and nil or capturedKey - if t.Refresh then t.Refresh() end - end, - } - end - local addBlock = GUI:CreateChoiceCardGroup(parent, { - title = L["ADD AN EFFECT"], - accent = tc, - width = (parent:GetWidth() or 320) - 16, - cards = routes, - }) - addBlock:SetPoint("TOPLEFT", 8, yPos) - addBlock:SetPoint("RIGHT", parent, "RIGHT", -8, 0) - yPos = yPos - (addBlock.layoutHeight + GUI.Space.section) - -- ── THE TILE GRID, once a signal is chosen ── - -- ⚠ THE SAME TILES THE DESIGNER DRAWS: CreateFrameTile with PaintEffectOnThumb, so the - -- picture on each one is the real miniature of that effect rather than a label on a box. - -- ⚠ FILTERED TO WHAT THE HELPER CAN DO. Sound has its own section (it is not a surface), - -- and a type the signal already holds is left out -- an add button that cannot add is the - -- lying control this panel keeps being cleaned of. - if pihAddSignal then - local held = {} - for _, s in ipairs(P.PIH_SurfacesOf(pihAddSignal)) do held[s] = true end - - local avail = {} - for _, eff in ipairs(P.AddFlowEffects and P.AddFlowEffects() or {}) do - if eff.type ~= "sound" and not held[eff.type] then avail[#avail + 1] = eff end - end + -- ⚠ 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 held = {} + for _, s in ipairs(P.PIH_SurfacesOf("burst")) do held[s] = true end - if #avail > 0 then - 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) - local rowTop, rowH = yPos, 0 - for i, eff in ipairs(avail) do - local col = (i - 1) % TILE_COLS - local capturedType = eff.type - local tile = CreateFrameTile(parent, { - width = TILE_W, - label = eff.label, - accent = BADGE_COLORS[eff.type] or tc, - tooltip = { title = eff.label, lines = { eff.desc } }, - Paint = function(pv) PaintEffectOnThumb(pv, capturedType) end, - onClick = function() - local ok, why = P.PIH_AddSurface(pihAddSignal, capturedType) - if not ok then DF:DebugWarn("AURADESIGNER", - "PIH: could not add %s -- %s", tostring(capturedType), tostring(why)) end - -- Fold the grid away on success, the way the designer's add flow - -- closes once it has what it asked for. - if ok then pihAddSignal = nil end - if t.Refresh then t.Refresh() end - 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 == #avail then - rowTop = rowTop - (rowH + TILE_GAP) - rowH = 0 - end - end - yPos = rowTop - 4 - end + local avail = {} + for _, eff in ipairs(P.AddFlowEffects and P.AddFlowEffects() or {}) do + if eff.type ~= "sound" and not held[eff.type] then avail[#avail + 1] = eff end end - -- ── ACTIVE EFFECTS ── - -- ⚠ CollectAllEffects({ includePIH = true }) is the designer's own collector asked for - -- the rows it normally hides from itself -- see its note. Then S.CreateEffectCard draws - -- each one: the same shell, badge, eye toggle, delete and expandable settings the - -- designer's Active Indicators list uses, because it IS that list. - local header = parent:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(header, 9, "") - header:SetPoint("TOPLEFT", 8, yPos) - header:SetText(L["ACTIVE INDICATORS"]) - header:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + 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 - local mine = {} - for _, effect in ipairs(P.CollectAllEffects({ includePIH = true }) or {}) do - local cfg = effect.config - if type(cfg) == "table" and cfg.pihSignal then mine[#mine + 1] = effect end + if #avail == 0 then + -- Every surface 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 - if #mine == 0 then - local empty = parent:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") - empty:SetPoint("TOPLEFT", 8, yPos) - empty:SetText(L["Nothing yet. Pick a signal above, then choose how it should show."]) - empty:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - yPos = yPos - 30 - else - for _, effect in ipairs(mine) do - yPos = S.CreateEffectCard(parent, yPos, effect) + -- ⚠ THE SAME TILES THE DESIGNER DRAWS: CreateFrameTile with PaintEffectOnThumb, so the + -- picture on each one is the real miniature of that effect rather than a label on a box. + 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) + local rowTop, rowH = yPos, 0 + for i, eff in ipairs(avail) do + local col = (i - 1) % TILE_COLS + local capturedType = eff.type + -- ⚠ THE ICON TILE SAYS WHAT IT ACTUALLY DOES HERE. Its shared description is "The + -- spell's own artwork", which is true in the designer and false on this pool: a + -- helper icon is pinned to Power Infusion (see pihCreateSignal). A tooltip that + -- describes the other pool's behaviour is worse than none. + local desc = (capturedType == "icon") and L["Shows the Power Infusion icon."] or eff.desc + local tile = CreateFrameTile(parent, { + width = TILE_W, + label = eff.label, + accent = BADGE_COLORS[eff.type] or tc, + tooltip = { title = eff.label, lines = { desc } }, + Paint = function(pv) PaintEffectOnThumb(pv, capturedType) end, + onClick = function() + 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, + }) + 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 == #avail then + rowTop = rowTop - (rowH + TILE_GAP) + rowH = 0 end end + return rowTop - 4 +end - -- ── SOUND ── - -- Not a surface, so it has no tile and no effect card: it is a property of the helper - -- rather than something drawn on a frame. Its own box, as before. - yPos = t.group(L["Sound Alert"], pihAddSound, yPos) - - S.activeBuffTab = prevTab - return yPos +-- ── 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 -S.PIH_TABS = { - { key = "triggers", label = L["Triggers"] }, - { key = "effects", label = L["Effects"] }, -} +-- 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 --- ── ONE TAB'S WORTH OF SETTINGS ── --- ⚠ THE CARD IS NOT HERE. It used to be, when this was S.BuildPIHelperPane and the whole --- panel was one column; now the page draws the enable banner, then the tab bar, then this. --- The banner has to sit ABOVE the tabs -- it turns the whole feature on, so it cannot be --- inside one of the two things it governs. +-- ── 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 = opts.startY or 0 local t = pihMakeTools(parent, opts) - local tab = opts.tab or "triggers" - if tab == "triggers" then + 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. @@ -6858,20 +6965,8 @@ S.BuildPIHelperBody = function(parent, opts) -- Krathe, 2026-09-08: "We seem to have two links to it? and confusing messaging." yPos = t.group(L["Classes and Cooldowns"], function(g) pihAddClasses(g, t) - pihAddIconLists(g, t) + pihAddTriggerExtras(g, t) end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) - else - -- ★★★ THE DESIGNER'S OWN EFFECTS TAB, SCOPED TO THE HELPER (2026-09-08). - -- ☠ I BUILT A LOOKALIKE TWICE AND IT WAS WRONG BOTH TIMES -- a dropdown, then a - -- stack of plain buttons -- while the real thing sat one call away. Krathe: "It - -- should basically function exactly as AD. Just without the My buffs / Debuffs / - -- Any Buff tabs and AD only stuff." - -- ⇒ The helper's records ARE Aura Designer records. So this uses the designer's own - -- parts: CreateFrameTile + PaintEffectOnThumb for the add tiles (the same pictures, - -- the same tooltips) and S.CreateEffectCard for the list (the same expandable card, - -- badge, eye toggle and delete the designer draws). Nothing is reimplemented, so - -- nothing can drift from it. - yPos = pihBuildEffectsTab(parent, yPos, t) end return yPos @@ -6934,7 +7029,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 @@ -7013,8 +7113,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, @@ -7179,8 +7293,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) @@ -7206,7 +7325,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 = {} @@ -7259,7 +7386,7 @@ S.BuildGlobalTab = function() 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, tab = "triggers", Refresh = Refresh }) + S.BuildPIHelperBody(parent, { startY = yPos, Refresh = Refresh }) end return end diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 8f2440aa..a216a901 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1856,6 +1856,11 @@ 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" -- ☠☠ 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 @@ -2084,6 +2089,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 @@ -2134,13 +2147,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) ── @@ -2199,15 +2210,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() diff --git a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index bc7d6a21..a51d7dce 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -5,6 +5,7 @@ -- 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. @@ -22,47 +23,103 @@ local S = DF.AuraDesigner._uiState -- 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, the layout groups: one implementation, four pools. +-- 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. -- --- ⚠ WHAT IS LEFT HERE IS THE DOOR. The nav entry exists because the tab is four levels deep --- and a priest should not have to know that 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 --- opens the designer with that pool selected. That is the whole file. +-- ⇒ 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". -- --- ⚠ IF THIS EVER NEEDS A CONTROL THE DESIGNER DOES NOT HAVE, add it to the designer for that --- pool -- do not grow a page here again. The three rebuilds are in the git history if the --- argument needs re-making. +-- ⚠ 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") + return true +end -- ============================================================ --- THE PAGE: OPEN THE DESIGNER ON THE HELPER'S POOL +-- THE STUB PAGE -- REACHED BY SEARCH, NOT BY THE NAV ROW -- ============================================================ --- Signature mirrors DF.BuildAuraDesignerPage so the two register identically in --- GUI/Pages/Auras.lua and neither is a special case at the call site. +-- ⚠ 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) - -- ☠☠ REQUESTED, NOT ASSIGNED, AND THE DIFFERENCE COST A BROKEN PAGE. Writing - -- S.activeBuffTab here does nothing on a FULL build: BuildAuraDesignerPage resets the - -- pool as part of its teardown, so the helper's own nav entry landed on My Buffs with - -- none of its controls on screen -- and only sometimes, because a REVISIT takes the - -- reuse path, which leaves the pool alone. Two behaviours from one click. - -- ⇒ S.pendingBuffTab is a one-shot the builder CONSUMES at the point it would otherwise - -- have defaulted. Set both: the pending one survives a full build, and the direct one is - -- what the reuse path (which never touches pending) reads. - if DF.IsPIHelperAvailable and DF.IsPIHelperAvailable() then - S.pendingBuffTab = "pihelper" - S.activeBuffTab = "pihelper" - end - if DF.BuildAuraDesignerPage then - DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef, Add, AddSpace) - end + 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 both callers want: CreateSubTab's --- `hidden` flag decides whether the nav entry is BUILT, and PoolDefs decides whether the pool --- tab exists at all. +-- 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() diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 4ff0e259..c0eeb22f 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -21,6 +21,7 @@ local C_TEXT_DIM = GUI.Colors.textDim local OPTS = P.OPTS local ResolveSpec = P.ResolveSpec local IsOtherTab = P.IsOtherTab +local IsPIHelperTab = P.IsPIHelperTab local IsDebuffTab = P.IsDebuffTab local CurrentAuraPool = P.CurrentAuraPool local CollectAllEffects = P.CollectAllEffects @@ -697,6 +698,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 @@ -762,6 +778,7 @@ local function BuildEffectsTabRows(ctx, shell) end if not ctx.adEnabled then addRow.disableOn = function() return true end end Add(addBand, nil, "both") + 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 @@ -811,7 +828,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 @@ -1269,6 +1289,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 @@ -1342,13 +1386,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 @@ -1465,16 +1521,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/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua index 55819b85..abf868a4 100644 --- a/DandersFrames_Options/GUI/Pages/Auras.lua +++ b/DandersFrames_Options/GUI/Pages/Auras.lua @@ -4838,26 +4838,48 @@ function DF._SetupGUIPagesPart3(GUI, CreateCategory, CreateSubTab, BuildPage, L, end) -- Auras > Power Infusion Helper (priest only) - -- ☠ ITS OWN PAGE, AND THAT IS THE WHOLE POINT. The helper used to live inside the - -- designer's Any Buff tab, because that is the pool its records must go in -- the pool - -- decides a record's caster filter and the helper watches OTHER people's cooldowns. - -- That constraint is real and unchanged. What was wrong is that it also decided where - -- the user had to LOOK: four levels deep, behind a tab that is not the default, with - -- the behaviour in one panel and the appearance of what it created in a different card - -- further down the same page. The CARD does not have to live where the RECORDS live. - -- ⚠ A SIBLING OF THE DESIGNER, not a sub-page of it: this is a feature with its own - -- rules, not a mode of the editor -- and sitting here is also what gives it the - -- designer's chrome for nothing, since it inherits the same category and page harness. + -- ☠☠ 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 From fb5910615850b180cbc71624f87de6ec51e06ad2 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 01:31:55 +0100 Subject: [PATCH 13/66] PI Helper: delete the icon group from the store it actually lives in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep found the retired cooldown-icon group and then failed to delete it, silently, so it kept drawing. Krathe: "I'm still seeing PI helper - Cooldowns on my AD despite it not being an option anymore." A FINDER THAT DOES NOT DEPEND ON THE POOL NEEDS A DELETER THAT DOES NOT EITHER. pihAnyIconGroup reads adDB.otherLayoutGroups directly -- the helper's groups are always there. P.DeleteLayoutGroup resolves its store through CurrentLayoutGroups(), which is pool-routed off S.activeBuffTab. Moving the sweep to page-build time in the previous commit meant it now runs with the pool still set to whatever the last session left, usually My Buffs -- so the delete searched the SPEC store, found no such id, and removed nothing. It has no return value, so the loop could not tell; my "refuse to spin" guard then turned the failure into a clean break. Two fixes, because the mismatch had two halves: · P.RemoveOtherLayoutGroupByID -- explicit store, clears the expand key, RETURNS whether it removed anything. Both the sweep and PIH_Remove use it, and both now warn instead of breaking quietly. PIH_Remove had the identical latent fault; it was only ever safe because it happened to run from an other-routed tab, which is a property of the caller, not of the code. · pihSweep pins the pool for its whole body. Step 4's Icon -> Square migration writes through EnsureAuraConfig, which is pool-routed too -- so an unswept profile could have had its migrated squares created into the spec pool. Same class of bug, one step earlier, not yet reported. This also mattered more than it looks: with Layout Groups gone from the helper's sub-tabs, a marked group is listed on no tab at all, so the sweep is the only thing that can remove one. A silent no-op there is unrecoverable. WHAT I SHOULD HAVE DONE: when I moved pihSweep to a new call site, checked which of the things it calls resolve their store from the open tab. Two of them do, and the one I hit is the one Krathe reported twice. Verified: 170 files parse, no new globals, CRLF intact. NOT verified in game. (cherry picked from commit d2c1463593e04dbebe990f590287ceffc893f633) --- .../AuraDesigner/UI/Cards.lua | 57 +++++++++++++++---- .../AuraDesigner/UI/Groups.lua | 31 ++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 3dd8dd00..1ca91332 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1461,12 +1461,19 @@ function P.PIH_Remove() -- group since schema 5 and pihSweep deletes the ones older builds made -- but a profile -- can arrive here unswept (a preset switched into after this panel was last opened), and -- a helper being removed must not leave icons running whatever made them. + -- ⚠ POOL-INDEPENDENT, for the reason pihSweep step 5 records: this loop FINDS the group + -- in adDB.otherLayoutGroups, so it must DELETE from there too rather than from whichever + -- store the open tab resolves to. It was the tab-routed delete, and it was only ever safe + -- because this ran from an other-routed tab -- which is a property of the caller, not of + -- the code, and exactly the assumption that broke the sweep. local ig = pihAnyIconGroup() - while ig and P.DeleteLayoutGroup do - P.DeleteLayoutGroup(ig.id) - local nextG = pihAnyIconGroup() - if nextG == ig then break end -- see pihSweep step 5: never spin on a failed delete - ig = nextG + while ig do + if not (P.RemoveOtherLayoutGroupByID and P.RemoveOtherLayoutGroupByID(ig.id)) then + DF:DebugWarn("AURADESIGNER", + "PIH: could not delete the cooldown-icon group (id %s)", tostring(ig.id)) + break + end + ig = pihAnyIconGroup() end -- ☠ SOUND IS NOT A CONTAINER, so nothing above reaches it. Removing the helper has to @@ -6098,6 +6105,23 @@ local function pihSweep() local s = P.PIH_Settings() if not s or s.schema == 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() @@ -6200,15 +6224,23 @@ local function pihSweep() -- ⚠ 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. + -- ⚠ THROUGH THE POOL-INDEPENDENT REMOVER, NOT THE TAB-ROUTED DELETE. The finder reads + -- adDB.otherLayoutGroups directly; a deleter that resolves its store from the open tab + -- cannot be trusted to look in the same place. See RemoveOtherLayoutGroupByID. + -- ⚠ AND IT SAYS SO WHEN IT FAILS. The first version broke out of the loop on a delete + -- that removed nothing -- correct, in that a spin is worse than a stale group, and silent, + -- which is how the same bug survived a second round. There is no manual escape either: + -- a marked group is hidden from the Layout Groups list on every pool that still has one. do local ig = pihAnyIconGroup() - while ig and P.DeleteLayoutGroup do - P.DeleteLayoutGroup(ig.id) - local nextG = pihAnyIconGroup() - -- Refuse to spin: a delete that did not remove the group would loop forever, and - -- a settings panel that hangs the client is worse than a stale group. - if nextG == ig then break end - ig = nextG + while ig do + if not (P.RemoveOtherLayoutGroupByID and P.RemoveOtherLayoutGroupByID(ig.id)) then + DF:DebugWarn("AURADESIGNER", + "PIH: could not delete the retired cooldown-icon group (id %s)", + tostring(ig.id)) + break + end + ig = pihAnyIconGroup() end if s.retainedCfg then s.retainedCfg.iconGroups = nil end end @@ -6227,6 +6259,7 @@ local function pihSweep() 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 0dc6e8ea..64516604 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1268,6 +1268,37 @@ local function DebuffSelectionView(sel) end P.DebuffSelectionView = DebuffSelectionView +-- ☠☠ DELETE A GROUP FROM THE *OTHER* STORE, WHATEVER TAB IS OPEN. +-- DeleteLayoutGroup below resolves its store through CurrentLayoutGroups(), which is +-- POOL-ROUTED off S.activeBuffTab -- correct for a user clicking a delete on the tab the +-- group is listed on, and wrong for any caller that FOUND the group some other way. +-- +-- ⚠ THAT MISMATCH SHIPPED. The Power Infusion Helper's sweep finds its retired cooldown-icon +-- group by reading adDB.otherLayoutGroups directly (the helper's groups are always there), +-- then handed the id to the pool-routed delete -- which, running at page-build time with the +-- pool still set to My Buffs, searched the SPEC store, found nothing and removed nothing. +-- Silently: the delete has no return value, so the caller could not tell. Krathe, twice: +-- "I'm still seeing PI helper - Cooldowns on my AD despite it not being an option anymore." +-- ⇒ A finder that does not depend on the pool needs a deleter that does not either. +-- +-- ⚠ NO MEMBER CASCADE, and none is needed: the other store's member groups are handled by +-- the pool-routed path above, and every caller of THIS function deletes a FILTER group, which +-- owns no placed indicators (it carries filterSelection, not members). Kept narrow on purpose +-- -- a second general-purpose delete would be a second set of rules about cascades. +-- Returns true when a group was actually removed, so a caller can say so when it was not. +local function RemoveOtherLayoutGroupByID(groupID) + local groups = GetOtherLayoutGroups(false) + for i, group in ipairs(groups) do + if group.id == groupID then + tremove(groups, i) + expandedGroups[GroupExpandKey(groupID)] = nil + return true + end + end + return false +end +P.RemoveOtherLayoutGroupByID = RemoveOtherLayoutGroupByID + -- 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) From 0dc039fb96e89da3a748b53793b9d027700ed018 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 01:40:04 +0100 Subject: [PATCH 14/66] PI Helper: purge stray marks by MARK, across every store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I read Krathe's SavedVariables instead of guessing again. The groups were never in the store any of my three attempts searched. profiles/Healer New/auraDesignerPresets/Base/ layoutGroups/HolyPriest -> EIGHT "PI Helper - Cooldown icons" auras/HolyPriest/@custom:cf9 -> one marked icon indicator otherAuras/@custom:cf12 -> one marked icon indicator <- the only one in the store every finder looks in WHY THEY ARE THERE. 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. Ticking it on My Buffs created a group in the SPEC store; the finder then could not see it, reported 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 but the helper's), not the sweep. A record no control can see is a record no control can turn off. · pihPurgeStrayMarks walks EVERY group store (otherLayoutGroups + each layoutGroups[spec], legacy flat array handled) and every aura pool, removing all marked groups and any marked effect outside adDB.otherAuras. A spec-pool helper effect cannot work -- poolFilter answers HELPFUL|PLAYER before it consults othersOnly, so it asks for other people's cooldowns cast by me -- and no list shows it, so it renders nothing, lists nowhere, deletes never. · pihIconGroup / pihAnyIconGroup / RemoveOtherLayoutGroupByID are gone. All three were one-store-by-id, which is the shape that failed three times. · Schema 6, because schema 5 stamped itself and deleted nothing -- the stamp says "swept" on profiles that were not. · The sweep now gates on the version it is coming FROM. Step 4 migrates every marked Icon to a Square, and Icon is a supported surface again in schema 5; ungated, the bump to 6 would have silently converted an icon the user had just added. That would have hit Krathe first. · The count goes in the debug log. A migration that deletes stored records has to say what it deleted. CORRECTION: my last two commit messages claimed "CRLF intact". The check I used (grep -c $'\r$') matches every line in this shell and proves nothing. Cards.lua, Rows.lua and PIHelperPage.lua were pure LF in the working tree -- git said so in three warnings I read as noise. Committed blobs were unaffected (autocrlf normalises to LF either way), so the repository never moved; the working tree is back to CRLF and the check is now a byte count. Verified: 170 files parse, no new globals, byte-counted CRLF, blobs unchanged. NOT verified in game. (cherry picked from commit 73e6f3507ee8652a21389bccfaf61b8e546d6c7d) --- .../AuraDesigner/UI/Cards.lua | 222 ++++++++++++++---- .../AuraDesigner/UI/Groups.lua | 36 +-- 2 files changed, 184 insertions(+), 74 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 1ca91332..bc726775 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -466,22 +466,72 @@ 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 +-- ⚠ 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 @@ -865,6 +915,73 @@ local function pihCreateSignal(key, surfaceOverride) return true end +-- ★★★ 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 + + for _, store in ipairs(pihAllGroupStores(adDB)) do + for i = #store, 1, -1 do + local g = store[i] + if type(g) == "table" and g.pihSignal 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 + + 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 + -- ⚠ `surface` IS OPTIONAL, and its absence means what it always meant: remove the signal's -- PRIMARY representation. Named, it removes exactly that one and leaves the signal's other -- surfaces alone -- which is what the per-effect remove button on the Effects tab needs now @@ -1461,20 +1578,10 @@ function P.PIH_Remove() -- group since schema 5 and pihSweep deletes the ones older builds made -- but a profile -- can arrive here unswept (a preset switched into after this panel was last opened), and -- a helper being removed must not leave icons running whatever made them. - -- ⚠ POOL-INDEPENDENT, for the reason pihSweep step 5 records: this loop FINDS the group - -- in adDB.otherLayoutGroups, so it must DELETE from there too rather than from whichever - -- store the open tab resolves to. It was the tab-routed delete, and it was only ever safe - -- because this ran from an other-routed tab -- which is a property of the caller, not of - -- the code, and exactly the assumption that broke the sweep. - local ig = pihAnyIconGroup() - while ig do - if not (P.RemoveOtherLayoutGroupByID and P.RemoveOtherLayoutGroupByID(ig.id)) then - DF:DebugWarn("AURADESIGNER", - "PIH: could not delete the cooldown-icon group (id %s)", tostring(ig.id)) - break - end - ig = pihAnyIconGroup() - end + -- ⚠ THE SAME STORE-WIDE PURGE THE SWEEP RUNS, and for the same reason: this used to hunt + -- one store by id through a store-routed delete, which is how eight of Krathe's groups + -- survived three attempts to remove them. + pihPurgeStrayMarks() -- ☠ 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. @@ -6099,11 +6206,27 @@ P.OpenFilterPopout = OpenFilterPopout -- 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. -local PIH_SCHEMA = 5 +-- 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. +local PIH_SCHEMA = 6 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: @@ -6125,6 +6248,12 @@ local function pihSweep() 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 @@ -6166,9 +6295,11 @@ local function pihSweep() if had then s.racials = true 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 + -- 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 @@ -6224,23 +6355,24 @@ local function pihSweep() -- ⚠ 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. - -- ⚠ THROUGH THE POOL-INDEPENDENT REMOVER, NOT THE TAB-ROUTED DELETE. The finder reads - -- adDB.otherLayoutGroups directly; a deleter that resolves its store from the open tab - -- cannot be trusted to look in the same place. See RemoveOtherLayoutGroupByID. - -- ⚠ AND IT SAYS SO WHEN IT FAILS. The first version broke out of the loop on a delete - -- that removed nothing -- correct, in that a spin is worse than a stale group, and silent, - -- which is how the same bug survived a second round. There is no manual escape either: - -- a marked group is hidden from the Layout Groups list on every pool that still has one. + 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 ig = pihAnyIconGroup() - while ig do - if not (P.RemoveOtherLayoutGroupByID and P.RemoveOtherLayoutGroupByID(ig.id)) then - DF:DebugWarn("AURADESIGNER", - "PIH: could not delete the retired cooldown-icon group (id %s)", - tostring(ig.id)) - break - end - ig = pihAnyIconGroup() + 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 64516604..2e113684 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1268,36 +1268,14 @@ local function DebuffSelectionView(sel) end P.DebuffSelectionView = DebuffSelectionView --- ☠☠ DELETE A GROUP FROM THE *OTHER* STORE, WHATEVER TAB IS OPEN. --- DeleteLayoutGroup below resolves its store through CurrentLayoutGroups(), which is --- POOL-ROUTED off S.activeBuffTab -- correct for a user clicking a delete on the tab the --- group is listed on, and wrong for any caller that FOUND the group some other way. --- --- ⚠ THAT MISMATCH SHIPPED. The Power Infusion Helper's sweep finds its retired cooldown-icon --- group by reading adDB.otherLayoutGroups directly (the helper's groups are always there), --- then handed the id to the pool-routed delete -- which, running at page-build time with the --- pool still set to My Buffs, searched the SPEC store, found nothing and removed nothing. --- Silently: the delete has no return value, so the caller could not tell. Krathe, twice: --- "I'm still seeing PI helper - Cooldowns on my AD despite it not being an option anymore." --- ⇒ A finder that does not depend on the pool needs a deleter that does not either. --- --- ⚠ NO MEMBER CASCADE, and none is needed: the other store's member groups are handled by --- the pool-routed path above, and every caller of THIS function deletes a FILTER group, which --- owns no placed indicators (it carries filterSelection, not members). Kept narrow on purpose --- -- a second general-purpose delete would be a second set of rules about cascades. --- Returns true when a group was actually removed, so a caller can say so when it was not. -local function RemoveOtherLayoutGroupByID(groupID) - local groups = GetOtherLayoutGroups(false) - for i, group in ipairs(groups) do - if group.id == groupID then - tremove(groups, i) - expandedGroups[GroupExpandKey(groupID)] = nil - return true - end - end - return false +-- 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.RemoveOtherLayoutGroupByID = RemoveOtherLayoutGroupByID +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 From e79fa0cb74229ae27e3d58ccf9209a8c309d788a Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 01:47:04 +0100 Subject: [PATCH 15/66] PI Helper: light the row you clicked, re-sync both strips on a reuse, name the effect Four things, three of them one bug: the island's REUSE path changes nothing but the content. 1. THE STRIPS WERE STALE. The helper's nav row asks for its pool and reopens the designer's page. Its Invalidate drops the HARNESS cache, not the island's, so the build takes the reuse path -- which redraws the tab content and leaves both strips describing the pool the panel was BUILT for. Hence the helper's settings under a strip reading Effects / Layout Groups / Global, with Any Buff still lit. AuraDesigner_RefreshPage now re-syncs the pool tabs (P.SyncPoolTabs, lifted out of SetMainTab) and the sub-tab strip before SwitchTab -- that order, because ApplySubTabStrip decides which buttons exist and SwitchTab decides which is active. 2. ...AND IT NEVER CONSUMED THE REQUEST. S.pendingBuffTab was read only by the full build, so the nav row -- which almost always lands on the reuse path -- left it standing for the next unrelated full build to pick up and pin the designer to the helper's pool for a visit nobody asked it of. 3. THE RAIL SAT ON THE WRONG ROW. The page is the designer's and the row clicked is the helper's; SelectTab lights the row that owns the page, which is true about the page and wrong about the click. GUI.SetNavHighlight makes the same three writes SelectTab's own tail makes, as a verb -- navMarker and C_TEXT are panel locals and a caller reaching for them would be reaching into that file's private state. Nothing about page state changes, and the next click on any row re-runs SelectTab's clear, so it cannot get stuck. 4. THE ROWS NAMED THE TRIGGER, NOT THE EFFECT. "PI Helper - Big cooldown" is what FIRES every helper effect -- the same words on every row, in the space where the row's own identity belongs, and the whole subject of the Triggers tab anyway. It is "PI Helper" now; the TYPE is already on the row as its coloured badge, which is how the designer tells two effects on one spell apart, so the text does not repeat it. The retired second signal keeps its own name -- the badge cannot say it is a different thing, and nothing creates one any more. Verified: 170 files parse, no new globals, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 27ebd620765c9e1e612326a14038427d0ad509c0) --- DandersFrames/Locales/enUS.lua | 6 +++- .../AuraDesigner/UI/Cards.lua | 30 +++++++++++++--- .../AuraDesigner/UI/Editor.lua | 24 +++++++++++++ .../AuraDesigner/UI/PIHelperPage.lua | 6 ++++ DandersFrames_Options/GUI/Panel.lua | 36 ++++++++++++++++++- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 4d469a59..fea12e95 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3062,6 +3062,10 @@ 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 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 bc726775..d6c7c6cf 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -264,8 +264,20 @@ 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. +-- ☠ 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. local function pihLabel(key) - if key == "burst" then return L["PI Helper — Big cooldown"] end + if key == "burst" then return L["PI Helper"] end if key == "infused" then return L["PI Helper — Already has active Power Infusion"] end end @@ -3497,6 +3509,18 @@ local function ApplySubTabStrip() 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 @@ -3541,9 +3565,7 @@ 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() -- ☠ THE TAB YOU WERE ON MAY NOT EXIST ON THE POOL YOU JUST PICKED. Effects is frosted on diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index a216a901..6e91696a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1840,6 +1840,18 @@ local function BuildAuraDesignerIsland(guiRef, pageRef, dbRef) 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() @@ -2324,6 +2336,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/PIHelperPage.lua b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua index a51d7dce..81fca68a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua +++ b/DandersFrames_Options/AuraDesigner/UI/PIHelperPage.lua @@ -86,6 +86,12 @@ function DF.OpenPIHelperInDesigner() 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 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 From 9c455d2f15d3dc1127306a8b1999e0b0ac1c9cd0 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 01:50:51 +0100 Subject: [PATCH 16/66] PI Helper: Others Only is not a choice on this pool, so stop offering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "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. So the tick was a control over a value the user does not get to pick, and the row suffix reported a constant as though it were a state -- the same constant-on-every-line fault the "Big cooldown" label had, in the same summary, one field along. P.ShowsOthersOnly = IsOtherTab() and not IsPIHelperTab(), used at the four sites that draw either half (the tick and the suffix, in each layout). One predicate rather than four copies of the same test, because the question is one question: does the user decide the caster rule on this pool? On Any Buff yes -- that pool means "any caster including you" and narrowing it is the whole point. On the helper, no. ⚠ THE STORED VALUE IS UNTOUCHED. othersOnly is what makes the effect correct and the engine still reads it; what goes is the control that pretends it is up for discussion. The infused signal's deliberate inversion (it IS your own cast) is unaffected for the same reason. Layout-group Others Only is left alone: it is gated on IsOtherTab() too, but the helper's pool has no Layout Groups tab to reach it from. Verified: 170 files parse, no new globals, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit bfb090a944d6109deeb9d9c65988006787ee77fa) --- .../AuraDesigner/UI/Cards.lua | 9 +++++++-- .../AuraDesigner/UI/Options.lua | 19 +++++++++++++++++++ .../AuraDesigner/UI/Rows.lua | 9 +++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index d6c7c6cf..de43977b 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -29,6 +29,7 @@ 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 @@ -4587,7 +4588,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") @@ -4780,7 +4783,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)) diff --git a/DandersFrames_Options/AuraDesigner/UI/Options.lua b/DandersFrames_Options/AuraDesigner/UI/Options.lua index 435fcf43..96f15233 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Options.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Options.lua @@ -605,6 +605,25 @@ local function IsOtherTab() 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index c0eeb22f..54490654 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -22,6 +22,7 @@ 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 @@ -379,7 +380,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 ") @@ -638,7 +641,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. From 68b14c3c6de19188636c6c733d3c10e4a6e88e63 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 02:04:15 +0100 Subject: [PATCH 17/66] PI Helper: paint the Icon tile with Power Infusion, not the placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "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 being right is what narrowed it: staticSpellID reaches the button and bindNative leaves the art alone, so frames were already correct. The tile is a different painter. 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 genuinely is no artwork to show -- the picture is swapped in later through pv.spellIcon once section 1 is answered. ☠ ON THE HELPER'S POOL THERE IS NO SECOND STEP. The cooldown list IS the spell, so nothing ever came back to swap it, and the one tile whose entire subject is a fixed picture was the one showing a question mark. PaintEffectOnThumb takes an optional staticSpellID and paints that spell's texture; the helper's Icon tile passes the same id pihCreateSignal pins, so the tile and the effect it creates cannot disagree. Every other caller passes nothing and is unchanged. ⚠ AND A PINNED TILE DOES NOT PUBLISH pv.spellIcon AT ALL. That field is the handle the add pane swaps through; leaving it set would let the pane repaint Power Infusion with whatever spell was picked -- the one thing pinned art exists to prevent. The pane already guards on the field being present, so it simply skips. Verified: 170 files parse, one new global (C_Spell, guarded at both uses), byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit b2b5750282538da95bacc3f1d1a4fd554ac6e305) --- .../AuraDesigner/UI/Cards.lua | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index de43977b..ed09c543 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -4977,7 +4977,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() @@ -4994,12 +5004,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 @@ -7069,12 +7085,18 @@ local function pihBuildAddTiles(parent, yPos, Refresh) -- helper icon is pinned to Power Infusion (see pihCreateSignal). A tooltip that -- describes the other pool's behaviour is worse than none. local desc = (capturedType == "icon") and L["Shows the Power Infusion icon."] or eff.desc + -- ⚠ AND THE TILE SHOWS THAT ICON, rather than the designer's question-mark + -- placeholder. The placeholder is correct where a spell is chosen in a later step; + -- here there is no later step, so it would simply never be replaced. Same id the + -- created indicator pins (pihCreateSignal), so the picture and the effect cannot + -- disagree. + local pinned = (capturedType == "icon") and PIH_PI_SPELL_ID or nil local tile = CreateFrameTile(parent, { width = TILE_W, label = eff.label, accent = BADGE_COLORS[eff.type] or tc, tooltip = { title = eff.label, lines = { desc } }, - Paint = function(pv) PaintEffectOnThumb(pv, capturedType) end, + Paint = function(pv) PaintEffectOnThumb(pv, capturedType, pinned) end, onClick = function() local ok, why = P.PIH_AddSurface("burst", capturedType) if not ok then DF:DebugWarn("AURADESIGNER", From 3cbd05173d0fd7c24e3be75af6e459581ca0b92d Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 02:13:20 +0100 Subject: [PATCH 18/66] PI Helper: sweep the leftovers from the rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every PIH symbol (definition vs uses, comments stripped) and every locale key (orphaned now vs referenced before this rework started at e3b99f29). DEAD, AND DELETED -- the retired surface dropdown and what only it used: P.PIH_SurfaceOf / P.PIH_SurfaceOptions / P.PIH_SetSurface, PIH_SURFACE_ORDER, pihCapture, pihSurfaceTakenBy, P.PIH_RemoveSurface, pihDeleteSignal, and the P.PIH_FoundAll export (the local is live; only the export was unread). They all answered one question -- "which SINGLE surface is this signal on" -- which is why picking an occupied row had to SWAP two signals. A signal holds several surfaces now, added and removed one at a time through the designer's own tiles and cards, so the question has no answer to give. ⚠ pihPlace SURVIVES: pihSweep step 4 still migrates an old Icon to a Square through it, and it is the only reader left. SEVEN ORPHANED LOCALE KEYS, deleted with the comments that described them: the helper's private "ADD AN EFFECT" heading, the two route cards and their descriptions, its empty-state line, "Big cooldown", and the dropdown's swap label. Orphan count for this rework is now zero. (100 pre-existing orphans are not this rework's doing and were left alone.) NOT DEAD -- A REAL BUG THE AUDIT FOUND: Deleting a helper effect through the designer's ✕ never re-derived the engine. pihRefresh's own note records why that matters -- "Danders' review found the resident half left armed, events registered, sound armed, for a helper with nothing in it" -- and every helper mutation used to end at that chokepoint. It stopped being a chokepoint when the Effects tab became the designer's: its ✕ removes the record, runs the AD refresh path, and knows nothing about the helper. So deleting your last PI effect left the watcher and the sound registered for a feature with nothing in it. Exactly the state that review caught, reachable again through a different door. ⇒ P.PIH_ReDerive, called from both layouts' delete after asking whether the doomed effect carried the mark (asked BEFORE the removal -- afterwards there is no config left to ask). FLAGGED, NOT TOUCHED -- for Krathe to decide: P.PIH_ClashOn / PIH_SiblingContends / PIH_SelfContends and their three helpers have no callers. The warning they feed was rendered on the old per-signal rows. The HAZARD is still real -- border and both texts take a single winner, so a helper border and one of the user's own means one silently does not draw -- so deleting a safety warning is not a cleanup. Kept, and marked as dead-but-live- looking so it cannot rot unnoticed. Verified: 170 files parse, no new globals, byte-counted CRLF, no BOM, zero locale orphans from this rework. NOT verified in game. (cherry picked from commit 0e9f7e695a1df294df69f57d09c57e2ca5051667) --- DandersFrames/Locales/enUS.lua | 23 +- .../AuraDesigner/UI/Cards.lua | 281 +++++------------- .../AuraDesigner/UI/Rows.lua | 3 + 3 files changed, 79 insertions(+), 228 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index fea12e95..ef2b7a9d 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2973,16 +2973,11 @@ L["Triggers"] = true -- ⚠ "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 Effects tab is the DESIGNER'S add flow, scoped to the helper: two route cards (which --- signal) and then the designer's own effect tiles. Its heading is the designer's --- "ADD AN INDICATOR" one verb across -- an EFFECT, because on this page you are not adding a --- thing to track, only a way of showing what the helper already watches. -L["ADD AN EFFECT"] = true -L["Someone has popped a cooldown worth infusing"] = true -L["Someone already has it, so do not double up"] = true --- ⚠ Names the NEXT STEP, not the absence. "Nothing here" tells someone what they can already --- see; this tells them the two clicks that fix it. -L["Nothing yet. Pick a signal above, then choose how it should show."] = true +-- ☠ 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 -- ⚠ The way in to the actual spell list. The class ticks say WHICH classes are watched; the @@ -2990,14 +2985,6 @@ L["Remove %s"] = true -- existed. A LINK rather than a second copy of the picker -- one place edits these. L["Individual cooldowns live in the filter this list drives."] = true L["Edit the cooldown list"] = true --- The signals. Each label has to stand alone with just the line beneath it for context. -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 -- 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index ed09c543..28411514 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -412,7 +412,8 @@ local function pihFoundAll() end return out end -P.PIH_FoundAll = pihFoundAll +-- ⚠ 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 = {} @@ -995,54 +996,32 @@ local function pihPurgeStrayMarks() return groups, effects end --- ⚠ `surface` IS OPTIONAL, and its absence means what it always meant: remove the signal's --- PRIMARY representation. Named, it removes exactly that one and leaves the signal's other --- surfaces alone -- which is what the per-effect remove button on the Effects tab needs now --- that a signal can hold several. -local function pihDeleteSignal(key, surface) - local hit - if surface then - for _, h in ipairs(pihFoundAll()[key] or {}) do - if h.typeKey == surface then hit = h break end - end - else - hit = pihFound()[key] - end - 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 - end - return true -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 @@ -1116,32 +1095,11 @@ 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. --- --- 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. --- --- ☠ "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 +-- ⚠ pihSurfaceTakenBy WENT WITH THE DROPDOWN (2026-09-09). It answered "which other +-- signal is already on this surface", a question only the swap logic ever asked -- the add +-- tiles simply do not offer a surface the signal already holds, and two signals on +-- DIFFERENT records never blocked each other in the first place. The contention that IS +-- real on border and the two texts is pihSiblingContends' business, below. -- 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 @@ -1174,77 +1132,16 @@ function P.PIH_SelfContends(surface, key) return (hit and pihContends(surface, hit.cfg)) and true or false end -function P.PIH_SurfaceOf(key) - local hit = pihFound()[key] - if hit then return hit.typeKey end - -- ⚠ THE "icons-only" ANSWER WENT WITH THE ICON GROUP (schema 5). With no group there is - -- no state where a signal is on and holds no surface, so an absent mark is simply absent. - 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") - -- ⭐ SQUARE STAYS. A flat colour block is a HIGHLIGHT, which is all this feature ever - -- needs to say: someone popped a cooldown, mark them. It costs one colour and reads at - -- a glance, which is the whole job. - -- ☠☠ ICON IS GONE (schema 4, 2026-09-08), AND THE REASON IS THE FEATURE'S SCOPE, NOT ITS - -- SHAPE. An icon shows a SPECIFIC BUFF'S ARTWORK, so offering one implies the helper - -- tracks which cooldown each player popped -- and it 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." An icon promises per-buff detail the feature does not - -- deliver, which is a control that lies about its own scope. - -- ⚠ I ARGUED THE OPPOSITE ONE COMMIT AGO -- cut Square as "a placement", kept Icon as - -- "the one picture that carries meaning". That was shape reasoning; this is scope - -- reasoning, and scope wins. Recorded so the swap does not look like drift. - -- ⚠ Existing Icon surfaces migrate to Square (pihSweep step 4) -- the nearest thing - -- that still marks the same unit in the same place. - opts.square = L["Square"] - 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) -- Bar rides with icon and square for the reason pihCreateSignal spells out: all three are @@ -1300,66 +1197,6 @@ 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) - if not PIH_SIGNALS[key] then return false, "no such signal" end - local found = pihFound() - local hit = found[key] - - -- "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" or surface == "bar" 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 - - local pool = pihOtherPoolRead() - local auraCfg = pool and pool[hit.auraName] - if not auraCfg then return false, "the record went missing" end - - local swapKey = pihSurfaceTakenBy(surface, key) - local swapHit = swapKey and found[swapKey] or nil - - -- 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 - - local mine, theirs = pihCapture(hit), swapHit and pihCapture(swapHit) or nil - local vacated = hit.typeKey - - auraCfg[vacated] = nil - if swapHit then auraCfg[swapHit.typeKey] = nil end - - if not pihPlace(key, hit.auraName, surface, mine) then - return false, "could not create the effect" - end - if swapHit then pihPlace(swapKey, swapHit.auraName, vacated, theirs) end - - pihRefresh() - return true -end - -- ★★★ 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 @@ -1379,13 +1216,31 @@ function P.PIH_AddSurface(key, surface) return ok, why end -function P.PIH_RemoveSurface(key, surface) - if not PIH_SIGNALS[key] then return false, "no such signal" end - if not surface then return false, "no surface" end - local removed = pihDeleteSignal(key, surface) - if removed then P.PIH_Apply() end - pihRefresh() - return removed +-- ⚠ 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. + +-- ★★★ 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 -- The surfaces a signal currently holds, in menu order (pihFoundAll sorts them). @@ -4616,6 +4471,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 @@ -4623,6 +4483,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() diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 54490654..9e509e8a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -464,6 +464,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 @@ -471,6 +473,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() From 84dbb135c4abf72b6840f02d1c3d61e093c04c5c Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 11:01:59 +0100 Subject: [PATCH 19/66] PI Helper: the clash warning comes back, on the effect card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe's call, and his border observation was right: "with border they can just offset and be able to show two borders like you can with AD anyway?" -- exactly, and the warning string has always named that escape hatch. Ticking "Give this aura its own border" opts an effect OUT of the contest (collectStackedBorders draws it alongside, sorted by priority), so both rings show. Checked, not assumed: that checkbox and the Priority slider the text version names are both still live controls on the effect card. THE HAZARD, for the record: border, name text and health text resolve through pickWinner, which takes ONE winner per surface from config alone and tears every other candidate down. A helper border under one of the user's own borders simply does not draw, with nothing on screen saying which lost or why. Health bar and background are MULTI and cannot clash -- that is what PIH_CONTENDED encodes. P.PIH_ClashText composes the message; the badge carries it. AttachWarningBadge gained opts.text, because until now every caller had a config warning KEY and this text is composed at render time -- it names the offending effect and how many others contend. A key still wins when both are present (a fact about the SPELL outranks a fact about this configuration), though they cannot collide in practice: a helper record is filter-owned and has no spec entry. ☠ AND IT ASKS ABOUT THE ROW IT IS ON. pihSurfaceTakenBy, pihSiblingContends and P.PIH_SelfContends all went: every one asked through pihFound(), which returns ONE hit per signal -- correct when a signal had a single surface, wrong the day it could hold several. "Does our border contend?" was answered about whichever surface hash order landed on, which might have been the square. The card hands over its own config instead. The sibling term went with them: 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. ⚠ It vanishes when the remedy is applied -- pihContends runs the real candidacy test on our own cfg first -- because a warning that survives its own fix teaches people to ignore warnings. ⚠ Both strings still say "signal", which is vocabulary the helper's UI no longer uses anywhere. Left as-is rather than minting new keys mid-sweep; flagged. Audit after this: zero unused PIH symbols, zero locale keys orphaned by the rework (the pre-existing orphan count fell 100 -> 96, these strings coming back). 170 files parse, no new globals, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit b19d5a6bc94018457f2b1359e4ad309181245b73) --- .../AuraDesigner/UI/Cards.lua | 86 ++++++++++++------- .../AuraDesigner/UI/Groups.lua | 12 ++- .../AuraDesigner/UI/Rows.lua | 4 + 3 files changed, 67 insertions(+), 35 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 28411514..96a6fb02 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1095,41 +1095,53 @@ function P.PIH_ClashOn(surface) return n, name end --- ⚠ pihSurfaceTakenBy WENT WITH THE DROPDOWN (2026-09-09). It answered "which other --- signal is already on this surface", a question only the swap logic ever asked -- the add --- tiles simply do not offer a surface the signal already holds, and two signals on --- DIFFERENT records never blocked each other in the first place. The contention that IS --- real on border and the two texts is pihSiblingContends' business, below. - --- 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) +-- ⚠ 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 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. +-- +-- ★ 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 -- ☠☠ THE SURFACE DROPDOWN'S WHOLE API LIVED HERE AND IS GONE (2026-09-09). @@ -4412,7 +4424,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, @@ -4420,6 +4439,7 @@ S.CreateEffectCard = function(parent, yPos, effect) offsetX = 4, offsetY = 0, size = 16, + text = clashText, }) -- Aura name + anchor/trigger/group info diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 2e113684..837da5b7 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -536,14 +536,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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 9e509e8a..7f7782da 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -450,10 +450,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 ── From 6aea4a38f8f35008b0192b6b361f50d9e8759155 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 12:04:50 +0100 Subject: [PATCH 20/66] PI Helper: the role box is called Roles, not Triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "Triggers should be Role for the DPS/Tanks etc section." 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 says nothing -- and the one thing it could have said, that this box is the ROLE filter, was the thing it left out. ⚠ THE COOLDOWN GATE STAYS INSIDE IT, and that was already argued rather than decided now: pihAddGateAndNotes' own note says a whole titled group around a single checkbox is more chrome than the setting is worth, and that "Show even if your Power Infusion is on cooldown" says what it does without a header to lean on. It names this exact case as the test for a control living under a heading that does not quite describe it, so the heading changing does not disturb it. L["Triggers"] stays -- it is the sub-tab's own label -- so nothing is orphaned. Verified: 170 files parse, no new globals, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit ede2f3e16f0ce0300ac74aecdd7a9643c66af561) --- DandersFrames/Locales/enUS.lua | 10 +++++++--- DandersFrames_Options/AuraDesigner/UI/Cards.lua | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index ef2b7a9d..09d2df25 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2998,9 +2998,13 @@ 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. They sit inside Triggers --- now -- 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. +-- 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 -- ⚠ 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 96a6fb02..6a64d18e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7039,7 +7039,16 @@ S.BuildPIHelperBody = function(parent, opts) -- ⚠ 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. - yPos = t.group(L["Triggers"], function(g) + -- ⚠ "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 even if your 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. + yPos = t.group(L["Roles"], function(g) pihAddRoles(g, t) pihAddGateAndNotes(g, t) end, yPos) From 1224d6bd21df9efaedfcf4fdbb26fc4376202e8e Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 12:10:35 +0100 Subject: [PATCH 21/66] PI Helper: disabling stashes every surface, and stops taking effects that are not ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "when I disable the PI tracker, it seems to remove my border effect I added." It did, and there were two separate ways for that to happen. 1. THE STASH HELD ONE SURFACE PER SIGNAL, AND A SIGNAL CAN HOLD SEVERAL. retainedCfg[key] was a single {surface, cfg} pair, written when a signal WAS one effect; the multi-surface work never revisited it, and PIH_Remove walked pihFound(), which answers one hit per signal. So a border + an icon + a square went in, ONE was stashed, all three were deleted, and re-enabling rebuilt the one. The other two were gone with nothing anywhere that remembered them. ⇒ The stash is a list in menu order (pihStashHits), PIH_Create names each surface explicitly rather than leaving pihCreateSignal's fallback to pick one, and pihKeptCfg/pihKeptSurfaces read BOTH shapes -- so nobody's retained customisation is lost by the fix for losing retained customisations. 2. THE DELETE TOOK THE WHOLE RECORD. It nil'd the pool key outright, justified by "a record here is identified BY a helper spell list, so nothing of the user's own can be sitting on it". That is false: the designer's From a Filter flow will hang an effect off the Power Infusion Helper filter, and it lands on this exact record. Turning the helper off took it with no warning and no stash. ⇒ Marked effects are removed one at a time. The helper-owned `sound` key -- the actual reason the wholesale delete existed, since the generic effects list offers no delete button for sound on a filter-owned record -- is cleared explicitly, and the record goes only once it holds nothing at all. The enable tick has always promised it "behaves like an enable even though records really are created and deleted underneath". It only does if the round trip is lossless; it wasn't. ⚠ Also corrected the result string: it said "removed %d signal(s)" while counting one per signal. It counts effects now, because that is what it removes. Verified: 170 files parse, no new globals, zero unused PIH symbols, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 24fde995aa0c362fd50c77547b229e5aad0d255b) --- .../AuraDesigner/UI/Cards.lua | 151 +++++++++++++++--- 1 file changed, 125 insertions(+), 26 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 6a64d18e..676f1575 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -666,13 +666,58 @@ local PIH_RECIPE_OWNED = { id = true, type = true, } -local function pihStash(key, hit) +-- ☠☠ THE STASH HELD ONE SURFACE PER SIGNAL, AND THE SIGNAL CAN HOLD SEVERAL (fixed +-- 2026-09-09). It was written when a signal WAS one effect, so `retainedCfg[key]` was one +-- {surface, cfg} pair -- and the multi-surface work never revisited it. +-- ⇒ Krathe: "when I disable the PI tracker, it seems to remove my border effect I added." +-- Exactly what it did. Disable deleted every marked effect, stashed ONE of them, and +-- re-enabling rebuilt that one. A border, an icon and a square went in; a border came back, +-- and the other two were gone with nothing anywhere that remembered them. +-- ⇒ The stash is a LIST now, in menu order (pihFoundAll sorts), and PIH_Create rebuilds all +-- of it. The promise the enable tick has always made -- "this behaves like a switch even +-- though records really are created and deleted underneath" -- is only true if it does. +-- +-- ⚠ THE OLD SHAPE IS STILL READ. A profile stashed by a previous build carries +-- {surface=, cfg=}; pihKeptCfg and pihKeptSurfaces answer for both, so nobody's retained +-- customisation is lost by the fix for losing retained customisations. +local function pihStashHits(key, hits) local s = P.PIH_Settings() - if not (s and hit and type(hit.cfg) == "table") then return end + if not s or not (hits and hits[1]) 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) } + local surfaces, order = {}, {} + for _, hit in ipairs(hits) do + -- The surface rides along: a stash is only restored onto the SAME surface, because + -- the surfaces do not share a settings vocabulary (a border has a style and a + -- thickness; a health bar has Replace-vs-Tint). + if type(hit.cfg) == "table" and not surfaces[hit.typeKey] then + surfaces[hit.typeKey] = pihDeepCopy(hit.cfg) + order[#order + 1] = hit.typeKey + end + end + s.retainedCfg[key] = { surfaces = surfaces, order = order } +end + +-- One stashed surface's cfg, from either shape. +local function pihKeptCfg(key, surface) + local s = P.PIH_Settings() + local kept = s and s.retainedCfg and s.retainedCfg[key] + if type(kept) ~= "table" then return nil end + if type(kept.surfaces) == "table" then + local c = kept.surfaces[surface] + return (type(c) == "table") and c or nil + end + if kept.surface == surface and type(kept.cfg) == "table" then return kept.cfg end + return nil +end + +-- Every stashed surface for a signal, in the order they should be rebuilt. +local function pihKeptSurfaces(key) + local s = P.PIH_Settings() + local kept = s and s.retainedCfg and s.retainedCfg[key] + if type(kept) ~= "table" then return {} end + if type(kept.order) == "table" and kept.order[1] then return kept.order end + if kept.surface then return { kept.surface } end + return {} end -- Overlay the stash onto a freshly created cfg. Runs AFTER the recipe's default @@ -680,10 +725,9 @@ end -- 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 + local kept = pihKeptCfg(key, surface) + if not kept then return end + for k, v in pairs(kept) do if not PIH_RECIPE_OWNED[k] then cfg[k] = pihDeepCopy(v) end end end @@ -806,9 +850,11 @@ local function pihCreateSignal(key, surfaceOverride) 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. + local tgt = surfaceOverride or pihKeptSurfaces(key)[1] or def.surface local cdId = pihEnsureFilter(PIH_FILTERS.cooldowns, nil, pihSeedIDs()) if not cdId then return false, "could not build the cooldown list" end @@ -1377,7 +1423,23 @@ end -- stored setting itself now, because there is no group left to read the truth off. function P.PIH_Create() - local ok, why = pihCreateSignal("burst") + -- ⭐ EVERY SURFACE THE STASH REMEMBERS, not just one. Turning the tick back on has to put + -- back what turning it off took away -- see pihStashHits for the shape and for the report + -- that found it ("it seems to remove my border effect I added"). Named explicitly rather + -- than left to pihCreateSignal's fallback, which can only choose one. + -- ⚠ FIRST WINS FOR THE RESULT. A later surface refusing ("already on") must not turn a + -- successful rebuild into a failure, and a first-surface failure is the one worth + -- reporting -- so `ok` latches true and `why` keeps the first refusal. + local ok, why + local wanted = pihKeptSurfaces("burst") + if wanted[1] then + for _, surface in ipairs(wanted) do + local o, w = pihCreateSignal("burst", surface) + if o then ok = true elseif not why then why = w end + end + else + ok, why = pihCreateSignal("burst") + end -- 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 @@ -1396,21 +1458,55 @@ end function P.PIH_Remove() local pool = pihOtherPoolRead() - local found = pihFound() + -- ⚠ pihFoundAll, NOT pihFound: the latter answers ONE hit per signal, which is what made + -- this stash a quarter of the user's work and this delete a guess at the rest. + local all = pihFoundAll() 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 + + -- STASH FIRST, EVERY SURFACE, BEFORE ANYTHING IS REMOVED. + for key, hits in pairs(all) do pihStashHits(key, hits) end + + -- ☠ THE MARKED EFFECTS GO, ONE AT A TIME -- NOT THE WHOLE RECORD. + -- It used to nil the pool key outright, on the argument that "a record here is identified + -- BY a helper spell list, so nothing of the user's own can be sitting on it". That is not + -- true: the designer's From a Filter flow will happily hang an effect off the Power + -- Infusion Helper filter, and that effect lands on this exact record. Turning the helper + -- off would have taken it with no warning and no stash. + -- ⚠ Precise removal is also what the multi-surface shape needs anyway -- there is no + -- longer one effect per record to reason about. + for _, hits in pairs(all) do + for _, hit in ipairs(hits) do + local auraCfg = pool and pool[hit.auraName] + if auraCfg then + if hit.indicatorID and type(auraCfg.indicators) == "table" then + for i, inst in ipairs(auraCfg.indicators) do + if inst.id == hit.indicatorID then + table.remove(auraCfg.indicators, i) + break + end + end + else + auraCfg[hit.typeKey] = nil + end + names[hit.auraName] = true + n = n + 1 + end + end 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 + -- ...and the helper-owned SOUND entry, which is the reason the wholesale delete existed. + -- The generic effects list refuses to show `sound` on a filter-owned record (Groups.lua), + -- so it offers no delete button for it and anything left behind there is unreachable. + -- Clearing that one key keeps the original intent without taking the user's own effects + -- with it; the record itself goes only once it holds nothing at all. + for name in pairs(names) do + local auraCfg = pool and pool[name] + if type(auraCfg) == "table" then + auraCfg.sound = nil + if P.AuraHoldsNoEffects and P.AuraHoldsNoEffects(auraCfg) then + pool[name] = nil + end + end end -- The lists go too. They exist only to feed these effects, and three "Power Infusion @@ -1481,7 +1577,10 @@ function P.PIH_Remove() if Engine and Engine.PIH_ApplySaved then Engine:PIH_ApplySaved() end pihRefresh() - return true, ("removed %d signal(s) and their spell lists"):format(n) + -- ⚠ EFFECTS, not signals: `n` counts every marked surface removed now that a signal can + -- hold several. The old wording said "signal(s)" while counting one per signal, which was + -- true then and would have quietly under-reported ever since. + return true, ("removed %d effect(s) and their spell lists"):format(n) end function P.PIH_SetRole(role, on) From 035a69c7df0c8513f4eae7569dab3e2dde3350dd Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 12:45:06 +0100 Subject: [PATCH 22/66] PI Helper: the enable tick is a stored flag, like the rest of AD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "it should function like the rest of AD". The rest of AD writes ONE boolean -- modeDB.auraDesignerEnabled -- and deletes nothing. The helper had no enabled flag at all: its existence WAS its records, so "off" had to mean "delete them", with a stash to fake reversibility. Every bug in this area had that one root -- a switch pretending to be a switch while actually being a delete. · adDB.pihelper.enabled is the switch. The records are never touched. · Engine: a `pihEnabled` file-local, distinct from pihGateEnabled because they do not collapse -- off means forced DARK, gate-off means forced OPEN, and both on means the watcher drives. The gate's machinery already meant "dark and nothing will open it", so this costs one local and three guards: the watcher, the PLAYER_ENTERING_WORLD re-check, and the readiness ticker (which exists to REOPEN the gate and would otherwise have undone the switch at 2Hz). · PIH_SetGateEnabled now early-outs while the feature is off -- both its branches mean SHOW, and ticking the cooldown option while disabled lit the helper up. BACK-COMPAT: `enabled` is absent on every existing profile. The engine reads `s.enabled ~= false`, so absent is ON -- a profile with helper records had a working helper and must come back working. The panel's accessor backfills once from PIH_Exists(), so a profile with NO records comes back off and the tick reads honestly. ⚠ `enabled` is deliberately NOT in PIH_Settings' default table: seeding it there would make the backfill unreachable, which is the defaults-before- migration trap this addon has hit three times. DELETED, because nothing serves the delete model any more: P.PIH_Create, P.PIH_Remove, pihStashHits, pihKeptCfg, pihKeptSurfaces, pihRestoreInto, PIH_RECIPE_OWNED, pihDeepCopy. Net -100 lines. ⚠ TWO CONSEQUENCES, SAID OUT LOUD SO NEITHER IS REDISCOVERED AS A BUG: · The three "Power Infusion Helper" custom filters are no longer auto-deleted. PIH_Remove used to drop them once no mark remained anywhere. Nothing removes them now -- correct, because the records referencing them are no longer removed either. They are ordinary custom filters, visible and deletable in the Filter Designer. A lingering list is cruft; a silently deleted one that an effect still points at is a broken effect. · adDB.pihelper.retainedCfg survives in old profiles, inert. Not swept: a migration that deletes data to tidy up is a worse trade than the bytes. ⚠ First enable on a profile with nothing still seeds the default border, 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 landing on 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. Verified: 170 files parse, no new globals (the _ENV diff caught pihRestoreInto and pihKeptSurfaces going nil mid-edit), zero unused PIH symbols, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 34b6452b8476a35880e9b2fe1f182578a5ac906b) --- DandersFrames/AuraDesigner/Engine.lua | 66 ++- .../AuraDesigner/UI/Cards.lua | 393 +++++------------- 2 files changed, 166 insertions(+), 293 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index bf61b900..96564a43 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -372,6 +372,26 @@ 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 @@ -386,10 +406,16 @@ 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 + -- Re-asked every tick, not only at start: the switch can move under us. + if not pihEnabled then return end if pihReadReady() then pihStopTicker() if not pihGateOpen then pihSet(false) end @@ -411,8 +437,32 @@ end -- the setting says. local pihGateEnabled = true +-- 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 pihReadReady()) -- resume from the real cooldown state + 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 if not pihGateEnabled then pihManual = nil pihSet(false) -- open, and nothing will shut it @@ -464,6 +514,7 @@ function Engine:PIH_ApplySaved() end pihManual = nil pihGateEnabled = true + 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 @@ -476,6 +527,13 @@ function Engine:PIH_ApplySaved() DF.AuraContainer.SetHelperExcludedRoles(any and s.roles or nil) end 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) @@ -559,6 +617,7 @@ pihWatcher:SetScript("OnEvent", function(_, event, unit, _, spellID) 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,7 +638,10 @@ 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. + if not pihEnabled or not pihGateEnabled 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 676f1575..90a317d9 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -609,6 +609,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 @@ -619,6 +642,10 @@ function P.PIH_Apply() -- Gate off means "never hide": force the gate open and leave it there. local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine 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 @@ -626,120 +653,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, -} - --- ☠☠ THE STASH HELD ONE SURFACE PER SIGNAL, AND THE SIGNAL CAN HOLD SEVERAL (fixed --- 2026-09-09). It was written when a signal WAS one effect, so `retainedCfg[key]` was one --- {surface, cfg} pair -- and the multi-surface work never revisited it. --- ⇒ Krathe: "when I disable the PI tracker, it seems to remove my border effect I added." --- Exactly what it did. Disable deleted every marked effect, stashed ONE of them, and --- re-enabling rebuilt that one. A border, an icon and a square went in; a border came back, --- and the other two were gone with nothing anywhere that remembered them. --- ⇒ The stash is a LIST now, in menu order (pihFoundAll sorts), and PIH_Create rebuilds all --- of it. The promise the enable tick has always made -- "this behaves like a switch even --- though records really are created and deleted underneath" -- is only true if it does. --- --- ⚠ THE OLD SHAPE IS STILL READ. A profile stashed by a previous build carries --- {surface=, cfg=}; pihKeptCfg and pihKeptSurfaces answer for both, so nobody's retained --- customisation is lost by the fix for losing retained customisations. -local function pihStashHits(key, hits) - local s = P.PIH_Settings() - if not s or not (hits and hits[1]) then return end - s.retainedCfg = s.retainedCfg or {} - local surfaces, order = {}, {} - for _, hit in ipairs(hits) do - -- The surface rides along: a stash is only restored onto the SAME surface, because - -- the surfaces do not share a settings vocabulary (a border has a style and a - -- thickness; a health bar has Replace-vs-Tint). - if type(hit.cfg) == "table" and not surfaces[hit.typeKey] then - surfaces[hit.typeKey] = pihDeepCopy(hit.cfg) - order[#order + 1] = hit.typeKey - end - end - s.retainedCfg[key] = { surfaces = surfaces, order = order } -end - --- One stashed surface's cfg, from either shape. -local function pihKeptCfg(key, surface) - local s = P.PIH_Settings() - local kept = s and s.retainedCfg and s.retainedCfg[key] - if type(kept) ~= "table" then return nil end - if type(kept.surfaces) == "table" then - local c = kept.surfaces[surface] - return (type(c) == "table") and c or nil - end - if kept.surface == surface and type(kept.cfg) == "table" then return kept.cfg end - return nil -end - --- Every stashed surface for a signal, in the order they should be rebuilt. -local function pihKeptSurfaces(key) - local s = P.PIH_Settings() - local kept = s and s.retainedCfg and s.retainedCfg[key] - if type(kept) ~= "table" then return {} end - if type(kept.order) == "table" and kept.order[1] then return kept.order end - if kept.surface then return { kept.surface } end - return {} -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 kept = pihKeptCfg(key, surface) - if not kept then return end - for k, v in pairs(kept) do - if not PIH_RECIPE_OWNED[k] then cfg[k] = pihDeepCopy(v) end - end -end - --- ☠ THE GROUP STASH IS GONE WITH THE GROUPS (schema 5, 2026-09-09). PIH_GROUP_OWNED, --- pihStashGroup and pihRestoreGroup kept a deleted cooldown-icon group's position, size and --- appearance so a re-tick brought it back where the user had dragged it. There is no re-tick --- and no group any more -- pihSweep step 5 deletes both the groups and the stash -- so a --- writer with no reader is all that would be left, which is the dead-mechanism half of the --- lying-control problem this file keeps auditing itself for. The EFFECT stash above is --- untouched and still does its job: unticking Enable really does delete records, and --- pihRestoreInto is what makes that behave like a switch. +-- ☠☠ 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 @@ -854,7 +780,11 @@ local function pihCreateSignal(key, surfaceOverride) -- ⚠ 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. - local tgt = surfaceOverride or pihKeptSurfaces(key)[1] or def.surface + -- ⚠ 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 @@ -930,11 +860,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 @@ -968,10 +894,6 @@ 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 @@ -1422,167 +1344,48 @@ end -- 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. -function P.PIH_Create() - -- ⭐ EVERY SURFACE THE STASH REMEMBERS, not just one. Turning the tick back on has to put - -- back what turning it off took away -- see pihStashHits for the shape and for the report - -- that found it ("it seems to remove my border effect I added"). Named explicitly rather - -- than left to pihCreateSignal's fallback, which can only choose one. - -- ⚠ FIRST WINS FOR THE RESULT. A later surface refusing ("already on") must not turn a - -- successful rebuild into a failure, and a first-surface failure is the one worth - -- reporting -- so `ok` latches true and `why` keeps the first refusal. - local ok, why - local wanted = pihKeptSurfaces("burst") - if wanted[1] then - for _, surface in ipairs(wanted) do - local o, w = pihCreateSignal("burst", surface) - if o then ok = true elseif not why then why = w end - end - else - ok, why = pihCreateSignal("burst") - end - -- 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() - -- ⚠ pihFoundAll, NOT pihFound: the latter answers ONE hit per signal, which is what made - -- this stash a quarter of the user's work and this delete a guess at the rest. - local all = pihFoundAll() - local names, n = {}, 0 - - -- STASH FIRST, EVERY SURFACE, BEFORE ANYTHING IS REMOVED. - for key, hits in pairs(all) do pihStashHits(key, hits) end - - -- ☠ THE MARKED EFFECTS GO, ONE AT A TIME -- NOT THE WHOLE RECORD. - -- It used to nil the pool key outright, on the argument that "a record here is identified - -- BY a helper spell list, so nothing of the user's own can be sitting on it". That is not - -- true: the designer's From a Filter flow will happily hang an effect off the Power - -- Infusion Helper filter, and that effect lands on this exact record. Turning the helper - -- off would have taken it with no warning and no stash. - -- ⚠ Precise removal is also what the multi-surface shape needs anyway -- there is no - -- longer one effect per record to reason about. - for _, hits in pairs(all) do - for _, hit in ipairs(hits) do - local auraCfg = pool and pool[hit.auraName] - if auraCfg then - if hit.indicatorID and type(auraCfg.indicators) == "table" then - for i, inst in ipairs(auraCfg.indicators) do - if inst.id == hit.indicatorID then - table.remove(auraCfg.indicators, i) - break - end - end - else - auraCfg[hit.typeKey] = nil - end - names[hit.auraName] = true - n = n + 1 - end - end - end - - -- ...and the helper-owned SOUND entry, which is the reason the wholesale delete existed. - -- The generic effects list refuses to show `sound` on a filter-owned record (Groups.lua), - -- so it offers no delete button for it and anything left behind there is unreachable. - -- Clearing that one key keeps the original intent without taking the user's own effects - -- with it; the record itself goes only once it holds nothing at all. - for name in pairs(names) do - local auraCfg = pool and pool[name] - if type(auraCfg) == "table" then - auraCfg.sound = nil - if P.AuraHoldsNoEffects and P.AuraHoldsNoEffects(auraCfg) then - pool[name] = nil - end - 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 +-- ⚠ 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 end - - -- ⚠ A BELT-AND-BRACES SWEEP, NOT A LIVE PATH ANY MORE. Nothing creates a cooldown-icon - -- group since schema 5 and pihSweep deletes the ones older builds made -- but a profile - -- can arrive here unswept (a preset switched into after this panel was last opened), and - -- a helper being removed must not leave icons running whatever made them. - -- ⚠ THE SAME STORE-WIDE PURGE THE SWEEP RUNS, and for the same reason: this used to hunt - -- one store by id through a store-routed delete, which is how eight of Krathe's groups - -- survived three attempts to remove them. - pihPurgeStrayMarks() - - -- ☠ 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 - + P.PIH_Apply() pihRefresh() - -- ⚠ EFFECTS, not signals: `n` counts every marked surface removed now that a signal can - -- hold several. The old wording said "signal(s)" while counting one per signal, which was - -- true then and would have quietly under-reported ever since. - return true, ("removed %d effect(s) and their spell lists"):format(n) + return s.enabled 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() -- PIH_Settings hands back a bare table when there is no Aura Designer config to write to, @@ -6434,7 +6237,11 @@ S.BuildPIHelperCard = function(parent, opts) pihSweep() local yPos = opts.startY or 0 local Refresh = opts.Refresh or function() end - local exists = P.PIH_Exists() + -- ⚠ 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 -- @@ -6465,14 +6272,17 @@ S.BuildPIHelperCard = function(parent, opts) local cb = CreateFrame("CheckButton", nil, banner, "BackdropTemplate") cb:SetPoint("TOPLEFT", banner, "TOPLEFT", 10, -10) DF.GUI:StyleCheckButton(cb) - cb:SetChecked(exists) + cb:SetChecked(enabled) cb:SetScript("OnClick", function(self) - -- ⚠ READ THE WORLD, NOT THE BOX. The tick's own state is what the user just did; - -- whether a helper exists is what the pool says. A double click, a profile switch - -- landing mid-build or a stale page would otherwise create a second helper or try - -- to remove one that is already gone. - if P.PIH_Exists() then P.PIH_Remove() else P.PIH_Create() end - self:SetChecked(P.PIH_Exists()) + -- ⚠ 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) @@ -6494,10 +6304,11 @@ S.BuildPIHelperCard = function(parent, opts) banner.checkbox = cb yPos = yPos - (banner:GetHeight() + GUI.Space.section) - -- ☠ THE SECOND RETURN STILL GATES THE SECTIONS, but on existence alone now. 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, exists and true or false + -- ☠ 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 ── From ebdea0f109c38ff2b59c89503ffb578046bfa326 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 12:47:27 +0100 Subject: [PATCH 23/66] PI Helper: no stack count on a marker that can never have stacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "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." It was worse than off-by-default: showStacks DEFAULTS TRUE for icons and squares (AuraDesigner/UI/Options.lua), so every helper marker ever created has been drawing one. And on the icon it is a number read off whichever cooldown matched, printed on artwork pinned to Power Infusion -- a count describing a spell the picture is not of. · The whole Stack Count GROUP is skipped, not just the tick: Font, Scale, Outline, Anchor, Offset X/Y and Colour are seven more controls for a number that is never drawn, and leaving them while unticking one is how a panel fills up with settings that do nothing. · HIDDEN, not greyed -- the opposite call from GateSWM two hundred lines up, 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. 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 -- stamped at creation, and schema 7 backfills the markers already out there -- so the record says what the frame draws rather than the panel hiding a true value. ⚠ Step 7 walks EVERY aura pool, like step 5, because an old marker can be sitting in a spec pool; pihPurgeStrayMarks documents how they got there. Placed only: a frame-level effect has no stacks to begin with. Set rather than cleared, because an absent field means "inherit" and the inherited default is the true one. What survives on a helper effect and why: Show Duration and Hide Cooldown Swipe both describe the TRIGGERING cooldown's remaining time, which is how long the burst window lasts -- genuinely useful. Show When Missing was already greyed with its reason. Verified: 170 files parse, no new globals, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 1df60d591dd028f48047e8ed7283a6df44d6b9e2) --- .../AuraDesigner/UI/Cards.lua | 35 ++++++++++++++++++- .../AuraDesigner/UI/Indicators.lua | 23 ++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 90a317d9..6cfb3a53 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -851,6 +851,14 @@ local function pihCreateSignal(key, surfaceOverride) -- 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. if tgt == "icon" then inst.staticSpellID = PIH_PI_SPELL_ID 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 @@ -6036,7 +6044,11 @@ P.OpenFilterPopout = OpenFilterPopout -- 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. -local PIH_SCHEMA = 6 +-- 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. +local PIH_SCHEMA = 7 local function pihSweep() local s = P.PIH_Settings() @@ -6216,6 +6228,27 @@ local function pihSweep() 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 + s.schema = PIH_SCHEMA S.activeBuffTab = prevPool if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index 9a77733d..71a0690e 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. @@ -814,6 +831,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 +844,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 +975,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 +988,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 From 35e6f1307eba531b47b2739e7430a873237d538a Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 19:01:38 +0100 Subject: [PATCH 24/66] PI Helper: the icon picks its picture and what it is allowed to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "let them pick what kind of icon it is and what it includes." Two ticks, first group on the icon card, helper effects only: Show the triggering cooldown's icon -- off = Power Infusion (default) Ignore trinkets, potions and racials -- off = anything that triggered it WHY THE SECOND ONE EXISTS. A placed icon renders ONE slot. With 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 -- and the "Also count" ticks make that COMMON rather than rare, because racials and trinkets are things people press ALONGSIDE a cooldown. That is what made them "amplifiers" in the first place. So the ambiguity is worst exactly where those ticks are on, and this is the control that resolves it. ☠ IT RIDES mutedSpellIDs -- THE SAME STORE THE TRACKED IDS TICKS WRITE, not a parallel one. That is why it needs no new render concept: the resolver already narrows a placement by its mutes (narrowByPlacementMutes), already treats "everything muted" as "matches nothing", and the per-ID ticks are the same setting at maximum resolution. A coarse control and a fine control over one store, and unticking clears only OUR ids so a hand-set mute survives. ☠ 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. A stored boolean would sit there claiming "ignored" while racials appeared. Same doctrine as the class ticks. ☠ THE PICTURE IS staticSpellID's PRESENCE, with no second field recording the choice: the field that does the thing is the field the control reads. Structural, so it rebuilds rather than restyles -- bindNative's SetIcon bind is once per slot and placedStructSig already carries the flag. ⚠ 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. Both verbs take a record rather than assuming one, so it generalises if asked. ⚠ The add tile keeps the Power Infusion artwork -- it is the default, and a tile that changed picture per instance would be describing one effect rather than the type. Only one icon can exist per helper, so the list stays unambiguous. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit dd7deee4929d5393bd1729016a41c8c037e090e0) --- DandersFrames/Locales/enUS.lua | 9 +++ .../AuraDesigner/UI/Cards.lua | 67 +++++++++++++++++++ .../AuraDesigner/UI/Indicators.lua | 43 ++++++++++++ 3 files changed, 119 insertions(+) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 09d2df25..82fdb145 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3042,6 +3042,15 @@ L["Potions"] = true -- which is true in the designer and false here: a helper icon is PINNED to Power Infusion, so -- it says "infuse this player" rather than reporting which cooldown they pressed. L["Shows the Power Infusion icon."] = 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 +L["Ignore trinkets, potions and racials"] = true +L["This icon only — the Triggers tab still decides what the helper watches. Useful with the icon showing their cooldown, since an amplifier is usually pressed alongside one."] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 6cfb3a53..10f304b3 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -731,6 +731,73 @@ 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 + +function P.PIH_IgnoresAmplifiers(rec) + local ids = P.PIH_AmplifierIDs() + -- Nothing ticked = nothing to ignore. Reads false so the box is not claiming to suppress + -- an empty set, which would tick itself the moment the user turned one on. + if not ids[1] then return false end + local m = type(rec) == "table" and rec.mutedSpellIDs + if type(m) ~= "table" then return false end + for _, id in ipairs(ids) do + if not m[id] then return false end + end + return true +end + +function P.PIH_SetIgnoreAmplifiers(rec, on) + if type(rec) ~= "table" then return end + local ids = P.PIH_AmplifierIDs() + if on then + rec.mutedSpellIDs = rec.mutedSpellIDs or {} + for _, id in ipairs(ids) do rec.mutedSpellIDs[id] = true end + elseif type(rec.mutedSpellIDs) == "table" then + -- ⚠ ONLY OUR IDS COME BACK OUT. A per-ID tick the user set by hand on some other + -- cooldown is theirs and is not ours to clear. + for _, id in ipairs(ids) do rec.mutedSpellIDs[id] = nil end + if not next(rec.mutedSpellIDs) then rec.mutedSpellIDs = nil end + end + pihRefresh() +end + +-- 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 + rec.staticSpellID = on and nil or PIH_PI_SPELL_ID + pihRefresh() +end + local function pihSyncTriggerExtras(s) local R = DF.FilterRegistry if not R then return end diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index 71a0690e..96204d9d 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua @@ -658,6 +658,49 @@ 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) + + local ampCb = GUI:CreateCheckbox(parent, + L["Ignore trinkets, potions and racials"], nil, nil, + function() if RPL then RPL() end end, + function() return P.PIH_IgnoresAmplifiers(pihRec) end, + function(v) P.PIH_SetIgnoreAmplifiers(pihRec, v) end) + ampCb.tooltip = L["This icon only — the Triggers tab still decides what the helper watches. Useful with the icon showing their cooldown, since an amplifier is usually pressed alongside one."] + g:AddWidget(ampCb, 28) + end) + end + end + AddGroup(L["Show When Missing"], function(g) local desatCb local function UpdateDesatState() From 11e8d1dd740c6269353cb28e3eb1906d48d52921 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 19:17:18 +0100 Subject: [PATCH 25/66] Filters: a list WE seeded toggles and resets, instead of being torn apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "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." THE TWO KINDS OF CUSTOM FILTER ARE NOT THE SAME THING, and the ✕ was treating them as if they were. A list the user built: membership IS the truth, so removing a spell is exactly the right verb -- they put it there. A list WE seeded from a curated set: removing a spell was destructive, unrecoverable, and there was no way back to the default. The Power Infusion Helper's cooldown list is the second kind and had the first kind's controls. ⭐ SO A CURATED FILTER NOW GETS WHAT A PRESET HAS, deliberately in the same shape so the UI can offer the same controls: preset curated custom hand-built custom IsSpellEnabled IsCustomSpellEnabled (none -- keeps the ✕) SetSpellEnabled SetCustomSpellEnabled ResetPreset ResetCuratedFilter · The state lives ON THE FILTER (f.disabled), not in the preset overrides table: it is per-filter data with the filter's own lifetime, so it travels with a profile export and dies with a DeleteCustomFilter. · ABSENT MEANS ENABLED, so every existing custom filter resolves byte-for-byte as before and only one actually ticked off carries anything. · f.dfDefaults is the mark AND the answer -- its presence says "we seeded this", its contents say what default means. One field, so they cannot disagree. pihEnsureFilter stamps it on every call, not only on create, so a profile made before the mark existed picks it up. · ResolveSelection honours the ticks at the ONE chokepoint every custom-filter expansion already goes through, mirroring the preset arm three lines up. · The existing Reset button serves both. Shown only when there is something to undo, which for a curated list means `disabled` is non-empty -- the same question IsPresetModified asks. ⚠ RESET RESTORES, IT DOES NOT PRUNE. Everything we seeded comes back and every tick comes back on; a spell the USER added to our list stays. "Reset to default" is reached for to undo what you turned off -- one that also threw away your own additions would be the destructive act this exists to replace. Verified: 170 files parse, one new global (`next`, stdlib), byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 6055292d72c02b61e008e67158bf16d559fba05a) --- DandersFrames/FilterRegistry/Registry.lua | 90 ++++++++++++++++++- .../AuraDesigner/UI/Cards.lua | 21 +++++ .../FilterRegistry/UI/Options.lua | 55 ++++++++++-- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/DandersFrames/FilterRegistry/Registry.lua b/DandersFrames/FilterRegistry/Registry.lua index c5c86d14..f99b5bd8 100644 --- a/DandersFrames/FilterRegistry/Registry.lua +++ b/DandersFrames/FilterRegistry/Registry.lua @@ -161,6 +161,82 @@ 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 + +-- 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) @@ -1083,9 +1159,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 +1175,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_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 10f304b3..f55fdb31 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -326,6 +326,27 @@ 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 diff --git a/DandersFrames_Options/FilterRegistry/UI/Options.lua b/DandersFrames_Options/FilterRegistry/UI/Options.lua index 64a3e607..8429d3dc 100644 --- a/DandersFrames_Options/FilterRegistry/UI/Options.lua +++ b/DandersFrames_Options/FilterRegistry/UI/Options.lua @@ -1697,11 +1697,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 +2313,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 +2722,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 +2849,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 @@ -3225,6 +3261,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 +3296,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) From 689338074adc53377e8cc93e171a84b913208bdc Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 21:44:07 +0100 Subject: [PATCH 26/66] Filters: stamp the curated mark on a helper that already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark shipped and reached nobody. dfDefaults is what gives our seeded list the on/off tick and the Reset button, and it is written 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 had the destructive ✕ and no Reset, one round after I said the stamp would reach him. WHAT I GOT WRONG: I wrote "pihEnsureFilter stamps it on every call, not only on create, so a profile made before the mark existed picks it up". True and useless -- the question is not what the function does when called, it is who calls it. A mark written by a CREATE path reaches nobody who already has the thing. · pihSweep step 8 stamps both seeded lists from the same seed functions pihEnsureFilter would have used. Not from the list's CURRENT contents: default means what the recipe seeds, so anything the user added since is theirs and stays out of what a reset restores. · ...and the sweep now also runs on the FILTER DESIGNER's page build. It ran only on the Aura Designer's, so opening the Filter Designer first -- exactly what someone inspecting that list does -- showed the unmarked version. Schema-stamped and priest-gated, so it is one comparison after the first run. Verified: 170 files parse, no new globals, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 970cd22311316a4fa7dc60b36b31906a98bdf674) --- .../AuraDesigner/UI/Cards.lua | 29 ++++++++++++++++++- DandersFrames_Options/GUI/Pages/Auras.lua | 13 +++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index f55fdb31..47fe7793 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6136,7 +6136,17 @@ P.OpenFilterPopout = OpenFilterPopout -- 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. -local PIH_SCHEMA = 7 +-- 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 = 8 local function pihSweep() local s = P.PIH_Settings() @@ -6337,6 +6347,23 @@ local function pihSweep() 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 + s.schema = PIH_SCHEMA S.activeBuffTab = prevPool if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end diff --git a/DandersFrames_Options/GUI/Pages/Auras.lua b/DandersFrames_Options/GUI/Pages/Auras.lua index abf868a4..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*, From 670c0724bb7f1f6a1ccdcff59f29b4b663511db0 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 21:48:36 +0100 Subject: [PATCH 27/66] Filters: the count follows the ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "the number does not change as I tick them on/off." THREE PLACES COUNTED THE SAME THING, THREE WAYS, AND NONE OF THEM ASKED WHETHER A SPELL WAS ON. The Filter Designer's left list had a local CustomSpellCount, its right-hand header called that same local, and R:ListFilters -- which the Buff Bar's picker reads -- walked the tables itself and set `enabled = n, total = n` unconditionally. All three were written when membership was the only state a custom filter had, and none was revisited when the ticks arrived. A count that ignores the control next to it is worse than no count. · R:CustomFilterCounts(cfId) is the one counter now, and it honours the ticks. A hand-built filter answers enabled == total -- IsCustomSpellEnabled is true with no disabled set -- so no caller branches on which kind of list it is. · The left row shows "34/39" once something is off and the plain total otherwise, plus the modified dot a preset row carries (R:IsCuratedFilterModified, the same question IsPresetModified asks). · The header does the same: "%d spells" until something is off, then "%d of %d spells". · The local CustomSpellCount is gone rather than left beside its replacement. ⚠ An ordinary custom filter's row and header are unchanged in every case, because enabled and total are equal for a list that cannot have anything switched off. Verified: 170 files parse, no new globals, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 31f6acb8d656ee67f7c92f70d95a3b765f26e747) --- DandersFrames/FilterRegistry/Registry.lua | 42 ++++++++++++++++--- DandersFrames/Locales/enUS.lua | 3 ++ .../FilterRegistry/UI/Options.lua | 24 +++++++---- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/DandersFrames/FilterRegistry/Registry.lua b/DandersFrames/FilterRegistry/Registry.lua index f99b5bd8..4cb19bf2 100644 --- a/DandersFrames/FilterRegistry/Registry.lua +++ b/DandersFrames/FilterRegistry/Registry.lua @@ -202,6 +202,37 @@ function R:SetCustomSpellEnabled(cfId, spellID, enabled) 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) @@ -835,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 diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 82fdb145..6acc8157 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -469,6 +469,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 diff --git a/DandersFrames_Options/FilterRegistry/UI/Options.lua b/DandersFrames_Options/FilterRegistry/UI/Options.lua index 8429d3dc..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() @@ -2998,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 @@ -3093,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. From 89612b88a4ecfad0ef7c289b16ab46d788786784 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 21:55:04 +0100 Subject: [PATCH 28/66] PI Helper: four trigger sources, each a tick with its count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "you should select them and it should show the number active next to them in a similar way to how you pick on the buff bar", and before that the question that exposed it -- "I assume Cooldowns is our custom filter for PI helper and the other 3 are normal filters?" A reasonable assumption, and wrong, which is the panel's fault. "Cooldowns" was a HEADING above a link, with no tick to say it was on. The other three were captioned "Also count" -- counted "also" to something the panel never named. All four were one custom list underneath and nothing said so. Now: Trigger Filters, four rows, four ticks, the number in each. ☑ Cooldowns 39/45 our curated list ☐ Trinkets 41 trinketsItems ☐ Potions 6 consumables ☐ Racials 4 the four offensive ones, not the category's thirteen · P.PIH_CooldownsOn / SetCooldownsOn give the base source the tick it never had. Read off the LIST, like the class ticks -- a stored boolean beside it is a second copy that drifts the moment anyone edits the filter by hand. Surgical like pihApplyClass: the seeded ids only, so a spell the user added to our list survives being switched off and back on. · Cooldowns' count is enabled/total from R:CustomFilterCounts, so ticking a spell off in the Filter Designer moves the number here. ⚠ That is a DIFFERENT question from the row's own tick: a spell ticked off is still IN the list, so the source stays included. The row shows both, which is why it needs both. · Racials says 4 because it IS 4. The `racials` category is every racial ability and nine of its thirteen -- Shadowmeld, Darkflight, Stoneform -- are the opposite of worth infusing behind, which is why the curated set exists. · The Filter Designer link moved down with them, so the whole filter block reads as one thing instead of a heading at the top and three ticks at the bottom. ⚠ STILL ONE CANDIDATE LIST UNDERNEATH, deliberately. A helper effect resolves from ONE filter ref -- its record key -- and a placed indicator has no union at all, so giving it one means changing the Factory's resolve path for every indicator in the addon. The ticks go on meaning "put these spells in the list", which is what they always did; what changed is that the panel says so, names each source and counts it. If per-effect sources are ever wanted, that is the Factory change, not this. ☠ The _ENV globals diff earned its keep twice here: the moved block closed over pihAddClasses' `parent` local, and a multi-line splice left 65 LF lines in a CRLF file. Both invisible to luac -p. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit e56e040793f486a28824a28be12531da1d719822) --- DandersFrames/Locales/enUS.lua | 15 +- .../AuraDesigner/UI/Cards.lua | 210 ++++++++++++------ 2 files changed, 151 insertions(+), 74 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 6acc8157..7faf3c88 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3028,13 +3028,18 @@ 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 three extra trigger ticks, under the class list. They used to feed a separate spell --- list that only the cooldown-icon group ever read; that group is retired, so they write into --- the ONE list the helper matches on -- ticking Trinkets makes a trinket proc fire whatever --- effects have been added on the Effects tab. "Also count" is the caption above them. +-- ★ THE FOUR TRIGGER SOURCES, each a tick with its own count -- the shape the Buff Bar +-- uses to pick its filters. "Cooldowns" was a HEADING before, above a link, with no tick to +-- say it was on; the other three were captioned "Also count", counted "also" to something +-- the panel never named. Krathe, 2026-09-09: "you should select them and it should show the +-- number active next to them in a similar way to how you pick on the buff bar." +-- ⚠ The numbers are appended to these labels at render time and need no translating. +L["Trigger Filters"] = true +-- Three of the four trigger sources. 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. The fourth is L["Cooldowns"], our own curated list. -- ⚠ L["Cooldowns"] is kept: the tick that used it is gone, but the string is a plain noun -- other surfaces use. -L["Also count"] = true L["Cooldowns"] = true L["Trinkets"] = true L["Potions"] = true diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 47fe7793..eb5946dd 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1498,6 +1498,40 @@ end -- ⚠ 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. +-- ★ THE COOLDOWNS SOURCE, AS A TICK LIKE THE OTHER THREE (2026-09-09). +-- ☠ IT WAS ALWAYS-ON AND HAD NO CONTROL, which is what made "Cooldowns" read as a heading +-- rather than a source -- and left the panel unable to say what it was the other three were +-- being counted "also" to. A tick per source, and the same question asked of each. +-- ⚠ READ OFF THE LIST, like the class ticks and for the same reason: the list is the truth, +-- and a stored boolean beside it is a second copy that drifts the moment anyone edits the +-- filter by hand. On means "any of the seeded cooldowns is still in it". +-- ⚠ NOT THE SAME QUESTION AS R:CustomFilterCounts' `enabled`. A spell ticked OFF in the +-- Filter Designer is still IN the list, so this stays on -- "is this source included at all" +-- and "how much of it is switched on" are different, and the row shows both. +function P.PIH_CooldownsOn() + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + local f = id and R and R.GetCustomFilter and R:GetCustomFilter(id) + if not f then return false end + for _, sid in ipairs(pihSeedIDs()) do + if f.spells[sid] or f.rawIDs[sid] then return true end + end + return false +end + +-- ⚠ SURGICAL, exactly like pihApplyClass: it adds or removes the SEEDED ids and nothing +-- else, so a spell the user put in our list by hand survives being switched off and back on. +function P.PIH_SetCooldownsOn(on) + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + if not (id and R) then return end + for _, sid in ipairs(pihSeedIDs()) do + if on then R:AddSpellToCustom(id, sid) + elseif R.RemoveSpellFromCustom then R:RemoveSpellFromCustom(id, sid) end + end + pihRefresh() +end + function P.PIH_SetAmplifier(which, on) local s = P.PIH_Settings() s[which] = on and true or false @@ -6832,54 +6866,9 @@ 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 - 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 - 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."]) - 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 @@ -6922,35 +6911,118 @@ end -- ⚠ 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. - -- ★★ WHAT ELSE COUNTS AS WORTH INFUSING -- three ticks under the class list. - -- ☠ THIS WAS "Icons", AND THE HEADING WAS THE BUG. Four ticks sat under it: a - -- "Cooldowns" one that created a Filter Group of live cooldown icons, and these three, - -- which fed a second spell list that only that group ever read. So a control captioned - -- Icons, filed under Classes and Cooldowns, was the only way to switch a whole display on - -- -- and once the display was on, nothing on the page said where it had come from. - -- Krathe found the result rather than the control: "I have stuck PI Helper Cooldown - - -- Icons on my AD despite that not even being an option now for PI helper." - -- ⇒ The icons tick is gone with the group (pihSweep step 5). These three stay, because - -- they are genuinely triggers -- "this CD/trinket being used" is Krathe's own definition - -- of one -- and they now write into the ONE list the effects match on, so ticking - -- Trinkets makes a trinket proc light whatever the user has added on the Effects tab. - -- ⚠ THEY ARE STILL Maelareth's (#263). The setting survives; only its consumer moved. - -- ⚠ READ OFF THE SETTING, not off the list. The class ticks read the list because a - -- class OWNS its spells and hand edits there are meaningful; these three are categories - -- that overlap nothing and whose ids are pulled from a shared database, so the stored - -- choice is the only stable record of what was asked for. - local function pihAddTriggerExtras(g, t) + -- ★★★ THE TRIGGER FILTERS — WHICH LISTS FIRE THE HELPER, PICKED THE WAY THE BUFF BAR + -- PICKS ITS OWN (2026-09-09). + -- + -- ☠ THIS WAS THREE TICKS CAPTIONED "Also count" AND A LABEL CAPTIONED "Cooldowns" THAT + -- WAS NOT A CONTROL AT ALL. Krathe read it the way anyone would: "I assume Cooldowns is + -- our custom filter for PI helper and the other 3 are normal filters?" -- a reasonable + -- assumption, and wrong. All four were ONE custom list, with the ticks adding and removing + -- ids inside it, and the one that was always on had no tick to say so. + -- ⇒ Four rows, four ticks, each with the number in it: "select them and it should show the + -- number active next to them in a similar way to how you pick on the buff bar." + -- + -- ⚠ IT IS STILL ONE CANDIDATE LIST UNDERNEATH, and that is deliberate rather than a + -- shortcut. A helper effect resolves from ONE filter ref (its record key) -- a placed + -- indicator has no union at all, and giving it one means changing the Factory's resolve + -- path for every indicator in the addon. So the ticks go on meaning "put these spells in + -- the list", which is what they always did; what changed is that the panel now says so, + -- names each source, and counts it. + -- ⚠ THE COUNTS ARE REAL, NOT DECORATIVE. Cooldowns reports enabled/total off the curated + -- filter itself (R:CustomFilterCounts), so ticking a spell off in the Filter Designer + -- moves the number here too. The other three report how big that source is, because they + -- are all-in-or-all-out. + local function pihSourceCount(catKey) + local R = DF.FilterRegistry + local recs = R and R.ByCategory and R.ByCategory[catKey] + return recs and #recs or 0 + end + + -- ⚠ 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. + local function pihCountLabel(text, a, b) + if b then return text .. " " .. a .. "/" .. b end + return text .. " " .. a + end + + local function pihAddTriggerSources(g, t) + -- ⚠ t.parent, and it is needed because the Filter Designer button at the foot of + -- this function came WITH the block: it was pihAddClasses' tail, where `parent` was + -- that function's own local. Moving code moves what it closes over. + local parent = t.parent local st = P.PIH_Settings() - t.settingLabel(g, L["Also count"]) - t.subCheck(g, L["Trinkets"], + t.settingLabel(g, L["Trigger Filters"]) + + -- COOLDOWNS -- our own curated list, and the only row whose count can move on its own. + local cdID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() + local R = DF.FilterRegistry + local cdOn, cdTotal = 0, 0 + if cdID and R and R.CustomFilterCounts then cdOn, cdTotal = R:CustomFilterCounts(cdID) end + t.subCheck(g, pihCountLabel(L["Cooldowns"], cdOn, (cdOn ~= cdTotal) and cdTotal or nil), + function() return P.PIH_CooldownsOn() end, + function(v) P.PIH_SetCooldownsOn(v) end) + + t.subCheck(g, pihCountLabel(L["Trinkets"], pihSourceCount(PIH_SEED.amplifiers.trinkets)), function() return st.trinkets == true end, function(v) P.PIH_SetAmplifier("trinkets", v) end) - t.subCheck(g, L["Potions"], + t.subCheck(g, pihCountLabel(L["Potions"], pihSourceCount(PIH_SEED.amplifiers.potions)), function() return st.potions == true end, function(v) P.PIH_SetAmplifier("potions", v) end) - t.subCheck(g, L["Racials"], + -- ⚠ FOUR, NOT THIRTEEN, and the count says four so the row cannot mislead. `racials` + -- is every racial ability, and nine of the thirteen -- Shadowmeld, Darkflight, + -- Stoneform and the rest -- are the opposite of worth infusing behind. PIH_RACIAL_IDS + -- is the hand-picked offensive set; see its own note for why the data cannot pick them. + t.subCheck(g, pihCountLabel(L["Racials"], #PIH_RACIAL_IDS), function() return st.racials == true end, function(v) P.PIH_SetAmplifier("racials", v) end) + + 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 + 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 + 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."]) + end -- ★★★ THE HELPER'S ADD BLOCK — the designer's own tiles, with the spell question removed. @@ -7127,7 +7199,7 @@ S.BuildPIHelperBody = function(parent, opts) -- Krathe, 2026-09-08: "We seem to have two links to it? and confusing messaging." yPos = t.group(L["Classes and Cooldowns"], function(g) pihAddClasses(g, t) - pihAddTriggerExtras(g, t) + pihAddTriggerSources(g, t) end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) end From 5496c6a38ebeca9e4db8b83214043e52eb5171b1 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 22:04:03 +0100 Subject: [PATCH 29/66] PI Helper: multiple icons, the icon's picture picked with pictures, and the link says what it reaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from one screenshot. 1. MULTIPLE ICONS -- the cooldown-icon group, back on purpose. ☠ A version of this shipped, stuck on Krathe's frames and took three attempts to delete. Every one of those failures was PLUMBING, not the idea: · created through the pool-routed CreateLayoutGroup from whatever tab was open, so it landed in the SPEC store where no finder looked · switched on by a tick captioned "Icons" buried under Classes and Cooldowns · 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 -- never through the creator whose store depends on the open tab · added by a TILE on the helper's own add grid, beside the surfaces · the helper's Layout Groups sub-tab is back, and VisibleLayoutGroups already shows exactly the marked groups there, so it can be moved, sized and deleted like any other group · pihPurgeStrayMarks is unchanged and still finds a marked group in ANY store, so the recovery path that cleaned up the old one still exists It earns its place because a placed icon is ONE slot showing one arbitrary match; this shows every cooldown they actually have up, one icon each. 2. THE ICON PICKS ITS PICTURE WITH PICTURES. 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 -- those two 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. A SECOND STEP rather than two icon tiles, because a signal holds one effect per surface: two icon tiles would both be "icon" and the second could never be added. The tick on the card stays -- it is how you change your mind without deleting and re-adding. 3. THE FILTER DESIGNER LINK REACHES ALL FOUR LISTS, so it no longer sits under a "Cooldowns" heading naming one of them, and its note no longer says "single cooldowns". Krathe: "the cooldowns header/footer of the Filter Designer link is wrong, as really they can modify all 4 filters in it." The button belongs to the tick list it follows -- the escape hatch for what those four cannot do -- so it needs no caption between them at all. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit cbf38d9c209b1f046dba870ff1d6543ac386433f) --- DandersFrames/Locales/enUS.lua | 18 +- .../AuraDesigner/UI/Cards.lua | 289 ++++++++++++++---- 2 files changed, 246 insertions(+), 61 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 7faf3c88..050245df 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3019,7 +3019,10 @@ L["Show even if your Power Infusion is on cooldown"] = true -- dead end. L["Classes and Cooldowns"] = true L["Classes"] = true -L["To add or remove single cooldowns, edit the list in the Filter Designer."] = true +-- ⚠ THE BUTTON REACHES ALL FOUR SOURCES, so the note must not name one. It said "to add +-- or remove single cooldowns, edit the list in the Filter Designer" -- true when there was +-- one list, and a quarter of the truth once the four trigger ticks went in above it. +L["Edit any of these lists spell by spell 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. @@ -3050,6 +3053,19 @@ L["Potions"] = true -- which is true in the designer and false here: a helper icon is PINNED to Power Infusion, so -- it says "infuse this player" rather than reporting which cooldown they pressed. L["Shows the Power Infusion icon."] = true +-- ★ THE HELPER'S ADD GRID. Two steps for the icon -- which KIND of indicator, then which +-- PICTURE -- because those two icons 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. +-- ⚠ "Cooldown Icons" is the GROUP: one icon per cooldown the unit actually has up, as +-- against a single marker that shows one of them. +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, or the cooldown they used."] = true +L["Cooldown Icons"] = true +L["One icon per cooldown they have up, instead of a single marker."] = 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index eb5946dd..3401ea1c 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1283,10 +1283,22 @@ end -- ⚠ 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. -function P.PIH_AddSurface(key, surface) +-- ⚠ `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 if not surface or surface == "none" then return false, "no surface" end local ok, why = pihCreateSignal(key, surface) + -- Applied to the record the create just made, found by its mark -- the create path has + -- no return channel for the instance and does not need one for a single field. + if ok and showsAura and surface == "icon" then + for _, hit in ipairs(pihFoundAll()[key] or {}) do + if hit.typeKey == "icon" and type(hit.cfg) == "table" then + hit.cfg.staticSpellID = nil + end + end + end if ok then P.PIH_Apply() end pihRefresh() return ok, why @@ -1319,6 +1331,76 @@ function P.PIH_ReDerive() if E and E.PIH_ApplySaved then E:PIH_ApplySaved() end end +-- ★★★ 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. +local PIH_ICON_GROUP_NAME = "PI Helper — Cooldowns" + +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 + +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 + -- ☠ 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 + groups[#groups + 1] = { + id = id, + name = PIH_ICON_GROUP_NAME, + kind = "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. + 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. + othersOnly = true, + filterSelection = { presets = {}, customs = { [cdId] = true } }, + anchor = "TOPRIGHT", + offsetX = 0, + offsetY = 0, + growDirection = "LEFT_DOWN", + iconsPerRow = 4, + spacing = 2, + } + pihRefresh() + return true +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. @@ -3251,9 +3333,18 @@ end -- reason it cannot be computed once. local function SubTabDefs() if IsPIHelperTab() then + -- ⚠ LAYOUT GROUPS IS BACK (2026-09-09), and the reason it went is the reason it + -- returns. It was cut because "the helper has no per-spell display to arrange" -- + -- true while every effect was a single marker. The cooldown-icon group is exactly + -- such a display (one icon per cooldown the unit has up), so it needs somewhere to + -- be moved, sized and deleted. VisibleLayoutGroups already shows ONLY the marked + -- groups on this pool, so the tab needs nothing of its own. + -- ☠ AND THAT IS THE OTHER HALF OF WHY THE OLD GROUP GOT STUCK: it had no tab to + -- be configured from. A display with no controls is a display nobody can turn off. return { { key = "global", label = L["Triggers"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, { key = "effects", label = L["Effects"], accent = nil }, + { key = "layout", label = L["Layout Groups"], accent = { r = 0.91, g = 0.66, b = 0.25 } }, } end return { @@ -3270,7 +3361,9 @@ P.SubTabDefs = SubTabDefs -- ⚠ Answers for EVERY pool, so a caller never has to know which one it is on. local function CoerceTabForPool(tabKey) if IsPIHelperTab() then - return (tabKey == "effects") and "effects" or "global" + -- All three of the helper's tabs are real now; only a Debuffs-only key coerces. + if tabKey == "effects" or tabKey == "layout" then return tabKey end + return "global" end if tabKey == "effects" and IsDebuffTab() then return "layout" end return tabKey @@ -6977,12 +7070,15 @@ end function() return st.racials == true end, function(v) P.PIH_SetAmplifier("racials", v) end) - 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. + -- ☠ NO HEADING OF ITS OWN ANY MORE, AND "Cooldowns" WAS THE WRONG ONE TWICE OVER. + -- It captioned this button back when the button opened the one list there was; now there + -- are four sources listed directly above it and the Filter Designer edits ANY of them, + -- so a "Cooldowns" heading here named one quarter of what the button reaches. Krathe, + -- 2026-09-09: "the cooldowns header/footer of the Filter Designer link is wrong, as + -- really they can modify all 4 filters in it." + -- ⚠ The button belongs to the tick list it follows -- it is the escape hatch for what + -- those four ticks cannot do, single spells rather than whole sources -- so it needs no + -- caption between them at all. -- -- ⭐ 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: @@ -7021,7 +7117,7 @@ end -- 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."]) + L["Edit any of these lists spell by spell in the Filter Designer."]) end @@ -7049,29 +7145,142 @@ end -- asking which of two things an effect is about was the question Krathe cut. -- -- Returns the y to carry on at. +-- ★ 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 TWO ICON TILES, because a signal holds one effect per surface: +-- two icon tiles side by side 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. +local pihAddPick = nil -- nil = the surface grid, "icon" = the art choice + 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) + + local head = parent:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(head, 9, "") + head:SetPoint("TOPLEFT", 8, yPos) + head:SetText(pihAddPick and L["Which icon?"] or L["ADD AN INDICATOR"]) + head:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) + yPos = yPos - 18 + + -- ⚠ 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, + tooltip = { title = it.label, lines = { it.desc } }, + Paint = it.Paint, + onClick = it.onClick, + }) + 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 + + -- ── STEP 2: WHICH PICTURE ── + 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 + yPos = grid({ + { label = L["Power Infusion"], accent = accent, + 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 }, + -- ⚠ NO PINNED ART ON THIS TILE, so it draws the designer's placeholder -- which is + -- honest here in a way it was not before: the picture genuinely is not known until + -- a cooldown matches. + { label = L["Their cooldown"], accent = accent, + desc = L["The buff they actually used — one of them, if several are up at once."], + Paint = function(pv) PaintEffectOnThumb(pv, "icon") end, + onClick = function() add(true) end }, + }, yPos) + local back = GUI:CreateButton(parent, L["Back"], 90, 20, function() + pihAddPick = nil + if Refresh then Refresh() end + end) + back:SetPoint("TOPLEFT", 8, yPos) + return yPos - 26 + 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 held = {} for _, s in ipairs(P.PIH_SurfacesOf("burst")) do held[s] = true end - local avail = {} + local items = {} for _, eff in ipairs(P.AddFlowEffects and P.AddFlowEffects() or {}) do - if eff.type ~= "sound" and not held[eff.type] then avail[#avail + 1] = eff end + if eff.type ~= "sound" and not held[eff.type] then + local capturedType = eff.type + local isIcon = capturedType == "icon" + 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, or the cooldown they used."] 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 - 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 + -- ★ THE GROUP IS A CHOICE ON THIS GRID, not a tick somewhere else -- which is half of what + -- went wrong with the last one. Offered only while none exists: a second would be two + -- containers competing for the same corner. + if not (P.PIH_IconGroup and P.PIH_IconGroup()) then + items[#items + 1] = { + label = L["Cooldown Icons"], + accent = BADGE_COLORS.icon or tc, + desc = L["One icon per cooldown they have up, instead of a single marker."], + Paint = function(pv) PaintEffectOnThumb(pv, "icon") 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 + if Refresh then Refresh() end + end, + } + end - if #avail == 0 then - -- Every surface is in use. Not an error and not empty: say so rather than drawing a + 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) @@ -7082,47 +7291,7 @@ local function pihBuildAddTiles(parent, yPos, Refresh) return yPos - (max(none:GetStringHeight(), 12) + 10) end - -- ⚠ THE SAME TILES THE DESIGNER DRAWS: CreateFrameTile with PaintEffectOnThumb, so the - -- picture on each one is the real miniature of that effect rather than a label on a box. - 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) - local rowTop, rowH = yPos, 0 - for i, eff in ipairs(avail) do - local col = (i - 1) % TILE_COLS - local capturedType = eff.type - -- ⚠ THE ICON TILE SAYS WHAT IT ACTUALLY DOES HERE. Its shared description is "The - -- spell's own artwork", which is true in the designer and false on this pool: a - -- helper icon is pinned to Power Infusion (see pihCreateSignal). A tooltip that - -- describes the other pool's behaviour is worse than none. - local desc = (capturedType == "icon") and L["Shows the Power Infusion icon."] or eff.desc - -- ⚠ AND THE TILE SHOWS THAT ICON, rather than the designer's question-mark - -- placeholder. The placeholder is correct where a spell is chosen in a later step; - -- here there is no later step, so it would simply never be replaced. Same id the - -- created indicator pins (pihCreateSignal), so the picture and the effect cannot - -- disagree. - local pinned = (capturedType == "icon") and PIH_PI_SPELL_ID or nil - local tile = CreateFrameTile(parent, { - width = TILE_W, - label = eff.label, - accent = BADGE_COLORS[eff.type] or tc, - tooltip = { title = eff.label, lines = { desc } }, - Paint = function(pv) PaintEffectOnThumb(pv, capturedType, pinned) end, - onClick = function() - 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, - }) - 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 == #avail then - rowTop = rowTop - (rowH + TILE_GAP) - rowH = 0 - end - end - return rowTop - 4 + return grid(items, yPos) end -- ── SOUND, WHICH IS NOT A SURFACE ── From 2a0d831bf9fa2dcce88d1426050c3fe79bea204e Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 22:10:50 +0100 Subject: [PATCH 30/66] PI Helper: the class ticks live with the source they narrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe, reading the layout: "the classes, they only effect the Cooldowns correct? Maybe we do separate that out." Correct, and checked rather than assumed: pihApplyClass only ever adds or removes entries from pihSeedRecords() -- the class-cooldown set -- inside the cooldown list. No class tick can reach the other three sources: trinkets and potions are items with no class at all, and racials are tagged class = "ALL". So thirteen class ticks sitting above a list of four sources said the opposite of the truth. Classes and Cooldowns 45 <- the count moves to the header ☑ Death Knight … (13) Untick a class to stop watching its cooldowns. Additional Filters ☐ Trinkets 41 ☐ Potions 6 ☐ Racials 4 [ Filter Designer ] ☠ AND THE COOLDOWNS TICK IS GONE, because it was redundant AND harmful. Redundant: unticking all thirteen classes IS turning class cooldowns off -- the classes are that source's control. Harmful: both it and the class ticks READ OFF THE LIST (no stored booleans, deliberately, so nothing can drift), so unticking the source removed all forty cooldowns, every class tick then 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 exactly the stored copy of a derived truth the class ticks exist to avoid. ⚠ THE HEADER COUNTS THE SEED SET, NOT THE 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. ⚠ One link, still, at the foot of the second box, and it reaches all four lists. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 55d3719e5308a930689365f0049cb10f992a36be) --- DandersFrames/Locales/enUS.lua | 26 ++--- .../AuraDesigner/UI/Cards.lua | 107 ++++++++++-------- 2 files changed, 72 insertions(+), 61 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 050245df..aaaad091 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3031,19 +3031,19 @@ 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 FOUR TRIGGER SOURCES, each a tick with its own count -- the shape the Buff Bar --- uses to pick its filters. "Cooldowns" was a HEADING before, above a link, with no tick to --- say it was on; the other three were captioned "Also count", counted "also" to something --- the panel never named. Krathe, 2026-09-09: "you should select them and it should show the --- number active next to them in a similar way to how you pick on the buff bar." --- ⚠ The numbers are appended to these labels at render time and need no translating. -L["Trigger Filters"] = true --- Three of the four trigger sources. 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. The fourth is L["Cooldowns"], our own curated list. --- ⚠ L["Cooldowns"] is kept: the tick that used it is gone, but the string is a plain noun --- other surfaces use. -L["Cooldowns"] = true +-- ★ THE SOURCES THE CLASS TICKS CANNOT REACH, in a box of their own. Trinkets and potions +-- are items with no class; racials are tagged class = "ALL". The class ticks narrow only +-- the cooldown list, which is why that one lives with them under its own header instead. +-- Krathe, 2026-09-09: "the classes, they only effect the Cooldowns correct? Maybe we do +-- separate that out." +-- ⚠ The counts are appended to these labels at render time and need no translating. +L["Additional Filters"] = 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 3401ea1c..542870fd 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1580,38 +1580,35 @@ end -- ⚠ 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. --- ★ THE COOLDOWNS SOURCE, AS A TICK LIKE THE OTHER THREE (2026-09-09). --- ☠ IT WAS ALWAYS-ON AND HAD NO CONTROL, which is what made "Cooldowns" read as a heading --- rather than a source -- and left the panel unable to say what it was the other three were --- being counted "also" to. A tick per source, and the same question asked of each. --- ⚠ READ OFF THE LIST, like the class ticks and for the same reason: the list is the truth, --- and a stored boolean beside it is a second copy that drifts the moment anyone edits the --- filter by hand. On means "any of the seeded cooldowns is still in it". --- ⚠ NOT THE SAME QUESTION AS R:CustomFilterCounts' `enabled`. A spell ticked OFF in the --- Filter Designer is still IN the list, so this stays on -- "is this source included at all" --- and "how much of it is switched on" are different, and the row shows both. -function P.PIH_CooldownsOn() +-- ★ 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) - if not f then return false end - for _, sid in ipairs(pihSeedIDs()) do - if f.spells[sid] or f.rawIDs[sid] then return true end - end - return false -end - --- ⚠ SURGICAL, exactly like pihApplyClass: it adds or removes the SEEDED ids and nothing --- else, so a spell the user put in our list by hand survives being switched off and back on. -function P.PIH_SetCooldownsOn(on) - local R = DF.FilterRegistry - local id = pihFilterIdByName(PIH_FILTERS.cooldowns) - if not (id and R) then return end - for _, sid in ipairs(pihSeedIDs()) do - if on then R:AddSpellToCustom(id, sid) - elseif R.RemoveSpellFromCustom then R:RemoveSpellFromCustom(id, sid) end + 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 - pihRefresh() + return on, #ids end function P.PIH_SetAmplifier(which, on) @@ -7045,17 +7042,14 @@ end -- that function's own local. Moving code moves what it closes over. local parent = t.parent local st = P.PIH_Settings() - t.settingLabel(g, L["Trigger Filters"]) - - -- COOLDOWNS -- our own curated list, and the only row whose count can move on its own. - local cdID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() - local R = DF.FilterRegistry - local cdOn, cdTotal = 0, 0 - if cdID and R and R.CustomFilterCounts then cdOn, cdTotal = R:CustomFilterCounts(cdID) end - t.subCheck(g, pihCountLabel(L["Cooldowns"], cdOn, (cdOn ~= cdTotal) and cdTotal or nil), - function() return P.PIH_CooldownsOn() end, - function(v) P.PIH_SetCooldownsOn(v) end) - + -- ⚠ NO SECTION LABEL AND NO COOLDOWNS ROW. This block is its own box now + -- ("Additional Filters"), so the header does the labelling -- and class cooldowns are + -- controlled by the class ticks in the box above, which is what makes a fourth row + -- here redundant. P.PIH_CooldownCounts has the whole argument. + -- ★ WHAT THESE THREE HAVE IN COMMON, and why they are a box of their own: no class + -- tick can reach any of them. Trinkets and potions are items with no class at all, and + -- racials are tagged class = "ALL". Krathe spotted the mismatch from the layout alone: + -- "the classes, they only effect the Cooldowns correct?" t.subCheck(g, pihCountLabel(L["Trinkets"], pihSourceCount(PIH_SEED.amplifiers.trinkets)), function() return st.trinkets == true end, function(v) P.PIH_SetAmplifier("trinkets", v) end) @@ -7359,17 +7353,34 @@ S.BuildPIHelperBody = 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. - -- ☠ NO SECOND FILTER LINK HERE. pihAddClasses ALREADY ends with one, and its button - -- uses GUI:OpenFilterInDesigner -- which switches the page AND scrolls to, selects - -- and pulses the cooldown filter. I added a bare SelectTab beside it without - -- checking, which gave the page two buttons to the same place, one of them the worse - -- version: the existing comment there records that 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." - yPos = t.group(L["Classes and Cooldowns"], function(g) + -- ☠ 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." + + -- ★★ TWO BOXES, BECAUSE THE CLASS TICKS ONLY REACH ONE OF THE SOURCES. + -- ☠ THEY WERE ONE BOX AND THE ORDER SAID THE OPPOSITE: thirteen class ticks, then a + -- list of four sources, when no class tick can touch three of them -- trinkets and + -- potions are items with no class, and racials are tagged class = "ALL". Krathe read + -- the layout and asked the right question: "the classes, they only effect the Cooldowns + -- correct? Maybe we do separate that out." + -- ⇒ The classes sit WITH the source they narrow, under its count; the three sources + -- nothing narrows get a box of their own. + -- ⚠ THE COUNT IS ON THE HEADER because the source has no row of its own any more -- + -- see P.PIH_CooldownCounts for why its tick was redundant AND harmful. Only shown as a + -- fraction when some are off, the same rule the Filter Designer's own rows follow. + 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) - pihAddTriggerSources(g, t) end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) + + yPos = t.group(L["Additional Filters"], function(g) + pihAddTriggerSources(g, t) + end, yPos) end return yPos From 55be1267ec436fd3d5e2ea477e4dedeb4b1b56af Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 22:13:10 +0100 Subject: [PATCH 31/66] PI Helper: the link sits with the list it opens, and the count moves as you tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two from one message. 1. THE LINK IS IN THE WRONG BOX. It opens the COOLDOWN filter, and it was sitting in "Additional Filters" -- where it ended up when all four sources were one list, and where splitting them left it: a route to the cooldown list parked under three filters it has nothing to do with. Krathe: "it needs a link to the cooldowns to modify too as it's not clear you have to go down to filters and click to edit". Moved into "Classes and Cooldowns", which is the box that owns the list it opens. ⚠ STILL EXACTLY ONE LINK. A second in the other box would be the duplicate this panel has already been cleaned of once, and it is not needed: the Filter Designer opens on the library, with every list -- including the other three -- in the left column. The note under the button says so. 2. THE HEADER COUNT WAS STALE. It is read at build time, and P.PIH_SetClassOn ends at pihRefresh, which redraws the FRAMES and the preview but not the panel. So "45" sat there while you unticked classes: "the number does not update unless you go back to the page as you tick off classes". The class tick calls t.Refresh as well as the write. ⚠ THE ONLY TICK IN THAT 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. Refreshing on those too would be a rebuild per click for no visible difference. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit cea3c0dcdd5e2d3efb914f1c734dfb4cc12f8f51) --- .../AuraDesigner/UI/Cards.lua | 124 ++++++++++-------- 1 file changed, 69 insertions(+), 55 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 542870fd..5079f963 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6940,7 +6940,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 @@ -6956,6 +6965,65 @@ local function pihAddClasses(g, t) end t.note(g, L["Untick a class to stop watching its cooldowns."]) + -- ★ THE LINK LIVES IN THIS BOX, because this is the box whose list it opens. + -- ☠ IT WAS IN "Additional Filters", one box down, which is where it ended up when the + -- four sources were one list -- and once they were split it was a route to the cooldown + -- filter parked under three filters it has nothing to do with. Krathe, 2026-09-09: "it + -- needs a link to the cooldowns to modify too as it's not clear you have to go down to + -- filters and click to edit". + -- ⚠ STILL EXACTLY ONE LINK. A second in the other box would be the duplicate this + -- panel has already been cleaned of once -- and it is not needed: the Filter Designer + -- opens on the library, where every list including the other three is in the left column. + -- The note under it says so. + -- ☠ NO HEADING OF ITS OWN ANY MORE, AND "Cooldowns" WAS THE WRONG ONE TWICE OVER. + -- It captioned this button back when the button opened the one list there was; now there + -- are four sources listed directly above it and the Filter Designer edits ANY of them, + -- so a "Cooldowns" heading here named one quarter of what the button reaches. Krathe, + -- 2026-09-09: "the cooldowns header/footer of the Filter Designer link is wrong, as + -- really they can modify all 4 filters in it." + -- ⚠ The button belongs to the tick list it follows -- it is the escape hatch for what + -- those four ticks cannot do, single spells rather than whole sources -- so it needs no + -- caption between them at all. + -- + -- ⭐ 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 + 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 + 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["Edit any of these lists spell by spell in the Filter Designer."]) + end @@ -7037,10 +7105,6 @@ end end local function pihAddTriggerSources(g, t) - -- ⚠ t.parent, and it is needed because the Filter Designer button at the foot of - -- this function came WITH the block: it was pihAddClasses' tail, where `parent` was - -- that function's own local. Moving code moves what it closes over. - local parent = t.parent local st = P.PIH_Settings() -- ⚠ NO SECTION LABEL AND NO COOLDOWNS ROW. This block is its own box now -- ("Additional Filters"), so the header does the labelling -- and class cooldowns are @@ -7063,56 +7127,6 @@ end t.subCheck(g, pihCountLabel(L["Racials"], #PIH_RACIAL_IDS), function() return st.racials == true end, function(v) P.PIH_SetAmplifier("racials", v) end) - - -- ☠ NO HEADING OF ITS OWN ANY MORE, AND "Cooldowns" WAS THE WRONG ONE TWICE OVER. - -- It captioned this button back when the button opened the one list there was; now there - -- are four sources listed directly above it and the Filter Designer edits ANY of them, - -- so a "Cooldowns" heading here named one quarter of what the button reaches. Krathe, - -- 2026-09-09: "the cooldowns header/footer of the Filter Designer link is wrong, as - -- really they can modify all 4 filters in it." - -- ⚠ The button belongs to the tick list it follows -- it is the escape hatch for what - -- those four ticks cannot do, single spells rather than whole sources -- so it needs no - -- caption between them at all. - -- - -- ⭐ 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 - 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 - 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["Edit any of these lists spell by spell in the Filter Designer."]) - end -- ★★★ THE HELPER'S ADD BLOCK — the designer's own tiles, with the spell question removed. From dd079aa325121cc42641358b986de3deaa9b3a16 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 22:42:24 +0100 Subject: [PATCH 32/66] PI Helper: the button names what it edits, and the note says the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "does it still have the link for filters too? maybe having it twice is odd." It does not -- the last commit MOVED it rather than adding one, so there is exactly one. But the fact that a second one seemed necessary is the finding: the panel never says how the four sources relate, so the single link did not read as the way to the cooldowns at all. · THE BUTTON SAID "Filter Designer" -- a destination. That is the wrong label on the one button in a box about a specific list, and it is why the route to the cooldowns was invisible while sitting right there. It says "Edit the cooldown list" now. ⚠ That key already existed unused, so this costs a key rather than minting one. · THE NOTE SAID "Edit any of these lists spell by spell in the Filter Designer", which implies the other three are lists the helper reads. They are not: ticking Trinkets COPIES that preset's spell ids into our list (pihSyncTriggerExtras) -- it does not reference it. Editing "Trinkets & Items" in the Filter Designer afterwards changes nothing the helper matches, because it took a snapshot at tick time. The note now says the model: one list, and the ticks copy into it. ⚠ WHY NOT PER-ROW FLASH LINKS, which is the obvious next idea and what Krathe suggested: for three of the four rows they would point at a filter the helper does NOT read. Someone would click through to Trinkets, untick something, come back and find nothing changed. The only list that IS read is ours, so every honest link has the same target -- which is the one link that already exists. Making them live (re-copying when the registry changes) would fix that and reintroduce the "a list that quietly refills itself is not a list anyone can own" problem the seeding code warns about, so it is a deliberate decision rather than an oversight. Also retired two locale keys this made dead, and one stranded comment block that was describing a key removed with them. Verified: 170 files parse, no new globals, zero locale orphans from this rework (the pre-existing count fell 96 -> 94), byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 0002464b246cc9fe38baba79fcc4120a8f956eec) --- DandersFrames/Locales/enUS.lua | 20 +++++++++++-------- .../AuraDesigner/UI/Cards.lua | 18 +++++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index aaaad091..28a59aa3 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2983,11 +2983,19 @@ L["Triggers"] = true -- no signal to choose: everything added is "worth infusing". L["Add an effect"] = true L["Remove %s"] = true --- ⚠ The way in to the actual spell list. The class ticks say WHICH classes are watched; the --- filter behind them holds the individual cooldowns, and nothing on the page said that list --- existed. A LINK rather than a second copy of the picker -- one place edits these. -L["Individual cooldowns live in the filter this list drives."] = true +-- ⚠ THE BUTTON NAMES WHAT IT EDITS, not where it goes. It read "Filter Designer" -- a +-- destination -- which is the wrong label on the one button in a box about a specific list, +-- and left people hunting for a second route to the cooldowns when this was that route. L["Edit the cooldown list"] = true +-- ☠ AND THE NOTE SAYS THE MODEL, because everything confusing about that box comes from +-- not knowing it: the helper matches ONE list, and the three ticks under Additional Filters +-- do not select filters it reads -- they COPY those filters' spells into this list when +-- ticked. Which is why there is one link and not four. +-- ⚠ It replaces "Edit any of these lists spell by spell in the Filter Designer", which +-- implied the other three were lists the helper reads, and +-- "Individual cooldowns live in the filter this list drives", an older phrasing that had +-- already lost its reader. +L["The helper matches this one list. Ticking Trinkets, Potions or Racials copies their spells into it."] = 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 @@ -3019,10 +3027,6 @@ L["Show even if your Power Infusion is on cooldown"] = true -- dead end. L["Classes and Cooldowns"] = true L["Classes"] = true --- ⚠ THE BUTTON REACHES ALL FOUR SOURCES, so the note must not name one. It said "to add --- or remove single cooldowns, edit the list in the Filter Designer" -- true when there was --- one list, and a quarter of the truth once the four trigger ticks went in above it. -L["Edit any of these lists spell by spell 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 5079f963..1327ff75 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6990,7 +6990,13 @@ local function pihAddClasses(g, t) -- 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() + -- ⚠ IT NAMES WHAT YOU WILL BE EDITING, not where you will end up. "Filter Designer" + -- is a destination, and a destination is the wrong label on the one button in a box + -- about a specific list -- it left the user looking for a second route to the cooldowns + -- ("it needs a link to the cooldowns to modify too", Krathe 2026-09-09) when this WAS + -- that route. L["Edit the cooldown list"] already existed unused; reusing it costs a key + -- rather than minting one. + local fdBtn = GUI:CreateButton(parent, L["Edit the cooldown list"], 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 @@ -7021,8 +7027,16 @@ local function pihAddClasses(g, t) -- 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. + -- ☠ THE NOTE HAS TO SAY THE MODEL, because everything confusing about this box comes + -- from not knowing it. The helper matches ONE list. The three ticks under Additional + -- Filters do not select filters the helper reads -- they COPY those filters' spells + -- into this list when ticked (pihSyncTriggerExtras). Which is why there is one link + -- and not four: editing "Trinkets & Items" in the Filter Designer afterwards changes + -- nothing the helper reads, because it took a snapshot at tick time. + -- ⚠ THE THREE ARE NAMED rather than called "the filters below": a note that points at + -- a position is a note that breaks the next time the boxes are reordered. t.note(g, - L["Edit any of these lists spell by spell in the Filter Designer."]) + L["The helper matches this one list. Ticking Trinkets, Potions or Racials copies their spells into it."]) end From bea81501097658d43584c44c36db072d3e81f0b2 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 9 Sep 2026 23:56:03 +0100 Subject: [PATCH 33/66] PI Helper: every source links to its own list, and the note is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -- a note apologising for an implementation detail. I had been rewording the apology instead of removing the reason for it. THE REASON: the three category ticks COPY a preset's spell ids into our list. Edit "Trinkets & Items" afterwards and the helper is unchanged, because it took a snapshot at tick time. Fixed at the source: · pihAmplifierIDs honours each preset's own per-spell ticks, so what we copy is what the user has actually left switched on there. · S.BuildPIHelperCard re-takes the copy on every Triggers build, so an edit made since the last visit reaches the helper. · ⚠ Safe against the "a list that quietly refills itself" trap the seeding code warns about, and only because of the curated-filter work earlier today: our list has per-spell TICKS now, so a spell the user does not want is switched off rather than removed -- and that survives a re-add. Membership is ours to manage; the choice is theirs and the re-sync cannot touch it. · ☠ The removal universe stays UNFILTERED. Narrowing it too would mean a spell just unticked in the preset drops out of the universe, is never visited by the removal loop, and stays in our list forever -- the fix for one direction breaking the other. SO THE PANEL CAN STOP EXPLAINING ITSELF: Trigger Filters Class cooldowns 38/45 ✎ ☐ Trinkets 41 ✎ ☐ Potions 6 ✎ ☐ Racials 4 Classes ▸ (13 ticks, collapsed) One row per source, its count, and a flash link to its own list. No button, no note. Racials has no link because it has no list -- it is a hand-picked set of four, not the thirteen-entry category, so linking there would offer to edit something that is not what the row means. Class cooldowns has no tick because the thirteen class ticks are its switch. AND LAYOUT GROUPS ONLY APPEARS WHEN THERE IS A GROUP. "Why is layout groups back showing on PI helper?" -- because I added it for the Cooldown Icons group and let it show unconditionally, so a helper without one met a third tab opening on nothing. It appears with the group and goes with it. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans from this rework, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 8db5dbd35cc129c49ee5514f01605fff69032397) --- DandersFrames/Locales/enUS.lua | 39 +- .../AuraDesigner/UI/Cards.lua | 335 +++++++++--------- 2 files changed, 177 insertions(+), 197 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 28a59aa3..f79fc910 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2983,19 +2983,6 @@ L["Triggers"] = true -- no signal to choose: everything added is "worth infusing". L["Add an effect"] = true L["Remove %s"] = true --- ⚠ THE BUTTON NAMES WHAT IT EDITS, not where it goes. It read "Filter Designer" -- a --- destination -- which is the wrong label on the one button in a box about a specific list, --- and left people hunting for a second route to the cooldowns when this was that route. -L["Edit the cooldown list"] = true --- ☠ AND THE NOTE SAYS THE MODEL, because everything confusing about that box comes from --- not knowing it: the helper matches ONE list, and the three ticks under Additional Filters --- do not select filters it reads -- they COPY those filters' spells into this list when --- ticked. Which is why there is one link and not four. --- ⚠ It replaces "Edit any of these lists spell by spell in the Filter Designer", which --- implied the other three were lists the helper reads, and --- "Individual cooldowns live in the filter this list drives", an older phrasing that had --- already lost its reader. -L["The helper matches this one list. Ticking Trinkets, Potions or Racials copies their spells into it."] = 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 @@ -3023,9 +3010,11 @@ L["Groups without assigned roles show everyone."] = true -- still defaults to gating, so this ships UNTICKED and nobody's saved choice changed meaning. L["Show even if your Power Infusion is on cooldown"] = 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. -L["Classes and Cooldowns"] = true +-- finer; the row links out to the editor that does go spell by spell, so the limit is not +-- a dead end. +-- ⚠ L["Classes and Cooldowns"] WENT WHEN THE BOX SPLIT IN TWO. It named a box holding +-- both, which is the arrangement that made the class ticks look as if they reached all +-- four sources. The classes have their own box (L["Classes"]) below the sources now. L["Classes"] = 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 @@ -3035,13 +3024,19 @@ 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 SOURCES THE CLASS TICKS CANNOT REACH, in a box of their own. Trinkets and potions --- are items with no class; racials are tagged class = "ALL". The class ticks narrow only --- the cooldown list, which is why that one lives with them under its own header instead. --- Krathe, 2026-09-09: "the classes, they only effect the Cooldowns correct? Maybe we do --- separate that out." +-- ★ 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. +-- ⚠ "Class cooldowns" is the row for OUR curated list; the Classes box below narrows it, +-- which is why that row has no tick of its own. -- ⚠ The counts are appended to these labels at render time and need no translating. -L["Additional Filters"] = true +L["Trigger Filters"] = true +L["Class cooldowns"] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 1327ff75..75cf7318 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -734,12 +734,27 @@ 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 intersect -- the -- four worth marking are listed by hand in PIH_RACIAL_IDS. -local function pihAmplifierIDs(s) +-- ⚠ `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 then out[#out + 1] = rec.id end + 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 @@ -829,7 +844,7 @@ local function pihSyncTriggerExtras(s) if not id then return end local want = {} for _, sid in ipairs(pihAmplifierIDs(s)) do want[sid] = true end - for _, sid in ipairs(pihAmplifierIDs(PIH_ALL_AMPLIFIERS)) do + for _, sid in ipairs(pihAmplifierIDs(PIH_ALL_AMPLIFIERS, true)) do if want[sid] then if R.AddSpellToCustom then R:AddSpellToCustom(id, sid) end elseif R.RemoveSpellFromCustom then @@ -3330,19 +3345,22 @@ end -- reason it cannot be computed once. local function SubTabDefs() if IsPIHelperTab() then - -- ⚠ LAYOUT GROUPS IS BACK (2026-09-09), and the reason it went is the reason it - -- returns. It was cut because "the helper has no per-spell display to arrange" -- - -- true while every effect was a single marker. The cooldown-icon group is exactly - -- such a display (one icon per cooldown the unit has up), so it needs somewhere to - -- be moved, sized and deleted. VisibleLayoutGroups already shows ONLY the marked - -- groups on this pool, so the tab needs nothing of its own. - -- ☠ AND THAT IS THE OTHER HALF OF WHY THE OLD GROUP GOT STUCK: it had no tab to - -- be configured from. A display with no controls is a display nobody can turn off. - return { + -- ⚠ LAYOUT GROUPS ONLY WHEN THERE IS ONE. It exists for the Cooldown Icons group -- + -- somewhere to move, size and delete it -- and for a helper that has not added one it is + -- a third tab opening on nothing, which is how Krathe met it: "why is layout groups back + -- showing on PI helper?" A tab that is empty until you do something unrelated is a + -- question the panel asks the user instead of answering. + -- ★ IT APPEARS WITH THE GROUP AND GOES WITH IT, so the strip always describes what is + -- actually there. + local defs = { { key = "global", label = L["Triggers"], accent = { r = 0.51, g = 0.86, b = 0.51 } }, { key = "effects", label = L["Effects"], accent = nil }, - { key = "layout", label = L["Layout Groups"], accent = { r = 0.91, g = 0.66, b = 0.25 } }, } + if P.PIH_IconGroup and P.PIH_IconGroup() then + defs[#defs + 1] = { key = "layout", label = L["Layout Groups"], + accent = { r = 0.91, g = 0.66, b = 0.25 } } + end + return defs end return { { key = "effects", label = L["Effects"], accent = nil }, -- theme-tracking @@ -3358,8 +3376,10 @@ P.SubTabDefs = SubTabDefs -- ⚠ Answers for EVERY pool, so a caller never has to know which one it is on. local function CoerceTabForPool(tabKey) if IsPIHelperTab() then - -- All three of the helper's tabs are real now; only a Debuffs-only key coerces. - if tabKey == "effects" or tabKey == "layout" then return tabKey end + if tabKey == "effects" then return "effects" end + -- ⚠ Layout Groups only exists while a group does, so a stale "layout" -- from the + -- tab the user was on when they deleted it -- has to land somewhere real. + if tabKey == "layout" and P.PIH_IconGroup and P.PIH_IconGroup() then return "layout" end return "global" end if tabKey == "effects" and IsDebuffTab() then return "layout" end @@ -6507,6 +6527,15 @@ S.BuildPIHelperCard = function(parent, opts) -- 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-TAKE THE CATEGORY SOURCES, so an edit made in the Filter Designer since the + -- last visit reaches the helper. The three ticks COPY a preset's spells into our list; + -- without this the copy is a snapshot from whenever the tick was clicked, and the + -- pencil beside each row would open a list whose edits went nowhere. + -- ⚠ SAFE TO REPEAT, and safe against the "a list that quietly refills itself" trap the + -- seeding code warns about: our list is CURATED now, so a spell the user does not want + -- is TICKED OFF (f.disabled) rather than removed -- and the tick survives a re-add. + -- Membership is ours to manage; the choice is theirs and this cannot touch it. + pihSyncTriggerExtras(P.PIH_Settings()) local yPos = opts.startY or 0 local Refresh = opts.Refresh or function() end -- ⚠ THE STORED FLAG, NOT THE RECORDS. "Is the helper on" and "does it hold any @@ -6965,78 +6994,14 @@ local function pihAddClasses(g, t) end t.note(g, L["Untick a class to stop watching its cooldowns."]) - -- ★ THE LINK LIVES IN THIS BOX, because this is the box whose list it opens. - -- ☠ IT WAS IN "Additional Filters", one box down, which is where it ended up when the - -- four sources were one list -- and once they were split it was a route to the cooldown - -- filter parked under three filters it has nothing to do with. Krathe, 2026-09-09: "it - -- needs a link to the cooldowns to modify too as it's not clear you have to go down to - -- filters and click to edit". - -- ⚠ STILL EXACTLY ONE LINK. A second in the other box would be the duplicate this - -- panel has already been cleaned of once -- and it is not needed: the Filter Designer - -- opens on the library, where every list including the other three is in the left column. - -- The note under it says so. - -- ☠ NO HEADING OF ITS OWN ANY MORE, AND "Cooldowns" WAS THE WRONG ONE TWICE OVER. - -- It captioned this button back when the button opened the one list there was; now there - -- are four sources listed directly above it and the Filter Designer edits ANY of them, - -- so a "Cooldowns" heading here named one quarter of what the button reaches. Krathe, - -- 2026-09-09: "the cooldowns header/footer of the Filter Designer link is wrong, as - -- really they can modify all 4 filters in it." - -- ⚠ The button belongs to the tick list it follows -- it is the escape hatch for what - -- those four ticks cannot do, single spells rather than whole sources -- so it needs no - -- caption between them at all. - -- - -- ⭐ 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() - -- ⚠ IT NAMES WHAT YOU WILL BE EDITING, not where you will end up. "Filter Designer" - -- is a destination, and a destination is the wrong label on the one button in a box - -- about a specific list -- it left the user looking for a second route to the cooldowns - -- ("it needs a link to the cooldowns to modify too", Krathe 2026-09-09) when this WAS - -- that route. L["Edit the cooldown list"] already existed unused; reusing it costs a key - -- rather than minting one. - local fdBtn = GUI:CreateButton(parent, L["Edit the cooldown list"], 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 - 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 - 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. - -- ☠ THE NOTE HAS TO SAY THE MODEL, because everything confusing about this box comes - -- from not knowing it. The helper matches ONE list. The three ticks under Additional - -- Filters do not select filters the helper reads -- they COPY those filters' spells - -- into this list when ticked (pihSyncTriggerExtras). Which is why there is one link - -- and not four: editing "Trinkets & Items" in the Filter Designer afterwards changes - -- nothing the helper reads, because it took a snapshot at tick time. - -- ⚠ THE THREE ARE NAMED rather than called "the filters below": a note that points at - -- a position is a note that breaks the next time the boxes are reordered. - t.note(g, - L["The helper matches this one list. Ticking Trinkets, Potions or Racials copies their spells into it."]) + -- ☠ THE STANDALONE BUTTON AND ITS NOTE WERE HERE AND BOTH ARE GONE (2026-09-09). + -- One button captioned for one of four lists, and a paragraph underneath explaining that + -- the other three were not really editable -- an implementation detail 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." Every source row carries its own link now (pihSourceRow), and the reason the note + -- existed was fixed rather than reworded -- see pihAmplifierIDs. + end @@ -7083,90 +7048,114 @@ end -- ⚠ 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. - -- ★★★ THE TRIGGER FILTERS — WHICH LISTS FIRE THE HELPER, PICKED THE WAY THE BUFF BAR - -- PICKS ITS OWN (2026-09-09). + -- ★★★ FOUR SOURCES, FOUR ROWS, EACH WITH THE WAY IN TO ITS OWN LIST (2026-09-09). -- - -- ☠ THIS WAS THREE TICKS CAPTIONED "Also count" AND A LABEL CAPTIONED "Cooldowns" THAT - -- WAS NOT A CONTROL AT ALL. Krathe read it the way anyone would: "I assume Cooldowns is - -- our custom filter for PI helper and the other 3 are normal filters?" -- a reasonable - -- assumption, and wrong. All four were ONE custom list, with the ticks adding and removing - -- ids inside it, and the one that was always on had no tick to say so. - -- ⇒ Four rows, four ticks, each with the number in it: "select them and it should show the - -- number active next to them in a similar way to how you pick on the buff bar." + -- ☠ 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. Fixed at the source instead: pihAmplifierIDs honours + -- each preset's own ticks and S.BuildPIHelperCard re-takes the copy on every visit, so an + -- edit in the Filter Designer REACHES the helper -- and then every row can simply offer a + -- link to its own list and say nothing at all. + -- ⚠ 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. -- - -- ⚠ IT IS STILL ONE CANDIDATE LIST UNDERNEATH, and that is deliberate rather than a - -- shortcut. A helper effect resolves from ONE filter ref (its record key) -- a placed - -- indicator has no union at all, and giving it one means changing the Factory's resolve - -- path for every indicator in the addon. So the ticks go on meaning "put these spells in - -- the list", which is what they always did; what changed is that the panel now says so, - -- names each source, and counts it. - -- ⚠ THE COUNTS ARE REAL, NOT DECORATIVE. Cooldowns reports enabled/total off the curated - -- filter itself (R:CustomFilterCounts), so ticking a spell off in the Filter Designer - -- moves the number here too. The other three report how big that source is, because they - -- are all-in-or-all-out. - local function pihSourceCount(catKey) - local R = DF.FilterRegistry - local recs = R and R.ByCategory and R.ByCategory[catKey] - return recs and #recs or 0 - end - + -- ⚠ 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. + -- 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. local function pihCountLabel(text, a, b) if b then return text .. " " .. a .. "/" .. b end return text .. " " .. a end + local function pihSourceRow(g, t, label, count, get, set, link) + local w + if set then + w = t.subCheck(g, pihCountLabel(label, count), get, set) + else + w = t.settingLabel(g, pihCountLabel(label, count)) + 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, { + size = 18, iconSize = 14, + -- ☠ DOUBLE BACKSLASHES in FILTER_ICON -- Lua 5.1 passes an unrecognised escape + -- through as the bare character, so a single-backslash path draws nothing. + texture = FILTER_ICON, + 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", -4, 0) + end + return w + end + + -- ⭐ 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 pihAddTriggerSources(g, t) local st = P.PIH_Settings() - -- ⚠ NO SECTION LABEL AND NO COOLDOWNS ROW. This block is its own box now - -- ("Additional Filters"), so the header does the labelling -- and class cooldowns are - -- controlled by the class ticks in the box above, which is what makes a fourth row - -- here redundant. P.PIH_CooldownCounts has the whole argument. - -- ★ WHAT THESE THREE HAVE IN COMMON, and why they are a box of their own: no class - -- tick can reach any of them. Trinkets and potions are items with no class at all, and - -- racials are tagged class = "ALL". Krathe spotted the mismatch from the layout alone: - -- "the classes, they only effect the Cooldowns correct?" - t.subCheck(g, pihCountLabel(L["Trinkets"], pihSourceCount(PIH_SEED.amplifiers.trinkets)), + local R = DF.FilterRegistry + + -- CLASS COOLDOWNS -- our own curated list, and the only source whose count can move on + -- its own (a spell ticked off in the Filter Designer stops firing without leaving). + local cdID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() + local cdOn, cdTotal = P.PIH_CooldownCounts() + pihSourceRow(g, t, L["Class cooldowns"], cdOn, nil, nil, + cdID and function() pihOpenFilter("custom", cdID) end or nil) + + local function catCount(catKey) + local recs = R and R.ByCategory and R.ByCategory[catKey] + return recs and #recs or 0 + end + local trink, potion = PIH_SEED.amplifiers.trinkets, PIH_SEED.amplifiers.potions + pihSourceRow(g, t, L["Trinkets"], catCount(trink), function() return st.trinkets == true end, - function(v) P.PIH_SetAmplifier("trinkets", v) end) - t.subCheck(g, pihCountLabel(L["Potions"], pihSourceCount(PIH_SEED.amplifiers.potions)), + function(v) P.PIH_SetAmplifier("trinkets", v) end, + function() pihOpenFilter("preset", trink) end) + pihSourceRow(g, t, L["Potions"], catCount(potion), function() return st.potions == true end, - function(v) P.PIH_SetAmplifier("potions", v) end) - -- ⚠ FOUR, NOT THIRTEEN, and the count says four so the row cannot mislead. `racials` - -- is every racial ability, and nine of the thirteen -- Shadowmeld, Darkflight, - -- Stoneform and the rest -- are the opposite of worth infusing behind. PIH_RACIAL_IDS - -- is the hand-picked offensive set; see its own note for why the data cannot pick them. - t.subCheck(g, pihCountLabel(L["Racials"], #PIH_RACIAL_IDS), + function(v) P.PIH_SetAmplifier("potions", v) end, + function() pihOpenFilter("preset", potion) end) + -- ⚠ FOUR, NOT THIRTEEN, and no link -- because there is no list to open. `racials` is + -- every racial ability and nine of its thirteen (Shadowmeld, Darkflight, Stoneform) + -- are the opposite of worth infusing behind, so this row is a hand-picked set rather + -- than a category. Linking to the category would offer to edit something that is not + -- what this row means. + pihSourceRow(g, t, L["Racials"], #PIH_RACIAL_IDS, function() return st.racials == true end, - function(v) P.PIH_SetAmplifier("racials", v) end) + function(v) P.PIH_SetAmplifier("racials", v) end, + nil) end --- ★★★ THE HELPER'S ADD BLOCK — the designer's own tiles, with the spell question removed. --- --- ☠☠ THIS REPLACES A WHOLE SECOND EFFECTS TAB, AND THAT TAB'S EXISTENCE WAS THE MISTAKE. --- pihBuildEffectsTab drew route cards ("Big cooldown" / "Already has active Power Infusion"), --- then a tile grid, then its own ACTIVE INDICATORS caption, then its own effect-card loop, --- then the sound box -- a private copy of the designer's Effects tab standing beside the real --- one. Krathe, 2026-09-09: "It should BE AD not a copy of it." And on the route cards, one --- round earlier: "we don't need 'already has active power infusion' or 'big cooldowns?'" --- ⇒ So there is ONE Effects tab -- the designer's -- and the helper contributes exactly one --- thing to it: this block, in place of the three scope cards. Everything below it (the --- caption, the type filter, the effect cards, the eye and the delete) is the designer's own, --- unchanged, because the helper's records ARE Aura Designer records. --- --- ⚠ NO SPELL QUESTION, AND THAT IS THE ONLY REAL DIFFERENCE. The designer asks route -> --- spell -> type because a user's effect hangs off a spell they choose. The helper's hangs off --- the cooldown list, which the Triggers tab owns -- so the spell is already answered and the --- tile grid is the whole flow. One click adds. --- --- ⚠ ONE SIGNAL. Everything added here is "worth infusing" (the `burst` mark). The second --- signal survives in the DATA -- an existing "already has Power Infusion" effect still --- renders, still lists itself and still deletes -- but nothing creates a new one, because --- asking which of two things an effect is about was the question Krathe cut. --- --- Returns the y to carry on at. -- ★ 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 @@ -7388,27 +7377,23 @@ S.BuildPIHelperBody = function(parent, opts) -- 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." - -- ★★ TWO BOXES, BECAUSE THE CLASS TICKS ONLY REACH ONE OF THE SOURCES. - -- ☠ THEY WERE ONE BOX AND THE ORDER SAID THE OPPOSITE: thirteen class ticks, then a - -- list of four sources, when no class tick can touch three of them -- trinkets and - -- potions are items with no class, and racials are tagged class = "ALL". Krathe read - -- the layout and asked the right question: "the classes, they only effect the Cooldowns - -- correct? Maybe we do separate that out." - -- ⇒ The classes sit WITH the source they narrow, under its count; the three sources - -- nothing narrows get a box of their own. - -- ⚠ THE COUNT IS ON THE HEADER because the source has no row of its own any more -- - -- see P.PIH_CooldownCounts for why its tick was redundant AND harmful. Only shown as a - -- fraction when some are off, the same rule the Filter Designer's own rows follow. - 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) + -- ★★ SOURCES FIRST, THEN THE ONE THAT NARROWS ONE OF THEM. + -- ☠ THE ORDER USED TO SAY THE OPPOSITE OF THE TRUTH: thirteen class ticks, then the + -- sources, when the classes reach only the FIRST source -- 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?" + -- ⚠ THE COUNT LEFT THE HEADER when Class cooldowns got a row of its own. A number on a + -- box header describing one of the rows inside it was the compromise that box needed; + -- the row does not need it. + -- ⚠ CLASSES STAYS COLLAPSIBLE: thirteen rows is the one list on this panel nobody can + -- scan, and most people will never open it. + yPos = t.group(L["Trigger Filters"], function(g) pihAddTriggerSources(g, t) end, yPos) + + yPos = t.group(L["Classes"], function(g) + pihAddClasses(g, t) + end, yPos, { collapsible = true, collapseKey = "pihelper:onlywatch" }) end return yPos From 8d02a33a610fd17c51c479702e27a4447f9adcec Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 00:04:15 +0100 Subject: [PATCH 34/66] PI Helper: the edit pencil, and the cooldowns go back in with their classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. THE PENCIL, NOT THE FILTER GLYPH. "you did not use the edit pencil you used a filter icon instead." 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 these buttons open a list for editing. Two verbs, one picture, and the wrong one. Media/Icons/edit is the pencil every other edit-this affordance uses (Rename, the nickname rows). ☠ Written with real double backslashes, and luac caught it when my first attempt collapsed them: Lua 5.1 would pass the single-backslash form through as the bare character and draw an empty square, silently. 2. CLASS COOLDOWNS MOVE IN WITH THE CLASSES, as a button. "Cooldowns should be in with the Classes and maybe should still be a button Edit Cooldowns." Classes and Cooldowns 38/45 ▸ ☑ Death Knight … (13) Untick a class to stop watching its cooldowns. [ Edit Cooldowns ] Additional Filters ☐ Trinkets 41 ✎ ☐ Potions 6 ✎ ☐ Racials 4 That box is now the WHOLE of that source: the ticks that narrow it, the note, and the button that edits it. The other three are additions you opt into, in a box that says so. ⚠ 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 its 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. ⚠ 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 apologising that the other three were not really editable. Both of those are gone. ☠ THE _ENV DIFF EARNED ITS KEEP AGAIN. Moving the button into pihAddClasses put a caller ABOVE pihOpenFilter's definition -- a nil global, invisible to luac -p. Hoisted, with a note saying it now has two callers. Verified: 170 files parse, no new globals, zero unused PIH symbols, zero locale orphans from this rework, byte-counted CRLF, no BOM. NOT verified in game. (cherry picked from commit 0eb39477c7faa35d807ed6c17a0a399edb2169c2) --- DandersFrames/Locales/enUS.lua | 19 ++- .../AuraDesigner/UI/Cards.lua | 137 +++++++++++------- 2 files changed, 98 insertions(+), 58 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index f79fc910..afb7bd50 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3012,9 +3012,14 @@ L["Show even if your Power Infusion is on cooldown"] = true -- Only watch. Classes rather than specs because the spell data records a class and nothing -- finer; the row links out to the editor that does go spell by spell, so the limit is not -- a dead end. --- ⚠ L["Classes and Cooldowns"] WENT WHEN THE BOX SPLIT IN TWO. It named a box holding --- both, which is the arrangement that made the class ticks look as if they reached all --- four sources. The classes have their own box (L["Classes"]) below the sources now. +-- ★ 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["Untick a class to stop watching its cooldowns."] = true -- Sound. The helper owns this entry outright: the generic effects list refuses to show sound @@ -3030,11 +3035,11 @@ L["Not available on a Power Infusion Helper signal."] = true -- 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. --- ⚠ "Class cooldowns" is the row for OUR curated list; the Classes box below narrows it, --- which is why that row has no tick of its own. +-- ⚠ 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["Trigger Filters"] = true -L["Class cooldowns"] = true +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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 75cf7318..7df24e63 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -6944,6 +6944,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"]) @@ -6994,13 +7017,32 @@ local function pihAddClasses(g, t) end t.note(g, L["Untick a class to stop watching its cooldowns."]) - -- ☠ THE STANDALONE BUTTON AND ITS NOTE WERE HERE AND BOTH ARE GONE (2026-09-09). - -- One button captioned for one of four lists, and a paragraph underneath explaining that - -- the other three were not really editable -- an implementation detail 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." Every source row carries its own link now (pihSourceRow), and the reason the note - -- existed was fixed rather than reworded -- see pihAmplifierIDs. + -- ★ 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 (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. + cdBtn.fullRow = true + g:AddWidget(cdBtn, 28) end @@ -7090,9 +7132,16 @@ end if w and link and GUI.CreateGlyphButton then local glyph = GUI:CreateGlyphButton(w, { size = 18, iconSize = 14, - -- ☠ DOUBLE BACKSLASHES in FILTER_ICON -- Lua 5.1 passes an unrecognised escape - -- through as the bare character, so a single-backslash path draws nothing. - texture = FILTER_ICON, + -- ⚠ 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, @@ -7102,36 +7151,15 @@ end return w end - -- ⭐ 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 pihAddTriggerSources(g, t) local st = P.PIH_Settings() local R = DF.FilterRegistry - -- CLASS COOLDOWNS -- our own curated list, and the only source whose count can move on - -- its own (a spell ticked off in the Filter Designer stops firing without leaving). - local cdID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() - local cdOn, cdTotal = P.PIH_CooldownCounts() - pihSourceRow(g, t, L["Class cooldowns"], cdOn, nil, nil, - cdID and function() pihOpenFilter("custom", cdID) end or nil) - + -- ⚠ 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. local function catCount(catKey) local recs = R and R.ByCategory and R.ByCategory[catKey] return recs and #recs or 0 @@ -7377,23 +7405,30 @@ S.BuildPIHelperBody = function(parent, opts) -- 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." - -- ★★ SOURCES FIRST, THEN THE ONE THAT NARROWS ONE OF THEM. - -- ☠ THE ORDER USED TO SAY THE OPPOSITE OF THE TRUTH: thirteen class ticks, then the - -- sources, when the classes reach only the FIRST source -- 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?" - -- ⚠ THE COUNT LEFT THE HEADER when Class cooldowns got a row of its own. A number on a - -- box header describing one of the rows inside it was the compromise that box needed; - -- the row does not need it. - -- ⚠ CLASSES STAYS COLLAPSIBLE: thirteen rows is the one list on this panel nobody can - -- scan, and most people will never open it. - yPos = t.group(L["Trigger Filters"], function(g) - pihAddTriggerSources(g, t) - end, yPos) - - yPos = t.group(L["Classes"], function(g) + -- ★★ 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) end return yPos From 263a19ab18a9e216471b658ba6265598887dd939 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 00:18:37 +0100 Subject: [PATCH 35/66] PI Helper: Cooldown Icons is an effect, not a layout group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding Cooldown Icons from the helper's Effects tab made the thing vanish from where it was made and reappear behind a Layout Groups sub-tab that grew at the same moment. 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" -- the second time that tab has been objected to. The group is now a card in ACTIVE INDICATORS, beside the effects it was added alongside, and the helper's sub-tab strip is back to two entries for good. What made this a tab in the first place was the card: every structural edit in it named "layout" by hand, so only the tab whose name it hardcoded could draw it. Both layouts' group builders are now parameterised and shared: · S.CreateLayoutGroupCard (split panel) and MountLayoutGroup (row layout), lifted verbatim out of their tabs. refreshTab says which tab an edit rebuilds; RunCardSections and MountGroup's Rebuild verb take it too. · omitFilters drops the Linked Filters block on the helper's copy -- these icons watch the cooldown list that Triggers owns, and a second way to say it could only disagree with the first. · asEffect wears the effect card's chrome instead of the Layout Groups amber, so a row that is meant to read as a normal effect does. CoerceTabForPool lands a stale "layout" on Effects, which is where the group someone was looking for now is. Two Others Only sites moved from IsOtherTab to ShowsOthersOnly while the card was open: the helper's pool answers yes to the first, and its groups are othersOnly by construction, so the tick offered to make the helper watch the priest's own cooldowns and the header spent its summary on a constant. (cherry picked from commit 5b088a6ddec00d8c33e16451e4756085f8ad8c5f) --- .../AuraDesigner/UI/Cards.lua | 63 ++- .../AuraDesigner/UI/Editor.lua | 411 ++++++++++-------- .../AuraDesigner/UI/Rows.lua | 125 ++++-- 3 files changed, 368 insertions(+), 231 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 7df24e63..6f0d12b4 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -3345,22 +3345,18 @@ end -- reason it cannot be computed once. local function SubTabDefs() if IsPIHelperTab() then - -- ⚠ LAYOUT GROUPS ONLY WHEN THERE IS ONE. It exists for the Cooldown Icons group -- - -- somewhere to move, size and delete it -- and for a helper that has not added one it is - -- a third tab opening on nothing, which is how Krathe met it: "why is layout groups back - -- showing on PI helper?" A tab that is empty until you do something unrelated is a - -- question the panel asks the user instead of answering. - -- ★ IT APPEARS WITH THE GROUP AND GOES WITH IT, so the strip always describes what is - -- actually there. - local defs = { + -- ☠ 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 }, } - if P.PIH_IconGroup and P.PIH_IconGroup() then - defs[#defs + 1] = { key = "layout", label = L["Layout Groups"], - accent = { r = 0.91, g = 0.66, b = 0.25 } } - end - return defs end return { { key = "effects", label = L["Effects"], accent = nil }, -- theme-tracking @@ -3376,10 +3372,11 @@ P.SubTabDefs = SubTabDefs -- ⚠ Answers for EVERY pool, so a caller never has to know which one it is on. local function CoerceTabForPool(tabKey) if IsPIHelperTab() then - if tabKey == "effects" then return "effects" end - -- ⚠ Layout Groups only exists while a group does, so a stale "layout" -- from the - -- tab the user was on when they deleted it -- has to land somewhere real. - if tabKey == "layout" and P.PIH_IconGroup and P.PIH_IconGroup() then return "layout" end + -- ⚠ "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 @@ -7805,7 +7802,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) @@ -7828,6 +7844,17 @@ 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", omitFilters = true, asEffect = true }) + end + parent:SetHeight(max(-yPos + 20, 200)) end diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 6e91696a..8c085fea 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -26,6 +26,10 @@ 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 @@ -121,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 @@ -139,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 @@ -660,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() @@ -1027,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 @@ -1037,11 +1051,226 @@ 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. +-- ============================================================ +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 .. " - " .. 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) + -- ⚠ REMOVED 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. + if opts.omitFilters and isFilterGroup then tremove(sections, 1) 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 = VisibleLayoutGroups() @@ -1062,174 +1291,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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 7f7782da..1154de36 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -68,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. @@ -851,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". @@ -881,6 +899,10 @@ local function BuildEffectsTabRows(ctx, shell) for _, effect in ipairs(filtered) do MountEffect(ctx, effect, shell) end + + if pihGroup then + MountLayoutGroup(ctx, pihGroup, { refreshTab = "effects", omitFilters = true }) + end end -- ============================================================ @@ -920,7 +942,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 @@ -1060,7 +1082,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. @@ -1124,6 +1148,64 @@ 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. +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. See the same removal in S.CreateLayoutGroupCard. + if opts.omitFilters and isFilterGroup then tremove(sections, 1) 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 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 @@ -1254,42 +1336,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 From f93d84c56e1c0a7417e7b755db4fe2cf9e071bf0 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 00:23:18 +0100 Subject: [PATCH 36/66] PI Helper: the edit pencils were under the row's own hover rect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 long ones wrap. So the label's hover rect covered the whole row, this button included: the pencil never took an OnEnter, never brightened, never showed its own tooltip, and hovering it raised the row's tooltip instead. The clicks did land -- that hit takes motion and explicitly not clicks -- but a control that gives no sign it is under the cursor is one you are guessing at. Three parts, all needed: · a 26x22 box around the 14px pencil, which the glyph factory already offers for "when the hit area is deliberately bigger than the art"; · a frame level above that hit, READ off both frames rather than computed as "+6" from a number g:AddWidget may have moved since; · the hit itself pulled 30px back off the button, through the dfTooltipHit handle AttachTooltip exposes for exactly this. Still anchored to the label, so it keeps tracking a re-set or re-fonted one. (cherry picked from commit 472dbbe90fbc6bd74c787cf709a6aa19b4cdac9c) --- .../AuraDesigner/UI/Cards.lua | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 6f0d12b4..dffaced9 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7128,7 +7128,23 @@ end -- the first time a label wrapped. if w and link and GUI.CreateGlyphButton then local glyph = GUI:CreateGlyphButton(w, { - size = 18, iconSize = 14, + -- ☠☠ 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 @@ -7143,7 +7159,28 @@ end tooltip = { title = L["Edit this list"], lines = { L["Open it in the Filter Designer."] } }, onClick = link, }) - glyph:SetPoint("RIGHT", w, "RIGHT", -4, 0) + 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 From c66c781c2dc9d90a339d93fd8b7b1ce4ff9e6778 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 00:40:38 +0100 Subject: [PATCH 37/66] PI Helper: Racials is a list, Icon asks which icon, and the group says what it watches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reports from Krathe, 2026-09-10. RACIALS SHOWED 4 AND HAD NO PENCIL. Trinkets and Potions point at preset categories and so have a list to open; Racials pointed at four spell IDs written out in Cards.lua, because the `racials` category is EVERY racial and nine of its thirteen (Shadowmeld, Stoneform, Darkflight) are the opposite of worth infusing behind. Those 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, and a racial we missed can now be added. It is created wherever the cooldown list is, never from a tick, and pihAmplifierIDs falls back to the seed until it exists, so the trigger is right before and after. Two things that came with it: · Trinkets and Potions counted the CATEGORY's size, which does not move when a spell is ticked off in the Filter Designer -- while pihAmplifierIDs, which decides what fires, honours those ticks. Same fault as the cooldown count, one row over. Both now read enabled/total, collapsing to one number when nothing is off. · An amplifier tick can no longer remove a CLASS COOLDOWN from the cooldown list. The removal universe is "every id an amplifier source could contribute", and now that Racials is a list the user can put anything in it -- including a class cooldown, which unticking Racials would then delete from Triggers with no control admitting to it. "COOLDOWN ICONS ALLOWS FOR TRINKETS + THE OTHER FILTERS? DON'T SEE THE OPTION." It does, automatically: pihSyncTriggerExtras writes the amplifier ids INTO the cooldown list, which is the list the group links. There is no option because there is nothing to choose -- but nothing on screen said so, and dropping the Linked Filters block from its card left it silent about its own contents. Its header now carries "N spells, from your Triggers", off the list's own enabled count, so it moves when a tick over there does. THE ICON TILE ASKS WHICH ICON. Cooldown Icons 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 kept going wrong with it. Krathe: "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 answers on two axes -- how many (one marker, or one per cooldown up) and what picture (always Power Infusion, or the buff they used); the fourth cell is four identical PI icons in a row. So: three tiles behind Icon. · Cooldown Icons gets a thumbnail of THREE icons, drawn at the group's own geometry. "How many" is the axis prose keeps failing at, and the tile used to paint a single icon. · Taken answers are GREYED with the reason, not removed. The tile vanishing the moment you clicked it was half of why the group felt lost. · The Icon tile itself survives its own surface being taken, since Cooldown Icons is still behind it. AND A TRAP CLOSED BEFORE BUMPING THE SCHEMA. pihPurgeStrayMarks deletes every pihSignal-marked group in every store -- correct when a marked group could only be the retired one, and no longer true: the Cooldown Icons group lives in otherLayoutGroups on purpose. Any schema bump, for any reason, would have silently deleted it on the next Aura Designer build; it has not bitten only because the group and schema 8 shipped together. It now exempts the one group P.PIH_IconGroup resolves to, exactly as the effect half has always exempted otherAuras. (cherry picked from commit 9fd6ed6750003783b9298882e8739d8559cc5f06) --- DandersFrames/Locales/enUS.lua | 33 +- .../AuraDesigner/UI/Cards.lua | 312 +++++++++++++++--- .../AuraDesigner/UI/Editor.lua | 6 +- .../AuraDesigner/UI/Rows.lua | 10 +- 4 files changed, 296 insertions(+), 65 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index afb7bd50..4b869da3 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3054,22 +3054,37 @@ L["Potions"] = true -- 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: a helper icon is PINNED to Power Infusion, so --- it says "infuse this player" rather than reporting which cooldown they pressed. -L["Shows the Power Infusion icon."] = true +-- 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 --- PICTURE -- because those two icons 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. --- ⚠ "Cooldown Icons" is the GROUP: one icon per cooldown the unit actually has up, as --- against a single marker that shows one of them. +-- 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 (a single marker, 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, or the cooldown they used."] = 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, instead of a single marker."] = 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. The two single-icon tiles share one message: they are one +-- effect seen from two sides, so adding either spends both, and the card's own tick is what +-- switches between them. +L["Already added. The card below switches which picture it shows."] = true +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 -- ★ 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index dffaced9..30d94e6a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -111,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 @@ -350,6 +356,41 @@ local function pihEnsureFilter(name, presetKeys, extraIDs, wipeFirst) 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 -- ───────────────────────────────────────────────────────────── @@ -760,7 +801,13 @@ local function pihAmplifierIDs(s, everything) if s.potions then addCat(PIH_SEED.amplifiers.potions) end if s.trinkets then addCat(PIH_SEED.amplifiers.trinkets) end if s.racials then - for _, id in ipairs(PIH_RACIAL_IDS) do out[#out + 1] = id end + -- ★ 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 out end @@ -844,10 +891,20 @@ local function pihSyncTriggerExtras(s) if not id then return end local want = {} for _, sid in ipairs(pihAmplifierIDs(s)) do want[sid] = true end + -- ☠ A CLASS COOLDOWN IS NEVER TAKEN OUT BY AN AMPLIFIER TICK. The removal universe is + -- "every id any amplifier source could contribute", and since Racials became a list the + -- user can put anything in it -- including a spell that is also in the class-cooldown + -- seed. Unticking Racials would then delete a class cooldown from Triggers: an id + -- disappearing from a list nobody touched, with no control anywhere admitting to it. + -- ⚠ The seed, not the list's contents: a spell the user ADDED to the cooldown list is + -- theirs, but it is also not something an amplifier tick put there, so it is not in this + -- universe to begin with and needs no guard. + local keep = {} + for _, sid in ipairs(pihSeedIDs()) do keep[sid] = true end for _, sid in ipairs(pihAmplifierIDs(PIH_ALL_AMPLIFIERS, true)) do if want[sid] then if R.AddSpellToCustom then R:AddSpellToCustom(id, sid) end - elseif R.RemoveSpellFromCustom then + elseif not keep[sid] and R.RemoveSpellFromCustom then R:RemoveSpellFromCustom(id, sid) end end @@ -891,6 +948,10 @@ local function pihCreateSignal(key, surfaceOverride) 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. @@ -1029,10 +1090,24 @@ local function pihPurgeStrayMarks() 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 then + 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. @@ -1380,12 +1455,33 @@ function P.PIH_IconGroup() return nil end +-- ★★ 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. pihSyncTriggerExtras +-- writes the trinket, potion and racial ids INTO the cooldown list, so a tick on Triggers +-- reaches these icons with no second control and no way for the two to disagree. The count is +-- the list's own enabled total, so it visibly moves when a tick over there does. +function P.PIH_IconGroupSummary() + local R = DF.FilterRegistry + local id = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() + local on = 0 + if id and R and R.CustomFilterCounts then on = R:CustomFilterCounts(id) end + return format(L["%d spells, from your Triggers"], on) +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) @@ -1644,6 +1740,25 @@ 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 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) -- ============================================================ @@ -6287,7 +6402,7 @@ P.OpenFilterPopout = OpenFilterPopout -- ⚠ 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 = 8 +local PIH_SCHEMA = 9 local function pihSweep() local s = P.PIH_Settings() @@ -6505,6 +6620,18 @@ local function pihSweep() 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 + s.schema = PIH_SCHEMA S.activeBuffTab = prevPool if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end @@ -7111,17 +7238,21 @@ end -- ⚠ 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 then return text .. " " .. a .. "/" .. b end - return text .. " " .. a + if b and b ~= a then return text .. " " .. a .. "/" .. b end + return text .. " " .. (b or a) end - local function pihSourceRow(g, t, label, count, get, set, link) + local function pihSourceRow(g, t, label, on, total, get, set, link) local w if set then - w = t.subCheck(g, pihCountLabel(label, count), get, set) + w = t.subCheck(g, pihCountLabel(label, on, total), get, set) else - w = t.settingLabel(g, pihCountLabel(label, count)) + 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 @@ -7194,28 +7325,43 @@ end -- 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. - local function catCount(catKey) - local recs = R and R.ByCategory and R.ByCategory[catKey] - return recs and #recs or 0 + -- ⚠ ENABLED / TOTAL, NOT THE CATEGORY'S SIZE. This was #recs, which does not move when + -- a trinket is ticked off in the Filter Designer -- while pihAmplifierIDs, which + -- decides what actually fires, honours those ticks. So the row reported 41 for a list + -- feeding 38 spells: the same fault Krathe caught on the cooldown count one box up + -- ("the number does not change as I tick them on/off"), one row over. + local function catCounts(catKey) + 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 local trink, potion = PIH_SEED.amplifiers.trinkets, PIH_SEED.amplifiers.potions - pihSourceRow(g, t, L["Trinkets"], catCount(trink), + local trinkOn, trinkAll = catCounts(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) - pihSourceRow(g, t, L["Potions"], catCount(potion), + local potOn, potAll = catCounts(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 no link -- because there is no list to open. `racials` is - -- every racial ability and nine of its thirteen (Shadowmeld, Darkflight, Stoneform) - -- are the opposite of worth infusing behind, so this row is a hand-picked set rather - -- than a category. Linking to the category would offer to edit something that is not - -- what this row means. - pihSourceRow(g, t, L["Racials"], #PIH_RACIAL_IDS, + -- ★ 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, - nil) + racID and function() pihOpenFilter("custom", racID) end or nil) end -- ★ THE ICON ASKS WHICH PICTURE, WITH PICTURES (2026-09-09). @@ -7223,13 +7369,54 @@ end -- 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 TWO ICON TILES, because a signal holds one effect per surface: --- two icon tiles side by side 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. +-- ⚠ 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. +-- +-- ★★ ...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 marker, 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 +-- ── 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. +local function PaintIconRowOnThumb(pv, 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) + ico:SetTexture(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 @@ -7254,10 +7441,18 @@ local function pihBuildAddTiles(parent, yPos, Refresh) width = TILE_W, label = it.label, accent = it.accent or tc, - tooltip = { title = it.label, lines = { it.desc } }, + -- ⚠ 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 @@ -7268,7 +7463,14 @@ local function pihBuildAddTiles(parent, yPos, Refresh) return rowTop - 4 end - -- ── STEP 2: WHICH PICTURE ── + -- 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) @@ -7278,18 +7480,37 @@ local function pihBuildAddTiles(parent, yPos, Refresh) if Refresh then Refresh() end end local accent = BADGE_COLORS.icon or tc + -- ⚠ ONE `taken` FOR BOTH SINGLE-ICON TILES, because they are one effect seen from two + -- sides: the icon surface holds one record, and which picture it wears is a tick on + -- its card. So once either has been added, BOTH are spent -- and the tooltip has to + -- say that switching is done on the card rather than by adding the other one. + local iconTaken = held.icon and L["Already added. The card below switches which picture it shows."] or nil yPos = grid({ - { label = L["Power Infusion"], accent = accent, + { label = L["Power Infusion"], accent = accent, taken = iconTaken, 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 }, -- ⚠ NO PINNED ART ON THIS TILE, so it draws the designer's placeholder -- which is -- honest here in a way it was not before: the picture genuinely is not known until -- a cooldown matches. - { label = L["Their cooldown"], accent = accent, + { label = L["Their cooldown"], accent = accent, taken = iconTaken, desc = L["The buff they actually used — one of them, if several are up at once."], Paint = function(pv) PaintEffectOnThumb(pv, "icon") 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, 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) local back = GUI:CreateButton(parent, L["Back"], 90, 20, function() pihAddPick = nil @@ -7303,19 +7524,21 @@ local function pihBuildAddTiles(parent, yPos, Refresh) -- ⚠ 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 held = {} - for _, s in ipairs(P.PIH_SurfacesOf("burst")) do held[s] = true end - local items = {} for _, eff in ipairs(P.AddFlowEffects and P.AddFlowEffects() or {}) do - if eff.type ~= "sound" and not held[eff.type] then + local isIcon = eff.type == "icon" + -- ☠ ICON SURVIVES ITS OWN SURFACE BEING TAKEN, and no other type does. Behind it are + -- THREE answers, only two of which spend the icon surface -- so hiding the tile the + -- moment a single icon exists would hide the only door to Cooldown Icons. It goes + -- when there is genuinely nothing left behind it, and not before. + local exhausted = isIcon and (held.icon and hasGroup) or (not isIcon and held[eff.type]) + if eff.type ~= "sound" and not exhausted then local capturedType = eff.type - local isIcon = capturedType == "icon" 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, or the cooldown they used."] or eff.desc, + 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, @@ -7334,24 +7557,6 @@ local function pihBuildAddTiles(parent, yPos, Refresh) end end - -- ★ THE GROUP IS A CHOICE ON THIS GRID, not a tick somewhere else -- which is half of what - -- went wrong with the last one. Offered only while none exists: a second would be two - -- containers competing for the same corner. - if not (P.PIH_IconGroup and P.PIH_IconGroup()) then - items[#items + 1] = { - label = L["Cooldown Icons"], - accent = BADGE_COLORS.icon or tc, - desc = L["One icon per cooldown they have up, instead of a single marker."], - Paint = function(pv) PaintEffectOnThumb(pv, "icon") 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 - if Refresh then Refresh() 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. @@ -7889,7 +8094,8 @@ S.BuildEffectsTab = function() -- 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", omitFilters = true, asEffect = true }) + { refreshTab = "effects", omitFilters = true, asEffect = true, + Summary = P.PIH_IconGroupSummary }) end parent:SetHeight(max(-yPos + 20, 200)) diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 8c085fea..8c5c6642 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1076,6 +1076,9 @@ end -- 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 {} @@ -1114,7 +1117,8 @@ S.CreateLayoutGroupCard = function(parent, yPos, group, stack, opts) nameText:SetPoint("RIGHT", header, "RIGHT", -60, 0) nameText:SetMaxLines(1) local isFilterGroup = (group.kind == "filter") - nameText:SetText(group.name .. " - " .. S.LayoutGroupSummary(group)) + 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 1154de36..8cab36aa 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -901,7 +901,8 @@ local function BuildEffectsTabRows(ctx, shell) end if pihGroup then - MountLayoutGroup(ctx, pihGroup, { refreshTab = "effects", omitFilters = true }) + MountLayoutGroup(ctx, pihGroup, { refreshTab = "effects", omitFilters = true, + Summary = P.PIH_IconGroupSummary }) end end @@ -1159,6 +1160,9 @@ end -- 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" @@ -1190,7 +1194,9 @@ function MountLayoutGroup(ctx, group, opts) -- ⚠ 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 S.LayoutGroupSummary(group) end, + 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 From 75190360de38a64dfb577cfed4c8726105998cdb Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 00:47:44 +0100 Subject: [PATCH 38/66] PI Helper: the icon step leaves by an X, like every other AD picker Krathe: "no back use X like we do on the other AD effects." The designer's own picker is a few hundred lines above this one (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". The icon step is the same question in the same place, so it takes the same control -- a Back button under the grid was a second vocabulary for leaving, invented for one grid. The heading is now two shapes rather than one fontstring doing both jobs: step 1 keeps the small-caps dim SECTION CAPTION (nothing to leave), step 2 gets the picker's own head in the picker's font and colour, with the X. L["Back"] goes with it. It was introduced for a tooltip in the designer's add pane, that call site is long gone, and this was its only remaining user -- checked against the dynamic-lookup sites too, since a key can be reached as L[x.name]. The equipment-slot table has a "Back" entry but renders slotName raw, never through L. (cherry picked from commit 69a83e1cece09b95daea8713527bcd511819d473) --- DandersFrames/Locales/enUS.lua | 1 - .../AuraDesigner/UI/Cards.lua | 52 ++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 4b869da3..bec0707e 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 30d94e6a..487b6b1d 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7423,12 +7423,43 @@ local function pihBuildAddTiles(parent, yPos, Refresh) local TILE_COLS, TILE_GAP = 3, 7 local TILE_W = math.floor((CW - TILE_GAP * (TILE_COLS - 1)) / TILE_COLS) - local head = parent:CreateFontString(nil, "OVERLAY") - GUI:SetSettingsFont(head, 9, "") - head:SetPoint("TOPLEFT", 8, yPos) - head:SetText(pihAddPick and L["Which icon?"] or L["ADD AN INDICATOR"]) - head:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b) - yPos = yPos - 18 + -- ── 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 @@ -7512,12 +7543,9 @@ local function pihBuildAddTiles(parent, yPos, Refresh) if Refresh then Refresh() end end }, }, yPos) - local back = GUI:CreateButton(parent, L["Back"], 90, 20, function() - pihAddPick = nil - if Refresh then Refresh() end - end) - back:SetPoint("TOPLEFT", 8, yPos) - return yPos - 26 + -- ⚠ 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 ── From 4cd4d5221f34c364c830c01d52fd0f4c5e8e8e2d Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:02:38 +0100 Subject: [PATCH 39/66] PI Helper: the amplifier ticks reference their lists instead of copying them 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." He is right, and the reason it could not be fixed with wording: each row has a PENCIL that opens the real list, which promises a reference, and the tick made a copy -- 51 spell IDs lifted out of Trinkets, Potions and Racials into ours, so his list read 91 where it should read 40 and the same ids lived in two places. A custom filter may now name other filters (`f.includes`), folded into the selection by R:ResolveSelection. Our list stays the 40 class cooldowns -- which is what "Edit Cooldowns" has always claimed to open -- and names the other three. IN THE REGISTRY, NOT IN THE AURA DESIGNER, and that is the load-bearing choice. Four paths resolve the helper's list: DF:ResolveADFilterRef for the placed effects, the layout-group path for the Cooldown Icons group, Engine.lua's pihResolvedMap for the sound registrations, and the Buff Bar's own picker if a user selects it there. Folding in any one of them would make one list mean different things in different places, which is worse than the copy it replaces. It is inert for every filter without `includes` -- the caller's table is handed straight back, no allocation -- and it folds ONE level, so no cycle is possible and no guard is needed. The fold runs BEFORE the empty-selection test, not after: a filter whose only content is its includes has no presets and no customs of its own, and that branch fails open to SHOW EVERYTHING. WHAT GOES WITH THE COPY: the removal universe, and yesterday's guard against an amplifier tick deleting a class cooldown that also sat in one of those lists. Neither was 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, so a trinket switched off in the Filter Designer now stops firing here for free. Schema 10 takes the copied spells back out, fenced by dfDefaults so a seeded cooldown that also sits in a trinket list stays. A hand-added trinket goes too: it is indistinguishable from a copied one in the store, and the include brings it straight back for anyone with that source ticked. The Cooldown Icons card counts P.PIH_WatchedCount now (the list plus each ticked source) rather than the list's own size, sharing one counter with the Triggers rows so the two screens cannot report the same feature differently. foldIncludes unit-tested standalone: identity on a plain filter, no mutation of the caller's table, uncategorised carried, an already-selected preset kept, a self-include a no-op, and never shrinking the selection (which is what lets the fail-open latch below it keep working). (cherry picked from commit 4472b44e6ad06c71516f6a918615556277ede811) --- DandersFrames/FilterRegistry/Registry.lua | 72 ++++++ .../AuraDesigner/UI/Cards.lua | 211 +++++++++++++----- 2 files changed, 222 insertions(+), 61 deletions(-) diff --git a/DandersFrames/FilterRegistry/Registry.lua b/DandersFrames/FilterRegistry/Registry.lua index 4cb19bf2..478b8bf1 100644 --- a/DandersFrames/FilterRegistry/Registry.lua +++ b/DandersFrames/FilterRegistry/Registry.lua @@ -1103,8 +1103,80 @@ 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. +local function foldIncludes(self, selection) + 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 487b6b1d..78cb6ef1 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -881,33 +881,58 @@ function P.PIH_SetIconShowsAura(rec, on) 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 - -- ⚠ 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. With no list - -- there is nothing to add to and nothing to take out of. local id = pihFilterIdByName(PIH_FILTERS.cooldowns) - if not id then return end - local want = {} - for _, sid in ipairs(pihAmplifierIDs(s)) do want[sid] = true end - -- ☠ A CLASS COOLDOWN IS NEVER TAKEN OUT BY AN AMPLIFIER TICK. The removal universe is - -- "every id any amplifier source could contribute", and since Racials became a list the - -- user can put anything in it -- including a spell that is also in the class-cooldown - -- seed. Unticking Racials would then delete a class cooldown from Triggers: an id - -- disappearing from a list nobody touched, with no control anywhere admitting to it. - -- ⚠ The seed, not the list's contents: a spell the user ADDED to the cooldown list is - -- theirs, but it is also not something an amplifier tick put there, so it is not in this - -- universe to begin with and needs no guard. - local keep = {} - for _, sid in ipairs(pihSeedIDs()) do keep[sid] = true end - for _, sid in ipairs(pihAmplifierIDs(PIH_ALL_AMPLIFIERS, true)) do - if want[sid] then - if R.AddSpellToCustom then R:AddSpellToCustom(id, sid) end - elseif not keep[sid] and R.RemoveSpellFromCustom then - R:RemoveSpellFromCustom(id, sid) - end - end + 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. + f.includes = (next(presets) or next(customs)) and { presets = presets, customs = customs } or nil end local function pihCreateSignal(key, surfaceOverride) @@ -1460,16 +1485,13 @@ end -- 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. pihSyncTriggerExtras --- writes the trinket, potion and racial ids INTO the cooldown list, so a tick on Triggers --- reaches these icons with no second control and no way for the two to disagree. The count is --- the list's own enabled total, so it visibly moves when a tick over there does. +-- ⇒ 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. function P.PIH_IconGroupSummary() - local R = DF.FilterRegistry - local id = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() - local on = 0 - if id and R and R.CustomFilterCounts then on = R:CustomFilterCounts(id) end - return format(L["%d spells, from your Triggers"], on) + return format(L["%d spells, from your Triggers"], P.PIH_WatchedCount and P.PIH_WatchedCount() or 0) end function P.PIH_AddIconGroup() @@ -1746,6 +1768,39 @@ 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 THE HELPER WATCHES, COUNTED THE WAY THE PANEL COUNTS IT: the cooldown list +-- plus each ticked source, each 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. +function P.PIH_WatchedCount() + local R = DF.FilterRegistry + local total = 0 + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + if id and R and R.CustomFilterCounts then total = R:CustomFilterCounts(id) end + local s = P.PIH_Settings() + 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. @@ -6402,7 +6457,7 @@ P.OpenFilterPopout = OpenFilterPopout -- ⚠ 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 = 9 +local PIH_SCHEMA = 10 local function pihSweep() local s = P.PIH_Settings() @@ -6632,6 +6687,46 @@ local function pihSweep() -- 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) + s.schema = PIH_SCHEMA S.activeBuffTab = prevPool if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end @@ -6651,14 +6746,15 @@ S.BuildPIHelperCard = function(parent, opts) -- 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-TAKE THE CATEGORY SOURCES, so an edit made in the Filter Designer since the - -- last visit reaches the helper. The three ticks COPY a preset's spells into our list; - -- without this the copy is a snapshot from whenever the tick was clicked, and the - -- pencil beside each row would open a list whose edits went nowhere. - -- ⚠ SAFE TO REPEAT, and safe against the "a list that quietly refills itself" trap the - -- seeding code warns about: our list is CURATED now, so a spell the user does not want - -- is TICKED OFF (f.disabled) rather than removed -- and the tick survives a re-add. - -- Membership is ours to manage; the choice is theirs and this cannot touch it. + -- ⚠ 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 @@ -7223,10 +7319,14 @@ end -- "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. Fixed at the source instead: pihAmplifierIDs honours - -- each preset's own ticks and S.BuildPIHelperCard re-takes the copy on every visit, so an - -- edit in the Filter Designer REACHES the helper -- and then every row can simply offer a - -- link to its own list and say nothing at all. + -- 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. -- @@ -7318,33 +7418,22 @@ end local function pihAddTriggerSources(g, t) local st = P.PIH_Settings() - local R = DF.FilterRegistry -- ⚠ 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. This was #recs, which does not move when - -- a trinket is ticked off in the Filter Designer -- while pihAmplifierIDs, which - -- decides what actually fires, honours those ticks. So the row reported 41 for a list - -- feeding 38 spells: the same fault Krathe caught on the cooldown count one box up - -- ("the number does not change as I tick them on/off"), one row over. - local function catCounts(catKey) - 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 + -- ⚠ 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 = catCounts(trink) + 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 = catCounts(potion) + 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, From 027123964c64daa1b4a68ebbf28cec7b417906cf Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:07:13 +0100 Subject: [PATCH 40/66] PI Helper: the icon tiles show real cooldowns instead of question marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "can we get the preview card here to actually show some example icons instead of ??" 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 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. What is unknown here is WHICH cooldown, never WHETHER there is art. Both tiles now sample the cooldown seed: Recklessness, Adrenaline Rush and Blade Flurry, the three lowest ids in it. Their cooldown shows the first; Cooldown Icons shows all three, so the row that means "one per cooldown they have up" finally looks like it. · FROM THE SEED, NOT THE LIVE LIST. The user's list moves with the class ticks, so sampling it would change these pictures when someone unticked Warrior. A tile is not a live readout. · SORTED, so the choice is the same on every client and every build rather than whatever pairs() happened to say 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. A short sample still draws a full-length row. · 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; a second difference in the picture would mean nothing. (cherry picked from commit a0ca021b49cc66e832fe20315f0c05fead9723a0) --- .../AuraDesigner/UI/Cards.lua | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 78cb6ef1..f76a8a1d 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7476,6 +7476,34 @@ end -- 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 @@ -7484,7 +7512,10 @@ local pihAddPick = nil -- nil = the surface grid, "icon" = the art choice -- ⚠ 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. -local function PaintIconRowOnThumb(pv, n) +-- ⚠ `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 @@ -7498,7 +7529,10 @@ local function PaintIconRowOnThumb(pv, n) 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 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 @@ -7600,6 +7634,10 @@ local function pihBuildAddTiles(parent, yPos, Refresh) 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` FOR BOTH SINGLE-ICON TILES, because they are one effect seen from two -- sides: the icon surface holds one record, and which picture it wears is a tick on -- its card. So once either has been added, BOTH are spent -- and the tooltip has to @@ -7610,12 +7648,13 @@ local function pihBuildAddTiles(parent, yPos, Refresh) 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 }, - -- ⚠ NO PINNED ART ON THIS TILE, so it draws the designer's placeholder -- which is - -- honest here in a way it was not before: the picture genuinely is not known until - -- a cooldown matches. + -- ⚠ 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 = iconTaken, desc = L["The buff they actually used — one of them, if several are up at once."], - Paint = function(pv) PaintEffectOnThumb(pv, "icon") end, + 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 @@ -7623,7 +7662,7 @@ local function pihBuildAddTiles(parent, yPos, Refresh) { 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, 3) end, + Paint = function(pv) PaintIconRowOnThumb(pv, egIDs, 3) end, onClick = function() local ok, why = P.PIH_AddIconGroup() if not ok then DF:DebugWarn("AURADESIGNER", From 47b39ba06e7656314b027c322e30195005ec760c Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:11:36 +0100 Subject: [PATCH 41/66] AD: one declaration of what a new layout group is 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 group field directly, so a missing field draws blank -- and the factory falls back to 8 for a filter group's max, which is the 8 he was seeing. The fields were missing because P.PIH_AddIconGroup writes its record as a table literal instead of calling CreateLayoutGroup, and that literal listed the fields it thought a filter group had. `iconSize` and `maxIcons` were not among them; CreateLayoutGroup has always set both. Not calling CreateLayoutGroup is still right -- that function picks its store from the OPEN TAB, the one line that put eight stray groups in Krathe's spec store and took three attempts to clean up. What was wrong was hand-writing the record as well as the store. P.NewLayoutGroupRecord is that record now. Both creators share it and differ only in where they insert; the helper overrides the three things it genuinely means differently (the mark, othersOnly, and TOPRIGHT/LEFT_DOWN so a row of cooldown icons runs away from the unit's name rather than across it). Copying a field list is how they drifted, and there is no longer a field list to copy. Schema 11 fills the gaps on a group that already exists, nil-only, reading the values off NewLayoutGroupRecord rather than typing them -- so the repair step cannot disagree with what a fresh group is made of, which is the fault it exists to repair. Anchor and grow direction are deliberately not in that list: filling those from the shared record would move an existing group. (cherry picked from commit 293eb9943cc810707aa255aa6069160f1f15b407) --- .../AuraDesigner/UI/Cards.lua | 68 +++++++++++++------ .../AuraDesigner/UI/Groups.lua | 67 ++++++++++++------ 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index f76a8a1d..30f34995 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1511,25 +1511,30 @@ function P.PIH_AddIconGroup() if not adDB.nextOtherLayoutGroupID then adDB.nextOtherLayoutGroupID = 1 end local id = adDB.nextOtherLayoutGroupID adDB.nextOtherLayoutGroupID = id + 1 - groups[#groups + 1] = { - id = id, - name = PIH_ICON_GROUP_NAME, - kind = "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. - 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. - othersOnly = true, - filterSelection = { presets = {}, customs = { [cdId] = true } }, - anchor = "TOPRIGHT", - offsetX = 0, - offsetY = 0, - growDirection = "LEFT_DOWN", - iconsPerRow = 4, - spacing = 2, - } + -- ☠☠ 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 + g.filterSelection = { presets = {}, customs = { [cdId] = true } } + -- 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 @@ -6457,7 +6462,7 @@ P.OpenFilterPopout = OpenFilterPopout -- ⚠ 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 = 10 +local PIH_SCHEMA = 11 local function pihSweep() local s = P.PIH_Settings() @@ -6727,6 +6732,29 @@ local function pihSweep() -- 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 + s.schema = PIH_SCHEMA S.activeBuffTab = prevPool if P.RefreshPlacedIndicators then P.RefreshPlacedIndicators() end diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 837da5b7..19ab361d 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1119,30 +1119,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, @@ -1161,6 +1159,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 From 37ca7a3326e72ea96de418644d3f19bbe3e221af Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:13:00 +0100 Subject: [PATCH 42/66] PI Helper: the sound tick says "Enable" and explains itself in a tooltip Krathe: "too verbose, make it Enable with a tooltip explaining what it does in better english." The label was "Play a sound when someone becomes worth infusing", under a box already captioned Sound Alert -- the feature stated twice, and wrapping to two lines to do it. The explanation moves to where an explanation goes: Sound Alert Plays your chosen sound when a group member's cooldown makes them worth infusing. A table spec rather than a bare string, so the tooltip keeps the BOX's title: ResolveTooltipSpec defaults a string's title to the widget's label, and "Enable" heading its own tooltip tells nobody which setting they are reading about. L["Enable"] and L["Sound Alert"] already existed; the old sentence key is retired. The "Only plays while the helper is showing" note underneath stays -- it is a caveat about when the sound is suppressed, not a description of it. (cherry picked from commit 321f90e93f3c767a331e9bbd104e17c552f1c868) --- DandersFrames/Locales/enUS.lua | 6 +++++- DandersFrames_Options/AuraDesigner/UI/Cards.lua | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index bec0707e..595bb0b4 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3023,7 +3023,11 @@ L["Classes"] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 30f34995..dd0d4d1c 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7302,9 +7302,22 @@ local function pihAddSound(g, t) -- 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", From a1e2436fe2bc29c4efcf51ccc5f54d7ddfdcb82b Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:14:31 +0100 Subject: [PATCH 43/66] PI Helper: the cooldown gate says "Show when I can't infuse" Krathe: "same with this, less verbose with tooltip and more clear." "Show even if your Power Infusion is on cooldown" spelled the whole rule out on the row and wrapped doing it. The rule moves to the tooltip: Show when I can't infuse Off: markers appear only while your Power Infusion is ready, so you are never pointed at someone you cannot infuse. On: they appear whatever your own cooldown is doing. Off first, because off is the default -- the reader's first line is the behaviour they currently have, the same shape as the icon tick's explainer. NOT "Show while on cooldown", which was shorter still and ambiguous in the one way that matters: this box is otherwise full of OTHER people's cooldowns, so a label that does not say whose reads as the tracked one. "I can't infuse" is what the row has to carry; the rest is explanation. A bare string tooltip here, so its title is the label -- unlike the sound tick, which passes a table because "Enable" titles nothing. Both notes that cite this label as the example of "a control that says what it does without a header to lean on" are updated to the new wording rather than left quoting a string that no longer exists. (cherry picked from commit 218d6624fca0f7c4135546c896cd92d50f3bd83b) --- DandersFrames/Locales/enUS.lua | 7 +++++- .../AuraDesigner/UI/Cards.lua | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 595bb0b4..d8ad3013 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3007,7 +3007,12 @@ L["Groups without assigned roles show everyone."] = true -- 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. -L["Show even if your Power Infusion is on cooldown"] = true +-- ⚠ ...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 whose cooldown is meant +-- -- this box is otherwise full of other people's -- and "I can't infuse" carries it, where +-- "Show while on cooldown" would have read as the tracked one. +L["Show when I can't infuse"] = true +L["Off: markers appear only while your Power Infusion is ready, so you are never pointed at someone you cannot infuse. On: they appear whatever your own cooldown is doing."] = true -- Only watch. Classes rather than specs because the spell data records a class and nothing -- finer; the row links out to the editor that does go spell by spell, so the limit is not -- a dead end. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index dd0d4d1c..a2343853 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7135,6 +7135,14 @@ 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. + -- ⚠ 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." What the row has to carry is which of your + -- OWN cooldowns is meant, and "I can't infuse" carries it in three words -- the rest is an + -- explanation, and explanations go in tooltips. + -- ☠ NOT "Show while on cooldown", which was shorter still and ambiguous in the one way + -- that matters here: this box is full of other people's cooldowns, so a label that does + -- not say WHOSE reads as the tracked one. -- ★ 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 @@ -7143,9 +7151,17 @@ local function pihAddGateAndNotes(g, t) -- ⚠ 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. - t.check(g, L["Show even if your Power Infusion is on cooldown"], + local gateCb = t.check(g, L["Show when I can't infuse"], function() return P.PIH_Settings().gateEnabled == false end, function(v) P.PIH_SetGateEnabled(not v) end) + -- ⚠ A BARE STRING, so the tooltip's title is the label -- which is the right title here. + -- The sound tick passes a table instead because ITS label is "Enable", which titles + -- nothing; this one names its own setting. + -- ⚠ OFF FIRST. The default is off, so the first line is the behaviour the reader + -- currently has -- the same shape as the icon tick's "Off: ... On: ..." explainer. + if gateCb then + gateCb.tooltip = L["Off: markers appear only while your Power Infusion is ready, so you are never pointed at someone you cannot infuse. On: they appear whatever your own cooldown is doing."] + end -- ☠ THE CONTENTION NOTES ARE GONE, AND THE ARGUMENT THAT KEPT THEM WAS THE @@ -7819,9 +7835,10 @@ S.BuildPIHelperBody = function(parent, opts) -- (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 even if your 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. + -- than the setting is worth, and "Show when I can't infuse" 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 + -- shrank to those four words rather than three. yPos = t.group(L["Roles"], function(g) pihAddRoles(g, t) pihAddGateAndNotes(g, t) From 75a056822bad0d299479495ea9faf59fc62e4a30 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:16:27 +0100 Subject: [PATCH 44/66] PI Helper: the gate label is "Show when Power Infusion is on Cooldown" Krathe's wording, verbatim, capital C included. My draft was "Show when I can't infuse" -- shorter, and it identified the cooldown only by implication. Naming the spell says it outright, which is what the row actually has to carry: this box is otherwise full of OTHER people's cooldowns, so a label that does not say which spell reads as the tracked one. Same reason "Show while on cooldown" was never an option. The tooltip is unchanged and still carries the rule. (cherry picked from commit 467b10bf3d8ee278a0ebf1b6ae1292bb935d9145) --- DandersFrames/Locales/enUS.lua | 9 ++++---- .../AuraDesigner/UI/Cards.lua | 23 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index d8ad3013..5bc05084 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3008,10 +3008,11 @@ L["Groups without assigned roles show everyone."] = true -- 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 whose cooldown is meant --- -- this box is otherwise full of other people's -- and "I can't infuse" carries it, where --- "Show while on cooldown" would have read as the tracked one. -L["Show when I can't infuse"] = true +-- 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 L["Off: markers appear only while your Power Infusion is ready, so you are never pointed at someone you cannot infuse. On: they appear whatever your own cooldown is doing."] = true -- Only watch. Classes rather than specs because the spell data records a class and nothing -- finer; the row links out to the editor that does go spell by spell, so the limit is not diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index a2343853..27b6ec8c 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7137,12 +7137,13 @@ local function pihAddGateAndNotes(g, t) -- control can live under a heading that does not quite describe it. -- ⚠ 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." What the row has to carry is which of your - -- OWN cooldowns is meant, and "I can't infuse" carries it in three words -- the rest is an - -- explanation, and explanations go in tooltips. - -- ☠ NOT "Show while on cooldown", which was shorter still and ambiguous in the one way - -- that matters here: this box is full of other people's cooldowns, so a label that does - -- not say WHOSE reads as the tracked one. + -- "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 @@ -7151,7 +7152,7 @@ local function pihAddGateAndNotes(g, t) -- ⚠ 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 I can't infuse"], + 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) -- ⚠ A BARE STRING, so the tooltip's title is the label -- which is the right title here. @@ -7835,10 +7836,10 @@ S.BuildPIHelperBody = function(parent, opts) -- (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 I can't infuse" 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 - -- shrank to those four words rather than three. + -- 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) From 0afb49cebe7a363a94025d58c49966ea57e3e808 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:18:20 +0100 Subject: [PATCH 45/66] PI Helper: the gate tooltip says "effects", in two plain lines Krathe: "markers? it should be effects and the wording itself is not very clear on the tooltip rethink it." Both faults were mine. "Effects" is this 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. And the sentence stated a CONSEQUENCE of the rule ("so you are never pointed at someone you cannot infuse") before it had finished stating the rule, in one long line covering both states. Show when Power Infusion is on Cooldown Off: the helper's effects only appear while your Power Infusion is off cooldown. On: they appear even while it is on cooldown. One line per state, each a plain sentence, off first because off is the default. "off cooldown" / "on cooldown" echoes the label rather than reaching for "ready" -- the tick says on Cooldown, so the explanation says the same words back. A table with no title rather than a bare string: ResolveTooltipSpec fills a missing title from the widget's label, which is what this one wants, and a table is what carries two lines. Two internal comments describing the same control as a "marker" are corrected too -- prose that teaches the wrong word is how the wrong word got into a string. The migration notes about helper markers keep theirs: they describe what was being reasoned about at the time. (cherry picked from commit b8fb59c205a495a5b6250323cfea45c08a23de8b) --- DandersFrames/Locales/enUS.lua | 14 ++++++++-- .../AuraDesigner/UI/Cards.lua | 26 ++++++++++++++----- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 5bc05084..b39471cd 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3013,7 +3013,17 @@ L["Groups without assigned roles show everyone."] = true -- "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 -L["Off: markers appear only while your Power Infusion is ready, so you are never pointed at someone you cannot infuse. On: they appear whatever your own cooldown is doing."] = 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 -- Only watch. Classes rather than specs because the spell data records a class and nothing -- finer; the row links out to the editor that does go spell by spell, so the limit is not -- a dead end. @@ -3069,7 +3079,7 @@ L["Potions"] = true -- ★ 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 (a single marker, or one per +-- ★★ 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 27b6ec8c..c9ca4756 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -7155,13 +7155,25 @@ local function pihAddGateAndNotes(g, t) 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) - -- ⚠ A BARE STRING, so the tooltip's title is the label -- which is the right title here. - -- The sound tick passes a table instead because ITS label is "Enable", which titles - -- nothing; this one names its own setting. - -- ⚠ OFF FIRST. The default is off, so the first line is the behaviour the reader - -- currently has -- the same shape as the icon tick's "Off: ... On: ..." explainer. + -- ⚠ 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 = L["Off: markers appear only while your Power Infusion is ready, so you are never pointed at someone you cannot infuse. On: they appear whatever your own cooldown is doing."] + 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 @@ -7528,7 +7540,7 @@ end -- 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 marker, or one per cooldown up) and WHAT +-- ⇒ 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. From 32db5a32ffba6a92da3c203f8530b8168d87ef25 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 01:28:22 +0100 Subject: [PATCH 46/66] PI Helper: off the Any Buff preview, and the two icons can coexist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE PREVIEW CANVAS NEVER LEARNED THE RULE THE EFFECTS LIST LEARNED. Krathe: "any buff tab on AD is showing our PI helper indicators, it should not." CollectAllEffects has hidden helper-owned records from every pool but the helper's since 2026-09-08; the canvas was written before that and kept painting them. Three leaks, one cause -- every one a DISPLAY site reading a STORE accessor: · the filter-group placeholder loop read CurrentLayoutGroups instead of VisibleLayoutGroups, so the Cooldown Icons group drew on every pool. That accessor's own note describes this exact split; this was the site that ignored it. · the placed-instance loop iterated CurrentAuraPool without testing the mark, so every helper icon and square drew on the designer's canvas. · RefreshPreviewEffects did the same for frame-level effects -- a helper BORDER painted itself over the Any Buff preview. That was the half with 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. P.PIHShowsMark is that rule in one place, read in both directions like VisibleLayoutGroups: on the helper's tab the marked records are the only ones that belong. P.PIHVisibleRecord hides frame-level effects PER TYPE KEY, not per record -- nothing stops a user adding an effect of their own to the helper's filter record from the Any Buff tab, and "this record is the helper's" would hide their work with ours. No copy is made when there is nothing to hide. TWO ICONS AT ONCE. "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 a placed target mints an INSTANCE and instances are per-id. The refusal was the guard being coarser than its reason -- two frame effects on one key genuinely cannot coexist, but two icons differing in art are not a duplicate. The guard now tests the ART, and only for icons; two squares are still a duplicate. · The art is decided AT CREATION. P.PIH_AddSurface used to create with the pin and then walk EVERY icon the signal held clearing staticSpellID -- harmless with one icon, destructive with two: adding "Their cooldown" would have unpinned the Power Infusion icon already on the frame. · A second icon is nudged one icon-width off the first, away from whichever edge its anchor names. Both take the type's default corner, so without it the pair arrives perfectly stacked and reads as a click that did nothing. · Each add tile greys on its own art now instead of the two sharing one state, and the Icon tile survives until all THREE answers behind it are spent. Guard, nudge and visibility rule unit-tested standalone: the six add/refuse combinations for the icon pair, that squares and icons do not block each other, the offset's sign per anchor, and PIHShowsMark in both directions. (cherry picked from commit ebfc32389f9cf92b68a8e48715f402243c686507) --- DandersFrames/Locales/enUS.lua | 9 +- .../AuraDesigner/UI/Cards.lua | 120 ++++++++++++++---- .../AuraDesigner/UI/Groups.lua | 72 ++++++++++- 3 files changed, 170 insertions(+), 31 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index b39471cd..ebceb79e 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3094,10 +3094,11 @@ 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. The two single-icon tiles share one message: they are one --- effect seen from two sides, so adding either spends both, and the card's own tick is what --- switches between them. -L["Already added. The card below switches which picture it shows."] = true +-- 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index c9ca4756..2ee5e8fd 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -935,7 +935,7 @@ local function pihSyncTriggerExtras(s) f.includes = (next(presets) or next(customs)) and { presets = presets, customs = customs } or nil end -local function pihCreateSignal(key, surfaceOverride) +local function pihCreateSignal(key, surfaceOverride, showsAura) local def = PIH_SIGNALS[key] if not def then return false, "no such signal" end -- ☠ THE GATE IS PER SURFACE NOW, NOT PER SIGNAL (2026-09-08). It used to refuse any @@ -945,6 +945,17 @@ local function pihCreateSignal(key, surfaceOverride) -- ⚠ 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 @@ -952,7 +963,13 @@ local function pihCreateSignal(key, surfaceOverride) local existing = pihFoundAll()[key] if surfaceOverride then for _, hit in ipairs(existing or {}) do - if hit.typeKey == surfaceOverride then return true, "already on" end + 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" @@ -1015,6 +1032,14 @@ local function pihCreateSignal(key, surfaceOverride) -- 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 @@ -1039,7 +1064,27 @@ local function pihCreateSignal(key, surfaceOverride) -- 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. - if tgt == "icon" then inst.staticSpellID = PIH_PI_SPELL_ID end + -- ★ 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 @@ -1404,16 +1449,14 @@ end function P.PIH_AddSurface(key, surface, showsAura) if not PIH_SIGNALS[key] then return false, "no such signal" end if not surface or surface == "none" then return false, "no surface" end - local ok, why = pihCreateSignal(key, surface) - -- Applied to the record the create just made, found by its mark -- the create path has - -- no return channel for the instance and does not need one for a single field. - if ok and showsAura and surface == "icon" then - for _, hit in ipairs(pihFoundAll()[key] or {}) do - if hit.typeKey == "icon" and type(hit.cfg) == "table" then - hit.cfg.staticSpellID = nil - end - end - 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 @@ -1539,6 +1582,23 @@ function P.PIH_AddIconGroup() 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. @@ -7708,13 +7768,18 @@ local function pihBuildAddTiles(parent, yPos, Refresh) -- 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` FOR BOTH SINGLE-ICON TILES, because they are one effect seen from two - -- sides: the icon surface holds one record, and which picture it wears is a tick on - -- its card. So once either has been added, BOTH are spent -- and the tooltip has to - -- say that switching is done on the card rather than by adding the other one. - local iconTaken = held.icon and L["Already added. The card below switches which picture it shows."] or nil + -- ★ 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 = iconTaken, + { 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 }, @@ -7722,7 +7787,8 @@ local function pihBuildAddTiles(parent, yPos, Refresh) -- 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 = iconTaken, + { 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 }, @@ -7754,10 +7820,16 @@ local function pihBuildAddTiles(parent, yPos, Refresh) 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, only two of which spend the icon surface -- so hiding the tile the - -- moment a single icon exists would hide the only door to Cooldown Icons. It goes - -- when there is genuinely nothing left behind it, and not before. - local exhausted = isIcon and (held.icon and hasGroup) or (not isIcon and held[eff.type]) + -- 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] = { diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 19ab361d..d3f20cb1 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 @@ -1536,6 +1541,53 @@ P.BADGE_COLORS = BADGE_COLORS -- 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 @@ -2723,7 +2775,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, @@ -2763,7 +2819,10 @@ local function RefreshPlacedIndicators() 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 @@ -2866,7 +2925,14 @@ local function RefreshPreviewEffects(opts) local sortedAuras = {} 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) From 189819b9a5b4972c1226cea71ef2b84a78d855ed Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 12:06:13 +0100 Subject: [PATCH 47/66] PI Helper: watch named players only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." A Players box on the Triggers tab, collapsed, with the count on its header so a folded box still says whether it is doing anything. Empty means everyone, so nothing changes for a profile that never opens it. THE PICKER IS THE PINNED-FRAMES ONE AT HALF THE WIDTH, not a reuse of it: "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". The dual-column widget is 460 wide with two 224px panes; this column is ~230, so one of its columns alone is the whole surface. GUI:CreateCompactRosterWidget is the same rows -- role icon, class colour, themed right-hand button, Add-by-name field -- in ONE list whose button toggles both ways instead of two panes with an arrow between them. Deliberately not carried over: · the role bulk-add buttons. "No need for an auto add function as you can pick class/role etc anyway" -- the helper narrows by role and class on the same tab already, so a button adding every DPS would be a third control saying it. · drag-to-reorder. Pinned frames needs an order because the order IS the layout; an allowlist is a set, and a hand-sorted set invites the reader to think the order means something. · the group number, the first thing that stops fitting. The list is still sorted by group, so the grouping shows -- it just is not labelled. Chosen players who are not in the group lead the list, dim and with no role icon (neither role nor class is knowable from here). They are the whole point of the name field, and a list that only showed who is present would give no way to see or remove what you had typed. THE ENGINE SIDE RIDES RAILS THAT WERE ALREADY THERE. Role exclusion is a per-unit test consumed at exactly two points in the container plus one verb for the sound path, pushed by a setter and re-pushed on five roster/role events. The allowlist is one more setter and one more clause. GROUP_ROSTER_UPDATE is already in that event set, so a list written before the raid forms takes effect the moment the named player walks in. · Both tests fold into ONE verb (helperUnitExcluded), so the container funnel and the sound path cannot answer differently for one unit. IsHelperRoleExcluded is renamed to IsHelperUnitExcluded with its single caller: keeping the old name while widening the answer is how the next reader gets it wrong. · The list is stored as an ARRAY (the picker's order of entry) and pushed as a MAP. Empty is nil, never an empty map -- the container reads a present map as "these players and nobody else", so an empty one would silence the helper for someone who added two names and removed them again. · It is cleared on PIH_ApplySaved's RESET branch too, or a list would go on narrowing a helper that is not there and then narrow the next one built. · The name test fails open twice: no list means everyone, and a unit whose name cannot be read is shown rather than hidden. Keys are "Name-Realm", the same shape the pinned list writes, and RosterSnapshot now treats an empty-string realm as absent -- UnitName returns the realm only when it differs from yours, and "Bob-" would match nothing while looking like a name that should. Unit-tested standalone: the predicate's fail-open cases (no list, nil unit, unreadable name), same-realm nil vs empty string, cross-realm, the array-to-map conversion collapsing empty and junk to nil, and the toggle never mutating the stored array. (cherry picked from commit 70fe8b3dd98cb14172a42305d398bbaba2b7e5df) --- DandersFrames/AuraDesigner/Engine.lua | 24 ++ DandersFrames/AuraDesigner/Factory.lua | 17 +- DandersFrames/Frames/AuraContainer.lua | 57 ++- DandersFrames/Locales/enUS.lua | 13 + .../AuraDesigner/UI/Cards.lua | 75 ++++ DandersFrames_Options/GUI/Controls.lua | 329 +++++++++++++++++- 6 files changed, 486 insertions(+), 29 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 96564a43..34f99126 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -512,6 +512,14 @@ function Engine:PIH_ApplySaved() 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 pihEnabled = true -- no helper here; the switch has nothing to suppress @@ -526,6 +534,22 @@ 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. + if DF.AuraContainer and DF.AuraContainer.SetHelperAllowedPlayers then + local map + for _, fullName in ipairs(s.players or {}) do + if type(fullName) == "string" and fullName ~= "" then + map = map or {} + map[fullName] = true + end + end + DF.AuraContainer.SetHelperAllowedPlayers(map) + end 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). diff --git a/DandersFrames/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua index f2b437b0..e012ebb1 100644 --- a/DandersFrames/AuraDesigner/Factory.lua +++ b/DandersFrames/AuraDesigner/Factory.lua @@ -4453,13 +4453,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/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 2174bfef..2b4e8686 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -1477,6 +1477,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 +1496,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 +1692,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) @@ -6690,6 +6726,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. @@ -8388,7 +8437,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 diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index ebceb79e..2686b88e 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3003,6 +3003,19 @@ L["Another effect"] = true -- thing it could have said (this box is the ROLE filter) went unsaid. L["Roles"] = true L["Groups without assigned roles show everyone."] = 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 +L["Empty means everyone. Add players here to watch only them."] = 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 2ee5e8fd..e2ba5a00 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1821,6 +1821,33 @@ function P.PIH_SetGateEnabled(on) 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 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() @@ -7583,6 +7610,37 @@ 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) + t.note(g, L["Empty means everyone. 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 @@ -7967,6 +8025,23 @@ S.BuildPIHelperBody = function(parent, opts) yPos = t.group(L["Additional Filters"], function(g) pihAddTriggerSources(g, t) end, 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. No count means no list + -- means everyone, and the note inside says so in words. + local pn = #P.PIH_Players() + 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 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 From 7d3abc0a8671cd77f13371816034652fa1272b65 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 12:35:10 +0100 Subject: [PATCH 48/66] Border animations: the blend mode is a setting, not a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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?" DF Chase is DF_ORBIT, and its sparkles were created with a hardcoded SetBlendMode("ADD"). ADD means "add my colour to whatever is behind me", so the effect showed its true red 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. He is right that it dates from the effects rework: c4b4e5eb, "drop LibCustomGlow". Each new effect got a blend mode chosen by hand -- DF_ORBIT and DF_PROC additive, DF_DASH / DF_PIXEL / BLINK / DF_FLASH none at all, which is BLEND. Same colour picker, two different meanings depending on which effect you picked, with nothing anywhere admitting to it. That inconsistency is the actual bug; the orange was the symptom. No blend mode for animations ever existed -- git log -S finds no such key on any branch. What exists is Border Blend Mode, which governs the border's own EDGES and not the effect drawn over them: an easy pair to remember as one control. Animation Blend Mode now sits with Scale and Frequency, on every effect that owns textures. DF Pulsate is absent: it has none, it modulates the border's own edges, so Border Blend Mode already IS its blend mode and a second control would leave the user watching whichever ran last. NOBODY'S FRAMES CHANGE. An unset key means "this effect's own default", and ANIM_BLEND_DEFAULT holds exactly what was hardcoded. The dropdown shows that state as "Default" and maps it back to nil, so the stored shape is unchanged and a profile exported before today imports untouched. Three things that would each have made the setting look broken: · SetBlendMode moved OUT of every `if not texture then` branch. These pools are reused across re-applies, so a mode set at birth is the mode the first frame happened to get -- changing the option would have done nothing until a reload. Same reason SetVertexColor was already out there. · animSpecHash carries blendMode. StartAnimation returns early on an unchanged hash, so without it the new mode would land only when some OTHER tunable moved -- the "nudge the frequency and it appears" symptom this file already documents for the driver check. · The Aura Designer's PLACED_BORDER_KEYS and rawBorderAnimStructTok carry it too, or an AD border would stay stale until /reload -- and it is structural there, since a container button's animation groups are frozen at build. The DF Dash OnUpdate path redraws 96 pooled quads per frame through a function handed geometry and a colour, so the resolved mode is stashed on the border and stamped only when it actually changes rather than threaded through four call layers or written every frame. The value reaches SetBlendMode, which throws on an unknown string, and it comes out of SavedVariables -- so it is validated against the four real modes and falls back to the effect's own default rather than erroring the animation pass. Unit-tested standalone: every effect's unset default matches what was hardcoded, each override is honoured, garbage/number/empty/nil all fall back correctly, and the dropdown's DEFAULT<->nil mapping round-trips and restores the effect default. (cherry picked from commit e36a679056b9178a5ec24a630740e9c35d98a502) --- DandersFrames/AuraDesigner/Factory.lua | 6 ++ DandersFrames/Frames/Border.lua | 90 ++++++++++++++++++- DandersFrames/Locales/enUS.lua | 11 +++ DandersFrames_Options/GUI/SettingsWidgets.lua | 31 +++++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/DandersFrames/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua index e012ebb1..ae4cd711 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 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 2686b88e..c31db32e 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2076,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 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 From bcef78afb82f88979073fd67eeaa3cec3a5e1195 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 12:53:49 +0100 Subject: [PATCH 49/66] PI Helper: Cooldown Icons picks its own sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "we should let people toggle cooldowns and the sub filters on/off so they can pick from any of the 4, by default it should match what they have set on triggers, but 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. Those are the same question until you have both a marker and a row, and different once you do. A SHOW block on the group's card, which is what now stands where the generic LINKED FILTERS picker was dropped. That slot was emptied because it offered a filter picker over the helper's own plumbing -- correct, and it left the card silent about its own contents. It holds the four real sources by name instead. ABSENT MEANS FOLLOW, which is the whole compatibility story. With no pihSources the group links the cooldown list and nothing else, and that list's own `includes` bring in whatever Triggers has ticked -- exactly what it did before, with no migration and no defaults table. Touch a tick and it stops inheriting and names all four itself, materialised from the EFFECTIVE set so the first click changes the one thing clicked and the other three keep what they were showing a moment earlier. A Follow Triggers button goes back, by DELETING the key rather than writing today's values into it -- "follow" has to keep following. THE FOOTER IS A STATE READOUT, not a caption. Four ticks that 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. One line says which. selection.noIncludes in the registry is what makes "cooldowns only" sayable at all: our cooldown list NAMES the other three now, so selecting it would drag them in behind it. A selection that lists its sources itself opts out of the fold. Inert for everyone else -- no other selection sets it. Two things that would have bitten: · PIH_AddIconGroup wrote filterSelection as a literal. It goes through the shared builder, so a new group and an edited one cannot disagree about the shape -- and with no pihSources it writes exactly what the literal did. · No file-scope table for the four keys. Cards.lua sits at Lua's 200-local ceiling in its main chunk, so `local PIH_SOURCE_ORDER = {...}` is a compile error rather than a style question. luac caught it; the note records why. R:SelectionSignature hashes the RESOLVED map, not the selection's shape, so a source toggle re-signs and rebuilds without noIncludes needing to be hashed. Unit-tested standalone: a fresh group follows and sets no noIncludes, the effective set mirrors Triggers, each override resolves to the right presets and customs, following again picks up a Triggers value that moved while overridden, and the first click preserves the other three. (cherry picked from commit c307bb52f5975215378038067ac47664e04d34d8) --- DandersFrames/FilterRegistry/Registry.lua | 6 + DandersFrames/Locales/enUS.lua | 13 ++ .../AuraDesigner/UI/Cards.lua | 152 +++++++++++++++++- .../AuraDesigner/UI/Editor.lua | 11 +- .../AuraDesigner/UI/Rows.lua | 11 +- 5 files changed, 186 insertions(+), 7 deletions(-) diff --git a/DandersFrames/FilterRegistry/Registry.lua b/DandersFrames/FilterRegistry/Registry.lua index 478b8bf1..c615fb56 100644 --- a/DandersFrames/FilterRegistry/Registry.lua +++ b/DandersFrames/FilterRegistry/Registry.lua @@ -1129,7 +1129,13 @@ local failOpenLogged = setmetatable({}, { __mode = "k" }) -- 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 diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index c31db32e..99e3d682 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3022,6 +3022,19 @@ L["Groups without assigned roles show everyone."] = true -- 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 L["Empty means everyone. Add players here to watch only them."] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index e2ba5a00..4512c27a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1523,6 +1523,55 @@ function P.PIH_IconGroup() return nil 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 + -- ★★ 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 @@ -1537,6 +1586,101 @@ function P.PIH_IconGroupSummary() return format(L["%d spells, from your Triggers"], P.PIH_WatchedCount and P.PIH_WatchedCount() or 0) 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 + +-- 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 + 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() @@ -1571,7 +1715,10 @@ function P.PIH_AddIconGroup() -- 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 - g.filterSelection = { presets = {}, customs = { [cdId] = 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. @@ -8468,7 +8615,8 @@ S.BuildEffectsTab = function() -- 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", omitFilters = true, asEffect = true, + { refreshTab = "effects", asEffect = true, + filtersSection = P.PIH_GroupSourceSection(pihGroup), Summary = P.PIH_IconGroupSummary }) end diff --git a/DandersFrames_Options/AuraDesigner/UI/Editor.lua b/DandersFrames_Options/AuraDesigner/UI/Editor.lua index 8c5c6642..d3151782 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Editor.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Editor.lua @@ -1238,10 +1238,17 @@ S.CreateLayoutGroupCard = function(parent, yPos, group, stack, opts) -- Members / Linked Filters, then Placement, then Growth -- the -- SAME list the row layout mounts, run down the card's cursor. local sections = CollectLayoutGroupSections(group) - -- ⚠ REMOVED BY POSITION, and CollectLayoutGroupSections is why that is safe: Linked + -- ⚠ 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. - if opts.omitFilters and isFilterGroup then tremove(sections, 1) end + -- ⚠ 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Rows.lua b/DandersFrames_Options/AuraDesigner/UI/Rows.lua index 8cab36aa..43a59c7a 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Rows.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Rows.lua @@ -901,7 +901,8 @@ local function BuildEffectsTabRows(ctx, shell) end if pihGroup then - MountLayoutGroup(ctx, pihGroup, { refreshTab = "effects", omitFilters = true, + MountLayoutGroup(ctx, pihGroup, { refreshTab = "effects", + filtersSection = P.PIH_GroupSourceSection(pihGroup), Summary = P.PIH_IconGroupSummary }) end end @@ -1172,8 +1173,12 @@ function MountLayoutGroup(ctx, group, opts) -- 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. See the same removal in S.CreateLayoutGroupCard. - if opts.omitFilters and isFilterGroup then tremove(sections, 1) end + -- 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) From 0354ed22bdadb47459d5cb7e63f95f460c5dc959 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 12:58:38 +0100 Subject: [PATCH 50/66] PI Helper: the icon group's count follows its own sources, and it is called Icons Two from Krathe, 2026-09-10. "THE NUMBER OF SPELLS TRACKED DOES NOT SEEM TO UPDATE ON THE SHOW TOGGLES BUT ONLY ON THE TRIGGERS." P.PIH_WatchedCount read P.PIH_Settings() outright, so the header reported what the HELPER fires on while the SHOW block one row beneath it listed what the GROUP shows. Two different questions, only one being asked. The sources are an argument now, defaulting to the Triggers ticks for every other caller. Cooldowns counts as a source rather than an always-on baseline: the group can switch it off, and a count that added the class list regardless would over-report by forty. The wording follows the state. A group that has been given its own set is no longer reporting "from your Triggers", and a header saying so while the block under it says it has stopped following would be the panel contradicting itself one row apart -- so following gets the Triggers phrasing and overridden gets the count alone. "MAYBE IT SHOULD BE CALLED PI HELPER - ICONS NOT COOLDOWNS AS IT CAN BE TRINKETS ETC TOO?" Right, and more so since the SHOW block: the group can now be set to trinkets ONLY, where the old name is not vague but wrong. Schema 12 renames an existing group -- but 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, and renaming that would be the addon overwriting their words to satisfy its own tidiness. The name stays stored data and never L[]: a translated string in a profile is a name that changes when the client's language does. The old name is a literal inside that migration rather than a constant beside the new one, and the schema's own note says why: this file is at Lua's 200-local ceiling in its main chunk, so the second local is a compile error, not a style question. luac refused it twice today; both refusals are now documented where the next person will hit them. Unit-tested standalone: the count for each combination of the four sources, including cooldowns off with amplifiers on, and his current 91. (cherry picked from commit bcb73bc7603b3f223a49f0c44ad8283147a8ae65) --- DandersFrames/Locales/enUS.lua | 4 ++ .../AuraDesigner/UI/Cards.lua | 67 ++++++++++++++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 99e3d682..c0e50c1a 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3142,6 +3142,10 @@ L["Already added. Remove it from the list below to change it."] = true -- 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 4512c27a..1defddea 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -1514,7 +1514,15 @@ end -- 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. -local PIH_ICON_GROUP_NAME = "PI Helper — Cooldowns" +-- ★ "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 @@ -1582,8 +1590,16 @@ end -- 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. -function P.PIH_IconGroupSummary() - return format(L["%d spells, from your Triggers"], P.PIH_WatchedCount and P.PIH_WatchedCount() or 0) +-- ⚠ 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 -- ★★★ THE COOLDOWN-ICON GROUP PICKS ITS OWN SOURCES (2026-09-10). @@ -2022,18 +2038,32 @@ function P.PIH_PresetCounts(catKey) return on, #recs end --- ★ EVERYTHING THE HELPER WATCHES, COUNTED THE WAY THE PANEL COUNTS IT: the cooldown list --- plus each ticked source, each contributing its own ENABLED total. +-- ★ 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. -function P.PIH_WatchedCount() +-- ⚠ `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 id and R and R.CustomFilterCounts then total = R:CustomFilterCounts(id) end - local s = P.PIH_Settings() + 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 @@ -6696,7 +6726,7 @@ P.OpenFilterPopout = OpenFilterPopout -- ⚠ 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 = 11 +local PIH_SCHEMA = 12 local function pihSweep() local s = P.PIH_Settings() @@ -6989,6 +7019,25 @@ local function pihSweep() end 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 From 329ca386f8e1ca82883e15285af31879db25cd66 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 13:05:29 +0100 Subject: [PATCH 51/66] PI Helper: the icon effect picks its sources too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "yes build the icon block the same". The icon card had ONE tick for this -- "Ignore trinkets, potions and racials" -- which is the same mechanism at a coarser grain: per-record mutes, applied to the resolved include map by narrowByPlacementMutes. It is now the same four sources the Cooldown Icons group got, so the two cards read alike. NOTHING TO MIGRATE, which is why the old API could simply go rather than being converted. A record saved by that tick carries exactly the mutes these readers read, so "all three ignored" reads back as three ticks off -- no conversion step, no schema bump. P.PIH_IgnoresAmplifiers / P.PIH_SetIgnoreAmplifiers are deleted with the control they served, and the note above their old home says so. IT CAN ONLY SUBTRACT, and the footer says so rather than the panel pretending otherwise. A placed effect is keyed by ONE filter reference, so it cannot carry a selection of its own the way a group can -- 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 switched off is GREYED with its own reason, not hidden: hiding it would leave the icon card and the group card disagreeing about how many sources this feature has. Class cooldowns is mutable too, which costs forty entries in the record for "show trinkets only". Accepted deliberately -- the alternative is a fourth source the box has to explain the absence of. ☠ THIRD luac REFUSAL TODAY on the 200-local ceiling in Cards.lua. P.PIH_SourceIDs is a table field rather than a `local function` for that reason alone, and says so, because it reads like a style smell and is actually a compile error. Unit-tested standalone: a fresh record reads all four on and writes no mute table, each toggle leaves the other three alone, turning one back on clears an emptied table, cooldowns can be muted away, a legacy "ignore amplifiers" record reads back as three ticks off, and an unwatched source reads off with its availability explaining why. (cherry picked from commit c7f2bd966b3c36a0f3250e7fc64e2c72b8fa95b5) --- DandersFrames/Locales/enUS.lua | 12 ++- .../AuraDesigner/UI/Cards.lua | 74 +++++++++++++++---- .../AuraDesigner/UI/Indicators.lua | 38 ++++++++-- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index c0e50c1a..77cac2af 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3153,8 +3153,16 @@ L["%d spells"] = true -- 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 -L["Ignore trinkets, potions and racials"] = true -L["This icon only — the Triggers tab still decides what the helper watches. Useful with the icon showing their cooldown, since an amplifier is usually pressed alongside one."] = 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. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 1defddea..a1434100 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -843,34 +843,80 @@ function P.PIH_AmplifierIDs() return pihAmplifierIDs(P.PIH_Settings()) end -function P.PIH_IgnoresAmplifiers(rec) - local ids = P.PIH_AmplifierIDs() - -- Nothing ticked = nothing to ignore. Reads false so the box is not claiming to suppress - -- an empty set, which would tick itself the moment the user turned one on. +-- ★★★ 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 + +-- 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 false end + if type(m) ~= "table" then return true end -- nothing muted = everything shows for _, id in ipairs(ids) do - if not m[id] then return false end + if not m[id] then return true end -- one survivor is enough to read ON end - return true + 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_SetIgnoreAmplifiers(rec, on) +function P.PIH_SetIconSourceOn(rec, key, on) if type(rec) ~= "table" then return end - local ids = P.PIH_AmplifierIDs() + 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 - elseif type(rec.mutedSpellIDs) == "table" then - -- ⚠ ONLY OUR IDS COME BACK OUT. A per-ID tick the user set by hand on some other - -- cooldown is theirs and is not ours to clear. - for _, id in ipairs(ids) do rec.mutedSpellIDs[id] = nil end - if not next(rec.mutedSpellIDs) then rec.mutedSpellIDs = nil 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index 96204d9d..9efc9102 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua @@ -690,13 +690,37 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff 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) - local ampCb = GUI:CreateCheckbox(parent, - L["Ignore trinkets, potions and racials"], nil, nil, - function() if RPL then RPL() end end, - function() return P.PIH_IgnoresAmplifiers(pihRec) end, - function(v) P.PIH_SetIgnoreAmplifiers(pihRec, v) end) - ampCb.tooltip = L["This icon only — the Triggers tab still decides what the helper watches. Useful with the icon showing their cooldown, since an amplifier is usually pressed alongside one."] - g:AddWidget(ampCb, 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 From 8664b5cd50fd31ac60a8edada4b8ffa3d3b5726d Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 13:11:52 +0100 Subject: [PATCH 52/66] PI Helper: the two icons are named apart in Active Indicators Krathe: "a placed PI icon should show as PI Helper - PI Icon / Icon / Icons, right now only the last actually shows." Helper effects are named "PI Helper" and told apart by their type BADGE -- which was the fix for the old "PI Helper - Big cooldown" on every row, and is still right for Border, Square and the rest, each unique on the signal. The badge stopped being enough the day both icons could exist: it says "Icon" for the one pinned to Power Infusion and "Icon" for the one showing their cooldown, so the pair added two changes ago arrives as two rows reading identically. That is exactly the "somebody deletes the wrong one" case the label's own note warns about, now reachable. pihLabel takes the record as well as the signal, and appends a name for the two icons only -- where it disambiguates, and nowhere else, so no row prints the word its badge is already showing. ("Icons", the group, is stored data and was renamed in the previous commit.) staticSpellID's presence is the art, as everywhere else -- there is no second field recording the choice. A frame-level cfg carries no `type`, so it can never reach that branch; only placed instances do. Unit-tested standalone: pinned and dynamic icons, square and bar falling through to the badge's word, a frame-level cfg with no type field, a nil cfg, the infused signal untouched, and an unknown signal still returning nil. (cherry picked from commit 2190b62267370631ae2823334845f831fb5ecdae) --- DandersFrames/Locales/enUS.lua | 8 +++++++ .../AuraDesigner/UI/Cards.lua | 21 +++++++++++++++++-- .../AuraDesigner/UI/Groups.lua | 6 ++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 77cac2af..2b792b2e 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3179,5 +3179,13 @@ L["Move and size the icons under Layout Groups."] = true -- 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 a1434100..e0a71d4c 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -283,9 +283,26 @@ end -- 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. -local function pihLabel(key) - if key == "burst" then return L["PI Helper"] end +-- ★★ ...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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index d3f20cb1..b5314292 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1654,8 +1654,10 @@ local function CollectAllEffects(opts) 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, @@ -1682,7 +1684,7 @@ local function CollectAllEffects(opts) -- 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], }) From 79e9642326502b53a1412bd5b2019829fa28e46f Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 16:47:26 +0100 Subject: [PATCH 53/66] PI Helper: writing the trigger references invalidates the aura layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "I could hear the sound trigger but did not see the border or PI icon at all." The two consumers of the helper's cooldown list do NOT resolve it the same way: · the SOUND registrations resolve fresh on every arm (Engine.lua's pihResolvedMap calls R:ResolveSelection directly); · 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 an older spell set while the cue plays off the current one -- a trigger that makes a noise and draws nothing, with no visible cause. P.PIH_SetAmplifier already invalidated, through 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, in the same pass that strips the 51 copied ids back out -- and the Triggers panel build. INVALIDATED ONLY ON A REAL CHANGE. This runs on every panel build and every sweep, so an unconditional bump would re-resolve every filter reference in the addon each time the tab is opened. The comparison is set-equality on both halves, which is also what makes the common case (rebuilding the same table) free. This is a gap introduced with the references yesterday and is worth closing on its own merits. Whether it is the whole of what Krathe saw is NOT established: the sound path also checks the per-unit gate at ARM time while the visuals check it at RENDER time, so a gate that closed after the cue was armed produces the same symptom by a different route. That one needs the answer to "was your own Power Infusion on cooldown at that moment", which only he can supply. Unit-tested standalone: nil<->set transitions invalidate, a preset or custom added or removed invalidates, and rebuilding an identical table -- every panel build, every sweep -- does not. (cherry picked from commit edf80024ba5f4dc376920679710b8c04accc7ff4) --- .../AuraDesigner/UI/Cards.lua | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index e0a71d4c..1dc91811 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -995,7 +995,34 @@ local function pihSyncTriggerExtras(s) end -- nil rather than an empty table: `includes` absent is the shape every other filter has, -- and foldIncludes short-circuits on it. - f.includes = (next(presets) or next(customs)) and { presets = presets, customs = customs } or nil + 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) From d306df21aeb4710ac69b25a1e728f66fa0e2533a Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 16:52:26 +0100 Subject: [PATCH 54/66] PI Helper: /df debug pi can see what the placed slots are carrying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four reports, four different theories from me, no measurement: · the border cleared and the PI icon did not; · the sound played and neither the border nor the icon drew; · the border and the Icons group drew, the PI icon did not, and the sound did not play. Every one of those is a slot whose LAST PUSH disagrees with the gate, and no field in the readout could show it. It 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 where all three reports live. AuraContainer.GetHelperSlotStatus answers it: how many helper slots exist, how many would be handed the DEAD filter right now, how many are waiting on a DEFERRED push, and how many are parked. Derived on every call, never stored, so it cannot drift from what the slots are doing -- the fault every "same config, different outcome" hunt in this addon has come down to. `pending` is the field that names a cause. SlotHandle:_applyHelperGate cannot call a native tuning setter under lockdown, so it defers to PLAYER_REGEN_ENABLED -- and Power Infusion is pressed in combat by definition. A slot at pending > 0 while the gate reads OPEN is that deferral, visible for the first time, and the line is marked bad in exactly that combination. Reading it against "gate intends": OPEN + dark 0 + pending 0 -> the slots agree; the fault is elsewhere. OPEN + pending > 0 -> a deferred push not yet drained. OPEN + dark > 0 -> a per-unit exclusion (role, or the named player list), not the gate. Diagnostic only: nothing about rendering changes. Adding the detector before the fix is the order this codebase's own rule asks for, and the last four rounds are what happens without it. (cherry picked from commit 07b006dcfae86db6f44e9b2c289c3fedafed7dc9) --- DandersFrames/AuraDesigner/Engine.lua | 27 ++++++++++++++++++++ DandersFrames/Frames/AuraContainer.lua | 35 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 34f99126..e224ee14 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -795,6 +795,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/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 2b4e8686..7d008a6c 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -6719,6 +6719,41 @@ 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. +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 From 5faa4956a35e0d67173e140a623abb1244cf3c03 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 16:57:17 +0100 Subject: [PATCH 55/66] PI Helper: the gate edge arms the processor, so a placed icon clears with the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "the PI icon does clear when I use PI on them but it seems to take longer than the border/icons and other indicators." Not stuck -- QUEUED. SetAuraSlotCandidateFilters is a dirty-mark, not a repaint: the container re-reads on its next processor pass, which is the unit's next UNIT_AURA or the next OnUpdate while visible. So "stop matching" is a request the engine honours WHEN IT NEXT LOOKS. The frame-level border is painted by DF on the same frame, and the slot waits for traffic that may be a moment or a couple of seconds away -- which is also why it read as intermittent. _applyHelperGate now arms the processor after the push. UpdateAllAuras is itself only a mark, so this collapses the wait to the next frame rather than making it instant; there is no synchronous re-parse to reach for. It runs on the gate edge -- twice per Power Infusion cycle, not per frame and not per aura event -- so the cost is not measurable. pcall(fn, self) rather than the closure form, per this file's own rule recorded at applyGroupTuning's tail. SetHelperGate's note names this exact path as the reason that rule exists. ☠ A COMMENT I NEARLY SHIPPED WAS FALSE. It claimed the combat deferral drains back through this function. It does not: _replayTuning clears _cfPushed and calls _pushFilter, which re-derives park vs live at drain time and pushes candidates itself, never re-entering here. Checked before committing, and the note now says what actually happens -- a gate edge deferred in combat drains with no arm of its own and picks up whatever the combat-exit kick does. _pushFilter is deliberately left alone. Arming there would cover that drain, and also park, restore and the death latch -- a far wider blast radius than the symptom this answers. If the lag is ever seen on those paths it should be its own change with its own testing. (cherry picked from commit ce70a12791ceac125f41390a9c3d4b094816fd07) --- DandersFrames/Frames/AuraContainer.lua | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 7d008a6c..43214092 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -8497,6 +8497,42 @@ function SlotHandle:_applyHelperGate() return true end pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) + + -- ★★★ ...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 return true end From 97c41edb70a1fa7be7b20dc8bcd3e87226242ec0 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 19:16:37 +0100 Subject: [PATCH 56/66] PI Helper: a no-op for anyone who is not a priest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the alpha: helper borders and icons rendering for NON-PRIESTS. There was no class gate on the resident side at all. Only the Options UI checked -- DF.IsPIHelperAvailable hides the page and PoolDefs omits the pool tab, on both layouts -- 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, and it should not have to. PIH_ApplySaved now refuses first, for anyone who is not a priest. ☠ DARK, NOT "NO HELPER". The existing `if not s` branch 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, so this is its own branch rather than another condition on that one. Getting that backwards would have shipped a fix that reproduced the bug. PIH_SetEnabled(false) is the lever, not a new one: forced dark that nothing reopens, the readiness ticker refused, the manual hold released, sound disarmed through pihSet. Everything a class gate needs was already written and tested -- and it latches, so the watcher, the gate switch and the ticker all decline on their own existing guards rather than needing new ones. 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 has to come back intact on the priest. Suppression only. pihSyncWatcher takes the same test, so five events are not registered on every alt of every priest who shares a profile for a feature they cannot enable. The watcher's tick would have declined anyway; not registering is the honest version. Read at call time rather than at load: UnitClass("player") is not dependable before login, and this runs on login and on every profile switch. A character's class cannot change, so there is nothing to re-check afterwards. (cherry picked from commit 2807248efcc498c6275c31702b113d4f30c93681) --- DandersFrames/AuraDesigner/Engine.lua | 53 ++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index e224ee14..149e0271 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -506,7 +506,54 @@ 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 @@ -605,7 +652,11 @@ local PIH_WATCH_EVENTS = { "SPELL_UPDATE_COOLDOWN", "SPELL_UPDATE_CHARGES", "UNIT_SPELLCAST_SUCCEEDED", "GROUP_ROSTER_UPDATE" } 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. From 8b3d5e86e7e530d18dd96d1cf53580342c7a23c1 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 19:28:17 +0100 Subject: [PATCH 57/66] PI Helper: show in combat only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An option Krathe asked for, beside the cooldown gate on the Triggers tab -- 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. Off by default and absent when off, so no existing profile changes and there is no defaults entry to keep in step with the engine's own `== true` read. pihShouldShow is now the one answer for every GLOBAL condition, as against helperUnitExcluded which is the per-unit half. Every gate decision goes through it: the readiness ticker, both switches, PLAYER_ENTERING_WORLD and the cooldown-cleared branch. Leaving any of them on pihReadReady would have reopened the gate out of combat the moment a cooldown ticked over. ☠ THE TWO CONDITIONS ARE INDEPENDENT, and the combat test runs FIRST. Someone who has switched the cooldown gate off still means it when they say combat only, so `not pihGateEnabled` can no longer short-circuit to "open" -- both the switch and the PLAYER_ENTERING_WORLD guard had to stop treating it as the only condition. ☠☠ THE EVENT CARRIES THE TRUTH, NOT DF.playerInCombat. That flag is the house source and is correct -- but Core.lua writes it from PLAYER_REGEN_DISABLED / _ENABLED, the same two events this now watches, and handler order between two frames is undefined. Reading it from inside our own handler can see the value from BEFORE the transition and resolve the gate backwards. pihShouldShow takes an optional override; the regen branch passes what the event itself means and every other caller reads the flag, which by then has settled. (Never InCombatLockdown: that is the addon-restriction state, not the player's.) ⚠ BOTH TRANSITIONS. The addon has a standing note that a combat-keyed gate needs a refresh on entering AND leaving, and that leaving is the half people forget. The branch sits ahead of the cooldown guards -- it was below them first, and those return early when the cooldown gate is off, which would have silently excluded exactly the users the independence above is for. Caught by reading the placement back rather than trusting the comment I had just written. Loaded before the gate setters on both paths (PIH_ApplySaved and the panel's PIH_Apply), since both resolve through pihShouldShow; loading it after would settle the gate from the old value and leave it wrong until the next transition. Cleared on the reset branch with the rest, so a stale hold cannot outlive the profile that set it. Unit-tested standalone: the default changes nothing, combat-only hides out of combat and shows in it, the cooldown gate still applies inside combat, the two compose independently with the cooldown gate off, and a stale combat flag at transition time resolves correctly in both directions. (cherry picked from commit 2725640bc6690924b2926449406986786b9900a7) --- DandersFrames/AuraDesigner/Engine.lua | 101 ++++++++++++++++-- DandersFrames/Locales/enUS.lua | 6 ++ .../AuraDesigner/UI/Cards.lua | 32 +++++- 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 149e0271..e087ae67 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -155,6 +155,12 @@ 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 + -- 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. @@ -416,7 +422,9 @@ local function pihSet(dark) if pihManual ~= nil then return end -- Re-asked every tick, not only at start: the switch can move under us. if not pihEnabled then return end - if pihReadReady() then + -- ⚠ 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 @@ -437,6 +445,39 @@ end -- the setting says. local 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 @@ -452,7 +493,7 @@ function Engine:PIH_SetEnabled(on) elseif not pihGateEnabled then pihSet(false) -- the gate is switched off: never hide else - pihSet(not pihReadReady()) -- resume from the real cooldown state + pihSet(not pihShouldShow()) -- resume from every live condition end return pihEnabled end @@ -463,16 +504,18 @@ function Engine:PIH_SetGateEnabled(on) -- 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 @@ -569,6 +612,7 @@ function Engine:PIH_ApplySaved() 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 @@ -597,6 +641,10 @@ function Engine:PIH_ApplySaved() 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). @@ -648,8 +696,14 @@ 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() -- ⚠ AND NOT FOR A NON-PRIEST, whatever the pool holds. PIH_ApplySaved already forces @@ -691,6 +745,27 @@ 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 @@ -716,13 +791,16 @@ pihWatcher:SetScript("OnEvent", function(_, event, unit, _, spellID) -- ⚠ 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. - if not pihEnabled or not pihGateEnabled or pihManual ~= nil then return end + -- ⚠ 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 @@ -736,8 +814,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") diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 2b792b2e..b6cd4f34 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3061,6 +3061,12 @@ L["Show when Power Infusion is on Cooldown"] = true -- 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 row links out to the editor that does go spell by spell, so the limit is not -- a dead end. diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 1dc91811..319d713e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -718,8 +718,13 @@ 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. 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 @@ -2074,6 +2079,15 @@ 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." @@ -7549,6 +7563,22 @@ local function pihAddGateAndNotes(g, t) } } 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 -- ARGUMENT AGAINST THEM. They survived an earlier cut because they were "the only From d56e27f2204b59d9b0f1e183ff81c94e6a2b3532 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 19:43:20 +0100 Subject: [PATCH 58/66] PI Helper: the gate follows a retargeted frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "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. _cf() re-derives at READ time, but nothing PUSHES after a retarget: the next push only comes on a gate edge, and there is no reason for one. The verdict travelled with the container. This is the exact class SetSlotOwnerUnit already re-seeds two other things for. Death and visibility are re-asked from their registries there because "the new unit may already be dead, and that edge will never fire again" -- role exclusion is that sentence with a different noun. It was missed because the gate is DERIVED rather than stored, which makes it look like it cannot go stale: the derivation is fresh, the push is not. Both retarget lanes get it: · SetSlotOwnerUnit re-pushes each helper slot's candidates (placed icons, squares, bars); · Handle:SetUnit re-tunes a helper group. config.unit is updated there, so recordCandidateFilters would DERIVE correctly -- but the bounce that follows re-parses against whatever map the container is still holding. NARROWED TO OUR OWN CONFIGS in both, matching 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. Lands on top of this morning's processor arm, so the re-derived filter is acted on next frame rather than at the unit's next aura event -- which on a freshly retargeted frame could be a long way off, and is the same "long-lived buff nobody re-casts" hole the slot-retarget kick was written for. (cherry picked from commit 7cf49acec32bf3973b5f133d452b235d2c5b6dcc) --- DandersFrames/Frames/AuraContainer.lua | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 43214092..a6f4bd80 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -6454,6 +6454,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 @@ -8768,6 +8776,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 From 9cc5e4f1d9ce22c2ef5a4a92fae2f8aa1dff3d68 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 19:55:23 +0100 Subject: [PATCH 59/66] PI Helper: read the LIVE designer, not the mode base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe's raid: the helper worked, then stopped when the auto layout kicked in at 20 people, and a reload did not bring it back. He spotted the cause himself -- the helper is not on the preset that layout switches to. That part is configuration, not a bug: a raid auto-layout may point its own AD PRESET at something other than the mode base, and his 21-30 layout uses a "Flex 21-30" preset while the helper lives in "Raid". The frames render from the overlay (DF:ResolveAuraDesigner honours it) and it holds no helper, so nothing draws. Nothing here changes that. What IS a bug is that the engine could not tell. pihSettings read GetModeBaseAuraDesigner -- the EDITOR's variant. Presets.lua says so on the function itself: "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, so with the overlay active it kept finding the helper in the BASE preset and 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 reporting a healthy helper while nothing renders. The feature could not diagnose itself because its two halves were reading different presets -- which is most of why this took as many rounds as it did. It does not make his helper appear. What it fixes is the engine agreeing with the screen: no helper on the active preset means silent, ungated, and a /df debug pi that SAYS "no helper installed" instead of describing one nobody can see. SoundEngine already used the live variant, which is why the sound followed the frames while the gate did not. (cherry picked from commit dc1c5f711215f7339d2a63310d4c390f4cc6cc13) --- DandersFrames/AuraDesigner/Engine.lua | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index e087ae67..11adf325 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -212,10 +212,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 From 44c2be232d706c201463245469caad23ebc92195 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 20:49:57 +0100 Subject: [PATCH 60/66] PI Helper: the player list is actually pushed, and the readout can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe: "I've added a list of players and it was showing the effects on other players not just them." P.PIH_Apply pushed the roles, the gate, the feature switch, the sound and the watcher -- and not the player list. 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. Mine, from the commit that added the feature. The tell was there to be read: roles and players are the two halves of ONE narrowing -- helperUnitExcluded reads both -- and only one of them was in this function. A setting the panel stores but never pushes is the "lying control" this feature has been cleaned of three times already; it came back through a path nobody re-read. Array in, map out, empty as nil -- the same conversion PIH_ApplySaved does, and for the same reason: 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 THE READOUT COULD NOT SEE IT EITHER, which is why a missing push was invisible. /df debug pi now prints "players watched" READ OFF THE CONTAINER, not off the profile -- a field that repeated the setting would have agreed with the panel and hidden the fault, exactly as "roles excluded" beside it reads the container's own copy. ⚠ The second half of the report -- effects showing while Power Infusion was on cooldown -- is NOT addressed here and is not explained by this. The gate is global and this list is per-unit, so a missing list cannot hold the gate open. Not guessing at it: the readout's "gate intends" / "chokepoint says" pair and the "driven by" line answer it directly, and one of the states they name (a manual hold from /df debug pi on, which suspends the watcher) would produce exactly that symptom. (cherry picked from commit 02f3463e5214b82e2e8300f8e1fa4ed0e80ad2d8) --- DandersFrames/AuraDesigner/Engine.lua | 12 ++++++++++ .../AuraDesigner/UI/Cards.lua | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 11adf325..95035887 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -931,6 +931,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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 319d713e..2ce27bd2 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -718,6 +718,28 @@ 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 + -- ☠☠ 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. + if DF.AuraContainer and DF.AuraContainer.SetHelperAllowedPlayers then + local map + for _, fullName in ipairs(s.players or {}) do + if type(fullName) == "string" and fullName ~= "" then + map = map or {} + map[fullName] = true + 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 From 528ed3d4ae05b463b63aa1cd46df481eac618af5 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 20:55:13 +0100 Subject: [PATCH 61/66] PI Helper: the gate reaches placed slots in combat, and says so in the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe's log answered it. 33 clean gate edges -- "DARK on cast" / "OPEN, cooldown cleared" alternating across 72-98 containers -- so the watcher, the gate and the broadcast were all working, while the placed icons carried on showing people during his cooldown. ☠ THE COUNT WAS LYING. SlotHandle:_applyHelperGate deferred to PLAYER_REGEN_ENABLED whenever InCombatLockdown() and returned TRUE, so SetHelperGate counted a QUEUED slot as a pushed one. In a raid you are in combat for the whole pull, so every edge on every placed slot queued and none of them landed during the fight -- while the log read as perfectly healthy. That is "the border DID go away but the PI icon did not", from the first report onwards: the border is frame-level and painted by DF, the icon is a container slot. ATTEMPT, THEN DEFER. The push is tried and the deferral is kept for an actual failure, so a refusal is MEASURED rather than assumed. 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 -- its own note says so. Two lanes of one broadcast cannot both be right about whether that is allowed, and the guard was the inconsistency. Pushed and deferred are now counted apart. AND THE DETECTOR GOES IN THE LOG, WHERE IT SHOULD HAVE GONE THE FIRST TIME. Krathe: "I've told you before I can't really run this debug commands in raid combat, you need to add what you need to the debug console." He had, and I put it behind /df debug pi twice anyway. One line per gate edge now carries pushed, deferred, the four slot counts, every condition in force (enabled, gateEnabled, combatOnly, inCombat, manual) and the size of the player list -- so a reload is the whole reporting procedure. Guarded by DF:DebugActive so the slot walk that builds it costs nothing with logging off. ☠ THE _ENV DIFF CAUGHT A BUG THE COMMENT WOULD NOT HAVE. pihGateEnabled is declared BELOW pihSet, so logging it compiled to a nil GLOBAL read -- the line would have reported "nil" forever while looking correct in the source. Hoisted to the forward-declaration block; the assignment stays at its own site. (cherry picked from commit f3ed12b0fb53117884f674184d6ce0ff16661880) --- DandersFrames/AuraDesigner/Engine.lua | 42 ++++++++++++++++++++++++-- DandersFrames/Frames/AuraContainer.lua | 40 ++++++++++++++++++++---- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 95035887..03159e5a 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -160,6 +160,11 @@ local pihManual = nil -- 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 @@ -418,9 +423,38 @@ local pihEnabled = true local function pihSet(dark) pihGateOpen = not dark - local n = 0 + local n, deferred = 0, 0 if DF.AuraContainer and DF.AuraContainer.SetHelperGate then - n = DF.AuraContainer.SetHelperGate(dark) + n, deferred = DF.AuraContainer.SetHelperGate(dark) + deferred = deferred 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 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 | 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, 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. @@ -461,7 +495,9 @@ 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 diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index a6f4bd80..bbdf2b60 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -6716,13 +6716,20 @@ 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 end function AuraContainer.GetHelperGate() return helperGateDark end @@ -8499,12 +8506,33 @@ 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 - pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) -- ★★★ ...AND ARM THE PROCESSOR, or the push is a note nobody reads until later. -- From 68bb6dee40ab206093432f470b6f0ab1e3097736 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 10 Sep 2026 21:30:09 +0100 Subject: [PATCH 62/66] PI Helper: the gate log separates the two lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Krathe's log, after the combat fix: deferred=0 on every edge and "56 tot/56 dark" on every DARK. So the slot lane is doing exactly what it should -- and the icons are still on screen. The one number that could not answer the next question was `pushed`. It counted group HANDLES and placed SLOTS together, and the two lanes fail for entirely different reasons: a handle is skipped when it is destroyed or its backend is gone, a slot when it is parked or the push is refused. 72 pushed with icons still showing, and no way to tell which 72. Now reported apart: · handlesSkipped -- ours, carrying the mark, and NOT reachable. Those render whatever they last had and nothing in the broadcast can correct them, so they are counted rather than passed over in silence (the walk used to skip them with no record at all). · groups N tot/N live/N dark, from GetHelperHandleStatus -- the handle lane's equivalent of the slot line. The Cooldown Icons group IS a handle, so nothing in GetHelperSlotStatus could ever see it, which is precisely the blind spot in the screenshot: a row of three icons is what that group draws. Diagnostic only; no rendering behaviour changes. What the next log will settle is whether the group is being pushed and ignoring it, or is not in the walk at all. (cherry picked from commit 8ec6c028e568d39ad440f107be0d435f92535919) --- DandersFrames/AuraDesigner/Engine.lua | 14 +++++++---- DandersFrames/Frames/AuraContainer.lua | 33 +++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 03159e5a..3a3dc638 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -423,10 +423,10 @@ local pihEnabled = true local function pihSet(dark) pihGateOpen = not dark - local n, deferred = 0, 0 + local n, deferred, hSkip = 0, 0, 0 if DF.AuraContainer and DF.AuraContainer.SetHelperGate then - n, deferred = DF.AuraContainer.SetHelperGate(dark) - deferred = deferred or 0 + 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). @@ -445,13 +445,17 @@ local function pihSet(dark) 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 | slots %d tot/%d dark/%d pending/%d parked" + "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, tot, dk, pend, parked, + 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") diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index bbdf2b60..c921c1fd 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -6696,9 +6696,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 @@ -6707,7 +6712,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 @@ -6729,7 +6739,7 @@ function AuraContainer.SetHelperGate(dark) -- ⚠ 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 + return n, deferred, hSkip end function AuraContainer.GetHelperGate() return helperGateDark end @@ -6752,6 +6762,23 @@ function AuraContainer.GetHelperGate() return helperGateDark end -- 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 From a1fdbf04e0e1761f9a657393ddc9b75b9177ddcc Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 11 Sep 2026 12:57:39 +0100 Subject: [PATCH 63/66] PI Helper: the dead filter is the verified park lever, not an include map Krathe's raid log, lane-split: on every DARK edge, 30 group handles and 30 placed slots, 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 broadcast reached every container, the push succeeded, the filter did not blank the slot. HELPER_GATE_DEAD_CF was { includeSpellIDs = { [1] = true } }: "match only spell 1", i.e. nothing. But includeSpellIDs is evaluated INSIDE CanApplyIdentityCandidateFilters, and when the identity gate declines -- range, visibility, a cinematic, every reason the latch work exists -- the include map is skipped entirely and every helpful aura passes. This file documents that fail-open at length (IDENTITY-GATE EXPOSURE), and its own classifier, filterVulnerableToIdentityGate, has been returning TRUE for the dead filter all along. A "dead" filter built on that lever 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. It is SLOT_PARK_CF -- verified in game 2026-08-30 against a unit carrying eight live buffs, rendering nothing -- and it is why parking a slot works when gating one did not. The dead filter is that lever now. A separate constant from SLOT_PARK_CF, deliberately: _cf() returns it by identity and the value-tracked push compares against it, and a gated slot must stay distinguishable from a parked one because the two are cleared by different things. One line, and it explains the whole history: the border (frame-level, painted by DF, never through candidate filters) obeyed from the first report, and the container-backed icons and groups obeyed only when the identity gate happened to be applied at the moment of the push. (cherry picked from commit d52f9dd1c22ceed97c5a332b065bb2ef2cd21cf9) --- DandersFrames/Frames/AuraContainer.lua | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index c921c1fd..adb13a33 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 From 5784d5712fa76ac7740ce806412a436cffcdc774 Mon Sep 17 00:00:00 2001 From: Krathe Date: Fri, 11 Sep 2026 16:08:26 +0100 Subject: [PATCH 64/66] PI Helper: the player list is data, not the switch 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 list was doing two jobs. Names meant narrowing and no names meant everyone, so the only way to stop narrowing for a night was to delete the names and type them back afterwards -- the same fault as the enable tick that used to delete records, a switch whose off position throws data away. playersOn separates them. Absent means on, so every existing profile loads exactly as it did: a profile with names was narrowing and still is, one without was not and still is not. Only an explicit off is stored, which is also why there is no defaults entry for a later migration to trip over. An empty list still means everyone even with the switch on. "Watch nobody" is not a state anyone asks for by emptying a box, and blanking the feature silently is the worse failure -- the same reason the engine has always pushed nil rather than an empty map. Both push sites make the decision the same way. The panel's live push and the engine's load push are two halves of one behaviour and have already drifted apart once, when the live half did not exist at all and an edited list did nothing until the next reload. The header count now reports how many the box is narrowing to rather than how many are saved, so a switched-off list shows none. A 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. (cherry picked from commit c638e9e2c6ffb3070081daa61ba6b22ff637632d) --- DandersFrames/AuraDesigner/Engine.lua | 19 ++++-- DandersFrames/Locales/enUS.lua | 16 ++++- .../AuraDesigner/UI/Cards.lua | 68 ++++++++++++++++--- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index 3a3dc638..4e413aaa 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -689,12 +689,23 @@ function Engine:PIH_ApplySaved() -- 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 - for _, fullName in ipairs(s.players or {}) do - if type(fullName) == "string" and fullName ~= "" then - map = map or {} - map[fullName] = true + 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) diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index b6cd4f34..f2692aef 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -3035,7 +3035,21 @@ L["SHOW"] = true L["Class cooldowns"] = true L["Following the Triggers tab. Changing one of these stops that."] = true L["Follow Triggers"] = true -L["Empty means everyone. Add players here to watch only them."] = 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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 2ce27bd2..fb26c283 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -730,12 +730,18 @@ function P.PIH_Apply() -- ⚠ 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 - for _, fullName in ipairs(s.players or {}) do - if type(fullName) == "string" and fullName ~= "" then - map = map or {} - map[fullName] = true + 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) @@ -2137,6 +2143,29 @@ function P.PIH_SetPlayers(list) 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) + P.PIH_Settings().playersOn = on and nil or false + 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() @@ -7963,7 +7992,27 @@ end -- 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) - t.note(g, L["Empty means everyone. Add players here to watch only them."]) + -- ★★ 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), @@ -8373,9 +8422,12 @@ S.BuildPIHelperBody = function(parent, opts) -- ⚠ 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. No count means no list - -- means everyone, and the note inside says so in words. - local pn = #P.PIH_Players() + -- 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) From bb60e54c3e558b87d2c968073f8cad4ee6988c17 Mon Sep 17 00:00:00 2001 From: Krathe Date: Mon, 14 Sep 2026 19:59:21 +0100 Subject: [PATCH 65/66] PI Helper: two ticks wrote through `x and nil or y`, which always yields y Krathe, 2026-09-14: "I can't seem to tick 'only watch these players', it's not doing anything." With nil as the true arm, the and/or idiom collapses: `on and nil` is nil, so `or y` wins on both inputs. P.PIH_SetPlayersOn stored false whether the tick was being switched on or off, so the first click unticked it and no click could tick it again. P.PIH_SetIconShowsAura had the same shape and always pinned the Power Infusion icon, so "show the cooldown's own artwork" never took. Both are explicit ifs now. A sweep found the same idiom in six more places outside this feature; they are reported separately rather than folded in here. (cherry picked from commit 179082bfec498200334cf758022bf0a6a6c4f60f) --- .../AuraDesigner/UI/Cards.lua | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index fb26c283..41c0397f 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -973,7 +973,14 @@ end -- InvalidateAuraLayout + ForceRefreshAllFrames is that rebuild. function P.PIH_SetIconShowsAura(rec, on) if type(rec) ~= "table" then return end - rec.staticSpellID = on and nil or PIH_PI_SPELL_ID + -- ☠ 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 @@ -2162,7 +2169,15 @@ function P.PIH_PlayersOn() end function P.PIH_SetPlayersOn(on) - P.PIH_Settings().playersOn = on and nil or false + -- ☠ 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 From 2a5810b4757940057877b1d07deb752214ff10ea Mon Sep 17 00:00:00 2001 From: Krathe Date: Mon, 14 Sep 2026 20:26:31 +0100 Subject: [PATCH 66/66] Retire every `x and nil or y` -- nil never wins an and/or, so y always did Sweep after the PI helper players tick (179082bf). Six more sites used the idiom with nil as the true arm, and in every one the intended nil branch had never once been taken: Cards.lua the Other tab resolved card icons with the spec it is meant to bypass Groups.lua (x2) the Other pool previewed with the spec it must not use BindingEditor macros showed the fallback line the comment says to hide AuraContainer the assist verdict named every TRUSTED unit "cannot-assist" in the idgate readout ElementAppearance the alpha-host dump toned every line BAD, successes too Factory `isBar and nil or defs` in the placed signature -- harmless by its own note (both resolve identically on a bar), written out so the expression says what it does Each is now an explicit if, or the and/or with a non-nil true arm. No global-name change in any file; line endings preserved (BindingEditor is LF in the index and stays LF). (cherry picked from commit 868e2e409b8b1708014d80b17f5dc8cd8b4841c0) --- DandersFrames/AuraDesigner/Factory.lua | 4 +++- DandersFrames/Features/ElementAppearance.lua | 3 ++- DandersFrames/Frames/AuraContainer.lua | 4 +++- DandersFrames_Options/AuraDesigner/UI/Cards.lua | 5 ++++- DandersFrames_Options/AuraDesigner/UI/Groups.lua | 10 ++++++++-- .../ClickCasting/UI/BindingEditor.lua | 5 ++++- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/DandersFrames/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua index ae4cd711..8ce9e421 100644 --- a/DandersFrames/AuraDesigner/Factory.lua +++ b/DandersFrames/AuraDesigner/Factory.lua @@ -2931,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), }, "|") 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/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index adb13a33..6ce62368 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -9621,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_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 41c0397f..c70e0660 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -5108,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 diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index b5314292..ead0783e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -2816,7 +2816,10 @@ 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 @@ -3062,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/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