From c49748b9e46302ce8098e7e5908f43abad43e29a Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Tue, 15 Sep 2026 03:48:12 +0100 Subject: [PATCH 1/4] feat(raidframes): add modifier-gated buff tooltips --- .../EUI_RaidFrames_ManagerPages.lua | 1 + EllesmereUIOptions/EUI_RaidFrames_Options.lua | 13 ++- .../EUI_RaidFrames_AuraContainers.lua | 101 +++++++++++++++++- .../EUI_RaidFrames_ClickCast.lua | 22 ++-- .../EUI_RaidFrames_DebuffManager.lua | 31 +++++- 5 files changed, 150 insertions(+), 18 deletions(-) diff --git a/EllesmereUIOptions/EUI_RaidFrames_ManagerPages.lua b/EllesmereUIOptions/EUI_RaidFrames_ManagerPages.lua index 17f43abff..033045be7 100644 --- a/EllesmereUIOptions/EUI_RaidFrames_ManagerPages.lua +++ b/EllesmereUIOptions/EUI_RaidFrames_ManagerPages.lua @@ -1550,6 +1550,7 @@ local function BuildBaseDetailDM(frame, fontPath) title = "Tooltips", rows = { { type = "dropdown", label = "Use Modifier", + tooltip = "Shared with buff tooltips. None shows tooltips without requiring a key.", values = { none = "None", shift = "Shift", control = "Control", alt = "Alt" }, order = { "none", "shift", "control", "alt" }, get = function() return p.debuffTooltipModifier or "none" end, diff --git a/EllesmereUIOptions/EUI_RaidFrames_Options.lua b/EllesmereUIOptions/EUI_RaidFrames_Options.lua index 94c27be08..15114305c 100644 --- a/EllesmereUIOptions/EUI_RaidFrames_Options.lua +++ b/EllesmereUIOptions/EUI_RaidFrames_Options.lua @@ -4115,12 +4115,12 @@ initFrame:SetScript("OnEvent", function(self) tipRows = { { type="dropdown", label="Buff Tooltips", tooltip="Tooltip behavior when hovering a buff/HoT icon on a raid or party frame.", - values={ hidden="Hidden", shown="Shown", cursor="Shown At Cursor", combat="Hidden In Combat" }, - order={ "hidden", "shown", "cursor", "combat" }, + values={ hidden="Hidden", shown="Shown", cursor="Shown At Cursor", combat="Hidden In Combat", modifier="Shown on Modifier" }, + order={ "hidden", "shown", "cursor", "modifier", "combat" }, get=function() local v = SVal("buffHideTooltips", true) if v == false then return "shown" end - if v == "cursor" or v == "combat" then return v end + if v == "cursor" or v == "combat" or v == "modifier" then return v end return "hidden" end, set=function(k) @@ -4128,6 +4128,13 @@ initFrame:SetScript("OnEvent", function(self) if k == "shown" then v = false elseif k == "hidden" then v = true end SSet("buffHideTooltips", v); if ns.ReloadFrames then ns.ReloadFrames() end end }, + { type="dropdown", label="Use Modifier", + tooltip="Shared with debuff tooltips. None shows tooltips without requiring a key.", + values={ none="None", shift="Shift", control="Control", alt="Alt" }, + order={ "none", "shift", "control", "alt" }, + get=function() return db.profile.debuffTooltipModifier or "none" end, + set=function(v) SSet("debuffTooltipModifier", v) end, + disabled=function() return SVal("buffHideTooltips", true) ~= "modifier" end }, } local _, cogShow = EllesmereUI.BuildCogPopup({ title = "Tooltip Settings", diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua index 986f2ba98..389e58a81 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua @@ -1283,7 +1283,7 @@ end -- aura buttons render their own tooltip when mouse motion is on, showing the real -- aura even while secret; effect slots (healthcolor/border) stay motion-off always -- (they overlay the health bar). 4-state mode: true/nil=hidden, false=shown, --- "combat"=hidden in combat, "cursor"=shown at cursor. +-- "combat"=hidden in combat, "cursor"=shown at cursor, "modifier"=secure hover gate. local function BmTipMode() local p = ns.db and ns.db.profile local v = p and p.buffHideTooltips @@ -1293,7 +1293,7 @@ end local function BmTipsOff() local v = BmTipMode() - return not (v == false or v == "combat" or v == "cursor") + return not (v == false or v == "combat" or v == "cursor" or v == "modifier") end local function BuildBmIconStyle(ind, iscale, size) @@ -2217,6 +2217,98 @@ local function AnchorBmSimpleContainer(container, health, bs, iscale, d) }) end +-- The secure modifier overlay covers the maximum configured footprint, just +-- like Debuff Manager. No native aura identity, visibility or bounds are read. +local function EnsureBmTipModifiers(button, d) + if not ns.DM_TipModSync then return end + ns.DM_TipModSync(d) + if BmTipMode() ~= "modifier" or ns.DM_TipMod() == "none" then + ns.DM_ParkBuffTipEaters(d) + return + end + if InCombatLockdown() then d.rfcBmPending = true; return end + local health = d.rfcHealth + if not health then return end + local s = ProxyFor(d) + local pinHost = ns.RF_AnchorHost and ns.RF_AnchorHost(health, s) or health + local hugBar = pinHost._euiHealth or pinHost + local iscale = BmScaleFor(d) + local seen = {} + local function Grid(key, container, n, size, spacing, per, vertical, padding, lineWidth) + if not container or n < 1 then return end + -- Containers are owned frames whose single pin is set by the layout + -- functions above; their dynamic aura buttons are never queried. + local point, host, corner, x, y = container:GetPoint(1) + if not point or not host then return end + local along = per and per > 0 and math.min(n, per) or n + local lines = per and per > 0 and math.ceil(n / per) or 1 + local w = math.max(size, along * size + (along - 1) * spacing, lineWidth or 0) + local h = math.max(size, lines * size + (lines - 1) * spacing) + if vertical then w, h = h, w end + padding = padding or 0 + w, h = w + padding * 2, h + padding * 2 + -- Expand around the same pin when members have custom gaps. + if point:find("LEFT", 1, true) then x = x - padding + elseif point:find("RIGHT", 1, true) then x = x + padding end + if point:find("TOP", 1, true) then y = y + padding + elseif point:find("BOTTOM", 1, true) then y = y - padding end + seen[key] = true + ns.DM_EnsureBuffTipEater(button, d, key, container, host, point, corner, x, y, w, h) + end + local bs = BmSimpleSettings() + if bs and bs.showBuffs and ns.BM_BaseActive and ns.BM_BaseActive() then + local vertical = bs.growDirection == "UP" or bs.growDirection == "DOWN" + local per = bs.iconsPerRow or 4 + if per < 2 then per = 0 end + Grid("simple", d.rfcBmSimple, bs.maxBuffs or 8, (bs.size or 18) * iscale, + (bs.spacing or 1) * iscale, vertical and 1 or per, false) + end + for _, m in ipairs(d.rfcBmMeta or {}) do + if m.isChain and not m.anchored then + local members = m.members or { { ind = m.ind, count = m.count or (m.ind.spells and #m.ind.spells) or 0 } } + local n, size, padding = 0, 0, 0 + local budgets = {} + for i, member in ipairs(members) do + local ind = member.ind + local count = member.count or 0 + local cap = ind.maxIcons or 0 + local index = member.memberIndex or i + if cap > 0 then + local remaining = budgets[index] or cap + count = math.min(count, remaining) + budgets[index] = remaining - count + end + n = n + count + size = math.max(size, (ind.size or 18) * iscale) + if i > 1 and (member.segIndex or 1) <= 1 and index > 1 then + local grow = members[1].ind.growDirection + local offset = (grow == "UP" or grow == "DOWN") and ind.offsetY or ind.offsetX + padding = padding + math.abs(offset or 0) * iscale + end + end + local ind = members[1].ind + local spacing = (ind.spacing or 0) * iscale + local per = tonumber(ind.iconsPerRow) or 0 + local width + -- Mixed-size groups may fit fewer cells than the root's row width. + if per > 0 then + width = per * (ind.size or 18) * iscale + (per - 1) * spacing + per = math.max(1, math.floor((width + spacing) / math.max(1, size + spacing))) + end + Grid("chain:" .. m.chainKey, d.rfcBmChain and d.rfcBmChain[m.chainKey], + n, size, spacing, per, ind.growDirection == "UP" or ind.growDirection == "DOWN", padding, width) + elseif not m.isChain and d.rfcBm and (m.kind == "icon" or m.kind == "square" or m.kind == "bar") then + local key = "slot:" .. m.key + seen[key] = true + ns.DM_EnsureBuffTipEater(button, d, key, d.rfcBm, pinHost, "CENTER", "CENTER", 0, 0, 1, 1, + function(e) BmAnchorOneSlot(e, m, pinHost, hugBar, iscale) end, + BmGeoFP({ m }, iscale, s)) + end + end + ns.DM_ParkBuffTipEaters(d, seen) +end +ns.RFC_EnsureBmTipModifiers = EnsureBmTipModifiers + local function CreateBmSimpleContainer(button, health, d, unit, specKey) local bs = BmSimpleSettings() or {} local iscale = BmScaleFor(d) @@ -2240,6 +2332,7 @@ local function CreateBmSimpleContainer(button, health, d, unit, specKey) st.style = BmSimpleStyleFP(bs, font, iscale) st.cand = BmSimpleCandFP(bs) st.geo = BmSimpleGeoFP(bs, iscale, ProxyFor(d)) + EnsureBmTipModifiers(button, d) return end @@ -2272,6 +2365,7 @@ local function CreateBmSimpleContainer(button, health, d, unit, specKey) st.style = BmSimpleStyleFP(bs, font, iscale) st.cand = BmSimpleCandFP(bs) st.geo = BmSimpleGeoFP(bs, iscale, ProxyFor(d)) + EnsureBmTipModifiers(button, d) return end local c = AK.CreateContainer(button, unit, { @@ -2299,6 +2393,7 @@ local function CreateBmSimpleContainer(button, health, d, unit, specKey) st.style = BmSimpleStyleFP(bs, font, iscale) st.cand = BmSimpleCandFP(bs) st.geo = BmSimpleGeoFP(bs, iscale, ProxyFor(d)) + EnsureBmTipModifiers(button, d) end local function ReloadBmSimple(button, d, cls) @@ -2658,6 +2753,7 @@ local function CreateBmContainer(button, health, d, unit) end end bmOwnFP[StyleKeyFor(d)] = BmOwnKey(meta) + EnsureBmTipModifiers(button, d) end -- Chains-only rebind for pool shells that finished building after the @@ -3503,6 +3599,7 @@ function ns.RFC_ReloadAll() clsCache[styleKey] = cls end ReloadBm(button, d, s, cls) + EnsureBmTipModifiers(button, d) end end end diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua index d11f14f14..82fe9ef5c 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua @@ -1504,7 +1504,8 @@ end -- hides itself so the hover falls through to the aura button beneath. -- The "eui_tipmod" state driver on this header flips only the HOVERED eater -- on a modifier edge (one macro-conditional check per press, nothing else) --- and re-shows the peeked eater on release. wrappedFrames is set here first, +-- and re-shows the peeked eaters on release. Overlapping eaters each hide +-- on entry while the key is held. wrappedFrames is set here first, -- so DoRegisterFrame (click attributes when enabled) never wraps them twice. ------------------------------------------------------------------------------- local tipEaters = setmetatable({}, { __mode = "k" }) @@ -1521,11 +1522,12 @@ local TIP_ENTER_BODY = [[ eui_hoveractive = true end local k = control:GetAttribute("eui_tipmod_key") - if k and not eui_tippeeked + if k and ((k == "shift" and IsShiftKeyDown()) or (k == "control" and IsControlKeyDown()) or (k == "alt" and IsAltKeyDown())) then - eui_tippeeked = self + eui_tippeeked = eui_tippeeked or newtable() + eui_tippeeked[self] = true self:Hide() end ]] @@ -1567,7 +1569,7 @@ function ns.CC_ReleaseTipEater(frame) header:SetFrameRef("eui_tipclear", frame) header:Execute([[ local f = self:GetFrameRef("eui_tipclear") - if eui_tippeeked == f then eui_tippeeked = nil end + if eui_tippeeked then eui_tippeeked[f] = nil end if eui_hoverframe == f then eui_hoverframe = nil end ]]) end @@ -1580,19 +1582,23 @@ function ns.CC_SetTipModKey(key) header:SetAttribute("eui_tipmod_key", key) if not key then header:Execute([[ - if eui_tippeeked then eui_tippeeked:Show(); eui_tippeeked = nil end + if eui_tippeeked then + for f in pairs(eui_tippeeked) do f:Show() end + eui_tippeeked = nil + end ]]) return end header:SetAttribute("_onstate-eui_tipmod", [[ if newstate == "held" then local f = eui_hoverframe - if f and not eui_tippeeked and f:GetAttribute("eui_tipeater") then - eui_tippeeked = f + if f and f:GetAttribute("eui_tipeater") then + eui_tippeeked = eui_tippeeked or newtable() + eui_tippeeked[f] = true f:Hide() end elseif eui_tippeeked then - eui_tippeeked:Show() + for f in pairs(eui_tippeeked) do f:Show() end eui_tippeeked = nil end ]]) diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua index 6dde33964..5fc06a9a8 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua @@ -433,11 +433,12 @@ local function ParkEater(e) if ns.CC_ReleaseTipEater then ns.CC_ReleaseTipEater(e) end end --- Is any debuff display actually in the "Shown on Modifier" mode? Base row --- plus enabled icon tiles' overrides (nil override inherits the base). +-- Is any aura display in the "Shown on Modifier" mode? Buffs share the key; +-- debuffs include the base row and enabled icon tiles' effective modes. local function TipModeInUse() local p = ns.db and ns.db.profile if not p then return false end + if p.buffHideTooltips == "modifier" then return true end if p.debuffHideTooltips == "modifier" then return true end local dm = DM() local tiles = dm and dm.tiles @@ -461,7 +462,7 @@ local tipModArmed = false -- without the feature never touches the header at all (zero cost while off). local tipModKeyApplied = false --- Feature wanted at all: a key is set AND some debuff display is in the +-- Feature wanted at all: a key is set AND some aura display is in the -- "modifier" mode. The apply pass gates every footprint/ensure write on this. function ns.DM_TipModWanted() return ns.DM_TipMod() ~= "none" and TipModeInUse() @@ -562,7 +563,7 @@ end -- reload re-runs this ensure. An unchanged eater costs a few compares and -- never touches the frame. local tipEaterCount = 0 -local function EnsureEater(d, slot, host, container, active, pinHost, point, corner, offX, offY, w, h) +local function EnsureEater(d, slot, host, container, active, pinHost, point, corner, offX, offY, w, h, anchor, anchorFP) local map = d.tipModEaters local e = map and map[slot] if not active or not pinHost then @@ -588,7 +589,7 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor local lvl = (container:GetFrameLevel() or 1) + 30 local geoChanged = not e or e._euiPin ~= point or e._euiCorner ~= corner or e._euiOX ~= offX or e._euiOY ~= offY or e._euiHost ~= pinHost - or e._euiW ~= w or e._euiH ~= h + or e._euiW ~= w or e._euiH ~= h or e._euiAnchorFP ~= anchorFP local lvlChanged = not e or e._euiLvl ~= lvl local armChanged = not e or not e._euiActive if not (geoChanged or lvlChanged or armChanged) then @@ -627,11 +628,13 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor e:SetFrameLevel(lvl) end if geoChanged then + e._euiAnchorFP = anchorFP e._euiPin, e._euiCorner, e._euiOX, e._euiOY = point, corner, offX, offY e._euiHost, e._euiW, e._euiH = pinHost, w, h e:ClearAllPoints() e:SetPoint(point, pinHost, corner, offX, offY) e:SetSize(w, h) + if anchor then anchor(e) end end if armChanged then e._euiActive = true @@ -639,6 +642,24 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor end end +-- Buffs share the secure modifier driver, but keep their own display handles. +-- Layout callbacks only anchor our eater; they never read native aura buttons. +function ns.DM_EnsureBuffTipEater(button, d, key, container, pinHost, point, corner, x, y, w, h, anchor, anchorFP) + d.buffTipSlots = d.buffTipSlots or {} + local slot = d.buffTipSlots[key] + if not slot then slot = {}; d.buffTipSlots[key] = slot end + EnsureEater(d, slot, button, container, true, pinHost, point, corner, x, y, w, h, anchor, anchorFP) +end + +function ns.DM_ParkBuffTipEaters(d, seen) + if not d.buffTipSlots then return end + for key, slot in pairs(d.buffTipSlots) do + if not seen or not seen[key] then + EnsureEater(d, slot, nil, nil, false) + end + end +end + -- Per-unit ensure, called from the containers reload loop and from the tail -- of every DM_ApplyDebuffConfig (fresh footprint inputs; tile containers -- built on the deferred lanes re-enter through that apply): base container From e28a23ccb3f4fb37e5c6d2d624383ae1e32a32bf Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Tue, 15 Sep 2026 04:42:20 +0100 Subject: [PATCH 2/4] fix(raidframes): constrain modifier blockers to their display --- .../EUI_RaidFrames_AuraContainers.lua | 14 +++-- .../EUI_RaidFrames_DebuffManager.lua | 31 ++++++++-- tests/run_buff_tooltip_tests.py | 58 +++++++++++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 tests/run_buff_tooltip_tests.py diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua index 389e58a81..2911dab48 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua @@ -2234,7 +2234,7 @@ local function EnsureBmTipModifiers(button, d) local hugBar = pinHost._euiHealth or pinHost local iscale = BmScaleFor(d) local seen = {} - local function Grid(key, container, n, size, spacing, per, vertical, padding, lineWidth) + local function Grid(key, container, n, size, spacing, per, vertical, padding, lineWidth, levelOffset) if not container or n < 1 then return end -- Containers are owned frames whose single pin is set by the layout -- functions above; their dynamic aura buttons are never queried. @@ -2253,7 +2253,8 @@ local function EnsureBmTipModifiers(button, d) if point:find("TOP", 1, true) then y = y + padding elseif point:find("BOTTOM", 1, true) then y = y - padding end seen[key] = true - ns.DM_EnsureBuffTipEater(button, d, key, container, host, point, corner, x, y, w, h) + ns.DM_EnsureBuffTipEater(button, d, key, container, host, point, corner, x, y, w, h, + nil, nil, button:GetFrameLevel() + (levelOffset or 13) + 1) end local bs = BmSimpleSettings() if bs and bs.showBuffs and ns.BM_BaseActive and ns.BM_BaseActive() then @@ -2266,7 +2267,7 @@ local function EnsureBmTipModifiers(button, d) for _, m in ipairs(d.rfcBmMeta or {}) do if m.isChain and not m.anchored then local members = m.members or { { ind = m.ind, count = m.count or (m.ind.spells and #m.ind.spells) or 0 } } - local n, size, padding = 0, 0, 0 + local n, size, padding, levelOffset = 0, 0, 0, 0 local budgets = {} for i, member in ipairs(members) do local ind = member.ind @@ -2280,6 +2281,7 @@ local function EnsureBmTipModifiers(button, d) end n = n + count size = math.max(size, (ind.size or 18) * iscale) + levelOffset = math.max(levelOffset, BM_FRAMELVL[ind.frameLevel or "medium"] or 13) if i > 1 and (member.segIndex or 1) <= 1 and index > 1 then local grow = members[1].ind.growDirection local offset = (grow == "UP" or grow == "DOWN") and ind.offsetY or ind.offsetX @@ -2296,13 +2298,15 @@ local function EnsureBmTipModifiers(button, d) per = math.max(1, math.floor((width + spacing) / math.max(1, size + spacing))) end Grid("chain:" .. m.chainKey, d.rfcBmChain and d.rfcBmChain[m.chainKey], - n, size, spacing, per, ind.growDirection == "UP" or ind.growDirection == "DOWN", padding, width) + n, size, spacing, per, ind.growDirection == "UP" or ind.growDirection == "DOWN", padding, width, levelOffset) elseif not m.isChain and d.rfcBm and (m.kind == "icon" or m.kind == "square" or m.kind == "bar") then local key = "slot:" .. m.key seen[key] = true ns.DM_EnsureBuffTipEater(button, d, key, d.rfcBm, pinHost, "CENTER", "CENTER", 0, 0, 1, 1, function(e) BmAnchorOneSlot(e, m, pinHost, hugBar, iscale) end, - BmGeoFP({ m }, iscale, s)) + BmGeoFP({ m }, iscale, s), + button:GetFrameLevel() + (BM_FRAMELVL[m.ind.frameLevel or + (m.kind == "bar" and "behindBorders" or "medium")] or 13) + 1) end end ns.DM_ParkBuffTipEaters(d, seen) diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua index 5fc06a9a8..e4ed685de 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua @@ -563,7 +563,7 @@ end -- reload re-runs this ensure. An unchanged eater costs a few compares and -- never touches the frame. local tipEaterCount = 0 -local function EnsureEater(d, slot, host, container, active, pinHost, point, corner, offX, offY, w, h, anchor, anchorFP) +local function EnsureEater(d, slot, host, container, active, pinHost, point, corner, offX, offY, w, h, anchor, anchorFP, level) local map = d.tipModEaters local e = map and map[slot] if not active or not pinHost then @@ -586,10 +586,11 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor if ph and ph > 0 and ph < maxH then maxH = ph end if w > maxW then w = maxW end if h > maxH then h = maxH end - local lvl = (container:GetFrameLevel() or 1) + 30 + local lvl = level or ((container:GetFrameLevel() or 1) + 30) local geoChanged = not e or e._euiPin ~= point or e._euiCorner ~= corner or e._euiOX ~= offX or e._euiOY ~= offY or e._euiHost ~= pinHost or e._euiW ~= w or e._euiH ~= h or e._euiAnchorFP ~= anchorFP + or (anchor and (e._euiClampW ~= maxW or e._euiClampH ~= maxH)) local lvlChanged = not e or e._euiLvl ~= lvl local armChanged = not e or not e._euiActive if not (geoChanged or lvlChanged or armChanged) then @@ -634,7 +635,27 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor e:ClearAllPoints() e:SetPoint(point, pinHost, corner, offX, offY) e:SetSize(w, h) - if anchor then anchor(e) end + if anchor then + anchor(e) + -- Only addon-owned frames are measured, outside combat. Anchoring + -- can replace the initial size, so constrain the FINAL rectangle. + local x, y, width, height = e:GetRect() + local hx, hy, hw, hh = host:GetRect() + if not x or not hx then + e._euiAnchorFP = nil + ParkEater(e); d.rfcBmPending = true; return + end + local left, bottom = math.max(x, hx), math.max(y, hy) + local right, top = math.min(x + width, hx + hw), math.min(y + height, hy + hh) + if right <= left or top <= bottom then + e._euiAnchorFP = nil + ParkEater(e); return + end + e:ClearAllPoints() + e:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", left - hx, bottom - hy) + e:SetSize(right - left, top - bottom) + e._euiClampW, e._euiClampH = maxW, maxH + end end if armChanged then e._euiActive = true @@ -644,11 +665,11 @@ end -- Buffs share the secure modifier driver, but keep their own display handles. -- Layout callbacks only anchor our eater; they never read native aura buttons. -function ns.DM_EnsureBuffTipEater(button, d, key, container, pinHost, point, corner, x, y, w, h, anchor, anchorFP) +function ns.DM_EnsureBuffTipEater(button, d, key, container, pinHost, point, corner, x, y, w, h, anchor, anchorFP, level) d.buffTipSlots = d.buffTipSlots or {} local slot = d.buffTipSlots[key] if not slot then slot = {}; d.buffTipSlots[key] = slot end - EnsureEater(d, slot, button, container, true, pinHost, point, corner, x, y, w, h, anchor, anchorFP) + EnsureEater(d, slot, button, container, true, pinHost, point, corner, x, y, w, h, anchor, anchorFP, level) end function ns.DM_ParkBuffTipEaters(d, seen) diff --git a/tests/run_buff_tooltip_tests.py b/tests/run_buff_tooltip_tests.py new file mode 100644 index 000000000..60dcaa02b --- /dev/null +++ b/tests/run_buff_tooltip_tests.py @@ -0,0 +1,58 @@ +"""Focused production-source regression tests. Requires lupa (Lua 5.1).""" +from pathlib import Path +import sys +import re +from lupa.lua51 import LuaRuntime +ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] +def source(pr, file): + return (ROOT / file).read_text(encoding="utf-8-sig") +def between(text, start, end): + return text[text.index(start):text.index(end, text.index(start))] +def runtime(): + return LuaRuntime(unpack_returned_tuples=True) + +dm=source(2095,'EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua') +lua=runtime() +lua.execute(''' +ns={}; EllesmereUI={}; combat=false; created=0 +InCombatLockdown=function() return combat end +local function nop() end +function Frame(x,y,w,h,level) + local f={x=x,y=y,w=w,h=h,level=level or 0} + f.GetSize=function(s) return s.w,s.h end + f.GetRect=function(s) return s.x,s.y,s.w,s.h end + f.GetFrameLevel=function(s) return s.level end + f.SetFrameLevel=function(s,l) s.level=l end + f.SetSize=function(s,w,h) s.w=w;s.h=h end + f.SetPoint=function(s,p,host,c,x,y) s.x=host.x+(x or 0);s.y=host.y+(y or 0) end + f.Show=function(s) s.shown=true end; f.Hide=function(s) s.shown=false end + f.ClearAllPoints=nop;f.RegisterForClicks=nop;f.SetAttribute=nop;f.HookScript=nop + return f +end +CreateFrame=function() created=created+1; return Frame(0,0,1,1) end +local tipModEaters={} +ForwardEnter=nop; ForwardLeave=nop +function ParkEater(e) e._euiActive=false;e:Hide() end +''') +lua.execute('local tipModEaters={}\n'+between(dm,'local tipEaterCount = 0','-- Per-unit ensure,') ) +lua.execute(''' +local host=Frame(100,100,100,40,10); local container=Frame(0,0,1,1,11); local d={} +local function oversized(e) e.x=75;e.y=90;e:SetSize(200,70) end +ns.DM_EnsureBuffTipEater(host,d,'bar',container,host,'CENTER','CENTER',0,0,1,1,oversized,'wide',18) +local e=d.tipModEaters[d.buffTipSlots.bar] +assert(e.x==100 and e.y==100 and e.w==100 and e.h==40,'final callback footprint must stay inside owning unit') +assert(e.level==18,'bar eater must follow visual layer, not blanket +30') +local old=created +ns.DM_EnsureBuffTipEater(host,d,'bar',container,host,'CENTER','CENTER',0,0,1,1,oversized,'wide',18) +assert(created==old) +host.w=80 +ns.DM_EnsureBuffTipEater(host,d,'bar',container,host,'CENTER','CENTER',0,0,1,1,oversized,'wide',18) +assert(e.w==80,'unit resize must invalidate clipping') +combat=true +ns.DM_EnsureBuffTipEater(host,d,'new',container,host,'CENTER','CENTER',0,0,1,1,oversized,'new',18) +assert(created==old and d.rfcBmPending,'combat changes must defer') +combat=false +ns.DM_ParkBuffTipEaters(d) +assert(not e.shown and not e._euiActive) +''') +print('PASS: final tooltip clipping, layer, resize, combat deferral and retirement') From 14eb972213461b98490d8fcb0146ac813e67f188 Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Tue, 15 Sep 2026 19:54:32 +0100 Subject: [PATCH 3/4] fix(raidframes): avoid secret buff tooltip anchor reads --- .../EUI_RaidFrames_AuraContainers.lua | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua index 389e58a81..aaf8bf73d 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_AuraContainers.lua @@ -1846,9 +1846,7 @@ end -- members = array of { ind, count }; members[1] is the ROOT driving position/growth/ -- wrap. Attached members (Anchor To) each contribute one flow group with their own -- element size and Max Icons cap. -local function AnchorBmChainContainer(container, health, members, iscale) - if not container then return end - local ind = members[1].ind +local function BmChainPin(ind, iscale) local pos = ind.position or "TOPLEFT" local grow = ind.growDirection or "RIGHT" local size = (ind.size or 18) * iscale @@ -1862,16 +1860,15 @@ local function AnchorBmChainContainer(container, health, members, iscale) local per = tonumber(ind.iconsPerRow) or 0 - container:ClearAllPoints() local gH, gV if grow == "CENTER" then local point = (posT and "TOP") or (posB and "BOTTOM") or "CENTER" - container:SetPoint(point, health, pos, ox, oy) -- Wrapped rows stack away from the anchored edge (simple-grid -- convention); the flow origin corner follows the stack direction. gH = "RIGHT" gV = (per > 0 and posB) and "UP" or "DOWN" - AK.SetContainerAnchor(container, (gV == "UP") and "BOTTOMLEFT" or "TOPLEFT") + return point, pos, ox, oy, gH, gV, + (gV == "UP") and "BOTTOMLEFT" or "TOPLEFT" else gH = (grow == "LEFT") and "LEFT" or "RIGHT" gV = (grow == "UP") and "UP" or "DOWN" @@ -1899,9 +1896,20 @@ local function AnchorBmChainContainer(container, health, members, iscale) else if posT then dy = -size elseif not posB then dy = -size / 2 end end - container:SetPoint(corner, health, pos, ox + dx, oy + dy) - AK.SetContainerAnchor(container, corner) + return corner, pos, ox + dx, oy + dy, gH, gV, corner end +end + +local function AnchorBmChainContainer(container, health, members, iscale) + if not container then return end + local ind = members[1].ind + local grow = ind.growDirection or "RIGHT" + local size = (ind.size or 18) * iscale + local per = tonumber(ind.iconsPerRow) or 0 + local point, corner, x, y, gH, gV, anchor = BmChainPin(ind, iscale) + container:ClearAllPoints() + container:SetPoint(point, health, corner, x, y) + AK.SetContainerAnchor(container, anchor) AK.SetContainerGrowth(container, FlowDir(gH), FlowDir(gV)) local spacing = (ind.spacing or 0) * iscale local vertical = (grow == "UP" or grow == "DOWN") @@ -2164,17 +2172,10 @@ end -- Mirrors the legacy AnchorSimpleGrid: the grid's start corner pinned at the same -- corner of the health bar, rows wrap after Icons Per Row and stack away from the -- anchored edge; CENTER growth centers rows on the anchor point. -local function AnchorBmSimpleContainer(container, health, bs, iscale, d) - if not container then return end - -- Uniform Icon Anchoring: bs is the bmSimple sub-table, so the toggle is - -- read from the button's class proxy. - if d and ns.RF_AnchorHost then health = ns.RF_AnchorHost(health, ProxyFor(d)) end +local function BmSimplePin(bs, iscale) local pos = bs.position or "topright" local corner = CORNERS[pos] or "TOPRIGHT" local grow = bs.growDirection or "LEFT" - local size = (bs.size or 18) * iscale - local spacing = (bs.spacing or 1) * iscale - local perRow = bs.iconsPerRow or 4 local ox = (bs.offsetX or 0) * iscale local oy = (bs.offsetY or 0) * iscale @@ -2184,22 +2185,33 @@ local function AnchorBmSimpleContainer(container, health, bs, iscale, d) local vEdge = bottomish and "BOTTOM" or "TOP" local gV = bottomish and "UP" or "DOWN" - container:ClearAllPoints() local anchorPoint, gH if not horizontal then gH = rightish and "LEFT" or "RIGHT" -- moot in a single column gV = grow anchorPoint = (grow == "UP" and "BOTTOM" or "TOP") .. (rightish and "RIGHT" or "LEFT") - container:SetPoint(anchorPoint, health, corner, ox, oy) elseif grow == "CENTER" then gH = "RIGHT" anchorPoint = vEdge .. "LEFT" - container:SetPoint(vEdge, health, corner, ox, oy) else gH = grow anchorPoint = vEdge .. ((grow == "LEFT") and "RIGHT" or "LEFT") - container:SetPoint(anchorPoint, health, corner, ox, oy) end + return grow == "CENTER" and vEdge or anchorPoint, corner, ox, oy, gH, gV, anchorPoint +end + +local function AnchorBmSimpleContainer(container, health, bs, iscale, d) + if not container then return end + -- Uniform Icon Anchoring: bs is the bmSimple sub-table, so the toggle is + -- read from the button's class proxy. + if d and ns.RF_AnchorHost then health = ns.RF_AnchorHost(health, ProxyFor(d)) end + local size = (bs.size or 18) * iscale + local spacing = (bs.spacing or 1) * iscale + local perRow = bs.iconsPerRow or 4 + local horizontal = bs.growDirection ~= "UP" and bs.growDirection ~= "DOWN" + local point, corner, x, y, gH, gV, anchorPoint = BmSimplePin(bs, iscale) + container:ClearAllPoints() + container:SetPoint(point, health, corner, x, y) AK.SetContainerAnchor(container, anchorPoint) AK.SetContainerGrowth(container, FlowDir(gH), FlowDir(gV)) @@ -2234,12 +2246,11 @@ local function EnsureBmTipModifiers(button, d) local hugBar = pinHost._euiHealth or pinHost local iscale = BmScaleFor(d) local seen = {} - local function Grid(key, container, n, size, spacing, per, vertical, padding, lineWidth) + local function Grid(key, container, n, size, spacing, per, vertical, padding, lineWidth, + point, corner, x, y) if not container or n < 1 then return end - -- Containers are owned frames whose single pin is set by the layout - -- functions above; their dynamic aura buttons are never queried. - local point, host, corner, x, y = container:GetPoint(1) - if not point or not host then return end + -- GetPoint can return secret values even on owned aura containers. + -- Share the settings-derived pin with the container's layout instead. local along = per and per > 0 and math.min(n, per) or n local lines = per and per > 0 and math.ceil(n / per) or 1 local w = math.max(size, along * size + (along - 1) * spacing, lineWidth or 0) @@ -2253,7 +2264,7 @@ local function EnsureBmTipModifiers(button, d) if point:find("TOP", 1, true) then y = y + padding elseif point:find("BOTTOM", 1, true) then y = y - padding end seen[key] = true - ns.DM_EnsureBuffTipEater(button, d, key, container, host, point, corner, x, y, w, h) + ns.DM_EnsureBuffTipEater(button, d, key, container, pinHost, point, corner, x, y, w, h) end local bs = BmSimpleSettings() if bs and bs.showBuffs and ns.BM_BaseActive and ns.BM_BaseActive() then @@ -2261,7 +2272,8 @@ local function EnsureBmTipModifiers(button, d) local per = bs.iconsPerRow or 4 if per < 2 then per = 0 end Grid("simple", d.rfcBmSimple, bs.maxBuffs or 8, (bs.size or 18) * iscale, - (bs.spacing or 1) * iscale, vertical and 1 or per, false) + (bs.spacing or 1) * iscale, vertical and 1 or per, false, nil, nil, + BmSimplePin(bs, iscale)) end for _, m in ipairs(d.rfcBmMeta or {}) do if m.isChain and not m.anchored then @@ -2296,7 +2308,8 @@ local function EnsureBmTipModifiers(button, d) per = math.max(1, math.floor((width + spacing) / math.max(1, size + spacing))) end Grid("chain:" .. m.chainKey, d.rfcBmChain and d.rfcBmChain[m.chainKey], - n, size, spacing, per, ind.growDirection == "UP" or ind.growDirection == "DOWN", padding, width) + n, size, spacing, per, ind.growDirection == "UP" or ind.growDirection == "DOWN", padding, width, + BmChainPin(ind, iscale)) elseif not m.isChain and d.rfcBm and (m.kind == "icon" or m.kind == "square" or m.kind == "bar") then local key = "slot:" .. m.key seen[key] = true From ea8ffd00e36d15ecbca163d2515160faaf5b2547 Mon Sep 17 00:00:00 2001 From: Andrea Bergonzo Date: Fri, 18 Sep 2026 03:15:20 +0100 Subject: [PATCH 4/4] fix(raidframes): constrain tooltip grids and clear stale unit targets --- .../EUI_RaidFrames_ClickCast.lua | 3 +++ .../EUI_RaidFrames_DebuffManager.lua | 13 ++++----- tests/run_buff_tooltip_tests.py | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua index 82fe9ef5c..7702b2fc4 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua @@ -1532,6 +1532,9 @@ local TIP_ENTER_BODY = [[ end ]] local TIP_LEAVE_BODY = [[ + -- A cached mouseover unit must not override the parent's current unit + -- after this overlay is hidden or its unit frame is reassigned. + self:SetAttribute("unit", nil) if eui_hoverframe == self then eui_hoverframe = nil end control:RunFor(self, control:GetAttribute("eui_setup_onleave")) ]] diff --git a/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua b/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua index e4ed685de..782a1276c 100644 --- a/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua +++ b/EllesmereUIRaidFrames/EUI_RaidFrames_DebuffManager.lua @@ -578,8 +578,8 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor -- with a centered or inward pin the excess would sit on the neighbouring -- units at a higher level, stealing their hover and clicks for this unit. -- Bounds = the smaller of the button and the pin host (both our frames, - -- settings-sized); pins that deliberately place icons outside the frame - -- keep their overshoot exactly as the icons themselves do. + -- settings-sized). The final positioned rectangle is also clipped below; + -- an offset grid can cross a neighbour even when its size fits this unit. local maxW, maxH = host:GetSize() local pw, ph = pinHost:GetSize() if pw and pw > 0 and pw < maxW then maxW = pw end @@ -590,7 +590,7 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor local geoChanged = not e or e._euiPin ~= point or e._euiCorner ~= corner or e._euiOX ~= offX or e._euiOY ~= offY or e._euiHost ~= pinHost or e._euiW ~= w or e._euiH ~= h or e._euiAnchorFP ~= anchorFP - or (anchor and (e._euiClampW ~= maxW or e._euiClampH ~= maxH)) + or e._euiClampW ~= maxW or e._euiClampH ~= maxH local lvlChanged = not e or e._euiLvl ~= lvl local armChanged = not e or not e._euiActive if not (geoChanged or lvlChanged or armChanged) then @@ -635,10 +635,11 @@ local function EnsureEater(d, slot, host, container, active, pinHost, point, cor e:ClearAllPoints() e:SetPoint(point, pinHost, corner, offX, offY) e:SetSize(w, h) - if anchor then - anchor(e) + if anchor then anchor(e) end + do -- Only addon-owned frames are measured, outside combat. Anchoring - -- can replace the initial size, so constrain the FINAL rectangle. + -- can replace the initial size. Grids have no callback, but their + -- offsets can still cross unit boundaries: constrain every rectangle. local x, y, width, height = e:GetRect() local hx, hy, hw, hh = host:GetRect() if not x or not hx then diff --git a/tests/run_buff_tooltip_tests.py b/tests/run_buff_tooltip_tests.py index 60dcaa02b..fd91d2995 100644 --- a/tests/run_buff_tooltip_tests.py +++ b/tests/run_buff_tooltip_tests.py @@ -54,5 +54,32 @@ def runtime(): combat=false ns.DM_ParkBuffTipEaters(d) assert(not e.shown and not e._euiActive) +-- Grid/chain paths pass no anchor callback. Size caps alone do not prevent +-- an offset footprint from extending onto the neighbouring unit. +local gd={} +ns.DM_EnsureBuffTipEater(host,gd,'grid',container,host,'BOTTOMLEFT','BOTTOMLEFT',-25,-10,80,40,nil,nil,24) +local g=gd.tipModEaters[gd.buffTipSlots.grid] +assert(g.x==100 and g.y==100 and g.w==55 and g.h==30,'grid overlay overlaps neighbouring unit') +host.w=40 +ns.DM_EnsureBuffTipEater(host,gd,'grid',container,host,'BOTTOMLEFT','BOTTOMLEFT',-25,-10,80,40,nil,nil,24) +assert(g.x==100 and g.w==15,'grid clipping must follow unit resize') +ns.DM_EnsureBuffTipEater(host,gd,'outside',container,host,'BOTTOMLEFT','BOTTOMLEFT',-100,0,40,20,nil,nil,24) +local o=gd.tipModEaters[gd.buffTipSlots.outside] +assert(not o.shown and not o._euiActive,'fully outside grid must not capture another unit') ''') print('PASS: final tooltip clipping, layer, resize, combat deferral and retirement') + +# Exercise the actual secure leave snippet with a retained old unit attribute. +# Native hover delivery still requires in-game verification. +cc=source(2095,'EllesmereUIRaidFrames/EUI_RaidFrames_ClickCast.lua') +leave=between(cc,'local TIP_LEAVE_BODY = [[',']]').split('[[',1)[1] +lua.execute(''' +parentUnit='party2' +self={unit='party1',SetAttribute=function(s,k,v) s[k]=v end} +eui_hoverframe=self +control={GetAttribute=function() return '' end,RunFor=function() end} +''') +lua.execute(leave) +assert lua.eval('self.unit == nil and eui_hoverframe == nil'), 'stale overlay unit survives hover exit' +assert lua.eval('(self.unit or parentUnit) == "party2"'), 'unit inheritance must use reassigned parent' +print('PASS: secure leave releases cached unit; native hover delivery pending')