Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
* (Aura Designer) Text-only icons no longer draw a leftover border (static or expiring). (by Krathe)
* (Aura Designer) Aura icon and square borders from older profiles keep their original look after the border rework, instead of appearing thinner or floating in a gap. (by Krathe)
* (Buff/Debuff) Icon borders from older profiles no longer float in a gap after the border rework — they hug the icon as before. (by Krathe)
* (Pinned & Raid Frames) Fixed frames cascading into a diagonal "staircase" when switching their grow direction between Horizontal and Vertical — the layout now re-flows cleanly without a `/reload`. Pinned layout changes (direction, spacing, size) made during combat are also applied when combat ends, instead of being dropped until the next change. (by Krathe)
* (Test Mode) Fixed pinned-frame preview issues — frames past the fourth showing blank, the resource bar overflowing the frame border, and the AFK timer not counting up. (by Krathe)

## [4.4.1]

Expand Down
16 changes: 15 additions & 1 deletion Features/FlatRaidFrames.lua
Original file line number Diff line number Diff line change
Expand Up @@ -684,10 +684,24 @@ function FlatRaidFrames:ApplyLayoutSettings(skipRefresh)
xOff = 0
end

-- CRITICAL: clear every child's anchor points BEFORE changing the layout
-- attributes below. Each SetAttribute("point"/"columnAnchorPoint"/…) fires
-- SecureGroupHeader_Update while the header is visible, and that function
-- re-anchors displayed children with SetPoint WITHOUT ClearAllPoints-ing them
-- first (Blizzard_RestrictedAddOnEnvironment/SecureGroupHeaders.lua). So flipping
-- the growth point (Horizontal LEFT -> Vertical TOP) leaves each child anchored by
-- both points, cascading diagonally (the "staircase"). Clearing first means every
-- re-layout SetPoint lands clean. Mirrors DF:UpdateRaidHeaderLayoutAttributes /
-- PinnedFrames:ApplyLayoutSettings.
for i = 1, 40 do
local child = header:GetAttribute("child" .. i)
if child then child:ClearAllPoints() end
end

header:SetAttribute("point", point)
header:SetAttribute("xOffset", xOff)
header:SetAttribute("yOffset", yOff)

-- Column anchor point
local colAnchorPoint, colSpacing
if horizontal then
Expand Down
48 changes: 43 additions & 5 deletions Features/PinnedFrames.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1584,7 +1584,16 @@ end
function PinnedFrames:ApplyLayoutSettings(setIndex)
local set = GetSetDB(setIndex)
if not set then return end
if InCombatLockdown() then return end
-- Layout changes touch secure header attributes (point/xOffset/size/anchor),
-- which are combat-restricted. Defer to PLAYER_REGEN_ENABLED instead of silently
-- dropping the change — otherwise a Direction/spacing/size tweak made mid-combat
-- (e.g. between pulls in a follower dungeon) never reaches the live header until
-- the next settings poke or a /reload. Mirrors FlatRaidFrames.pendingLayoutUpdate.
if InCombatLockdown() then
self.pendingLayoutUpdate = self.pendingLayoutUpdate or {}
self.pendingLayoutUpdate[setIndex] = true
return
end

-- Refresh Test Mode frames regardless of frame type. Cheapest correct
-- approach: full Exit+Enter cycle, same as the test count slider uses.
Expand Down Expand Up @@ -1674,6 +1683,22 @@ function PinnedFrames:ApplyLayoutSettings(setIndex)
xOff = 0
end

-- CRITICAL: clear every child's anchor points BEFORE we change the layout
-- attributes below. Each SetAttribute("point"/"columnAnchorPoint"/…) fires
-- SecureGroupHeader_OnAttributeChanged, which (while the header is visible)
-- synchronously runs SecureGroupHeader_Update — and that function re-anchors
-- displayed children with SetPoint WITHOUT ClearAllPoints-ing them first
-- (confirmed in Blizzard_RestrictedAddOnEnvironment/SecureGroupHeaders.lua).
-- So when the growth point flips (Horizontal LEFT -> Vertical TOP) each child
-- keeps its stale anchor AND gains the new one, cascading diagonally (the
-- "staircase"); a /reload only hides it by rebuilding the frames. Clearing the
-- points first means every re-layout SetPoint lands on a clean child. Mirrors
-- DF:UpdateRaidHeaderLayoutAttributes (Frames/Headers.lua).
for i = 1, 40 do
local child = header:GetAttribute("child" .. i)
if child then child:ClearAllPoints() end
end

header:SetAttribute("point", point)
header:SetAttribute("xOffset", xOff)
header:SetAttribute("yOffset", yOff)
Expand Down Expand Up @@ -2522,6 +2547,16 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, ...)
PinnedFrames.pendingVisibilityUpdate = nil
end

-- Replay layout changes (Direction/spacing/size/anchor) that were attempted
-- in combat. Skipped harmlessly when pendingReinitialize already ran above
-- (it returns early and re-applies every set's layout via ProcessAllSets).
if PinnedFrames.pendingLayoutUpdate then
for idx in pairs(PinnedFrames.pendingLayoutUpdate) do
PinnedFrames:ApplyLayoutSettings(idx)
end
PinnedFrames.pendingLayoutUpdate = nil
end

-- Reset slot allocator + reapply layout now that we're out of combat.
-- Fresh pull starts with all slots free; any frames still visible
-- (rare — e.g. we left combat mid-add) re-enter via onBossShow.
Expand Down Expand Up @@ -2992,8 +3027,9 @@ function PinnedFrames:EnsurePlayerTestFramePool(setIndex, count, isRaidMode, isB
local container = self.testContainers[setIndex]
if not container then return end
if count < 1 then count = 1 end
-- Boss mode caps at 8 (WoW API limit); player mode caps at 40 (max raid)
local cap = isBossSet and 8 or 40
-- Boss mode caps at 8 (WoW API limit); raid player sets at 40 (max raid);
-- party player sets at 5 (a party can't exceed 5).
local cap = isBossSet and 8 or (isRaidMode and 40 or 5)
if count > cap then count = cap end

self.testFrames[setIndex] = self.testFrames[setIndex] or {}
Expand Down Expand Up @@ -3043,8 +3079,10 @@ function PinnedFrames:ApplyPlayerTestLayout(setIndex, set, isRaidMode)
local anchor = GetContainerAnchorPoint(set)

local n = set.testCount or 3
-- Boss: 8 (WoW limit); raid player: 40 (max raid); party player: 5 (party max).
local maxN = IsBossSet(set) and 8 or (isRaidMode and 40 or 5)
if n < 1 then n = 1 end
if n > 40 then n = 40 end
if n > maxN then n = maxN end

-- Size container to fit N frames in the set's layout (mirrors
-- ResizeContainer for real pinned sets). Frames anchor inside at the
Expand Down Expand Up @@ -3165,7 +3203,7 @@ function PinnedFrames:EnterTestMode()
if set and set.enabled then
local isBossSet = IsBossSet(set)
local n = set.testCount or 3
local cap = isBossSet and 8 or 40
local cap = isBossSet and 8 or (isRaidMode and 40 or 5)
if n < 1 then n = 1 end
if n > cap then n = cap end

Expand Down
253 changes: 116 additions & 137 deletions Frames/Bars.lua
Original file line number Diff line number Diff line change
Expand Up @@ -67,154 +67,133 @@ function DF:ShouldShowResourceBar(unit, db)
return true
end

function DF:ApplyResourceBarLayout(frame)
if not frame then return end

-- Use raid DB for raid frames, party DB for party frames
local db = DF:GetFrameDB(frame)

-- The power bar is created in Frames/Create.lua
if not frame.dfPowerBar then return end

local bar = frame.dfPowerBar

-- Check if resource bar should be shown
if not db.resourceBarEnabled then
bar:Hide()
return
-- Shared resource-bar GEOMETRY + appearance: texture, orientation/fill, size
-- (incl. the border inset), anchor, frame level, background, and border. Both the
-- live layout (DF:ApplyResourceBarLayout) and the test render (DF:UpdateTestPowerBar)
-- call this so the two can never drift. The caller does the enabled/role checks and
-- sets the bar VALUE + fill COLOUR (live UnitPower vs test mock) — the only per-caller
-- part. Assumes the bar should be shown.
function DF:LayoutResourceBar(frame, db)
local bar = frame and frame.dfPowerBar
if not bar then return end

bar:Show()
bar:ClearAllPoints()

-- Fill texture (configurable; defaults to the DF house texture)
DF:SafeSetStatusBarTexture(bar, db.resourceBarTexture or "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist")

-- Orientation & Fill Direction
bar:SetOrientation(db.resourceBarOrientation or "HORIZONTAL")
bar:SetReverseFill(db.resourceBarReverseFill)

local isVertical = (db.resourceBarOrientation == "VERTICAL")
local length = db.resourceBarWidth or 50
local thickness = db.resourceBarHeight or 4
local ppLength = db.pixelPerfect and DF:PixelPerfect(length) or length
local ppThickness = db.pixelPerfect and DF:PixelPerfect(thickness) or thickness

-- Compute health bar dimensions from settings instead of GetWidth/GetHeight
-- which can return stale values before WoW's layout engine processes anchor changes.
-- Prefer a pinned set's resolved size (Match baseline + Width/Height override) so a
-- "Match Frame Width" resource bar tracks the pinned frame, not the shared per-mode
-- db width. Main frames have no stamp and use the mode db.
local padding = db.framePadding or 0
local frameWidth = frame.dfPinnedWidth or db.frameWidth or 120
local frameHeight = frame.dfPinnedHeight or db.frameHeight or 50
if db.pixelPerfect and DF.PixelPerfect then
frameWidth = DF:PixelPerfect(frameWidth)
frameHeight = DF:PixelPerfect(frameHeight)
padding = DF:PixelPerfect(padding)
end
local healthBarWidth = frameWidth - (2 * padding)
local healthBarHeight = frameHeight - (2 * padding)

-- Account for frame border inset (matches other bar calculations)
local borderInset = 0
if db.frameShowBorder ~= false then
borderInset = db.frameBorderSize or 1
end
-- When Pixel Perfect is on, borderInset must be snapped to a whole screen pixel
-- before being subtracted from healthBarWidth — otherwise barW is fractional and
-- WoW rounds it differently per frame, producing a 1px gap alternating left/right.
local bInset = db.pixelPerfect and DF:PixelPerfect(borderInset) or borderInset

if isVertical then
-- SWAP: "Width" applies to Height (Length), "Height" applies to Width (Thickness)
bar:SetWidth(ppThickness)
bar:SetHeight(ppLength)
if db.resourceBarMatchWidth and healthBarHeight > 1 then
bar:SetHeight(healthBarHeight - bInset * 2)
end
else
bar:SetWidth(ppLength)
bar:SetHeight(ppThickness)
if db.resourceBarMatchWidth and healthBarWidth > 1 then
bar:SetWidth(healthBarWidth - bInset * 2)
end
end

-- Need unit for role check
if not frame.unit then
bar:Hide()
return

local anchor = db.resourceBarAnchor or "CENTER"
bar:SetPoint(anchor, frame, anchor, db.resourceBarX or 0, db.resourceBarY or 0)

-- Frame level - relative to the main frame. Default 2 puts it below the frame
-- border (at +10); values above 10 render above it.
local frameLevelOffset = db.resourceBarFrameLevel or 2
bar:SetFrameLevel(frame:GetFrameLevel() + frameLevelOffset)
if bar.border then
bar.border:SetFrameLevel(bar:GetFrameLevel() + 1)
end

-- Check role-based filtering
if not DF:ShouldShowResourceBar(frame.unit, db) then
bar:Hide()
return
-- Background visibility and color
if bar.bg then
if db.resourceBarBackgroundEnabled ~= false then -- Default to enabled
bar.bg:Show()
local bgC = db.resourceBarBackgroundColor or {r = 0.1, g = 0.1, b = 0.1, a = 0.8}
bar.bg:SetColorTexture(bgC.r, bgC.g, bgC.b, bgC.a or 0.8)
else
bar.bg:Hide()
end
end

-- Bar is visible - apply layout
do
bar:Show()
bar:ClearAllPoints()
-- Border via unified DF.Border backend (Stage 4.2). ctx.unit / ctx.frame let the
-- Class / Role colour resolvers fire on live and test frames alike (test frames
-- resolve via ctx.frame's dfIsTestFrame).
if bar.border then
DF.Border:Apply(bar.border, DF.Border:BuildSpec(db, "resourceBar", {
unit = frame.unit,
frame = frame,
}))
end
end

-- Fill texture (configurable; defaults to the DF house texture)
DF:SafeSetStatusBarTexture(bar, db.resourceBarTexture or "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist")
function DF:ApplyResourceBarLayout(frame)
if not frame then return end

-- Orientation & Fill Direction
bar:SetOrientation(db.resourceBarOrientation or "HORIZONTAL")
bar:SetReverseFill(db.resourceBarReverseFill)
-- Use raid DB for raid frames, party DB for party frames
local db = DF:GetFrameDB(frame)

local isVertical = (db.resourceBarOrientation == "VERTICAL")
local length = db.resourceBarWidth or 50
local thickness = db.resourceBarHeight or 4
-- The power bar is created in Frames/Create.lua
if not frame.dfPowerBar then return end
local bar = frame.dfPowerBar

-- Apply pixel-perfect adjustments
local ppLength = db.pixelPerfect and DF:PixelPerfect(length) or length
local ppThickness = db.pixelPerfect and DF:PixelPerfect(thickness) or thickness

-- Compute health bar dimensions from settings instead of GetWidth/GetHeight
-- which can return stale values before WoW's layout engine processes anchor changes
local padding = db.framePadding or 0
-- Prefer a pinned set's resolved size (Match baseline + Width/Height override) so a
-- "Match Frame Width" resource bar tracks the pinned frame, not the shared
-- per-mode db width. Main frames have no stamp and use the mode db.
local frameWidth = frame.dfPinnedWidth or db.frameWidth or 120
local frameHeight = frame.dfPinnedHeight or db.frameHeight or 50
if db.pixelPerfect and DF.PixelPerfect then
frameWidth = DF:PixelPerfect(frameWidth)
frameHeight = DF:PixelPerfect(frameHeight)
padding = DF:PixelPerfect(padding)
end
local healthBarWidth = frameWidth - (2 * padding)
local healthBarHeight = frameHeight - (2 * padding)

-- Account for frame border inset (matches other bar calculations)
local borderInset = 0
if db.frameShowBorder ~= false then
borderInset = db.frameBorderSize or 1
end
-- Enabled + unit + role gates
if not db.resourceBarEnabled then bar:Hide() return end
if not frame.unit then bar:Hide() return end
if not DF:ShouldShowResourceBar(frame.unit, db) then bar:Hide() return end

if isVertical then
-- SWAP: "Width" Value applies to Height (Length), "Height" value applies to Width (Thickness)
bar:SetWidth(ppThickness)
bar:SetHeight(ppLength)
else
-- NORMAL: "Width" Value applies to Width, "Height" value applies to Height
bar:SetWidth(ppLength)
bar:SetHeight(ppThickness)
end

-- When Pixel Perfect is on, borderInset must be snapped to a whole screen pixel
-- before being subtracted from healthBarWidth. Without this, barW is a fractional
-- screen-pixel value and WoW rounds it differently per frame depending on screen
-- position, producing a 1-pixel gap alternating left/right. Snapping borderInset
-- guarantees (FW - barW) = 2*(P+K) screen pixels (always even), so centering
-- always lands on a clean pixel boundary regardless of frame width parity.
local bInset = db.pixelPerfect and DF:PixelPerfect(borderInset) or borderInset

if isVertical then
if db.resourceBarMatchWidth then
if healthBarHeight > 1 then
bar:SetHeight(healthBarHeight - bInset * 2)
end
end
else
if db.resourceBarMatchWidth then
if healthBarWidth > 1 then
bar:SetWidth(healthBarWidth - bInset * 2)
end
end
end
-- Shared geometry/appearance (size, anchor, level, background, border).
DF:LayoutResourceBar(frame, db)

local anchor = db.resourceBarAnchor or "CENTER"
bar:SetPoint(anchor, frame, anchor, db.resourceBarX or 0, db.resourceBarY or 0)

-- Frame level - relative to the main frame, not health bar
-- Default of 2 puts it below the frame border (which is at +10)
-- Values above 10 will render above the frame border
local frameLevelOffset = db.resourceBarFrameLevel or 2
bar:SetFrameLevel(frame:GetFrameLevel() + frameLevelOffset)

-- Border frame level needs to be above the bar itself
if bar.border then
bar.border:SetFrameLevel(bar:GetFrameLevel() + 1)
end

-- Background visibility and color
if bar.bg then
if db.resourceBarBackgroundEnabled ~= false then -- Default to enabled
bar.bg:Show()
local bgC = db.resourceBarBackgroundColor or {r = 0.1, g = 0.1, b = 0.1, a = 0.8}
bar.bg:SetColorTexture(bgC.r, bgC.g, bgC.b, bgC.a or 0.8)
else
bar.bg:Hide()
end
end

-- Border via unified DF.Border backend (Stage 4.2). BuildSpec reads
-- canonical resourceBar*Border* keys; ctx.unit / ctx.frame let the
-- Class / Role colour resolvers fire on live and test frames alike.
if bar.border then
DF.Border:Apply(bar.border, DF.Border:BuildSpec(db, "resourceBar", {
unit = frame.unit,
frame = frame,
}))
end

-- Set power value and color immediately so the bar doesn't appear white
local unit = frame.unit
if unit and UnitExists(unit) then
local power = UnitPower(unit)
local maxPower = UnitPowerMax(unit)
bar:SetMinMaxValues(0, maxPower)
bar:SetValue(power)
local cr, cg, cb = DF:GetResourceBarColor(unit, db)
bar:SetStatusBarColor(cr, cg, cb, 1)
end
-- Live value + fill colour (the only part the test path does differently).
local unit = frame.unit
if unit and UnitExists(unit) then
local power = UnitPower(unit)
local maxPower = UnitPowerMax(unit)
bar:SetMinMaxValues(0, maxPower)
bar:SetValue(power)
local cr, cg, cb = DF:GetResourceBarColor(unit, db)
bar:SetStatusBarColor(cr, cg, cb, 1)
end
end

Expand Down
Loading