From f0198eda9f560e3077b5915acfcd1c5faaf814a5 Mon Sep 17 00:00:00 2001 From: JuJuFX-dev Date: Fri, 11 Sep 2026 23:47:15 +0200 Subject: [PATCH 1/4] Fix Player Aura Bars position drifting with the resolution PAB snapped icon size, padding and row gap with PP.Scale, which truncates to whole physical pixels. The per-icon loss added up along a row, so a bar measured a different number of UI units at 1080p and at 1440p, and the CENTER anchor spread that difference over both edges. A bar placed flush against another element at one resolution opened a gap or overlapped it at the other. Round every PAB grid number to the nearest pixel through a local PabSnap, the same rounding Action Bars and Cooldown Manager use. ComputeGrid now also returns the unrounded design size. Stored positions refer to that design frame, and OriginShift offsets the SetPoint so the growth origin (the parent corner the aura container is pinned to, or the center for centered growth) no longer depends on the resolution. The unlock-mode movers of the default and custom bars convert between the visual and the stored position in both directions, so a save and load round trip is exact. The in-session resize compensation in ApplyLiveConfig now moves a stored center toward the actual growth origin. It always pinned the top-right corner before, which was only right for leftward growth with rows wrapping down. --- .../EllesmereUIUnitFrames_PlayerAuraBars.lua | 199 ++++++++++++------ 1 file changed, 140 insertions(+), 59 deletions(-) diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua index e8da7e841..797b45d3d 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua @@ -285,6 +285,16 @@ local function PabShapedSize(rawSize, shape) return rawSize end +-- Nearest physical pixel at UIParent scale, for every PAB grid number (icon size, +-- padding, row gap). Not PP.Scale: it truncates, and the per-icon loss adds up +-- along a row, so a bar measured a different number of UI units per resolution. +-- Same rounding as EllesmereUIActionBars.lua's ComputeBarLayout. +local function PabSnap(x) + local m = EllesmereUI.PP.mult + if x == 0 or m == 1 then return x end + return math.floor(x / m + 0.5) * m +end + local function PAB_ApplyDmFx(button, d, style) local cat = d.dmCat local e = style.fxList and PAB_FxBlockFor(style.fxList, cat) or nil @@ -955,8 +965,7 @@ local function BuildStyle(isBuff, cfg) -- Snapped to the physical pixel grid (like MaxIconSizeFor and ApplyGroupConfig's -- gap snap) so the rendered size agrees with the container's cross-axis extent -- math at any UIParent scale. - local PP = EllesmereUI.PP - local iconSize = PP.Scale(PabShapedSize(cfg.iconSize or 32, cfg.iconShape)) + local iconSize = PabSnap(PabShapedSize(cfg.iconSize or 32, cfg.iconShape)) local style = { width = iconSize, @@ -1072,7 +1081,7 @@ local function BuildStyle(isBuff, cfg) pos = dip, -- Scaled like iconSize (button-geometry class); offsets stay raw -- like durationX (fine-tune class). - size = PP.Scale(cfg.dispelIconSize or 16), + size = PabSnap(cfg.dispelIconSize or 16), offX = cfg.dispelIconOffsetX or 0, offY = cfg.dispelIconOffsetY or 0, } @@ -1264,20 +1273,18 @@ local function EnsurePabSizedStyle(baseKey, size, shape) -- keyed by the raw size. Shape-expand applies the same as BuildStyle's own -- iconSize -- base already carries iconShape/shapeMaskPath/etc via the shallow -- copy above, only width/height need recomputing for this variant's own size. - local PP = EllesmereUI.PP - v.width = PP.Scale(PabShapedSize(size, shape)) - v.height = PP.Scale(PabShapedSize(size, shape)) + v.width = PabSnap(PabShapedSize(size, shape)) + v.height = PabSnap(PabShapedSize(size, shape)) AK.styles[variantKey] = v AK.RestyleSoon(variantKey) return variantKey end local function BuildGroupLayout(cfg, gap, rowGap, size) - local PP = EllesmereUI.PP rowGap = rowGap or gap - size = PP.Scale(PabShapedSize(size or cfg.iconSize or 32, cfg.iconShape)) - gap = PP.Scale(gap) - rowGap = PP.Scale(rowGap) + size = PabSnap(PabShapedSize(size or cfg.iconSize or 32, cfg.iconShape)) + gap = PabSnap(gap) + rowGap = PabSnap(rowGap) return { elementWidth = size, elementHeight = size, @@ -1444,16 +1451,17 @@ local function MaxIconSizeFor(isBuff, cfg) -- snap): a raw iconSize also feeds the container's cross-axis extent math -- (ComputeGrid) at a non-pixel-perfect UIParent scale. Shape-expand applied last so -- the bar frame's footprint (ComputeGrid) always matches the buttons' real size. - local PP = EllesmereUI.PP - return PP.Scale(PabShapedSize(size, cfg.iconShape)) + -- Second value: the unsnapped size, for ComputeGrid's design extent. + local shaped = PabShapedSize(size, cfg.iconShape) + return PabSnap(shaped), shaped end local function ComputeGrid(isBuff, cfg) - local iconSize = MaxIconSizeFor(isBuff, cfg) + local iconSize, rawIconSize = MaxIconSizeFor(isBuff, cfg) local pad = cfg.padding or 5 local rowGap = cfg.rowSpacing or 12 - local layoutPad = EllesmereUI.PP.Scale(pad) - local layoutRowGap = EllesmereUI.PP.Scale(rowGap) + local layoutPad = PabSnap(pad) + local layoutRowGap = PabSnap(rowGap) local cols = math.max(1, cfg.iconsPerRow or (isBuff and 11 or 8)) local rows = math.max(1, cfg.maxRows or (isBuff and 3 or 2)) local configuredMax = cfg.maxTotal or (isBuff and 32 or 16) @@ -1479,11 +1487,17 @@ local function ComputeGrid(isBuff, cfg) local vertical = (cfg.growDirection == "UP" or cfg.growDirection == "DOWN" or cfg.growDirection == "CENTER_VERTICAL") local width = vertical and crossExtent or lineExtent local height = vertical and lineExtent or crossExtent + -- Design extent: the same box from the raw config numbers, identical at every + -- resolution. Stored bar positions refer to this box (see OriginShift). + local designLine = cols * rawIconSize + (cols - 1) * pad + local designCross = usedRows * rawIconSize + (usedRows - 1) * rowGap return { effectiveMax = effectiveMax, rowWidth = rowWidth, width = width, height = height, + designWidth = vertical and designCross or designLine, + designHeight = vertical and designLine or designCross, rowGap = rowGap, } end @@ -2102,7 +2116,7 @@ local function ApplyContainerAnchorAndGrowth(container, parent, cfg, grid) containerDirections[container] = direction if directionChanged then container:Hide() end - local size = EllesmereUI.PP.Scale(cfg.iconSize or 32) + local size = PabSnap(cfg.iconSize or 32) container:ClearAllPoints() container:SetSize(size, size) container:SetPoint(containerAnchor, parent, containerAnchor, 0, 0) @@ -2153,6 +2167,29 @@ local function SnapBarPos(frame, point, relPoint, x, y) return PP.SnapForES(x, es), PP.SnapForES(y, es) end +-- Pixel-rounding compensation. A stored position places the DESIGN-size frame +-- (ComputeGrid's designWidth/designHeight); the live frame is the snapped grid, +-- slightly larger or smaller per resolution. Returns the SetPoint offset that keeps +-- the growth origin (the parent corner the container is pinned to, the center for +-- centered growth) where the design frame puts it, for a frame anchored at `point`. +-- dw/dh are (design - snapped) at apply time; ApplyLiveConfig also passes a design +-- size change to move a stored center with its origin. +local OriginShift +do + local FRAC_X = { TOPLEFT = 0, LEFT = 0, BOTTOMLEFT = 0, TOP = 0.5, CENTER = 0.5, + BOTTOM = 0.5, TOPRIGHT = 1, RIGHT = 1, BOTTOMRIGHT = 1 } + local FRAC_Y = { BOTTOMLEFT = 0, BOTTOM = 0, BOTTOMRIGHT = 0, LEFT = 0.5, CENTER = 0.5, + RIGHT = 0.5, TOPLEFT = 1, TOP = 1, TOPRIGHT = 1 } + function OriginShift(cfg, point, dw, dh) + local dir = cfg.growDirection or "LEFT" + local origin = (dir == "CENTER_HORIZONTAL" or dir == "CENTER_VERTICAL") and "CENTER" + or CornerFor(dir, cfg.iconWrapDirection or "LEFT") + point = point or "CENTER" + return (FRAC_X[origin] - (FRAC_X[point] or 0.5)) * dw, + (FRAC_Y[origin] - (FRAC_Y[point] or 0.5)) * dh + end +end + -- Centered growth needs a position whose meaning does not change with the mover's -- configured grid size. Rebase any edge-anchored saved position to CENTER/CENTER at -- the frame's current visual center before resizing it. @@ -2170,21 +2207,23 @@ end -- Applies the saved position (if any) or the default to the given parent frame. -- Shared between initial creation and the unlock-mode applyPos callback so the two --- never drift into different SetPoint logic. -local function ApplyBarPosition(parent, isBuff) +-- never drift into different SetPoint logic. `grid` is optional (computed when nil). +local function ApplyBarPosition(parent, isBuff, grid) local s = PAB() local posKey = BarPositionKey(isBuff) local pos = s and s[posKey] local def = isBuff and DEFAULT_POS.buffs or DEFAULT_POS.debuffs - parent:ClearAllPoints() - if pos and pos.point then - local x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, pos.x, pos.y) - parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) - else - local x, y = SnapBarPos(parent, def.point, def.relPoint, def.x, def.y) - parent:SetPoint(def.point, UIParent, def.relPoint, x, y) - end local cfg = s and (isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s)) + local p = (pos and pos.point) and pos or def + local x, y = p.x, p.y + if cfg and x and y then + grid = grid or ComputeGrid(isBuff, cfg) + local dx, dy = OriginShift(cfg, p.point, grid.designWidth - grid.width, grid.designHeight - grid.height) + x, y = x + dx, y + dy + end + parent:ClearAllPoints() + x, y = SnapBarPos(parent, p.point, p.relPoint or p.point, x, y) + parent:SetPoint(p.point, UIParent, p.relPoint or p.point, x, y) if cfg and (cfg.growDirection == "CENTER_HORIZONTAL" or cfg.growDirection == "CENTER_VERTICAL") then s[posKey] = RebaseBarPositionToCenter(parent, pos) end @@ -2261,7 +2300,7 @@ local function ShiftBuffsForEnchants(container, parent, cfg, grid) local n = (cfg.showWeaponEnchants == true and ns.WeaponEnchants_Count and ns.WeaponEnchants_Count()) or 0 local containerAnchor = BuildContainerSpec(parent, cfg, grid) local dir = cfg.growDirection or "LEFT" - local cell = EllesmereUI.PP.Scale(cfg.iconSize or 32) + EllesmereUI.PP.Scale(cfg.padding or 5) + local cell = PabSnap(cfg.iconSize or 32) + PabSnap(cfg.padding or 5) container:ClearAllPoints() -- Centered modes: the enchant cells must hug the RUN's moving edge, which -- only the container's live rect knows. rec.parent must stay the PLAIN bar @@ -2296,7 +2335,7 @@ local function ShiftBuffsForEnchants(container, parent, cfg, grid) ns._weaponEnchPAB.point = "BOTTOM" ns._weaponEnchPAB.relativePoint = "TOP" ns._weaponEnchPAB.x = 0 - ns._weaponEnchPAB.y = math.max(0, n - 1) * cell + EllesmereUI.PP.Scale(cfg.padding or 5) + ns._weaponEnchPAB.y = math.max(0, n - 1) * cell + PabSnap(cfg.padding or 5) ns._weaponEnchPAB.dir = "DOWN" end return @@ -2399,13 +2438,13 @@ local function CreateBars() buffsParent = buffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Buffs", UIParent) buffsParent:SetSize(buffGrid.width, buffGrid.height) - ApplyBarPosition(buffsParent, true) - lastSize.buffs = { w = buffGrid.width, h = buffGrid.height } + ApplyBarPosition(buffsParent, true, buffGrid) + lastSize.buffs = { w = buffGrid.width, h = buffGrid.height, dw = buffGrid.designWidth, dh = buffGrid.designHeight } debuffsParent = debuffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Debuffs", UIParent) debuffsParent:SetSize(debuffGrid.width, debuffGrid.height) - ApplyBarPosition(debuffsParent, false) - lastSize.debuffs = { w = debuffGrid.width, h = debuffGrid.height } + ApplyBarPosition(debuffsParent, false, debuffGrid) + lastSize.debuffs = { w = debuffGrid.width, h = debuffGrid.height, dw = debuffGrid.designWidth, dh = debuffGrid.designHeight } -- Enable toggles (cfg.enabled, nil = enabled): containers and groups still -- build below so a live re-enable needs no reload; a disabled bar just @@ -2441,7 +2480,7 @@ local function CreateBars() -- Snapped like the shift's own cell stride above: the buttons add -- this to an already-snapped style.width, so a raw gap would place -- them off the engine's grid at a non-native UI scale. - pad = EllesmereUI.PP.Scale(buffPad), styleKey = STYLE_BUFFS, canCancel = true } + pad = PabSnap(buffPad), styleKey = STYLE_BUFFS, canCancel = true } else ns._weaponEnchPAB = nil end @@ -2551,6 +2590,12 @@ function RegisterPABUnlock() local MK = EllesmereUI.MakeUnlockElement local function MakeBarElement(key, label, order, isBuff, getParent) + -- Stored (design-frame) position -> visual offset, see OriginShift. + local function Shift(s, point) + local cfg = isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s) + local grid = ComputeGrid(isBuff, cfg) + return OriginShift(cfg, point, grid.designWidth - grid.width, grid.designHeight - grid.height) + end return MK({ key = key, label = label, @@ -2578,16 +2623,28 @@ function RegisterPABUnlock() local grid = ComputeGrid(isBuff, isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s)) return grid.width, grid.height end, + -- The mover works in VISUAL positions (the snapped live frame), the store + -- in design-frame positions. Both directions apply the same shift, so a + -- load/save round trip (RevertPositions) is exact. savePos = function(_, point, relPoint, x, y) local s = PAB() if not s then return end + if x and y then + local dx, dy = Shift(s, point) + x, y = x - dx, y - dy + end s[BarPositionKey(isBuff)] = { point = point, relPoint = relPoint or point, x = x, y = y } end, loadPos = function() local s = PAB() local pos = s and s[BarPositionKey(isBuff)] if not pos then return nil end - return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y } + local x, y = pos.x, pos.y + if x and y then + local dx, dy = Shift(s, pos.point) + x, y = x + dx, y + dy + end + return { point = pos.point, relPoint = pos.relPoint, x = x, y = y } end, clearPos = function() local s = PAB() @@ -2772,7 +2829,8 @@ local function ApplyLiveConfig(isBuff) -- still sees the real last-applied size (the fixed-corner compensation needs it), -- and the stored pos is not mutated for a resize that never landed. if InCombatLockdown() then - local sizeChanged = not (prev and prev.w == grid.width and prev.h == grid.height) + local sizeChanged = not (prev and prev.w == grid.width and prev.h == grid.height + and prev.dw == grid.designWidth and prev.dh == grid.designHeight) local rebasePending = centered and not (pos and pos.point == "CENTER" and (pos.relPoint or pos.point) == "CENTER") if sizeChanged or rebasePending then @@ -2783,23 +2841,23 @@ local function ApplyLiveConfig(isBuff) pos = RebaseBarPositionToCenter(parent, pos) s[posKey] = pos end - if not centered and pos and pos.point == "CENTER" - and prev and (prev.w ~= grid.width or prev.h ~= grid.height) then - pos.x = pos.x + (prev.w - grid.width) / 2 - pos.y = pos.y + (prev.h - grid.height) / 2 - -- Snap against the NEW grid.width/height (what parent:SetSize is about to - -- apply), not parent:GetWidth/GetHeight -- those still read the OLD size, the - -- resize hasn't run yet. The STORED pos keeps the raw accumulation; only the - -- SetPoint values are snapped. - local PP = EllesmereUI.PP - local es = parent:GetEffectiveScale() - local sx = PP.SnapCenterForDim(pos.x, grid.width, es) - local sy = PP.SnapCenterForDim(pos.y, grid.height, es) - parent:ClearAllPoints() - parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, sx, sy) - end - lastSize[sizeKey] = { w = grid.width, h = grid.height } + local resized = not centered and prev + and (prev.w ~= grid.width or prev.h ~= grid.height + or prev.dw ~= grid.designWidth or prev.dh ~= grid.designHeight) + if resized and pos and pos.point == "CENTER" then + -- The stored center belongs to the design-size frame, so it moves by the + -- DESIGN delta, toward whichever side the growth origin is on. + local dx, dy = OriginShift(cfg, pos.point, prev.dw - grid.designWidth, prev.dh - grid.designHeight) + pos.x = pos.x + dx + pos.y = pos.y + dy + end + lastSize[sizeKey] = { w = grid.width, h = grid.height, dw = grid.designWidth, dh = grid.designHeight } parent:SetSize(grid.width, grid.height) + -- Re-seated AFTER SetSize so SnapBarPos snaps against the new size and the + -- rounding shift follows the new grid. The STORED pos keeps the raw + -- accumulation. Only on a real change, so an anchor-linked bar is not pulled + -- back to its stored position on every slider tick. + if resized then ApplyBarPosition(parent, isBuff, grid) end end local pad = cfg.padding or 5 @@ -2814,7 +2872,7 @@ local function ApplyLiveConfig(isBuff) ns._weaponEnchPAB = { parent = parent, corner = liveCorner, dir = cfg.growDirection or "LEFT", -- Snapped, as in CreateBars' publish above. - pad = EllesmereUI.PP.Scale(pad), styleKey = STYLE_BUFFS, canCancel = true } + pad = PabSnap(pad), styleKey = STYLE_BUFFS, canCancel = true } else ns._weaponEnchPAB = nil end @@ -3816,10 +3874,16 @@ end -- Applies bar.pos (or the default) to a custom bar's parent frame. Same SetPoint -- logic as ApplyBarPosition, kept separate only because custom bars key off bar.pos -- on the bar object, not a fixed s[BarPositionKey] slot. -local function ApplyCustomBarPosition(parent, bar, barId) +local function ApplyCustomBarPosition(parent, bar, barId, isBuff, grid) local pos = bar.pos or DefaultCustomPos(barId) + local x, y = pos.x, pos.y + if x and y then + grid = grid or ComputeGrid(isBuff, bar) + local dx, dy = OriginShift(bar, pos.point, grid.designWidth - grid.width, grid.designHeight - grid.height) + x, y = x + dx, y + dy + end parent:ClearAllPoints() - local x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, pos.x, pos.y) + x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, x, y) parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) if bar.growDirection == "CENTER_HORIZONTAL" or bar.growDirection == "CENTER_VERTICAL" then local centeredPos = RebaseBarPositionToCenter(parent, pos) @@ -3853,6 +3917,11 @@ local function RegisterPABCustomUnlock() local function MakeCustomBarElement(barId, bar, order, isBuff, parents) local key = (isBuff and "PAB_CustomBuff_" or "PAB_CustomDebuff_") .. barId MapElementSettings(key, isBuff and "buff" or "debuff", barId) + -- Stored (design-frame) position -> visual offset, see OriginShift. + local function Shift(b, point) + local grid = ComputeGrid(isBuff, b) + return OriginShift(b, point, grid.designWidth - grid.width, grid.designHeight - grid.height) + end return key, MK({ key = key, label = "PAB: " .. (bar.name or (isBuff and "Buff Bar" or "Debuff Bar")), @@ -3875,14 +3944,26 @@ local function RegisterPABCustomUnlock() local grid = ComputeGrid(isBuff, b) return grid.width, grid.height end, + -- Visual <-> stored conversion, as in RegisterPABUnlock's MakeBarElement. savePos = function(_, point, relPoint, x, y) local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) if not b then return end + if x and y then + local dx, dy = Shift(b, point) + x, y = x - dx, y - dy + end b.pos = { point = point, relPoint = relPoint or point, x = x, y = y } end, loadPos = function() local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) - return b and b.pos or nil + local pos = b and b.pos + if not pos then return nil end + local x, y = pos.x, pos.y + if x and y then + local dx, dy = Shift(b, pos.point) + x, y = x + dx, y + dy + end + return { point = pos.point, relPoint = pos.relPoint, x = x, y = y } end, clearPos = function() local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) @@ -3891,7 +3972,7 @@ local function RegisterPABCustomUnlock() applyPos = function() local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) local parent = parents[barId] - if b and parent then ApplyCustomBarPosition(parent, b, barId) end + if b and parent then ApplyCustomBarPosition(parent, b, barId, isBuff) end end, }) end @@ -4029,7 +4110,7 @@ local function ReloadCustomBuffBarImpl(barId) if geomLocked then QueuePABRegenApply("custom-buff-" .. barId, function() ns.PAB_ReloadCustomBuffBar(barId) end) else - ApplyCustomBarPosition(parent, bar, barId) + ApplyCustomBarPosition(parent, bar, barId, true, grid) end -- Effective render verdict: the bar's own toggle AND its editing-spec -- bucket's applicability to the current spec AND this spec's per-spec @@ -4169,7 +4250,7 @@ local function ReloadCustomDebuffBarImpl(barId) if geomLocked then QueuePABRegenApply("custom-debuff-" .. barId, function() ns.PAB_ReloadCustomDebuffBar(barId) end) else - ApplyCustomBarPosition(parent, bar, barId) + ApplyCustomBarPosition(parent, bar, barId, false, grid) end -- Same effective-render verdict as the custom buff reload above. local barActive = ns.PAB_BarActive(bar, barBucket) @@ -5308,7 +5389,7 @@ local function RenderPreviewIcons(box, icons, isBuff, cfg, fontPath, pool) end btn.typeIcon:SetAtlas(PV_DISPEL_ICON_ATLAS[dispel]) -- Geometry from the (panel-scaled) cfg, like iconSize above -- - -- style carries the live PP.Scale'd size, wrong units here. + -- style carries the live pixel-snapped size, wrong units here. local tiSz = cfg.dispelIconSize or 16 btn.typeIcon:SetSize(tiSz, tiSz) btn.typeIcon:ClearAllPoints() From 5db270ae62e5b9b0e897ab78e6d8c2f40881d754 Mon Sep 17 00:00:00 2001 From: JuJuFX-dev Date: Fri, 11 Sep 2026 23:53:26 +0200 Subject: [PATCH 2/4] Keep existing Player Aura Bars positions until the bar is moved Rounding to the nearest pixel changes the snapped bar size. With the design-frame positions of the previous commit, every existing bar would have moved once after the update, by up to half the accumulated rounding. A stored position now refers to the design frame only after an unlock-mode move has saved it, which marks it with design = true. Every other position, the defaults included, keeps its pre-fix placement: ComputeGrid also returns the extent the old PP.Scale truncation produced, and BarAnchorOffset puts the growth origin exactly where that truncated frame, snapped as before, had it at the current resolution. Such a position stays resolution-dependent, as before, until the bar is moved. The movers' savePos goes through MoverStorePos, which keeps the stored table untouched when the incoming position equals what loadPos returned, so a discard or an untouched commit does not convert it. The in-session resize compensation moves each position by the delta of its own reference frame. --- .../EllesmereUIUnitFrames_PlayerAuraBars.lua | 142 ++++++++++-------- 1 file changed, 79 insertions(+), 63 deletions(-) diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua index 797b45d3d..8d8a28331 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua @@ -1487,10 +1487,15 @@ local function ComputeGrid(isBuff, cfg) local vertical = (cfg.growDirection == "UP" or cfg.growDirection == "DOWN" or cfg.growDirection == "CENTER_VERTICAL") local width = vertical and crossExtent or lineExtent local height = vertical and lineExtent or crossExtent - -- Design extent: the same box from the raw config numbers, identical at every - -- resolution. Stored bar positions refer to this box (see OriginShift). + -- Reference boxes for stored positions (see BarAnchorOffset): the design extent + -- from the raw config numbers, identical at every resolution, and the legacy + -- extent the pre-fix PP.Scale truncation produced at this resolution. + local PP = EllesmereUI.PP + local legacyIcon, legacyPad, legacyRowGap = PP.Scale(rawIconSize), PP.Scale(pad), PP.Scale(rowGap) local designLine = cols * rawIconSize + (cols - 1) * pad local designCross = usedRows * rawIconSize + (usedRows - 1) * rowGap + local legacyLine = cols * legacyIcon + (cols - 1) * legacyPad + local legacyCross = usedRows * legacyIcon + (usedRows - 1) * legacyRowGap return { effectiveMax = effectiveMax, rowWidth = rowWidth, @@ -1498,6 +1503,8 @@ local function ComputeGrid(isBuff, cfg) height = height, designWidth = vertical and designCross or designLine, designHeight = vertical and designLine or designCross, + legacyWidth = vertical and legacyCross or legacyLine, + legacyHeight = vertical and legacyLine or legacyCross, rowGap = rowGap, } end @@ -2154,26 +2161,27 @@ end -- dimension handling keeps both edges on whole pixels, while plain SnapForES would -- round the center itself and push edges onto half pixels; every other anchor point -- uses SnapForES. -local function SnapBarPos(frame, point, relPoint, x, y) +local function SnapBarPos(frame, point, relPoint, x, y, w, h) if not (x and y) then return x, y end local PP = EllesmereUI.PP local es = frame:GetEffectiveScale() local isCenterAnchor = (point == "CENTER" or point == nil) and (relPoint == "CENTER" or relPoint == nil) + -- w/h replace the frame's own size when given (legacy positions, BarAnchorOffset). if isCenterAnchor then - return PP.SnapCenterForDim(x, frame:GetWidth() or 0, es), - PP.SnapCenterForDim(y, frame:GetHeight() or 0, es) + return PP.SnapCenterForDim(x, w or frame:GetWidth() or 0, es), + PP.SnapCenterForDim(y, h or frame:GetHeight() or 0, es) end return PP.SnapForES(x, es), PP.SnapForES(y, es) end --- Pixel-rounding compensation. A stored position places the DESIGN-size frame --- (ComputeGrid's designWidth/designHeight); the live frame is the snapped grid, --- slightly larger or smaller per resolution. Returns the SetPoint offset that keeps --- the growth origin (the parent corner the container is pinned to, the center for --- centered growth) where the design frame puts it, for a frame anchored at `point`. --- dw/dh are (design - snapped) at apply time; ApplyLiveConfig also passes a design --- size change to move a stored center with its origin. +-- Pixel-rounding compensation. A stored position places a reference frame (see +-- BarAnchorOffset); the live frame is the snapped grid, slightly larger or smaller. +-- Returns the SetPoint offset that keeps the growth origin (the parent corner the +-- container is pinned to, the center for centered growth) where the reference frame +-- puts it, for a frame anchored at `point`. dw/dh are (reference - snapped) at apply +-- time; ApplyLiveConfig also passes a reference size change to move a stored center +-- with its origin. local OriginShift do local FRAC_X = { TOPLEFT = 0, LEFT = 0, BOTTOMLEFT = 0, TOP = 0.5, CENTER = 0.5, @@ -2190,6 +2198,38 @@ do end end +-- Pre-snap SetPoint offsets for a stored bar position. A position saved by an +-- unlock-mode move since the rounding fix (pos.design) refers to the design frame. +-- Any other position, defaults included, keeps the placement it had before the fix: +-- its growth origin stays where the truncated legacy frame, snapped as before, put +-- it at this resolution, until the bar is moved. +local function BarAnchorOffset(frame, cfg, grid, pos) + local x, y = pos.x, pos.y + if not (x and y) then return x, y end + local rw, rh = grid.designWidth, grid.designHeight + if not pos.design then + rw, rh = grid.legacyWidth, grid.legacyHeight + x, y = SnapBarPos(frame or UIParent, pos.point, pos.relPoint or pos.point, x, y, rw, rh) + end + local dx, dy = OriginShift(cfg, pos.point, rw - grid.width, rh - grid.height) + return x + dx, y + dy +end + +-- Unlock-mode savePos: x/y is the VISUAL position the mover hands over, `cur` the +-- stored one. An unchanged position (a discard or an untouched commit writes back +-- what loadPos returned) keeps `cur` as it is, so a legacy position only converts +-- on a real move. Anything else is stored as a design position. +local function MoverStorePos(frame, cfg, grid, cur, point, relPoint, x, y) + relPoint = relPoint or point + if not (x and y) then return { point = point, relPoint = relPoint, x = x, y = y } end + if cur and cur.point == point and (cur.relPoint or cur.point) == relPoint then + local cx, cy = BarAnchorOffset(frame, cfg, grid, cur) + if cx and cy and math.abs(x - cx) < 0.001 and math.abs(y - cy) < 0.001 then return cur end + end + local dx, dy = OriginShift(cfg, point, grid.designWidth - grid.width, grid.designHeight - grid.height) + return { point = point, relPoint = relPoint, x = x - dx, y = y - dy, design = true } +end + -- Centered growth needs a position whose meaning does not change with the mover's -- configured grid size. Rebase any edge-anchored saved position to CENTER/CENTER at -- the frame's current visual center before resizing it. @@ -2216,11 +2256,7 @@ local function ApplyBarPosition(parent, isBuff, grid) local cfg = s and (isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s)) local p = (pos and pos.point) and pos or def local x, y = p.x, p.y - if cfg and x and y then - grid = grid or ComputeGrid(isBuff, cfg) - local dx, dy = OriginShift(cfg, p.point, grid.designWidth - grid.width, grid.designHeight - grid.height) - x, y = x + dx, y + dy - end + if cfg then x, y = BarAnchorOffset(parent, cfg, grid or ComputeGrid(isBuff, cfg), p) end parent:ClearAllPoints() x, y = SnapBarPos(parent, p.point, p.relPoint or p.point, x, y) parent:SetPoint(p.point, UIParent, p.relPoint or p.point, x, y) @@ -2439,12 +2475,14 @@ local function CreateBars() buffsParent = buffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Buffs", UIParent) buffsParent:SetSize(buffGrid.width, buffGrid.height) ApplyBarPosition(buffsParent, true, buffGrid) - lastSize.buffs = { w = buffGrid.width, h = buffGrid.height, dw = buffGrid.designWidth, dh = buffGrid.designHeight } + lastSize.buffs = { w = buffGrid.width, h = buffGrid.height, dw = buffGrid.designWidth, + dh = buffGrid.designHeight, lw = buffGrid.legacyWidth, lh = buffGrid.legacyHeight } debuffsParent = debuffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Debuffs", UIParent) debuffsParent:SetSize(debuffGrid.width, debuffGrid.height) ApplyBarPosition(debuffsParent, false, debuffGrid) - lastSize.debuffs = { w = debuffGrid.width, h = debuffGrid.height, dw = debuffGrid.designWidth, dh = debuffGrid.designHeight } + lastSize.debuffs = { w = debuffGrid.width, h = debuffGrid.height, dw = debuffGrid.designWidth, + dh = debuffGrid.designHeight, lw = debuffGrid.legacyWidth, lh = debuffGrid.legacyHeight } -- Enable toggles (cfg.enabled, nil = enabled): containers and groups still -- build below so a live re-enable needs no reload; a disabled bar just @@ -2590,12 +2628,6 @@ function RegisterPABUnlock() local MK = EllesmereUI.MakeUnlockElement local function MakeBarElement(key, label, order, isBuff, getParent) - -- Stored (design-frame) position -> visual offset, see OriginShift. - local function Shift(s, point) - local cfg = isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s) - local grid = ComputeGrid(isBuff, cfg) - return OriginShift(cfg, point, grid.designWidth - grid.width, grid.designHeight - grid.height) - end return MK({ key = key, label = label, @@ -2623,27 +2655,22 @@ function RegisterPABUnlock() local grid = ComputeGrid(isBuff, isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s)) return grid.width, grid.height end, - -- The mover works in VISUAL positions (the snapped live frame), the store - -- in design-frame positions. Both directions apply the same shift, so a - -- load/save round trip (RevertPositions) is exact. + -- The mover works in VISUAL positions (the snapped live frame): loadPos + -- converts the stored one (BarAnchorOffset), savePos goes back through + -- MoverStorePos, which leaves an unchanged position untouched. savePos = function(_, point, relPoint, x, y) local s = PAB() if not s then return end - if x and y then - local dx, dy = Shift(s, point) - x, y = x - dx, y - dy - end - s[BarPositionKey(isBuff)] = { point = point, relPoint = relPoint or point, x = x, y = y } + local posKey = BarPositionKey(isBuff) + local cfg = isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s) + s[posKey] = MoverStorePos(getParent(), cfg, ComputeGrid(isBuff, cfg), s[posKey], point, relPoint, x, y) end, loadPos = function() local s = PAB() local pos = s and s[BarPositionKey(isBuff)] if not pos then return nil end - local x, y = pos.x, pos.y - if x and y then - local dx, dy = Shift(s, pos.point) - x, y = x + dx, y + dy - end + local cfg = isBuff and DefaultBuffsCfg(s) or DefaultDebuffsCfg(s) + local x, y = BarAnchorOffset(getParent(), cfg, ComputeGrid(isBuff, cfg), pos) return { point = pos.point, relPoint = pos.relPoint, x = x, y = y } end, clearPos = function() @@ -2845,13 +2872,20 @@ local function ApplyLiveConfig(isBuff) and (prev.w ~= grid.width or prev.h ~= grid.height or prev.dw ~= grid.designWidth or prev.dh ~= grid.designHeight) if resized and pos and pos.point == "CENTER" then - -- The stored center belongs to the design-size frame, so it moves by the - -- DESIGN delta, toward whichever side the growth origin is on. - local dx, dy = OriginShift(cfg, pos.point, prev.dw - grid.designWidth, prev.dh - grid.designHeight) + -- The stored center belongs to its reference frame (design or legacy, see + -- BarAnchorOffset), so it moves by that frame's delta, toward whichever + -- side the growth origin is on. + local dx, dy + if pos.design then + dx, dy = OriginShift(cfg, pos.point, prev.dw - grid.designWidth, prev.dh - grid.designHeight) + else + dx, dy = OriginShift(cfg, pos.point, prev.lw - grid.legacyWidth, prev.lh - grid.legacyHeight) + end pos.x = pos.x + dx pos.y = pos.y + dy end - lastSize[sizeKey] = { w = grid.width, h = grid.height, dw = grid.designWidth, dh = grid.designHeight } + lastSize[sizeKey] = { w = grid.width, h = grid.height, dw = grid.designWidth, + dh = grid.designHeight, lw = grid.legacyWidth, lh = grid.legacyHeight } parent:SetSize(grid.width, grid.height) -- Re-seated AFTER SetSize so SnapBarPos snaps against the new size and the -- rounding shift follows the new grid. The STORED pos keeps the raw @@ -3876,12 +3910,7 @@ end -- on the bar object, not a fixed s[BarPositionKey] slot. local function ApplyCustomBarPosition(parent, bar, barId, isBuff, grid) local pos = bar.pos or DefaultCustomPos(barId) - local x, y = pos.x, pos.y - if x and y then - grid = grid or ComputeGrid(isBuff, bar) - local dx, dy = OriginShift(bar, pos.point, grid.designWidth - grid.width, grid.designHeight - grid.height) - x, y = x + dx, y + dy - end + local x, y = BarAnchorOffset(parent, bar, grid or ComputeGrid(isBuff, bar), pos) parent:ClearAllPoints() x, y = SnapBarPos(parent, pos.point, pos.relPoint or pos.point, x, y) parent:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) @@ -3917,11 +3946,6 @@ local function RegisterPABCustomUnlock() local function MakeCustomBarElement(barId, bar, order, isBuff, parents) local key = (isBuff and "PAB_CustomBuff_" or "PAB_CustomDebuff_") .. barId MapElementSettings(key, isBuff and "buff" or "debuff", barId) - -- Stored (design-frame) position -> visual offset, see OriginShift. - local function Shift(b, point) - local grid = ComputeGrid(isBuff, b) - return OriginShift(b, point, grid.designWidth - grid.width, grid.designHeight - grid.height) - end return key, MK({ key = key, label = "PAB: " .. (bar.name or (isBuff and "Buff Bar" or "Debuff Bar")), @@ -3948,21 +3972,13 @@ local function RegisterPABCustomUnlock() savePos = function(_, point, relPoint, x, y) local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) if not b then return end - if x and y then - local dx, dy = Shift(b, point) - x, y = x - dx, y - dy - end - b.pos = { point = point, relPoint = relPoint or point, x = x, y = y } + b.pos = MoverStorePos(parents[barId], b, ComputeGrid(isBuff, b), b.pos, point, relPoint, x, y) end, loadPos = function() local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) local pos = b and b.pos if not pos then return nil end - local x, y = pos.x, pos.y - if x and y then - local dx, dy = Shift(b, pos.point) - x, y = x + dx, y + dy - end + local x, y = BarAnchorOffset(parents[barId], b, ComputeGrid(isBuff, b), pos) return { point = pos.point, relPoint = pos.relPoint, x = x, y = y } end, clearPos = function() From 12ab9ec054b918928dc11c90e80ae1caa34b536d Mon Sep 17 00:00:00 2001 From: JuJuFX-dev Date: Sat, 12 Sep 2026 00:12:55 +0200 Subject: [PATCH 3/4] Re-place Player Aura Bars after a grow direction change Review follow-ups to the two commits before this one. ApplyLiveConfig only re-placed a bar when its grid size changed. A grow or wrap direction change moves the growth origin without changing that size, so the bar kept the previous rounding shift until the next reload, and loadPos disagreed with where the frame actually was. lastSize now carries the direction pair and the re-place runs on a change of it too, in combat through the regen queue. The re-place is skipped for a bar anchored in unlock mode: the anchor owns its placement and re-applies itself on a size change, so re-seating such a bar to its stored position pulled it off the anchor. RebaseBarPositionToCenter marks its result as a design position. Centered growth has no rounding shift at CENTER, so the measured center already is the design center; without the flag the next apply snapped it twice and could jump a pixel. PabSnap rounds ties up with the same 0.001 guard PP.SnapForES uses, so float dust at an exact half pixel cannot flip a size between sessions. --- .../EllesmereUIUnitFrames_PlayerAuraBars.lua | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua index 8d8a28331..5ddf2a1da 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua @@ -288,11 +288,12 @@ end -- Nearest physical pixel at UIParent scale, for every PAB grid number (icon size, -- padding, row gap). Not PP.Scale: it truncates, and the per-icon loss adds up -- along a row, so a bar measured a different number of UI units per resolution. --- Same rounding as EllesmereUIActionBars.lua's ComputeBarLayout. +-- Rounds like EllesmereUIActionBars.lua's ComputeBarLayout, plus the 0.001 tie +-- guard PP.SnapForES uses, so an exact half pixel cannot flip between sessions. local function PabSnap(x) local m = EllesmereUI.PP.mult if x == 0 or m == 1 then return x end - return math.floor(x / m + 0.5) * m + return math.floor(x / m + 0.5 + 0.001) * m end local function PAB_ApplyDmFx(button, d, style) @@ -2242,7 +2243,9 @@ local function RebaseBarPositionToCenter(frame, pos) local sx, sy = SnapBarPos(frame, "CENTER", "CENTER", x, y) frame:ClearAllPoints() frame:SetPoint("CENTER", UIParent, "CENTER", sx, sy) - return { point = "CENTER", relPoint = "CENTER", x = x, y = y } + -- design: centered growth has no rounding shift at CENTER, so the measured + -- center already is the design center (BarAnchorOffset). + return { point = "CENTER", relPoint = "CENTER", x = x, y = y, design = true } end -- Applies the saved position (if any) or the default to the given parent frame. @@ -2476,13 +2479,15 @@ local function CreateBars() buffsParent:SetSize(buffGrid.width, buffGrid.height) ApplyBarPosition(buffsParent, true, buffGrid) lastSize.buffs = { w = buffGrid.width, h = buffGrid.height, dw = buffGrid.designWidth, - dh = buffGrid.designHeight, lw = buffGrid.legacyWidth, lh = buffGrid.legacyHeight } + dh = buffGrid.designHeight, lw = buffGrid.legacyWidth, lh = buffGrid.legacyHeight, + gd = buffCfg.growDirection, wd = buffCfg.iconWrapDirection } debuffsParent = debuffsParent or CreateFrame("Frame", "EllesmereUIPlayerAuraBars_Debuffs", UIParent) debuffsParent:SetSize(debuffGrid.width, debuffGrid.height) ApplyBarPosition(debuffsParent, false, debuffGrid) lastSize.debuffs = { w = debuffGrid.width, h = debuffGrid.height, dw = debuffGrid.designWidth, - dh = debuffGrid.designHeight, lw = debuffGrid.legacyWidth, lh = debuffGrid.legacyHeight } + dh = debuffGrid.designHeight, lw = debuffGrid.legacyWidth, lh = debuffGrid.legacyHeight, + gd = debuffCfg.growDirection, wd = debuffCfg.iconWrapDirection } -- Enable toggles (cfg.enabled, nil = enabled): containers and groups still -- build below so a live re-enable needs no reload; a disabled bar just @@ -2857,7 +2862,8 @@ local function ApplyLiveConfig(isBuff) -- and the stored pos is not mutated for a resize that never landed. if InCombatLockdown() then local sizeChanged = not (prev and prev.w == grid.width and prev.h == grid.height - and prev.dw == grid.designWidth and prev.dh == grid.designHeight) + and prev.dw == grid.designWidth and prev.dh == grid.designHeight + and prev.gd == cfg.growDirection and prev.wd == cfg.iconWrapDirection) local rebasePending = centered and not (pos and pos.point == "CENTER" and (pos.relPoint or pos.point) == "CENTER") if sizeChanged or rebasePending then @@ -2884,14 +2890,19 @@ local function ApplyLiveConfig(isBuff) pos.x = pos.x + dx pos.y = pos.y + dy end + local turned = prev and (prev.gd ~= cfg.growDirection or prev.wd ~= cfg.iconWrapDirection) lastSize[sizeKey] = { w = grid.width, h = grid.height, dw = grid.designWidth, - dh = grid.designHeight, lw = grid.legacyWidth, lh = grid.legacyHeight } + dh = grid.designHeight, lw = grid.legacyWidth, lh = grid.legacyHeight, + gd = cfg.growDirection, wd = cfg.iconWrapDirection } parent:SetSize(grid.width, grid.height) -- Re-seated AFTER SetSize so SnapBarPos snaps against the new size and the - -- rounding shift follows the new grid. The STORED pos keeps the raw - -- accumulation. Only on a real change, so an anchor-linked bar is not pulled - -- back to its stored position on every slider tick. - if resized then ApplyBarPosition(parent, isBuff, grid) end + -- rounding shift follows the new grid, or the new origin after a grow/wrap + -- direction change. The STORED pos keeps the raw accumulation. Never for an + -- unlock-anchored bar: the anchor owns its placement and re-applies itself on + -- a size change. + local anchored = EllesmereUI.IsUnlockAnchored + and EllesmereUI.IsUnlockAnchored(isBuff and "PAB_Buffs" or "PAB_Debuffs") + if (resized or turned) and not anchored then ApplyBarPosition(parent, isBuff, grid) end end local pad = cfg.padding or 5 From 186710e06f23cde4c2ecb3937ed4408c956f5f1d Mon Sep 17 00:00:00 2001 From: JuJuFX-dev Date: Sat, 12 Sep 2026 00:21:05 +0200 Subject: [PATCH 4/4] Let spec-override unlock layers bank raw element positions The layers harvest element geometry through loadPosition and write it back through savePosition. For Player Aura Bars those are the VISUAL positions: they depend on the resolution and carry no design flag, so a layer harvested at one resolution and flushed at another moved the bar and converted its stored position along the way. Add an opt-in pair, loadRawPosition and saveRawPosition. When an element exposes both, the harvest banks its stored table verbatim (marked with rawPos for the flush) and the flush hands that table straight back, guarded by an exact equality that counts the element's own position flags rather than coordinates alone. Elements without the pair keep the existing loadPosition/savePosition path unchanged, including the anchor-owned skip and the settle. PlayerAuraBars' default and custom bar elements expose the pair, so their stored positions survive a layer round trip at any resolution. --- .../EllesmereUIUnitFrames_PlayerAuraBars.lua | 30 +++++++++++ EllesmereUI_SpecOverrides.lua | 52 +++++++++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua index 5ddf2a1da..1707c200b 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua @@ -2678,6 +2678,22 @@ function RegisterPABUnlock() local x, y = BarAnchorOffset(getParent(), cfg, ComputeGrid(isBuff, cfg), pos) return { point = pos.point, relPoint = pos.relPoint, x = x, y = y } end, + -- Spec-override unlock layers bank and restore the STORED table through + -- these, not the visual position loadPos returns: a layer harvested at + -- one resolution stays valid at another and keeps the design flag. + loadRawPosition = function() + local s = PAB() + local pos = s and s[BarPositionKey(isBuff)] + if not pos then return nil end + return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y, + design = pos.design } + end, + saveRawPosition = function(_, p) + local s = PAB() + if not (s and p and p.point) then return end + s[BarPositionKey(isBuff)] = { point = p.point, relPoint = p.relPoint or p.point, + x = p.x, y = p.y, design = p.design } + end, clearPos = function() local s = PAB() if s then s[BarPositionKey(isBuff)] = nil end @@ -3992,6 +4008,20 @@ local function RegisterPABCustomUnlock() local x, y = BarAnchorOffset(parents[barId], b, ComputeGrid(isBuff, b), pos) return { point = pos.point, relPoint = pos.relPoint, x = x, y = y } end, + -- Raw stored position for spec-override layers, as in MakeBarElement. + loadRawPosition = function() + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + local pos = b and b.pos + if not pos then return nil end + return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y, + design = pos.design } + end, + saveRawPosition = function(_, p) + local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) + if not (b and p and p.point) then return end + b.pos = { point = p.point, relPoint = p.relPoint or p.point, + x = p.x, y = p.y, design = p.design } + end, clearPos = function() local b = isBuff and ns.PAB_GetCustomBuffBar(barId) or ns.PAB_GetCustomDebuffBar(barId) if b then b.pos = nil end diff --git a/EllesmereUI_SpecOverrides.lua b/EllesmereUI_SpecOverrides.lua index f848aceeb..e0867b007 100644 --- a/EllesmereUI_SpecOverrides.lua +++ b/EllesmereUI_SpecOverrides.lua @@ -1550,7 +1550,22 @@ local function HarvestLayer() for key, elem in pairs(elems) do if not LayerSkipsKey(key) then local e - if elem.loadPosition then + -- Elements whose STORED position is not the visual one loadPosition + -- reports (PlayerAuraBars' rounding-shifted bars) expose + -- loadRawPosition/saveRawPosition: bank that flat table verbatim, so + -- a layer harvested at one resolution stays valid at another and + -- keeps the element's own position flags. + if elem.loadRawPosition and elem.saveRawPosition then + local ok, p = pcall(elem.loadRawPosition, key) + if ok and type(p) == "table" and p.point then + e = {} + for k, v in pairs(p) do + if type(v) ~= "table" then e[k] = v end + end + e.relPoint = e.relPoint or e.point + e.rawPos = true + end + elseif elem.loadPosition then local ok, p = pcall(elem.loadPosition, key) if ok and p and p.point then e = { point = p.point, relPoint = p.relPoint or p.point, @@ -1614,6 +1629,22 @@ local function ElemNear(a, b) return near(a.x, b.x) and near(a.y, b.y) and near(a.w, b.w) and near(a.h, b.h) end +-- Exact equality for RAW element positions (loadRawPosition): the element's own +-- stored fields, so a position flag it carries counts as a difference too. The +-- layer's own bookkeeping keys are not part of the position. +local RAW_POS_LAYER_KEYS = { w = true, h = true, rawPos = true, relPoint = true } +local function RawPosEqual(cur, e) + if not cur then return false end + if (cur.relPoint or cur.point) ~= (e.relPoint or e.point) then return false end + for k, v in pairs(e) do + if not RAW_POS_LAYER_KEYS[k] and cur[k] ~= v then return false end + end + for k, v in pairs(cur) do + if not RAW_POS_LAYER_KEYS[k] and e[k] ~= v then return false end + end + return true +end + --- Writes a layer into the live stores. CRITICAL: the raw CDM/AB position tables --- are mutated IN PLACE (wipe + refill) -- the owning addons keep mirror references --- to these exact tables that only refresh on profile applies, so replacing the @@ -2074,7 +2105,15 @@ function EllesmereUI.SpecOverrides_FlushUnlock() -- live for most elements; only real deltas write and settle. -- Anchor-owned keys never take elem positions (see -- UnlockElemAnchorOwned): the anchor is the authority. - if e.point and elem.savePosition and not UnlockElemAnchorOwned(key) then + -- Raw-position elements take their stored table back verbatim, + -- flags included (see the harvest). + if e.rawPos and elem.saveRawPosition and not UnlockElemAnchorOwned(key) then + if not RawPosEqual(elem.loadRawPosition and elem.loadRawPosition(key), e) then + pcall(elem.saveRawPosition, key, e) + if elem.applyPosition then pcall(elem.applyPosition, key) end + _unlockSettleWanted = true + end + elseif e.point and elem.savePosition and not UnlockElemAnchorOwned(key) then local cur = elem.loadPosition and elem.loadPosition(key) if not (cur and cur.point == e.point and (cur.relPoint or cur.point) == (e.relPoint or e.point) @@ -2121,7 +2160,14 @@ function EllesmereUI.SpecOverrides_FlushUnlock() elseif elems and elems[key] then local elem = elems[key] -- Same anchor-owned skip as the pend path above. - if e.point and elem.savePosition and not UnlockElemAnchorOwned(key) then + -- Same raw-position branch as the pend path above. + if e.rawPos and elem.saveRawPosition and not UnlockElemAnchorOwned(key) then + if not RawPosEqual(elem.loadRawPosition and elem.loadRawPosition(key), e) then + pcall(elem.saveRawPosition, key, e) + if elem.applyPosition then pcall(elem.applyPosition, key) end + _unlockSettleWanted = true + end + elseif e.point and elem.savePosition and not UnlockElemAnchorOwned(key) then local cur = elem.loadPosition and elem.loadPosition(key) if not (cur and cur.point == e.point and (cur.relPoint or cur.point) == (e.relPoint or e.point)