From e43ac430bb9f9ba3e356319618de9e6265ac2280 Mon Sep 17 00:00:00 2001 From: Sebastian Tannert Date: Sat, 1 Aug 2026 13:37:03 +0200 Subject: [PATCH] feat(cdm): replace cooldown slots with active buffs --- .../EUI_CooldownManager_Options.lua | 103 ++++++++++++++- .../EllesmereUICdmHooks.lua | 124 +++++++++++++++++- .../EllesmereUICdmSpellPicker.lua | 42 ++++++ .../EllesmereUICooldownManager.lua | 16 +++ Locales/_keys.txt | 5 +- 5 files changed, 277 insertions(+), 13 deletions(-) diff --git a/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua b/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua index 6e6f3703..82f7f105 100644 --- a/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua +++ b/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua @@ -7814,7 +7814,7 @@ initFrame:SetScript("OnEvent", function(self) -- Lists the class's CDM-trackable buffs plus a Custom Spell ID entry. No -- durations (aura-driven, never cast-timed) and no item/preset rows (those are -- cast-timer / CD-utility concepts that do not belong on an aura tracker). - local function ShowBuffToCDPicker(anchorFrame, targetBarKey, onChanged) + local function ShowBuffToCDPicker(anchorFrame, targetBarKey, onChanged, onPicked) if _spellPickerMenu and _spellPickerMenu:IsShown() then _spellPickerMenu:Hide() if _spellPickerMenu._anchorFrame == anchorFrame then return end @@ -7853,8 +7853,9 @@ initFrame:SetScript("OnEvent", function(self) local alreadyCd = sdCur and ns.CollectCdClaimSet(sdCur) local knownSpells = {} for _, sp in ipairs(allSpells) do - if sp.cdmCatGroup == "buff" and sp.spellID and not already[sp.spellID] - and not (sp.cdID and alreadyCd and alreadyCd[sp.cdID]) then + if sp.cdmCatGroup == "buff" and sp.spellID + and (onPicked or (not already[sp.spellID] + and not (sp.cdID and alreadyCd and alreadyCd[sp.cdID]))) then knownSpells[#knownSpells + 1] = sp end end @@ -7875,6 +7876,32 @@ initFrame:SetScript("OnEvent", function(self) local mH = 4 + if onPicked then + local clearItem = CreateFrame("Button", nil, inner) + clearItem:SetHeight(ITEM_H) + clearItem:SetPoint("TOPLEFT", inner, "TOPLEFT", 1, -mH) + clearItem:SetPoint("TOPRIGHT", inner, "TOPRIGHT", -1, -mH) + clearItem:SetFrameLevel(menu:GetFrameLevel() + 2) + local clearLbl = clearItem:CreateFontString(nil, "OVERLAY") + clearLbl:SetFont(FONT_PATH, 11, GetCDMOptOutline()) + clearLbl:SetPoint("LEFT", 10, 0) + clearLbl:SetText(EllesmereUI.L("None (use cooldown icon)")) + clearLbl:SetTextColor(tDimR, tDimG, tDimB, tDimA) + local clearHl = clearItem:CreateTexture(nil, "ARTWORK") + clearHl:SetAllPoints(); clearHl:SetColorTexture(1, 1, 1, hlA); clearHl:SetAlpha(0) + clearItem:SetScript("OnEnter", function() + clearLbl:SetTextColor(1, 1, 1, 1); clearHl:SetAlpha(hlA) + end) + clearItem:SetScript("OnLeave", function() + clearLbl:SetTextColor(tDimR, tDimG, tDimB, tDimA); clearHl:SetAlpha(0) + end) + clearItem:SetScript("OnClick", function() + menu:Hide() + onPicked(nil, nil) + end) + mH = mH + ITEM_H + end + -- Post-add refresh; picker stays open so several buffs add in a row. -- onChanged reanchors the live bars and refreshes the preview in place -- (same flow as the CD/utility "+" add). No RefreshCDPreview here: its @@ -7906,8 +7933,26 @@ initFrame:SetScript("OnEvent", function(self) csItem:SetScript("OnClick", function() menu:Hide() ShowCustomSpellIDPopup(targetBarKey, false, function(sid) - ns.AddBuffToCDUtilBar(targetBarKey, sid) - AfterAdd() + if onPicked then + local match + for _, candidate in ipairs(allSpells) do + if candidate.cdmCatGroup == "buff" and candidate.spellID == sid then + match = candidate + break + end + end + if not match then + EllesmereUI.Print("|cff0cd29fEllesmereUI CDM:|r " + .. EllesmereUI.L("That Spell ID is not in Blizzard's tracked-buff catalog.")) + return + end + local collisionID = match.cdID and ns.IsCollidedBuffSid + and ns.IsCollidedBuffSid(match.spellID) and match.cdID or nil + onPicked(match.spellID, collisionID) + else + ns.AddBuffToCDUtilBar(targetBarKey, sid) + AfterAdd() + end end) end) mH = mH + ITEM_H @@ -7955,6 +8000,13 @@ initFrame:SetScript("OnEvent", function(self) end) item:SetScript("OnClick", function() if notLearned then EllesmereUI.HideWidgetTooltip() end + if onPicked then + local collisionID = sp.cdID and ns.IsCollidedBuffSid + and ns.IsCollidedBuffSid(sp.spellID) and sp.cdID or nil + menu:Hide() + onPicked(sp.spellID, collisionID) + return + end -- Collided pair (two viewer slots, one shared spellID): claim -- by cooldownID so each slot is hostable on its own. Non- -- collided buffs keep the sid path (identity survives talent @@ -12308,6 +12360,47 @@ initFrame:SetScript("OnEvent", function(self) -- synced bars were promoted to bar-level settings by the -- cdm_spell_settings_tiers_v1 migration.) + -- Cooldown-to-buff replacement. The mapping belongs to the + -- cooldown's per-spec settings entry; the live renderer swaps + -- in Blizzard's real buff frame only while that aura is active. + if not isBuffBar and not isHostedBuff + and type(spellID) == "number" and spellID > 0 then + local replacementSID = rawget(ss, "replacementBuffSpellID") + local replacementName = replacementSID and C_Spell.GetSpellName(replacementSID) + local replaceRow = CreateFrame("Button", nil, inner) + replaceRow:SetHeight(ITEM_H) + replaceRow:SetPoint("TOPLEFT", inner, "TOPLEFT", 1, -mH) + replaceRow:SetPoint("TOPRIGHT", inner, "TOPRIGHT", -1, -mH) + replaceRow:SetFrameLevel(menu:GetFrameLevel() + 2) + local replaceLbl = replaceRow:CreateFontString(nil, "OVERLAY") + replaceLbl:SetFont(FONT_PATH, 11, GetCDMOptOutline()) + replaceLbl:SetPoint("LEFT", 10, 0) + replaceLbl:SetPoint("RIGHT", -10, 0) + replaceLbl:SetJustifyH("LEFT") + replaceLbl:SetText(EllesmereUI.L("Replace with Buff") .. ": " + .. (replacementName or EllesmereUI.L("None"))) + replaceLbl:SetTextColor(tDimR, tDimG, tDimB, tDimA) + local replaceHl = replaceRow:CreateTexture(nil, "ARTWORK") + replaceHl:SetAllPoints(); replaceHl:SetColorTexture(1, 1, 1, hlA) + replaceHl:SetAlpha(0) + replaceRow:SetScript("OnEnter", function() + replaceLbl:SetTextColor(1, 1, 1, 1); replaceHl:SetAlpha(1) + end) + replaceRow:SetScript("OnLeave", function() + replaceLbl:SetTextColor(tDimR, tDimG, tDimB, tDimA); replaceHl:SetAlpha(0) + end) + replaceRow:SetScript("OnClick", function() + menu:Hide() + ShowBuffToCDPicker(replaceRow, barKey, nil, function(buffSID, buffCdID) + if ns.SetCooldownBuffReplacement then + ns.SetCooldownBuffReplacement(barKey, spellID, buffSID, buffCdID) + end + RefreshCDPreview() + end) + end) + mH = mH + ITEM_H + end + -- Custom Icon (per-spell ONLY -- deliberately outside the -- Apply-to-Bar tier system: an icon replacement is a per-slot -- identity choice, so no bar tiers, no apply strip, and no diff --git a/EllesmereUICooldownManager/EllesmereUICdmHooks.lua b/EllesmereUICooldownManager/EllesmereUICdmHooks.lua index a8f6c3b3..7d781bf0 100644 --- a/EllesmereUICooldownManager/EllesmereUICdmHooks.lua +++ b/EllesmereUICooldownManager/EllesmereUICdmHooks.lua @@ -677,6 +677,8 @@ local _divertedSpellsCD = {} -- Checked by ResolveCDIDToBar BEFORE the sid-level map so the cd-level claim -- outranks a whole-pair sid claim. local _divertedBuffCdIDs = {} +local _replacementBuffRoutes = {} +local _replacementBuffCdRoutes = {} -- EXACT assigned ids, split out of the maps above. The sid maps also hold keys -- derived from each assignment's variant family, and one cooldown slot can carry -- several family members the player put on different bars (Divine Toll and its @@ -765,6 +767,8 @@ function ns.RebuildSpellRouteMap() wipe(_divertedVarBaseBuff) wipe(_divertedVarBaseCD) wipe(_divertedBuffCdIDs) + wipe(_replacementBuffRoutes) + wipe(_replacementBuffCdRoutes) _routeMapBuilt = false local p = ECME.db and ECME.db.profile @@ -900,6 +904,43 @@ function ns.RebuildSpellRouteMap() end end end + -- Pass 3c: cooldown-to-buff replacements. Dormant profiles skip this + -- entire pass. Settings live in the cooldown family store, so normal + -- spec and conditional override capture applies without special handling. + if ns._cdmAnyBuffReplacement then + for _, bd in ipairs(p.cdmBars.bars) do + if bd.enabled and not bd.isGhostBar + and bd.barType ~= "buffs" and bd.barType ~= "custom_buff" then + local sd = ns.GetBarSpellData(bd.key) + local settings = ns.GetSpellSettingsStore(bd.key) + if sd and sd.assignedSpells and settings then + for _, targetSID in ipairs(sd.assignedSpells) do + if type(targetSID) == "number" and targetSID > 0 then + local ss = ns.ResolveVariantValue(settings, targetSID) + local buffSID = ss and rawget(ss, "replacementBuffSpellID") + local buffCdID = ss and rawget(ss, "replacementBuffCooldownID") + if type(buffSID) == "number" and buffSID > 0 then + local route = { + barKey = bd.key, + targetSpellID = targetSID, + buffSpellID = buffSID, + buffCooldownID = buffCdID, + } + if type(buffCdID) == "number" and buffCdID > 0 then + _replacementBuffCdRoutes[buffCdID] = route + _divertedBuffCdIDs[buffCdID] = bd.key + else + SVV(_replacementBuffRoutes, buffSID, route, false) + StoreDirect(_divertedSpellsBuff, buffSID, bd.key) + end + end + end + end + end + end + end + end + -- Pass 4: ghost bars LAST = HIGHEST priority. A spell the user HID stays -- hidden even if it's also on a visible bar (the "both-state"), so a hidden -- spell never reappears regardless of how it ended up in two bars. Adding a @@ -914,6 +955,15 @@ function ns.RebuildSpellRouteMap() _routeMapBuilt = true end +function ns.GetBuffReplacementRoute(spellID, cooldownID) + if not ns._cdmAnyBuffReplacement then return nil end + if cooldownID then + local exact = _replacementBuffCdRoutes[cooldownID] + if exact then return exact end + end + return ns.ResolveVariantValue and ns.ResolveVariantValue(_replacementBuffRoutes, spellID) +end + --- Lazily resolve a cooldownID to a bar key. Called per-frame at reanchor --- time. Uses _cdidRouteMap as a memoization cache; on cache miss, computes --- the route from the per-family diversion map or falls back to @@ -5621,6 +5671,8 @@ local function CollectAndReanchor() ------------------------------------------------------- local targetBar, displaySID, baseSID = CategorizeFrame(frame, defaultBarKey) if targetBar and displaySID and displaySID > 0 then + local replacementRoute = ns.GetBuffReplacementRoute + and ns.GetBuffReplacementRoute(displaySID, frame.cooldownID) local barSeen = seenSpell[targetBar] if not barSeen then barSeen = {}; seenSpell[targetBar] = barSeen end local dedupKey = frame.cooldownID @@ -5640,12 +5692,29 @@ local function CollectAndReanchor() cf[#cf + 1] = frame local fc = FC(frame) fc.barKey = targetBar - fc.spellID = baseSID or displaySID - -- Hosted buff: Phase 3 ranks it by its hosted - -- MARKER slot, independent of the same spell's - -- cooldown entry on this bar. - fc.isHostedBuff = true + fc.displayBuffSpellID = baseSID or displaySID + if replacementRoute then + -- Hybrid identity: target cooldown owns layout, + -- keybind and Rotation Assist; the real buff frame + -- keeps native icon, swipe, duration and stacks. + fc.spellID = replacementRoute.targetSpellID + fc.replacementTargetSpellID = replacementRoute.targetSpellID + fc.isReplacementBuff = true + fc.isHostedBuff = nil + else + fc.spellID = baseSID or displaySID + fc.replacementTargetSpellID = nil + fc.isReplacementBuff = nil + -- Hosted buff: Phase 3 ranks it by its hosted + -- MARKER slot, independent of the same spell's + -- cooldown entry on this bar. + fc.isHostedBuff = true + end else + local fc = FC(frame) + fc.isReplacementBuff = nil + fc.replacementTargetSpellID = nil + fc.displayBuffSpellID = nil if not barLists[targetBar] then barLists[targetBar] = {} end barLists[targetBar][#barLists[targetBar] + 1] = AcquireEntry(frame, displaySID, baseSID or displaySID, frame.layoutIndex or 0) @@ -5739,7 +5808,7 @@ local function CollectAndReanchor() -- placeholder routes through the CD pipeline (Phase 3), not barLists. local hostCD = bd and bd.barType ~= "buffs" and bd.barType ~= "custom_buff" local showInactive = bd and (bd.showInactiveBuffIcons or bd.hidePlaceholderIcon) and true or false - if hostCD then showInactive = true end + if hostCD and not replacementRoute then showInactive = true end -- Hosted "Visibility When Missing" (per-spell, BUFF -- family store; hosted entries never chain to bar -- tiers, so this can never come from Apply-to-Bar). @@ -5755,7 +5824,7 @@ local function CollectAndReanchor() -- frame -- same outcome as Hidden on CD (Shift -- Icons) for cooldowns). local hostedMissingVis - if hostCD then + if hostCD and not replacementRoute then local phMV = GetOrCreatePlaceholderFrame(targetBar, realSID, nil, phIdent) local ssMV = ns.ResolveSpellSettings(phMV, realSID, ns.GetBarSpellData(targetBar), targetBar) local mv = ssMV and ssMV.hostedMissingVis @@ -5779,6 +5848,9 @@ local function CollectAndReanchor() elseif ssAS.alwaysShow == "off" then showInactive = false end end end + -- Replacements are conditional: when the aura is + -- missing, the original cooldown frame owns the slot. + if replacementRoute then showInactive = false end if bd and bd.enabled and (bd.barType == "buffs" or hostCD) and showInactive and hostedMissingVis ~= "hiddenShift" and targetBar ~= ns.FOCUSKICK_BAR_KEY @@ -5861,6 +5933,9 @@ local function CollectAndReanchor() fc.barKey = barKey fc.spellID = baseSID or displaySID fc.isHostedBuff = nil + fc.isReplacementBuff = nil + fc.replacementTargetSpellID = nil + fc.displayBuffSpellID = nil end end end @@ -6364,6 +6439,41 @@ local function CollectAndReanchor() end end + -- An active replacement and its underlying cooldown intentionally share + -- one slot identity. Keep the native buff frame and remove only the + -- underlying cooldown from this collect pass; Phase 4 restores it as soon + -- as the buff frame disappears on the next event-driven reanchor. + if ns._cdmAnyBuffReplacement then + for _, frames in pairs(cdFrames) do + local targets + for _, frame in ipairs(frames) do + local fc = _ecmeFC[frame] + if fc and fc.isReplacementBuff and fc.replacementTargetSpellID then + if not targets then targets = {} end + targets[fc.replacementTargetSpellID] = true + end + end + if targets then + for i = #frames, 1, -1 do + local frame = frames[i] + local fc = _ecmeFC[frame] + if fc and not fc.isReplacementBuff and fc.spellID then + local claimed = targets[fc.spellID] + if not claimed and ns.IsVariantOf then + for targetSID in pairs(targets) do + if ns.IsVariantOf(fc.spellID, targetSID) then + claimed = true + break + end + end + end + if claimed then table.remove(frames, i) end + end + end + end + end + end + -- Pre-build claim set for racial/custom spell checks: collect all -- spellIDs already claimed by Blizzard frames across all bars. This -- replaces the O(frames * FindSpellOverrideByID) inner loop with a diff --git a/EllesmereUICooldownManager/EllesmereUICdmSpellPicker.lua b/EllesmereUICooldownManager/EllesmereUICdmSpellPicker.lua index 9ea2eefc..16c6daec 100644 --- a/EllesmereUICooldownManager/EllesmereUICdmSpellPicker.lua +++ b/EllesmereUICooldownManager/EllesmereUICdmSpellPicker.lua @@ -1757,6 +1757,48 @@ function ns.AddBuffToCDUtilBar(barKey, spellID) return true end +--- Configure the buff that temporarily occupies a cooldown slot while active. +--- The settings store is spec/profile aware, so the existing override system +--- captures these scalar fields exactly like every other per-spell option. +function ns.SetCooldownBuffReplacement(barKey, targetSpellID, buffSpellID, buffCooldownID) + if type(targetSpellID) ~= "number" or targetSpellID <= 0 then return false end + if buffSpellID ~= nil and (type(buffSpellID) ~= "number" or buffSpellID <= 0) then + return false + end + local store = ns.GetSpellSettingsStore(barKey, true) + if not store then return false end + + -- A tracked buff may own only one cooldown slot. CooldownID distinguishes + -- collided Blizzard catalog entries that share a canonical spell ID. + if buffSpellID then + for key, other in pairs(store) do + if type(other) == "table" and key ~= targetSpellID then + local otherSID = rawget(other, "replacementBuffSpellID") + local otherCdID = rawget(other, "replacementBuffCooldownID") + local same = buffCooldownID and otherCdID == buffCooldownID + if not same and not buffCooldownID and not otherCdID and otherSID then + same = ns.IsVariantOf and ns.IsVariantOf(otherSID, buffSpellID) + end + if same then + other.replacementBuffSpellID = nil + other.replacementBuffCooldownID = nil + end + end + end + end + + local ss = store[targetSpellID] + if not ss then ss = {}; store[targetSpellID] = ss end + ss.replacementBuffSpellID = buffSpellID + ss.replacementBuffCooldownID = buffSpellID and buffCooldownID or nil + if buffSpellID then ns._cdmAnyBuffReplacement = true end + ns._cdmResGen = (ns._cdmResGen or 0) + 1 + ns._spellOrderDirty = true + if ns.RebuildSpellRouteMap then ns.RebuildSpellRouteMap() end + if ns.QueueReanchor then ns.QueueReanchor() end + return true +end + --- Host a single collided-buff SLOT (cooldownID) on a CD/util bar. Same --- collision escape hatch as ns.AddTrackedBuffByCdID (buff-family bars): two --- viewer slots can share one canonical spellID (Diabolist Demonic Art vs diff --git a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua index a823ab10..d62f4073 100644 --- a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua +++ b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua @@ -1413,6 +1413,21 @@ function ns.RescanCdReadySoundFlag() end) end +-- Cooldown-to-buff replacement gate. Routing and frame compaction skip all +-- replacement work unless a saved mapping exists or one is enabled live. +function ns.RescanBuffReplacementFlag() + if ns._cdmAnyBuffReplacement or ns._buffReplacementFlagScanned then return end + if not EllesmereUIDB then return end + ns._buffReplacementFlagScanned = true + ns.ForEachSavedSettingsBlock(function(ss) + if type(ss.replacementBuffSpellID) == "number" + and ss.replacementBuffSpellID > 0 then + ns._cdmAnyBuffReplacement = true + return true + end + end) +end + -- "Hide CD Text (Charges)" gate: set ns._cdmAnyChargeHideCdText once if any saved -- spell (any spec) has the toggle enabled, so RefreshCDMIconAppearance skips its -- per-icon watch check for anyone who never uses the feature. Same monotonic, @@ -7638,6 +7653,7 @@ BuildAllCDMBars = function() ns.RescanChargeCdTextFlag() -- set the Hide CD Text (Charges) gate (once) before refresh ns.RescanBuffSoundFlag() -- set the Audio on Buff Gain/Loss gate (once) before refresh ns.RescanCdReadySoundFlag() -- set the Audio Effect on CD Ready gate (once) before refresh + ns.RescanBuffReplacementFlag() -- set the cooldown-to-buff replacement gate (once) ns.RescanCustomItemFlag() -- set the custom-item buff-injection gate (once) ns.RescanCustomForceCountFlag() -- set the "Show Charges" custom-spell gate (once) ns.RescanReverseSwipeFlag() -- set the Reverse Swipe gate (once) before refresh diff --git a/Locales/_keys.txt b/Locales/_keys.txt index ab237edb..b2952c4b 100644 --- a/Locales/_keys.txt +++ b/Locales/_keys.txt @@ -1,6 +1,6 @@ # Auto-generated by .tools/extract-locale-keys.sh -- do not edit by hand. # Canonical list of translatable English keys passed as string literals -# (646 unique). Regenerate after wrapping new strings. Keys passed as +# (649 unique). Regenerate after wrapping new strings. Keys passed as # variables are not listed here -- use the in-game /euiloc harvester for # the complete runtime set. (Raid) @@ -387,6 +387,7 @@ No talent No talent reminders configured None None (Disabled) +None (use cooldown icon) Not Available Yet Not Bound Not Included @@ -464,6 +465,7 @@ Rename Preset Rename Profile Renders this slot's element above the rest of the nameplate. Repaired all items for %s +Replace with Buff Reposition freely with Reposition this element with Shift+Click and Drag. Reposition this element within Blizzard Edit Mode @@ -556,6 +558,7 @@ Talent gating is only available while playing this spec's class Target Target Unit Targeting: +That Spell ID is not in Blizzard's tracked-buff catalog. The pasted string could not be read completely. Please paste it again. The single stack threshold is off while Multiple Thresholds is on. Thickness