diff --git a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua index e8da7e841..1707c200b 100644 --- a/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua +++ b/EllesmereUIUnitFrames/EllesmereUIUnitFrames_PlayerAuraBars.lua @@ -285,6 +285,17 @@ 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. +-- 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 + 0.001) * 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 +966,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 +1082,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 +1274,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 +1452,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 +1488,24 @@ 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 + -- 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, width = width, 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 @@ -2102,7 +2124,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) @@ -2140,19 +2162,75 @@ 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 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, + 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 + +-- 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. @@ -2165,26 +2243,26 @@ 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. -- 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 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) if cfg and (cfg.growDirection == "CENTER_HORIZONTAL" or cfg.growDirection == "CENTER_VERTICAL") then s[posKey] = RebaseBarPositionToCenter(parent, pos) end @@ -2261,7 +2339,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 +2374,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 +2477,17 @@ 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, 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) - 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, 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 @@ -2441,7 +2523,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 @@ -2578,16 +2660,39 @@ 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): 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 - 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 - return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y } + 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, + -- 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() @@ -2772,7 +2877,9 @@ 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 + 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 @@ -2783,23 +2890,35 @@ 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) + 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 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 } + 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, + 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, 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 @@ -2814,7 +2933,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 +3935,11 @@ 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 = BarAnchorOffset(parent, bar, grid or ComputeGrid(isBuff, bar), pos) 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) @@ -3875,14 +3995,32 @@ 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 - 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) - return b and b.pos or nil + local pos = b and b.pos + if not pos then return nil end + 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) @@ -3891,7 +4029,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 +4167,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 +4307,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 +5446,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() 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)