diff --git a/AuraDesigner/Engine.lua b/AuraDesigner/Engine.lua index c689cff9..b7afd6a7 100644 --- a/AuraDesigner/Engine.lua +++ b/AuraDesigner/Engine.lua @@ -1138,8 +1138,16 @@ function Engine:ForceRefreshAllFrames() end local function TryUpdate(frame) - if frame and frame:IsVisible() and DF:IsAuraDesignerEnabled(frame) then - Engine:UpdateFrame(frame) + if not frame then return end + if DF:IsAuraDesignerEnabled(frame) then + if frame:IsVisible() then + Engine:UpdateFrame(frame) + end + else + -- AD is OFF for this frame's mode (toggled off, or a profile swap to + -- an AD-off profile) — tear down any leftover indicators so they + -- don't freeze on screen (timers stopped) until the next /reload. + Engine:ClearFrame(frame) end end diff --git a/AuraDesigner/Indicators.lua b/AuraDesigner/Indicators.lua index 6d2ab003..dd4c79bd 100644 --- a/AuraDesigner/Indicators.lua +++ b/AuraDesigner/Indicators.lua @@ -27,13 +27,10 @@ local issecretvalue = issecretvalue or function() return false end local GetAuraDataByAuraInstanceID = C_UnitAuras and C_UnitAuras.GetAuraDataByAuraInstanceID local IsAuraFilteredOut = C_UnitAuras and C_UnitAuras.IsAuraFilteredOutByInstanceID --- Check if an interpolated color result differs from the original color. --- result.r/g/b may be secret (tainted) values from EvaluateRemainingDuration/Percent; --- arithmetic on secret values throws. If tainted, the engine IS interpolating → expiring. -local function IsColorExpiring(result, oc) - if issecretvalue(result.r) then return true end - return (math.abs(result.r - oc.r) > 0.01 or math.abs(result.g - oc.g) > 0.01 or math.abs(result.b - oc.b) > 0.01) -end +-- Secret-safe "is this colour-curve result the expiring colour?" — now lives on +-- the shared DF.Expiring engine; kept as a local alias so the call sites below +-- read unchanged. +local IsColorExpiring = DF.Expiring.IsColorExpiring DF.AuraDesigner = DF.AuraDesigner or {} @@ -156,104 +153,37 @@ local function AdjustOffsetForBorder(anchor, offsetX, offsetY, borderSize, borde end -- ============================================================ --- SHARED EXPIRING TICKER --- Processes all registered indicators with expiring settings --- at ~3 FPS. Same dual-path approach as bar's OnUpdate: --- API path: Build a Step color curve per element, evaluate --- via durationObj:EvaluateRemainingPercent → apply --- Preview path: Manual pct calculation, compare to threshold +-- EXPIRING — thin AD-side adapters over the shared DF.Expiring engine +-- (engine: registry + ~3 FPS ticker + colour curve live in Frames/Expiring.lua). +-- The pending* flags are AD's "Show When Missing" mechanism: Apply sets them +-- before dispatching to Configure, and the RegisterExpiring wrapper injects them +-- into entryData (keeping that coupling AD-side; the shared engine reads only +-- entryData). The call sites below (RegisterExpiring / UnregisterExpiring / +-- BuildExpiringColorCurve) are unchanged — these locals just delegate. -- ============================================================ -local expiringRegistry = {} local pendingHideWhenNotExpiring = false -- Set by Apply before dispatch, read by RegisterExpiring local pendingUseShowHide = false -- When true, ticker uses Show/Hide instead of SetAlpha local pendingHiddenAlpha = nil -- Alpha to use when "not expiring" (nil = 0 for borders, savedAlpha for framealpha) local function RegisterExpiring(element, entryData) - -- Propagate Show When Missing visibility flag + -- Propagate Show When Missing visibility flag into entryData (the shared + -- engine has no knowledge of AD's pending state). if pendingHideWhenNotExpiring then entryData.hideWhenNotExpiring = true entryData.visibleAlpha = entryData.originalAlpha or 1 entryData.useShowHide = pendingUseShowHide or false entryData.hiddenAlpha = pendingHiddenAlpha -- nil = use 0, number = use that alpha end - expiringRegistry[element] = entryData - - -- Evaluate immediately so the Apply function ends with the correct - -- color. Without this the Apply sets the *original* color, then the - -- ticker (3 FPS) overrides it later → visible flicker. - -- Same approach as bar's "Set initial bar color" block in ConfigureBar. - local applied = false - if entryData.colorCurve and entryData.unit and entryData.auraInstanceID - and C_UnitAuras and C_UnitAuras.GetAuraDuration then - local durationObj = C_UnitAuras.GetAuraDuration(entryData.unit, entryData.auraInstanceID) - if durationObj then - local result - if entryData.thresholdMode == "SECONDS" and durationObj.EvaluateRemainingDuration then - result = durationObj:EvaluateRemainingDuration(entryData.colorCurve) - elseif durationObj.EvaluateRemainingPercent then - result = durationObj:EvaluateRemainingPercent(entryData.colorCurve) - end - if result and entryData.applyResult then - entryData.applyResult(element, result, entryData) - applied = true - end - end - end - if not applied then - local dur = entryData.duration - local exp = entryData.expirationTime - if dur and exp and not issecretvalue(dur) and not issecretvalue(exp) and dur > 0 then - local remaining = max(0, exp - GetTime()) - local isExpiring - if entryData.thresholdMode == "SECONDS" then - isExpiring = remaining <= (entryData.threshold or 10) - else - local pct = remaining / dur - isExpiring = pct <= ((entryData.threshold or 30) / 100) - end - if entryData.applyManual then - entryData.applyManual(element, isExpiring, entryData) - end - elseif entryData.applyManual then - -- duration=0 means permanent or synthetic (missing) aura — not expiring - entryData.applyManual(element, false, entryData) - end - end + DF.Expiring:Register(element, entryData) end local function UnregisterExpiring(element) - if element then - expiringRegistry[element] = nil - end + DF.Expiring:Unregister(element) end --- Build a Step color curve encoding two states: --- Below threshold → expiring color --- At/above threshold → original color --- Same pattern as bar's dfAD_colorCurve for expiring-only mode. --- thresholdMode: nil/"PERCENT" = percentage (0-100), "SECONDS" = seconds (1-60) local function BuildExpiringColorCurve(threshold, expiringColor, originalColor, thresholdMode) - if not C_CurveUtil or not C_CurveUtil.CreateColorCurve then return nil end - local curve = C_CurveUtil.CreateColorCurve() - curve:SetType(Enum.LuaCurveType.Step) - local ecR = expiringColor.r or 1 - local ecG = expiringColor.g or 0.2 - local ecB = expiringColor.b or 0.2 - local ocR = originalColor.r or 1 - local ocG = originalColor.g or 1 - local ocB = originalColor.b or 1 - curve:AddPoint(0, CreateColor(ecR, ecG, ecB, 1)) - if thresholdMode == "SECONDS" then - -- Curve points in seconds for EvaluateRemainingDuration - curve:AddPoint(threshold, CreateColor(ocR, ocG, ocB, 1)) - curve:AddPoint(600, CreateColor(ocR, ocG, ocB, 1)) -- 10min cap - else - -- Curve points as decimal percentage for EvaluateRemainingPercent - curve:AddPoint(threshold / 100, CreateColor(ocR, ocG, ocB, 1)) - curve:AddPoint(1, CreateColor(ocR, ocG, ocB, 1)) - end - return curve + return DF.Expiring:BuildColorCurve(threshold, expiringColor, originalColor, thresholdMode) end -- Build a Step color curve for hiding duration text above a seconds threshold. @@ -314,24 +244,37 @@ local function GetOrCreateWholeAlphaPulse(frame) return frame.dfAD_wholeAlphaPulse end --- Create or return a bounce (translation) animation. --- For squares, a wrapper frame is used to avoid CooldownFrameTemplate rendering glitches --- when Translation is applied directly to a frame with a Cooldown child. --- The wrapper is created and managed in the square expiring setup section. +-- Bounce driver. A real Translation animation moves the frame's render +-- TRANSFORM, which child-frame overlays (the expiring Tint and the Expiring- +-- Animation glow) don't track cleanly under the AD preview's per-frame refresh — +-- they accumulate the offset and drift off-screen. Instead we move the element +-- with a real SetPoint LAYOUT offset (relative to its base anchor, stored as +-- el.dfAD_basePos by UpdateIcon/Square/Bar), which propagates to every descendant +-- correctly. The driver mimics the AnimationGroup interface (Play/Stop/IsPlaying) +-- so every existing call site works unchanged. +local BOUNCE_AMP, BOUNCE_PERIOD = 4, 0.6 -- pixels, seconds per full up-down cycle local function GetOrCreateBounceAnim(frame) if not frame.dfAD_bounceAnim then - frame.dfAD_bounceAnim = frame:CreateAnimationGroup() - frame.dfAD_bounceAnim:SetLooping("REPEAT") - local up = frame.dfAD_bounceAnim:CreateAnimation("Translation") - up:SetOffset(0, 4) - up:SetDuration(0.25) - up:SetOrder(1) - up:SetSmoothing("OUT") - local down = frame.dfAD_bounceAnim:CreateAnimation("Translation") - down:SetOffset(0, -4) - down:SetDuration(0.25) - down:SetOrder(2) - down:SetSmoothing("IN") + local d = CreateFrame("Frame") + d:Hide() + d.elapsed = 0 + d.IsPlaying = function(self) return self:IsShown() end + d.Play = function(self) self.elapsed = 0; self:Show() end + d.Stop = function(self) + self:Hide() + local b = frame.dfAD_basePos -- snap back to the resting position + if b then frame:ClearAllPoints(); frame:SetPoint(b.point, b.rel, b.relPoint, b.x, b.y) end + end + d:SetScript("OnUpdate", function(self, dt) + self.elapsed = self.elapsed + dt + local b = frame.dfAD_basePos + if not b then return end + -- Smooth 0→AMP→0 each cycle (zero-slope endpoints, no seam on loop). + local off = (1 - math.cos(self.elapsed * (2 * math.pi / BOUNCE_PERIOD))) * 0.5 * BOUNCE_AMP + frame:ClearAllPoints() + frame:SetPoint(b.point, b.rel, b.relPoint, b.x, b.y + off) + end) + frame.dfAD_bounceAnim = d end return frame.dfAD_bounceAnim end @@ -359,6 +302,175 @@ local function UpdateBounceState(el, isExpiring) end end +-- Drive the anim effects (pulse / whole-alpha / bounce) from an applyResult tick, +-- but ONLY on NON-secret auras. On a secret aura the curve result is tainted, so +-- IsColorExpiring returns true-always — a play/stop that branches on that would +-- keep the effect running forever (e.g. a bounce that never stops drifts upward +-- and "flies off" in the preview). Per design the anim effects are non-secret- +-- only, so on a secret aura we force them STOPPED (effExp = false → revert to base +-- position/alpha). `pulseFrame` = the element's fill/border pulse frame (or nil). +local function DriveExpiringEffects(el, result, isExp, pulseFrame) + local effExp = (not issecretvalue(result.r)) and isExp or false + if pulseFrame then UpdatePulseState(pulseFrame, effExp) end + UpdateWholeAlphaPulseState(el, effExp) + UpdateBounceState(el, effExp) +end + +-- Secret-safe expiring TINT for AD indicators (icon / square / bar). A colour +-- overlay that fades in below threshold, driven by the shared DF.Expiring engine +-- (alpha-gated via SetAlphaFromBoolean — works on SECRET auras, never branches on +-- the secret remaining-time). `host` is the frame the texture attaches to +-- (textOverlay where present, else the element). Idempotent: reuses host.dfAD_tint. +-- `host` = frame the texture attaches to (textOverlay where present, else the +-- element); `el` = the element carrying the stored dfAD_* config (set in Configure). +local function SetupExpiringTint(host, layer, el, frame, auraData) + if not host or not el then return end + -- Lazy: don't allocate a tint texture for the common (disabled) case; just + -- tear down any existing one. + if not el.dfAD_expiringTintEnabled then + if host.dfAD_tint then + DF.Expiring:Unregister(host.dfAD_tint) + host.dfAD_tint:Hide() + end + return + end + if not host.dfAD_tint then + host.dfAD_tint = host:CreateTexture(nil, layer or "ARTWORK") + host.dfAD_tint:SetAllPoints(host) + host.dfAD_tint:SetBlendMode("ADD") + host.dfAD_tint:Hide() + end + DF.Expiring:UpdateTint(host.dfAD_tint, { + unit = frame and frame.unit, + auraInstanceID = auraData and auraData.auraInstanceID, + threshold = el.dfAD_expiringThreshold or 30, + thresholdMode = el.dfAD_expiringThresholdMode, + duration = auraData and auraData.duration, + expirationTime = auraData and auraData.expirationTime, + enabled = el.dfAD_expiringTintEnabled, + color = el.dfAD_expiringTintColor, + }) +end + +-- Tear down an element's tint (unregister from the engine + hide). +local function ClearExpiringTint(host) + if host and host.dfAD_tint then + DF.Expiring:Unregister(host.dfAD_tint) + host.dfAD_tint:Hide() + end +end + +-- ============================================================ +-- EXPIRING BORDER STATE (shared by indicator types) +-- Generic versions of the icon's per-aura buildAnim / applyState, reading +-- every input from `el.dfAD_*` fields so any indicator that stores the same +-- fields (icon, square, …) can drive its DF.Border through the expiring +-- state-replace model. The element must carry, from its Configure pass: +-- dfADBorder, texture, dfAD_hideIcon +-- dfAD_baseBorderSize/Inset/Color/Style/Gradient/Texture/Shadow/Blend/OffsetX/Y +-- dfAD_baseAnim* (Type/Color/Frequency/Particles/Length/Thickness/Scale/ +-- Inset/OffsetX/OffsetY/Mask/SidesAxis/CornerLength) +-- dfAD_ExpiringBorderSize/Alpha, dfAD_ExpiringAnimation* +-- dfAD_expiringEnabled (colour override on), dfAD_expiringColor +-- All three placed border indicators (icon / square / bar) now drive their +-- DF.Border through these shared helpers — the icon's old inline closures were +-- removed (task #46), so there is a single source of truth for the expiring +-- border state-swap. +-- ============================================================ +local function ADExpiringBorderHasAnim(el) + local t = el.dfAD_ExpiringAnimationType + return t and t ~= "NONE" +end + +-- State-replace: below threshold with an expiring animation, use the FULL +-- expiring tunable set (own colour/particles/etc.); else the base animation. +local function ADBuildExpiringBorderAnim(el, isExp) + if isExp and ADExpiringBorderHasAnim(el) then + return { + type = el.dfAD_ExpiringAnimationType, + color = el.dfAD_ExpiringAnimationColor or el.dfAD_expiringColor, + frequency = el.dfAD_ExpiringAnimationFrequency, + particles = el.dfAD_ExpiringAnimationParticles, + length = el.dfAD_ExpiringAnimationLength, + thickness = el.dfAD_ExpiringAnimationThickness, + scale = el.dfAD_ExpiringAnimationScale, + inset = el.dfAD_ExpiringAnimationInset, + offsetX = el.dfAD_ExpiringAnimationOffsetX, + offsetY = el.dfAD_ExpiringAnimationOffsetY, + mask = el.dfAD_ExpiringAnimationMask, + sidesAxis = el.dfAD_ExpiringAnimationSidesAxis, + cornerLength = el.dfAD_ExpiringAnimationCornerLength, + } + elseif el.dfAD_baseAnimType and el.dfAD_baseAnimType ~= "NONE" then + return { + type = el.dfAD_baseAnimType, + color = el.dfAD_baseAnimColor, + frequency = el.dfAD_baseAnimFrequency, + particles = el.dfAD_baseAnimParticles, + length = el.dfAD_baseAnimLength, + thickness = el.dfAD_baseAnimThickness, + scale = el.dfAD_baseAnimScale, + inset = el.dfAD_baseAnimInset, + offsetX = el.dfAD_baseAnimOffsetX, + offsetY = el.dfAD_baseAnimOffsetY, + mask = el.dfAD_baseAnimMask, + sidesAxis = el.dfAD_baseAnimSidesAxis, + cornerLength = el.dfAD_baseAnimCornerLength, + } + end + return nil +end + +-- Apply the expiring (or base) border state to el.dfADBorder. `color` is the +-- tint for SOLID mode (curve / override colour when expiring, base otherwise). +local function ADApplyExpiringBorderState(el, isExp, color) + if not el.dfADBorder then return end + local applyColor = el.dfAD_expiringEnabled + local thickness + if isExp then + thickness = el.dfAD_ExpiringBorderSize or el.dfAD_baseBorderSize or 1 + else + thickness = el.dfAD_baseBorderSize or 1 + end + local insetVal = el.dfAD_baseBorderInset or 1 + local sizeVal = thickness + -- Inset the artwork/fill by the current thickness so the band frames it and + -- a thicker expiring band stays visible (not covered by the texture). + if el.texture and not el.dfAD_hideIcon then + el.texture:ClearAllPoints() + el.texture:SetPoint("TOPLEFT", thickness, -thickness) + el.texture:SetPoint("BOTTOMRIGHT", -thickness, thickness) + end + -- Colour carries its own alpha (the expiring border colour's alpha below + -- threshold via borderTintFor, base alpha otherwise) — no separate + -- multiplier, so the picker's alpha is the single source of truth. + local pickedColor = color + -- Preserve the base presentation (gradient / texture / shadow / blend); + -- flatten to SOLID only when the colour override is actively tinting below + -- threshold (a single override colour can't be drawn as a two-stop gradient). + local flattenToSolid = applyColor and isExp + local useStyle = flattenToSolid and "SOLID" or (el.dfAD_baseBorderStyle or "SOLID") + local useGradient = (not flattenToSolid) and el.dfAD_baseBorderGradient or nil + local useTexture = (not flattenToSolid) and el.dfAD_baseBorderTexture or nil + -- Respect the base Show Border state — don't let an expiring override + -- re-enable a border the user has turned off (defaults to enabled when the + -- field is unset, so consumers that don't store it behave as before). + DF.Border:Apply(el.dfADBorder, { + enabled = el.dfAD_baseBorderEnabled ~= false, + style = useStyle, + texture = useTexture, + gradient = useGradient, + shadow = el.dfAD_baseBorderShadow, + blendMode = el.dfAD_baseBorderBlend, + size = sizeVal, + inset = -insetVal, + offsetX = el.dfAD_baseBorderOffsetX or 0, + offsetY = el.dfAD_baseBorderOffsetY or 0, + color = pickedColor, + animation = ADBuildExpiringBorderAnim(el, isExp), + }) +end + local function BuildDurationHideCurve(threshold) if not C_CurveUtil or not C_CurveUtil.CreateColorCurve then return nil end DF.durationHideCurves = DF.durationHideCurves or {} @@ -428,91 +540,9 @@ local function ApplyDeferredDurationStyling(indicator) text:Show() end -local expiringFrame = CreateFrame("Frame") -local expiringElapsed = 0 -expiringFrame:Show() -- CRITICAL: OnUpdate only fires on visible frames - -expiringFrame:SetScript("OnUpdate", function(_, elapsed) - expiringElapsed = expiringElapsed + elapsed - if expiringElapsed < 0.33 then return end -- ~3 FPS - expiringElapsed = 0 - - for element, entry in pairs(expiringRegistry) do - if not element:IsShown() then - expiringRegistry[element] = nil - else - local applied = false - - -- API path: evaluate color curve (same as bar's OnUpdate) - if entry.colorCurve and entry.unit and entry.auraInstanceID - and C_UnitAuras and C_UnitAuras.GetAuraDuration then - local durationObj = C_UnitAuras.GetAuraDuration(entry.unit, entry.auraInstanceID) - if durationObj then - local result - if entry.thresholdMode == "SECONDS" and durationObj.EvaluateRemainingDuration then - result = durationObj:EvaluateRemainingDuration(entry.colorCurve) - elseif durationObj.EvaluateRemainingPercent then - result = durationObj:EvaluateRemainingPercent(entry.colorCurve) - end - if result and entry.applyResult then - entry.applyResult(element, result, entry) - applied = true - end - end - end - - -- Preview fallback: manual comparison (same as bar's preview path) - if not applied then - local dur = entry.duration - local exp = entry.expirationTime - if dur and exp and not issecretvalue(dur) and not issecretvalue(exp) and dur > 0 then - local remaining = max(0, exp - GetTime()) - local isExpiring - if entry.thresholdMode == "SECONDS" then - isExpiring = remaining <= (entry.threshold or 10) - else - local pct = remaining / dur - isExpiring = pct <= ((entry.threshold or 30) / 100) - end - if entry.applyManual then - entry.applyManual(element, isExpiring, entry) - end - elseif entry.applyManual then - -- duration=0 means permanent or synthetic (missing) aura — not expiring - entry.applyManual(element, false, entry) - end - end - - -- Show When Missing: toggle visibility based on expiring state. - -- Icons/squares use Hide()/Show() so OOR alpha restore won't undo us. - -- Borders use SetAlpha() since they're not in the OOR icon/square loop. - if entry.hideWhenNotExpiring then - local dur = entry.duration - local exp = entry.expirationTime - local isExp = false - if dur and exp and not issecretvalue(dur) and not issecretvalue(exp) and dur > 0 then - local rem = max(0, exp - GetTime()) - if entry.thresholdMode == "SECONDS" then - isExp = rem <= (entry.threshold or 10) - else - isExp = (rem / dur) <= ((entry.threshold or 30) / 100) - end - end - if entry.useShowHide then - if isExp then - element:Show() - element:SetAlpha(entry.visibleAlpha or 1) - else - element:Hide() - end - else - local notExpAlpha = entry.hiddenAlpha or 0 - element:SetAlpha(isExp and (entry.visibleAlpha or 1) or notExpAlpha) - end - end - end - end -end) +-- (The ~3 FPS expiring ticker now lives on the shared DF.Expiring engine in +-- Frames/Expiring.lua — every registered element across the addon, AD indicators +-- and standard buff borders alike, is driven by that one OnUpdate.) -- ============================================================ -- PER-FRAME STATE @@ -664,7 +694,10 @@ function Indicators:Apply(frame, typeKey, config, auraData, defaults, auraName, if config.borderMode == "custom" and frame.dfAD_customBorders then ch = frame.dfAD_customBorders[auraName] end - if ch then ch:SetAlpha(0) end + if ch then + DF.Border:Apply(ch, { enabled = false }) -- hide edges + stop animation + ch.dfAD_sig = nil + end elseif typeKey == "framealpha" then -- Revert to normal alpha — don't make the frame transparent local state = frame.dfAD @@ -721,9 +754,8 @@ function Indicators:EndFrame(frame) for key, ch in pairs(frame.dfAD_customBorders) do if not state.activeCustomBorders[key] then UnregisterExpiring(ch) - DF.ApplyHighlightStyle(ch, "NONE", 2, 0, 1, 1, 1, 1) - ch.dfAD_style = nil - ch.dfAD_auraID = nil + DF.Border:Apply(ch, { enabled = false }) + ch.dfAD_sig = nil end end end @@ -787,159 +819,290 @@ end -- textures. Does NOT modify the existing frame.border. -- ============================================================ --- Map old border style names to highlight-compatible uppercase keys +-- Map old border style names to the current uppercase keys local BORDER_STYLE_MIGRATION = { Solid = "SOLID", Glow = "GLOW", Pulse = "SOLID" } -local function GetOrCreateADBorder(frame) - if frame.dfAD_border then - -- Update points (frame may have moved) - frame.dfAD_border:ClearAllPoints() - frame.dfAD_border:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - frame.dfAD_border:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - return frame.dfAD_border - end - - -- Create overlay frame parented to UIParent (avoids clipping) - -- Uses same structure as the highlight system so we can reuse - -- DF.ApplyHighlightStyle for all 6 border modes. - local ch = CreateFrame("Frame", nil, UIParent) - ch:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - ch:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - ch:SetFrameStrata(frame:GetFrameStrata()) - ch:SetFrameLevel(frame:GetFrameLevel() + 8) -- Below aggro(+9) highlight - ch:Hide() - - -- 4 edge textures (named to match highlight system) - ch.topLine = ch:CreateTexture(nil, "OVERLAY") - ch.bottomLine = ch:CreateTexture(nil, "OVERLAY") - ch.leftLine = ch:CreateTexture(nil, "OVERLAY") - ch.rightLine = ch:CreateTexture(nil, "OVERLAY") - - -- Hook owner OnHide to hide border - frame:HookScript("OnHide", function() - if frame.dfAD_border then - frame.dfAD_border:Hide() - end - end) +-- Stage 5.4: the border-type indicator now renders through DF.Border (a 4-edge +-- widget covering the whole unit frame) instead of the highlight overlay. The +-- legacy `style` enum maps onto a DF.Border style + animation: +-- SOLID → solid edges +-- GLOW → TEXTURE style + the bundled "DF Glow" edgeFile +-- DASHED → base hidden + DF_DASH @ freq 0 (static dashes) +-- ANIMATED → base hidden + DF_DASH @ freq 1 (marching ants) +-- CORNERS → base hidden + CORNERS_ONLY +-- (Inset is positive-INWARD here, matching the highlight system's convention.) +local function BuildBorderTypeSpec(config) + local thickness = config.thickness or 2 + local inset = config.inset or 0 + local color = config.color or { r = 0, g = 0, b = 0, a = 1 } + local style = BORDER_STYLE_MIGRATION[config.style] or config.style or "SOLID" + local spec = { + enabled = true, style = "SOLID", + size = thickness, inset = inset, color = color, + } + if style == "GLOW" then + spec.style = "TEXTURE" + spec.texture = "DF Glow" + elseif style == "DASHED" or style == "ANIMATED" then + spec.size = 0 -- hide the solid base; the dashes ARE the border + spec.animation = { type = "DF_DASH", + frequency = (style == "ANIMATED") and 1 or 0, + thickness = thickness, inset = inset, color = color } + elseif style == "CORNERS" then + spec.size = 0 + spec.animation = { type = "CORNERS_ONLY", thickness = thickness, color = color } + end + return spec +end + +-- Whole-frame DF.Border widget. SetAllPoints tracks the frame automatically, +-- and being a child of the frame it hides when the frame does. +local function NewADBorderWidget(frame, levelOffset) + local w = DF.Border:New(frame, { frameLevelOffset = levelOffset, layer = "OVERLAY" }) + -- Remember the creation offset so ApplyBorderToOverlay can re-derive the + -- frame level from it when the "Draw above frame border" toggle changes. + w.dfAD_baseLevelOffset = levelOffset + return w +end - frame.dfAD_border = ch - return ch +local function GetOrCreateADBorder(frame) + if frame.dfAD_border then return frame.dfAD_border end + frame.dfAD_border = NewADBorderWidget(frame, 8) -- below aggro(+9) + return frame.dfAD_border end local function GetOrCreateCustomBorder(frame, key) - if not frame.dfAD_customBorders then - frame.dfAD_customBorders = {} - end + if not frame.dfAD_customBorders then frame.dfAD_customBorders = {} end local pool = frame.dfAD_customBorders - if pool[key] then - pool[key]:ClearAllPoints() - pool[key]:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - pool[key]:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - return pool[key] - end - - local ch = CreateFrame("Frame", nil, UIParent) - ch:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) - ch:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) - ch:SetFrameStrata(frame:GetFrameStrata()) - ch:SetFrameLevel(frame:GetFrameLevel() + 7) -- Below shared border(+8) - ch:Hide() + if pool[key] then return pool[key] end + pool[key] = NewADBorderWidget(frame, 7) -- below shared border(+8) + return pool[key] +end - ch.topLine = ch:CreateTexture(nil, "OVERLAY") - ch.bottomLine = ch:CreateTexture(nil, "OVERLAY") - ch.leftLine = ch:CreateTexture(nil, "OVERLAY") - ch.rightLine = ch:CreateTexture(nil, "OVERLAY") +-- Comprehensive change-detection signature for the border-type spec. MUST +-- cover every render-affecting field, or a GUI edit to an uncovered field +-- won't reach live frames until /reload (the preview rebuilds every refresh so +-- it always reflects edits, masking the gap). +local function colorSig(c) + if not c then return "_" end + return tostring(c.r or c[1]) .. "," .. tostring(c.g or c[2]) .. "," + .. tostring(c.b or c[3]) .. "," .. tostring(c.a or c[4]) +end +local function BorderTypeSpecSig(spec, auraID, config) + local an, gr, sh = spec.animation, spec.gradient, spec.shadow + return table.concat({ + tostring(spec.style), tostring(spec.size), tostring(spec.inset), + tostring(spec.offsetX), tostring(spec.offsetY), tostring(spec.texture), + tostring(spec.blendMode), colorSig(spec.color), + gr and ("G" .. colorSig(gr.startColor) .. colorSig(gr.endColor) .. tostring(gr.direction)) or "_", + sh and ("S" .. tostring(sh.enabled) .. colorSig(sh.color) .. tostring(sh.size) + .. tostring(sh.offsetX) .. tostring(sh.offsetY)) or "_", + an and ("A" .. tostring(an.type) .. tostring(an.frequency) .. tostring(an.particles) + .. tostring(an.length) .. tostring(an.thickness) .. tostring(an.scale) + .. tostring(an.inset) .. tostring(an.offsetX) .. tostring(an.offsetY) + .. tostring(an.mask) .. tostring(an.sidesAxis) .. tostring(an.cornerLength) + .. colorSig(an.color)) or "_", + tostring(auraID), + tostring(config.drawAboveFrameBorder), + tostring(config.expiringFeatureEnabled), + tostring(config.expiringEnabled), tostring(config.expiringPulsate), + tostring(config.expiringThreshold), tostring(config.expiringThresholdMode), + colorSig(config.expiringColor), tostring(config.expiringAlpha), + -- Expiring-border overrides — included so editing them rebuilds the + -- base + expiring spec pair. + tostring(config.ExpiringBorderSize), tostring(config.ExpiringBorderAlpha), + tostring(config.ExpiringAnimationType), tostring(config.ExpiringAnimationFrequency), + tostring(config.ExpiringAnimationThickness), + colorSig(config.ExpiringAnimationColor), + tostring(config.ExpiringAnimationParticles), tostring(config.ExpiringAnimationLength), + tostring(config.ExpiringAnimationScale), tostring(config.ExpiringAnimationInset), + tostring(config.ExpiringAnimationOffsetX), tostring(config.ExpiringAnimationOffsetY), + tostring(config.ExpiringAnimationMask), tostring(config.ExpiringAnimationSidesAxis), + tostring(config.ExpiringAnimationCornerLength), + }, "|") +end - frame:HookScript("OnHide", function() - if pool[key] then - pool[key]:Hide() +-- Build the EXPIRING-state spec for the border-type: clone the base spec, then +-- apply the expiring overrides (thickness / alpha / colour / animation swap). +-- `applyColor` true recolours to the expiring colour (with its own alpha); +-- else keep the base colour and alpha. The ticker swaps base ↔ expiring on the +-- threshold crossing; RecolorActive applies the colour between. +local function buildBorderExpiringSpec(baseSpec, config, ec, applyColor) + local s = {} + for k, v in pairs(baseSpec) do s[k] = v end + -- Alpha rides with the colour (no separate multiplier): the expiring colour + -- carries its own alpha; the base colour keeps the base alpha. + local base = baseSpec.color or { r = 1, g = 1, b = 1, a = 1 } + if applyColor then + s.color = { r = ec.r or 1, g = ec.g or 0.2, b = ec.b or 0.2, a = ec.a or ec[4] or 1 } + else + s.color = { r = base.r or base[1] or 1, g = base.g or base[2] or 1, + b = base.b or base[3] or 1, a = (base.a or base[4]) or 1 } + end + local expThick = config.ExpiringBorderSize + local expAnim = config.ExpiringAnimationType + if expAnim and expAnim ~= "NONE" then + s.animation = { + type = expAnim, + color = config.ExpiringAnimationColor or s.color, + frequency = config.ExpiringAnimationFrequency, + thickness = expThick or config.ExpiringAnimationThickness, + particles = config.ExpiringAnimationParticles, + length = config.ExpiringAnimationLength, + scale = config.ExpiringAnimationScale, + inset = config.ExpiringAnimationInset, + offsetX = config.ExpiringAnimationOffsetX, + offsetY = config.ExpiringAnimationOffsetY, + mask = config.ExpiringAnimationMask, + sidesAxis = config.ExpiringAnimationSidesAxis, + cornerLength = config.ExpiringAnimationCornerLength, + } + if expAnim == "DF_DASH" or expAnim == "CORNERS_ONLY" or expAnim == "SIDES_ONLY" then + s.size = 0 -- these effects ARE the border; hide the base edges end - end) - - pool[key] = ch - return ch + elseif expThick then + if s.size and s.size > 0 then + s.size = expThick + elseif s.animation then + local a = {}; for k, v in pairs(s.animation) do a[k] = v end + a.thickness = expThick; s.animation = a + end + end + return s end -- Shared logic for applying border style, change detection, and expiring -- registration to a border overlay frame. Used by both shared and custom borders. local function ApplyBorderToOverlay(ch, frame, config, auraData) - local color = config.color - if not color then return end - - local r, g, b = color[1] or color.r or 1, color[2] or color.g or 1, color[3] or color.b or 1 - local alpha = color[4] or color.a or 1 - local thickness = config.thickness or 2 - local inset = config.inset or 0 - - local style = BORDER_STYLE_MIGRATION[config.style] or config.style or "SOLID" - - local auraID = auraData and auraData.auraInstanceID - local expiringPulsate = config.expiringPulsate or false - if ch:IsShown() - and ch.dfAD_style == style - and ch.dfAD_r == r and ch.dfAD_g == g and ch.dfAD_b == b and ch.dfAD_a == alpha - and ch.dfAD_thickness == thickness and ch.dfAD_inset == inset - and ch.dfAD_auraID == auraID - and ch.dfAD_expiringPulsate == expiringPulsate then + -- Legacy configs (still carrying the old `style` enum, pre-migration or a + -- fresh import) map via BuildBorderTypeSpec; migrated configs build the + -- canonical spec directly. Both produce the same DF.Border spec shape. + local spec = config.style and BuildBorderTypeSpec(config) or DF.Border:BuildSpec(config, "") + if not spec.color then spec.color = { r = 0, g = 0, b = 0, a = 1 } end + if spec.enabled == nil then spec.enabled = true end + if spec.enabled == false then + DF.Border:Apply(ch, { enabled = false }) + UnregisterExpiring(ch) + ch.dfAD_sig = "off" return end - DF.ApplyHighlightStyle(ch, style, thickness, inset, r, g, b, alpha) - - ch.dfAD_style = style - ch.dfAD_r, ch.dfAD_g, ch.dfAD_b, ch.dfAD_a = r, g, b, alpha - ch.dfAD_thickness = thickness - ch.dfAD_inset = inset - ch.dfAD_auraID = auraID - + local bc = spec.color + local r = bc.r or bc[1] or 1 + local g = bc.g or bc[2] or 1 + local b = bc.b or bc[3] or 1 + local alpha = bc.a or bc[4] or 1 + local auraID = auraData and auraData.auraInstanceID + local expiringPulsate = config.expiringPulsate or false local expiringEnabled = config.expiringEnabled if expiringEnabled == nil then expiringEnabled = false end + local ec = config.expiringColor + + -- Change detection — ApplyBorder runs every Apply cycle (frame-level + -- indicator), so skip the rebuild + expiring re-register when nothing the + -- border cares about changed. Comprehensive (covers every render-affecting + -- spec field) so GUI edits reach live frames without /reload. The expiring + -- ticker recolours live via RecolorActive between rebuilds. + local sig = BorderTypeSpecSig(spec, auraID, config) + if ch.dfAD_sig == sig then return end + ch.dfAD_sig = sig + + DF.Border:Apply(ch, spec) + + -- Draw order: by default lift the border-type above the frame's class border + -- (parent+10) and aggro (parent+9) so it fully covers them instead of being + -- rendered underneath. +4 preserves the shared(+8)/custom(+7) relative order + -- (→ 12 / 11). Toggling it off restores the creation offset so it tucks back + -- under the class border. + local baseOff = ch.dfAD_baseLevelOffset or 8 + local lvlOff = (config.drawAboveFrameBorder ~= false) and (baseOff + 4) or baseOff + ch:SetFrameLevel(frame:GetFrameLevel() + lvlOff) -- Lazy-create pulse animation group (reused across aura changes) if expiringPulsate then GetOrCreatePulseAnim(ch) end ch.dfAD_expiringPulsate = expiringPulsate - if expiringEnabled then - local ec = config.expiringColor or {r = 1, g = 0.2, b = 0.2} + -- Expiring features (Stage 5.4 parity): master gate + colour override / + -- pulsate / thickness / alpha / animation swap. When thickness/alpha/ + -- animation differ from base, we build an EXPIRING spec the ticker swaps in + -- on the threshold crossing; the colour override (if on) smooths the colour + -- via RecolorActive between crossings (no per-tick tear-down). + local masterEnabled = config.expiringFeatureEnabled ~= false + local applyColor = expiringEnabled + local expAnimType = config.ExpiringAnimationType + local hasExpAnim = expAnimType and expAnimType ~= "NONE" + local baseThick = (spec.size and spec.size > 0) and spec.size + or (spec.animation and spec.animation.thickness) or 0 + local hasExpThick = config.ExpiringBorderSize and config.ExpiringBorderSize ~= baseThick + -- Alpha is no longer a separate override: it rides with the expiring colour + -- (expiringColor.a), applied by RecolorActive when the colour override is on + -- — matching the base Border Alpha = BorderColor.a model. + local anyExp = masterEnabled and (applyColor or expiringPulsate + or hasExpAnim or hasExpThick) + + if anyExp then + local ecc = ec or {r = 1, g = 0.2, b = 0.2} local oc = {r = r, g = g, b = b} + ch.dfAD_baseSpec = spec + ch.dfAD_expSpec = (hasExpAnim or hasExpThick) + and buildBorderExpiringSpec(spec, config, ecc, applyColor) or nil + ch.dfAD_lastExp = nil -- force the ticker to (re)apply the right spec + + -- Shared state transition: swap the base/expiring spec on the threshold + -- crossing, then SNAP the colour to the full expiring colour (the curve + -- is a STEP curve so there's no washed-out fade). Reached from both + -- applyResult (live, secret-safe colour-curve path) and applyManual + -- (preview / non-colour fallback). + local function applyBorderExpState(el, isExp, entry) + if el.dfAD_expSpec and isExp ~= el.dfAD_lastExp then + el.dfAD_lastExp = isExp + DF.Border:Apply(el, isExp and el.dfAD_expSpec or el.dfAD_baseSpec) + end + -- Recolour only when the colour override is on; otherwise the spec + -- swap already carries the right colour. + if entry.applyColor then + if isExp then + local c = entry.color + DF.Border:RecolorActive(el, c.r or 1, c.g or 0.2, c.b or 0.2, entry.expiringAlpha) + else + local c = entry.originalColor + DF.Border:RecolorActive(el, c.r, c.g, c.b, entry.originalAlpha) + end + end + UpdatePulseState(el, isExp) + end + RegisterExpiring(ch, { unit = frame.unit, auraInstanceID = auraID, threshold = config.expiringThreshold or 30, duration = auraData and auraData.duration, expirationTime = auraData and auraData.expirationTime, - colorCurve = BuildExpiringColorCurve(config.expiringThreshold or 30, ec, oc, config.expiringThresholdMode), + -- Colour curve drives the secret-safe live detection path: live aura + -- durations are tainted, so the manual fallback can't read them and + -- the curve's EvaluateRemainingPercent is the only way to detect the + -- threshold crossing on real frames. It's a STEP curve, so the + -- colour SNAPS to the full expiring colour (no washed-out blend). + colorCurve = applyColor and BuildExpiringColorCurve(config.expiringThreshold or 30, ecc, oc, config.expiringThresholdMode) or nil, thresholdMode = config.expiringThresholdMode, - color = ec, originalColor = oc, - originalAlpha = alpha, expiringAlpha = config.expiringAlpha or 1.0, style = style, thickness = thickness, inset = inset, - -- Border expiring callbacks: only the color and alpha change - -- as the aura approaches expiration. The style/thickness/inset - -- were fixed when ApplyHighlightStyle was originally called in - -- the parent function, so every tick here is a pure recolor. - -- Use UpdateHighlightStyleColor to skip the full tear-down - -- (especially important for ANIMATED where tearing down hides - -- 80 dashes, removes from the animator, and re-initializes - -- everything 3 times per second). + color = ecc, originalColor = oc, + originalAlpha = alpha, + -- Expiring alpha = the expiring colour's own alpha (in sync with its + -- picker), not a separate slider. + expiringAlpha = ecc.a or ecc[4] or 1, + applyColor = applyColor, applyResult = function(el, result, entry) - local oc2 = entry.originalColor - local isExp = IsColorExpiring(result, oc2) - local a = isExp and entry.expiringAlpha or entry.originalAlpha - DF.UpdateHighlightStyleColor(el, entry.style, result.r, result.g, result.b, a) - UpdatePulseState(el, isExp) + -- Fires only when colorCurve is set (colour override on). The + -- stepped result is either the base or full expiring colour. + applyBorderExpState(el, IsColorExpiring(result, entry.originalColor), entry) end, applyManual = function(el, isExp, entry) - if isExp then - local c = entry.color - DF.UpdateHighlightStyleColor(el, entry.style, c.r or 1, c.g or 0.2, c.b or 0.2, entry.expiringAlpha) - else - local c = entry.originalColor - DF.UpdateHighlightStyleColor(el, entry.style, c.r, c.g, c.b, entry.originalAlpha) - end - UpdatePulseState(el, isExp) + applyBorderExpState(el, isExp, entry) end, }) else UnregisterExpiring(ch) + ch.dfAD_baseSpec, ch.dfAD_expSpec, ch.dfAD_lastExp = nil, nil, nil -- Stop pulsation when expiring is disabled if ch.dfAD_pulse and ch.dfAD_pulse:IsPlaying() then ch.dfAD_pulse:Stop() @@ -969,11 +1132,10 @@ end function Indicators:RevertBorder(frame) if frame and frame.dfAD_border then UnregisterExpiring(frame.dfAD_border) - -- Use NONE mode to properly clean up all styles (animated, glow, corners, etc.) - DF.ApplyHighlightStyle(frame.dfAD_border, "NONE", 2, 0, 1, 1, 1, 1) - -- Clear cached state so next ApplyBorder won't skip via change detection - frame.dfAD_border.dfAD_style = nil - frame.dfAD_border.dfAD_auraID = nil + -- enabled=false hides the edges AND stops any animation (dashes/corners). + DF.Border:Apply(frame.dfAD_border, { enabled = false }) + -- Clear the change-detection signature so the next ApplyBorder rebuilds. + frame.dfAD_border.dfAD_sig = nil end end @@ -981,9 +1143,8 @@ function Indicators:RevertCustomBorders(frame) if frame and frame.dfAD_customBorders then for _, ch in pairs(frame.dfAD_customBorders) do UnregisterExpiring(ch) - DF.ApplyHighlightStyle(ch, "NONE", 2, 0, 1, 1, 1, 1) - ch.dfAD_style = nil - ch.dfAD_auraID = nil + DF.Border:Apply(ch, { enabled = false }) + ch.dfAD_sig = nil end end end @@ -1067,6 +1228,22 @@ function Indicators:ApplyHealthBar(frame, config, auraData) local overlay = GetOrCreateTintOverlay(frame) if overlay then + -- Keep the AD overlay off the frame border. With framePadding 0 the health + -- bar fills the whole frame and the border is drawn inward over its edge, so + -- a full-bar overlay sits *under* the border. Out of range the border fades + -- to its OOR alpha and the AD colour beneath shows through it, tinting the + -- border (e.g. green over a class-coloured border). Inset the overlay by the + -- border thickness so it never reaches under the border. + local _fdb = DF:GetFrameDB(frame) + local _bInset = (frame.border and frame.border:IsShown() and _fdb and _fdb.frameBorderSize) or 0 + overlay:ClearAllPoints() + if _bInset > 0 then + overlay:SetPoint("TOPLEFT", healthBar, "TOPLEFT", _bInset, -_bInset) + overlay:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", -_bInset, _bInset) + else + overlay:SetAllPoints(healthBar) + end + -- Re-sync texture in case the health bar's texture changed since the overlay -- was first created (frame recycled to a different unit can swap textures). local currentTex = healthBar:GetStatusBarTexture() @@ -1093,8 +1270,15 @@ function Indicators:ApplyHealthBar(frame, config, auraData) -- here so SetValue's reset is a no-op; the frame alpha channel it -- doesn't touch carries the real opacity, and UpdateHealthBarAppearance -- re-asserts it on every health event (see ElementAppearance.lua). + -- + -- Use the OOR-aware effective blend (the alpha UpdateAuraDesignerAppearance + -- last wrote for the current range state), falling back to the configured + -- alpha on first apply / in range. Without this, every re-apply (UNIT_AURA) + -- snapped the underlying bar back to full opacity while the OOR path held + -- it faded — on a phased/out-of-range unit, whose auras the client re-syncs + -- constantly, the two fought and the bar flickered (element-specific OOR mode). hbTex:SetVertexColor(r, g, b) - hbTex:SetAlpha(a) + hbTex:SetAlpha(state.healthbarEffectiveBlend or a) end else -- Tint mode: the underlying bar must show its normal colour through the overlay. @@ -1516,6 +1700,20 @@ local function GetIconMap(frame) return frame.dfAD_icons end +-- Lazy-create the unified DF.Border widget that replaces the icon factory's +-- default 1px backdrop. The factory's `icon.border` (a single BACKGROUND +-- ColorTexture) is hidden once and stays hidden — AD icons render their +-- border exclusively through dfADBorder so the new feature set (style, +-- gradient, shadow, blendMode, offset) is available on every aura icon. +-- Non-AD callers of CreateAuraIcon are untouched because we don't modify +-- the factory itself. +local function GetOrCreateADIconBorder(icon) + if icon.dfADBorder then return icon.dfADBorder end + icon.dfADBorder = DF.Border:New(icon, { frameLevelOffset = 0, layer = "BACKGROUND" }) + if icon.border then icon.border:Hide() end + return icon.dfADBorder +end + local function GetOrCreateADIcon(frame, auraName) local map = GetIconMap(frame) if map[auraName] then return map[auraName] end @@ -1580,26 +1778,64 @@ function Indicators:ConfigureIcon(frame, config, defaults, auraName, priority) icon.dfAD_hideIcon = hideIcon -- ======================================== - -- BORDER (the black background behind the icon texture) + -- BORDER (unified DF.Border — replaces the icon factory's 1px backdrop) + -- + -- Stage 5.1c: spec comes from DF.Border:BuildSpec(config, "") so the + -- canonical Style / Texture / Color / Gradient / Shadow / BlendMode / + -- Offset keys flow through automatically. The empty prefix is correct: + -- AD's icon proxy is already type-scoped, so BuildSpec's key("BorderX") + -- builder ("" .. "BorderX" = "BorderX") lands directly on config. + -- + -- AD-specific overrides on top of BuildSpec: + -- * BorderInset semantics — AD's slider means "extend perimeter OUTWARD + -- by N pixels". DF.Border's inset is positive-INWARD. So we invert + -- to spec.inset = -BorderInset. + -- * Visible band combines AD's BorderSize (inner thickness, behind the + -- icon's inset) and BorderInset (outer extension) → spec.size = + -- BorderSize + BorderInset. This preserves the pre-5.1a visual + -- where the "border" straddled the icon's edge. + -- * spec.enabled is gated by both ShowBorder AND hideIcon — hideIcon + -- is a text-only mode where the icon TEXTURE is hidden and showing a + -- border around nothing looks broken. + -- + -- Legacy fallback (borderEnabled / borderThickness / borderInset) covers + -- in-memory state that hasn't been migrated yet — e.g. a fresh import + -- where ApplyImportedProfile doesn't trigger ADDON_LOADED. -- ======================================== - local borderEnabled = config.borderEnabled + local borderEnabled = config.ShowBorder + if borderEnabled == nil then borderEnabled = config.borderEnabled end if borderEnabled == nil then borderEnabled = true end - local borderThickness = config.borderThickness or 1 - local borderInset = config.borderInset or 1 - - if icon.border then - if borderEnabled and not hideIcon then - icon.border:ClearAllPoints() - PixelUtil.SetPoint(icon.border, "TOPLEFT", icon, "TOPLEFT", -borderInset, borderInset) - PixelUtil.SetPoint(icon.border, "BOTTOMRIGHT", icon, "BOTTOMRIGHT", borderInset, -borderInset) - icon.border:SetColorTexture(0, 0, 0, 0.8) - icon.border:Show() - else - icon.border:Hide() - end - end - - -- Adjust texture inset to sit inside border + local borderThickness = config.BorderSize or config.borderThickness or 1 + local borderInset = config.BorderInset or config.borderInset or 1 + + local adBorder = GetOrCreateADIconBorder(icon) + -- Border geometry: BorderSize is the band THICKNESS on its own — Inset no + -- longer adds to it (the old `size = thickness + inset` coupling made the + -- band visibly thicker as you raised Inset). Inset just repositions the + -- constant-width band: spec.inset = -BorderInset moves it outward (AD's + -- "extend outward by N" convention). At Inset 0 the band sits flush + -- against the icon edge; positive Inset opens a gap; negative pulls it in. + -- The cached values feed the spec below; the expiring path recomputes the + -- same way so the two stay consistent. + adBorder.dfADIconSize = borderThickness + adBorder.dfADIconInset = -borderInset + + local spec = DF.Border:BuildSpec(config, "") + spec.enabled = borderEnabled and not hideIcon + spec.size = adBorder.dfADIconSize + spec.inset = adBorder.dfADIconInset + -- BuildSpec doesn't seed a default colour when the static-colour key + -- (BorderColor) is missing — for migrated configs without an explicit + -- BorderColor it returns nil colour, which Apply then reads as 0/0/0/1. + -- Fall back to the pre-5.1a hardcoded translucent black so legacy + -- profiles render identically until the user picks a colour. + if not spec.color then + spec.color = { r = 0, g = 0, b = 0, a = 0.8 } + end + DF.Border:Apply(adBorder, spec) + + -- Inset the artwork by the border thickness so the icon stays its original + -- size and the band frames it instead of sitting on top of the art. if icon.texture and not hideIcon then icon.texture:ClearAllPoints() local texInset = borderEnabled and borderThickness or 0 @@ -1722,8 +1958,12 @@ function Indicators:ConfigureIcon(frame, config, defaults, auraName, priority) if expiringEnabled == nil then expiringEnabled = false end local expiringPulsate = config.expiringPulsate or false - -- Lazy-create a wrapper frame for the border texture so we can animate its alpha - if expiringPulsate and icon.border then + -- Lazy-create a wrapper frame so we can animate the border's alpha. The + -- DF.Border widget is a frame with 4 edge textures — reparenting the whole + -- widget under the pulse wrapper lets the wrapper's alpha animation + -- propagate to every edge at once (child frames inherit parent alpha). + -- Stage 5.1a: was `icon.border` (single texture); now `icon.dfADBorder`. + if expiringPulsate and icon.dfADBorder then if not icon.adBorderPulseFrame then icon.adBorderPulseFrame = CreateFrame("Frame", nil, icon) icon.adBorderPulseFrame:SetAllPoints(icon) @@ -1731,7 +1971,7 @@ function Indicators:ConfigureIcon(frame, config, defaults, auraName, priority) icon.adBorderPulseFrame:EnableMouse(false) end if not icon.adBorderReparented then - icon.border:SetParent(icon.adBorderPulseFrame) + icon.dfADBorder:SetParent(icon.adBorderPulseFrame) icon.adBorderReparented = true end GetOrCreatePulseAnim(icon.adBorderPulseFrame) @@ -1762,11 +2002,86 @@ function Indicators:ConfigureIcon(frame, config, defaults, auraName, priority) end -- Store expiring config flags for UpdateIcon to read + icon.dfAD_expiringFeatureEnabled = config.expiringFeatureEnabled icon.dfAD_expiringEnabled = expiringEnabled icon.dfAD_expiringColor = config.expiringColor or {r = 1, g = 0.2, b = 0.2} icon.dfAD_expiringThreshold = config.expiringThreshold or 30 icon.dfAD_expiringThresholdMode = config.expiringThresholdMode + icon.dfAD_expiringTintEnabled = config.expiringTintEnabled + icon.dfAD_expiringTintColor = config.expiringTintColor icon.dfAD_expiringPulsate = expiringPulsate + -- Stage 5.1d.2: Border-Animation effect to swap into spec.animation when + -- the aura crosses the threshold. NONE means no animation override; + -- the base Border Animation (if any) runs continuously. Other values + -- mirror Border Animation's effect set. Frequency is per-state so + -- "slow continuous pulse / fast expiring flash" works. + -- Full per-state animation tunables (parity with base Border Animation). + -- buildAnim reads these when below threshold so the expiring animation has + -- its own colour / particles / thickness / offset / etc. independent of + -- the base animation. + icon.dfAD_ExpiringAnimationType = config.ExpiringAnimationType or "NONE" + icon.dfAD_ExpiringAnimationColor = config.ExpiringAnimationColor + icon.dfAD_ExpiringAnimationFrequency = config.ExpiringAnimationFrequency or 1 + icon.dfAD_ExpiringAnimationParticles = config.ExpiringAnimationParticles + icon.dfAD_ExpiringAnimationLength = config.ExpiringAnimationLength + icon.dfAD_ExpiringAnimationThickness = config.ExpiringAnimationThickness + icon.dfAD_ExpiringAnimationScale = config.ExpiringAnimationScale + icon.dfAD_ExpiringAnimationInset = config.ExpiringAnimationInset + icon.dfAD_ExpiringAnimationOffsetX = config.ExpiringAnimationOffsetX + icon.dfAD_ExpiringAnimationOffsetY = config.ExpiringAnimationOffsetY + icon.dfAD_ExpiringAnimationMask = config.ExpiringAnimationMask + icon.dfAD_ExpiringAnimationSidesAxis = config.ExpiringAnimationSidesAxis + icon.dfAD_ExpiringAnimationCornerLength = config.ExpiringAnimationCornerLength + + -- Stage 5.1d.3: per-state thickness / alpha overrides. Stored alongside + -- the base BorderSize / BorderInset so the expiring callback can + -- recompute the combined size + inset using the AD-specific translation + -- (spec.size = thickness + inset, spec.inset = -inset). + -- Also store the base BorderColor so applyState can fall back to it when + -- thickness/alpha overrides are configured without a colour override. + icon.dfAD_baseBorderSize = borderThickness + icon.dfAD_baseBorderInset = borderInset + icon.dfAD_baseBorderColor = spec.color + -- Capture the base PRESENTATION (style + gradient / texture / shadow / + -- blend) so the expiring callback can preserve it. Without this, applyState + -- hand-built a SOLID spec and dropped the gradient / texture entirely — so + -- any icon with an expiring feature lost its gradient border. The expiring + -- callback flattens to SOLID only when the Expiring Colour Override is the + -- thing actively tinting the border (a single override colour can't be + -- expressed as a two-stop gradient); otherwise it keeps the base style. + icon.dfAD_baseBorderStyle = spec.style + icon.dfAD_baseBorderGradient = spec.gradient + icon.dfAD_baseBorderTexture = spec.texture + icon.dfAD_baseBorderShadow = spec.shadow + icon.dfAD_baseBorderBlend = spec.blendMode + -- Capture the static Border Offset X/Y from the base spec (BuildSpec + -- reads BorderOffsetX/Y). applyState builds its Apply spec by hand and + -- must re-supply these — otherwise the expiring callback's Apply defaults + -- offset to 0,0 and snaps the border off the user's configured position. + icon.dfAD_baseBorderOffsetX = spec.offsetX + icon.dfAD_baseBorderOffsetY = spec.offsetY + icon.dfAD_ExpiringBorderSize = config.ExpiringBorderSize or borderThickness + -- Expiring alpha = the expiring colour's own alpha (in sync with the + -- Expiring Color picker's alpha bar) — matches base Border Alpha = colour.a. + -- The render forces the tint colour to a=1 then multiplies by this, so it + -- ends up as exactly the colour's alpha. + icon.dfAD_ExpiringBorderAlpha = (config.expiringColor and (config.expiringColor.a or config.expiringColor[4])) or 1 + -- Capture the base animation spec from the config so the expiring + -- callback can restore it when the aura returns above threshold. + -- Mirrors what BuildSpec puts on spec.animation. + icon.dfAD_baseAnimType = config.BorderAnimationType or "NONE" + icon.dfAD_baseAnimColor = config.BorderAnimationColor + icon.dfAD_baseAnimFrequency = config.BorderAnimationFrequency + icon.dfAD_baseAnimParticles = config.BorderAnimationParticles + icon.dfAD_baseAnimLength = config.BorderAnimationLength + icon.dfAD_baseAnimThickness = config.BorderAnimationThickness + icon.dfAD_baseAnimScale = config.BorderAnimationScale + icon.dfAD_baseAnimInset = config.BorderAnimationInset + icon.dfAD_baseAnimOffsetX = config.BorderAnimationOffsetX + icon.dfAD_baseAnimOffsetY = config.BorderAnimationOffsetY + icon.dfAD_baseAnimMask = config.BorderAnimationMask + icon.dfAD_baseAnimSidesAxis = config.BorderAnimationSidesAxis + icon.dfAD_baseAnimCornerLength = config.BorderAnimationCornerLength -- Missing-mode config icon.dfAD_missingDesaturate = config.missingDesaturate @@ -1815,12 +2130,24 @@ function Indicators:UpdateIcon(frame, config, auraData, defaults, auraName, prio local anchor = config.anchor or "TOPLEFT" local offsetX = config.offsetX or 0 local offsetY = config.offsetY or 0 - -- Compensate for border overhang at frame edges - local borderEnabledForPos = config.borderEnabled - if borderEnabledForPos == nil then borderEnabledForPos = true end - offsetX, offsetY = AdjustOffsetForBorder(anchor, offsetX, offsetY, config.borderInset or 1, borderEnabledForPos) - icon:ClearAllPoints() - icon:SetPoint(anchor, frame, anchor, offsetX, offsetY) + -- Position is the user's offset only. We deliberately do NOT shift the + -- icon by the border inset any more — the band is a constant-width ring + -- whose Inset slider expands it outward, and tying the icon's position to + -- Inset made the whole icon slide every time the slider moved. The icon + -- stays put; only the ring around it grows out / in. + -- Only re-anchor when the position actually changed (or the frame lost its + -- points on recycle). The AD preview re-runs UpdateIcon every frame; re- + -- SetPointing each frame fights an active Bounce Translation and drifts the + -- child-frame overlays (tint / anim glow) up the screen. Live frames rarely + -- re-run this, which is why the bug was preview-only. + if icon:GetNumPoints() == 0 or icon.dfAD_posAnchor ~= anchor + or icon.dfAD_posX ~= offsetX or icon.dfAD_posY ~= offsetY then + icon.dfAD_posAnchor, icon.dfAD_posX, icon.dfAD_posY = anchor, offsetX, offsetY + local b = icon.dfAD_basePos or {}; icon.dfAD_basePos = b + b.point, b.rel, b.relPoint, b.x, b.y = anchor, frame, anchor, offsetX, offsetY + icon:ClearAllPoints() + icon:SetPoint(anchor, frame, anchor, offsetX, offsetY) + end -- Read stored config flags from ConfigureIcon local hideIcon = icon.dfAD_hideIcon @@ -2054,13 +2381,36 @@ function Indicators:UpdateIcon(frame, config, auraData, defaults, auraName, prio local expiringPulsate = icon.dfAD_expiringPulsate local expiringWholeAlphaPulse = icon.dfAD_expiringWholeAlphaPulse local expiringBounce = icon.dfAD_expiringBounce - - -- Register if ANY expiring feature is active (color, pulsate, alpha pulse, bounce) - local anyExpiringFeature = expiringEnabled or expiringPulsate or expiringWholeAlphaPulse or expiringBounce + local expiringAnimType = icon.dfAD_ExpiringAnimationType + + -- Register if ANY expiring feature is active (color, pulsate, alpha pulse, + -- bounce, animation override, OR a Stage 5.1d.3 thickness/alpha override + -- that differs from the base — otherwise those sliders would silently + -- do nothing when set alone). + local hasExpiringAnim = expiringAnimType and expiringAnimType ~= "NONE" + local hasExpiringThickness = icon.dfAD_ExpiringBorderSize + and icon.dfAD_ExpiringBorderSize ~= icon.dfAD_baseBorderSize + local hasExpiringAlpha = icon.dfAD_ExpiringBorderAlpha + and icon.dfAD_ExpiringBorderAlpha ~= 1 + -- Master enable gates the WHOLE feature: when off, no expiring override + -- registers regardless of the individual settings. nil (legacy / unset) + -- counts as enabled so existing configs are unaffected. + local masterEnabled = icon.dfAD_expiringFeatureEnabled ~= false + local anyExpiringFeature = masterEnabled and (expiringEnabled or expiringPulsate + or expiringWholeAlphaPulse or expiringBounce + or hasExpiringAnim + or hasExpiringThickness or hasExpiringAlpha) if anyExpiringFeature then local ec = icon.dfAD_expiringColor local oc = {r = 0, g = 0, b = 0} -- icon border default = black local applyColor = expiringEnabled + + -- Border state-swap (geometry / colour / style / animation) is the + -- shared ADApplyExpiringBorderState — the SAME helper square and bar use, + -- so the three placed border indicators never drift (task #46). The + -- icon's old inline buildAnim/applyState (with a now-dead ExpiringBorder + -- Alpha multiplier — the GUI edits the colour's own alpha) were removed. + RegisterExpiring(icon, { unit = frame.unit, auraInstanceID = auraData and auraData.auraInstanceID, @@ -2071,30 +2421,32 @@ function Indicators:UpdateIcon(frame, config, auraData, defaults, auraName, prio thresholdMode = icon.dfAD_expiringThresholdMode, color = ec, originalColor = oc, applyResult = function(el, result, entry) - -- applyResult only fires when colorCurve is set (i.e. applyColor = true) - if el.border then - el.border:SetColorTexture(result.r, result.g, result.b, result.a or 1) - end - local oc2 = entry.originalColor - local isExp = IsColorExpiring(result, oc2) - if el.adBorderPulseFrame then - UpdatePulseState(el.adBorderPulseFrame, isExp) - end - UpdateWholeAlphaPulseState(el, isExp) - UpdateBounceState(el, isExp) + -- applyResult only fires when colorCurve is set + -- (applyColor = true, i.e. user enabled Expiring Color Override). + local isExp = IsColorExpiring(result, entry.originalColor) + ADApplyExpiringBorderState(el, isExp, { r = result.r, g = result.g, b = result.b, a = result.a or 1 }) + -- Anim effects: non-secret only (force-stopped on secret auras). + DriveExpiringEffects(el, result, isExp, el.adBorderPulseFrame) end, applyManual = function(el, isExp, entry) - if applyColor and el.border then - if isExp then + -- Fire applyState whenever ANY border-affecting expiring + -- feature is configured. Without this, setting only + -- Expiring Thickness / Alpha did nothing because applyState + -- was only reached via colour-override and animation paths. + if applyColor or hasExpiringAnim or hasExpiringThickness or hasExpiringAlpha then + local color + if applyColor and isExp then local c = entry.color - el.border:SetColorTexture(c.r or 1, c.g or 0.2, c.b or 0.2, 1) + color = { r = c.r or 1, g = c.g or 0.2, b = c.b or 0.2, a = 1 } else - el.border:SetColorTexture(0, 0, 0, 0.8) + -- No colour override active for this tick — use base + -- colour so thickness / alpha overrides still apply + -- on the user's chosen border colour. + color = icon.dfAD_baseBorderColor or { r = 0, g = 0, b = 0, a = 0.8 } end + ADApplyExpiringBorderState(el, isExp, color) end - if el.adBorderPulseFrame then - UpdatePulseState(el.adBorderPulseFrame, isExp) - end + if el.adBorderPulseFrame then UpdatePulseState(el.adBorderPulseFrame, isExp) end UpdateWholeAlphaPulseState(el, isExp) UpdateBounceState(el, isExp) end, @@ -2114,6 +2466,11 @@ function Indicators:UpdateIcon(frame, config, auraData, defaults, auraName, prio end end + -- Expiring TINT (independent of the border feature; secret-safe, on the + -- shared engine). Self-gates: UpdateTint registers when enabled, else + -- unregisters. Hosted on textOverlay so it sits above the icon art. + SetupExpiringTint(icon.textOverlay or icon, "ARTWORK", icon, frame, auraData) + icon:Show() end @@ -2123,6 +2480,7 @@ function Indicators:HideUnusedIcons(frame, activeMap) for auraName, icon in pairs(map) do if not activeMap[auraName] then UnregisterExpiring(icon) + ClearExpiringTint(icon.textOverlay or icon) icon:Hide() -- Clear stale aura data (matches bar cleanup pattern) if icon.auraData then @@ -2199,6 +2557,15 @@ local function GetOrCreateADSquare(frame, auraName) return sq end +-- Stage 5.2a: lazily attach a unified DF.Border widget to a square and hide +-- the legacy single-texture `sq.border`. Mirrors GetOrCreateADIconBorder. +local function GetOrCreateADSquareBorder(sq) + if sq.dfADBorder then return sq.dfADBorder end + sq.dfADBorder = DF.Border:New(sq, { frameLevelOffset = 0, layer = "BACKGROUND" }) + if sq.border then sq.border:Hide() end + return sq.dfADBorder +end + -- ============================================================ -- ConfigureSquare: static config applied once per config change -- Sets size, scale, alpha, frame level/strata, border, color, @@ -2239,31 +2606,91 @@ function Indicators:ConfigureSquare(frame, config, defaults, auraName, priority) sq.dfAD_hideIcon = hideIcon -- ======================================== - -- BORDER + -- BORDER (Stage 5.2 — unified DF.Border backend) + -- Canonical keys (ShowBorder / BorderSize / BorderInset) with legacy + -- fallback (showBorder / borderThickness / borderInset) for configs that + -- haven't run the migration shim yet. Same geometry model as the icon + -- (Stage 5.1): BorderSize is the band thickness alone; Inset repositions a + -- constant-width band outward (spec.inset = -BorderInset). BuildSpec also + -- carries Style / Texture / Colour / Gradient / Shadow / Blend / Offset / + -- Animation through, so Apply renders + animates the border in one call. -- ======================================== - local showBorder = config.showBorder - if showBorder == nil then showBorder = true end - local borderThickness = config.borderThickness or 1 - local borderInset = config.borderInset or 1 - - if showBorder and not hideIcon then - sq.border:ClearAllPoints() - PixelUtil.SetPoint(sq.border, "TOPLEFT", sq, "TOPLEFT", -borderInset, borderInset) - PixelUtil.SetPoint(sq.border, "BOTTOMRIGHT", sq, "BOTTOMRIGHT", borderInset, -borderInset) - sq.border:SetColorTexture(0, 0, 0, 1) - sq.border:Show() - else - sq.border:Hide() - end - - -- Adjust texture inset to sit inside border + local borderEnabled = config.ShowBorder + if borderEnabled == nil then borderEnabled = config.showBorder end + if borderEnabled == nil then borderEnabled = true end + local borderThickness = config.BorderSize or config.borderThickness or 1 + local borderInset = config.BorderInset or config.borderInset or 1 + + local adBorder = GetOrCreateADSquareBorder(sq) + adBorder.dfADIconSize = borderThickness + adBorder.dfADIconInset = -borderInset + + local spec = DF.Border:BuildSpec(config, "") + spec.enabled = borderEnabled and not hideIcon + spec.size = adBorder.dfADIconSize + spec.inset = adBorder.dfADIconInset + -- Legacy square border was opaque black; fall back to it when no explicit + -- BorderColor (BuildSpec returns nil colour for unmigrated configs). + if not spec.color then + spec.color = { r = 0, g = 0, b = 0, a = 1 } + end + DF.Border:Apply(adBorder, spec) + + -- Inset the fill texture by the border thickness so the band frames the + -- square instead of sitting over it (same as the icon). if not hideIcon then sq.texture:ClearAllPoints() - local texInset = showBorder and borderThickness or 0 + local texInset = borderEnabled and borderThickness or 0 sq.texture:SetPoint("TOPLEFT", texInset, -texInset) sq.texture:SetPoint("BOTTOMRIGHT", -texInset, texInset) end + -- Stage 5.2 expiring-border parity: store the base presentation + expiring + -- overrides so UpdateSquare's expiring callback can recolour / thicken / + -- animate the BORDER below threshold via ADApplyExpiringBorderState (shared + -- with the fill's expiring colour). Mirrors what ConfigureIcon stores. + sq.dfAD_baseBorderEnabled = borderEnabled and not hideIcon + sq.dfAD_baseBorderSize = borderThickness + sq.dfAD_baseBorderInset = borderInset + sq.dfAD_baseBorderColor = spec.color + sq.dfAD_baseBorderStyle = spec.style + sq.dfAD_baseBorderGradient = spec.gradient + sq.dfAD_baseBorderTexture = spec.texture + sq.dfAD_baseBorderShadow = spec.shadow + sq.dfAD_baseBorderBlend = spec.blendMode + sq.dfAD_baseBorderOffsetX = spec.offsetX + sq.dfAD_baseBorderOffsetY = spec.offsetY + sq.dfAD_baseAnimType = config.BorderAnimationType or "NONE" + sq.dfAD_baseAnimColor = config.BorderAnimationColor + sq.dfAD_baseAnimFrequency = config.BorderAnimationFrequency + sq.dfAD_baseAnimParticles = config.BorderAnimationParticles + sq.dfAD_baseAnimLength = config.BorderAnimationLength + sq.dfAD_baseAnimThickness = config.BorderAnimationThickness + sq.dfAD_baseAnimScale = config.BorderAnimationScale + sq.dfAD_baseAnimInset = config.BorderAnimationInset + sq.dfAD_baseAnimOffsetX = config.BorderAnimationOffsetX + sq.dfAD_baseAnimOffsetY = config.BorderAnimationOffsetY + sq.dfAD_baseAnimMask = config.BorderAnimationMask + sq.dfAD_baseAnimSidesAxis = config.BorderAnimationSidesAxis + sq.dfAD_baseAnimCornerLength = config.BorderAnimationCornerLength + sq.dfAD_ExpiringBorderColor = config.ExpiringBorderColor or {r = 1, g = 0.2, b = 0.2, a = 1} + sq.dfAD_ExpiringBorderSize = config.ExpiringBorderSize or borderThickness + sq.dfAD_ExpiringBorderAlpha = config.ExpiringBorderAlpha or 1 + sq.dfAD_ExpiringAnimationType = config.ExpiringAnimationType or "NONE" + sq.dfAD_ExpiringAnimationColor = config.ExpiringAnimationColor + sq.dfAD_ExpiringAnimationFrequency = config.ExpiringAnimationFrequency or 1 + sq.dfAD_ExpiringAnimationParticles = config.ExpiringAnimationParticles + sq.dfAD_ExpiringAnimationLength = config.ExpiringAnimationLength + sq.dfAD_ExpiringAnimationThickness = config.ExpiringAnimationThickness + sq.dfAD_ExpiringAnimationScale = config.ExpiringAnimationScale + sq.dfAD_ExpiringAnimationInset = config.ExpiringAnimationInset + sq.dfAD_ExpiringAnimationOffsetX = config.ExpiringAnimationOffsetX + sq.dfAD_ExpiringAnimationOffsetY = config.ExpiringAnimationOffsetY + sq.dfAD_ExpiringAnimationMask = config.ExpiringAnimationMask + sq.dfAD_ExpiringAnimationSidesAxis = config.ExpiringAnimationSidesAxis + sq.dfAD_ExpiringAnimationCornerLength = config.ExpiringAnimationCornerLength + sq.dfAD_expiringFeatureEnabled = config.expiringFeatureEnabled + -- Color (static config) local color = config.color if not hideIcon then @@ -2433,6 +2860,8 @@ function Indicators:ConfigureSquare(frame, config, defaults, auraName, priority) sq.dfAD_expiringColor = config.expiringColor or {r = 1, g = 0.2, b = 0.2} sq.dfAD_expiringThreshold = config.expiringThreshold or 30 sq.dfAD_expiringThresholdMode = config.expiringThresholdMode + sq.dfAD_expiringTintEnabled = config.expiringTintEnabled + sq.dfAD_expiringTintColor = config.expiringTintColor sq.dfAD_expiringPulsate = expiringPulsate -- Missing-mode config @@ -2476,16 +2905,23 @@ function Indicators:UpdateSquare(frame, config, auraData, defaults, auraName, pr end -- Position — each aura has its own anchor, no growth - -- Position is dynamic because layout groups compute offsets per-event + -- Position is dynamic because layout groups compute offsets per-event. + -- Position is the user's offset only — like the icon (Stage 5.2), we no + -- longer shift the square by the border inset, so dragging Inset expands + -- the ring without sliding the whole square. local anchor = config.anchor or "TOPLEFT" local offsetX = config.offsetX or 0 local offsetY = config.offsetY or 0 - -- Compensate for border overhang at frame edges - local showBorderForPos = config.showBorder - if showBorderForPos == nil then showBorderForPos = true end - offsetX, offsetY = AdjustOffsetForBorder(anchor, offsetX, offsetY, config.borderInset or 1, showBorderForPos) - sq:ClearAllPoints() - sq:SetPoint(anchor, frame, anchor, offsetX, offsetY) + -- Only re-anchor when the position changed (see UpdateIcon) so the preview's + -- per-frame refresh doesn't fight an active Bounce Translation. + if sq:GetNumPoints() == 0 or sq.dfAD_posAnchor ~= anchor + or sq.dfAD_posX ~= offsetX or sq.dfAD_posY ~= offsetY then + sq.dfAD_posAnchor, sq.dfAD_posX, sq.dfAD_posY = anchor, offsetX, offsetY + local b = sq.dfAD_basePos or {}; sq.dfAD_basePos = b + b.point, b.rel, b.relPoint, b.x, b.y = anchor, frame, anchor, offsetX, offsetY + sq:ClearAllPoints() + sq:SetPoint(anchor, frame, anchor, offsetX, offsetY) + end -- Read stored config flags from ConfigureSquare local hideIcon = sq.dfAD_hideIcon @@ -2699,13 +3135,37 @@ function Indicators:UpdateSquare(frame, config, auraData, defaults, auraName, pr local expiringWholeAlphaPulse = sq.dfAD_expiringWholeAlphaPulse local expiringBounce = sq.dfAD_expiringBounce - -- Register if ANY expiring feature is active (color, pulsate, alpha pulse, bounce) - local anyExpiringFeature = expiringEnabled or expiringPulsate or expiringWholeAlphaPulse or expiringBounce + -- Stage 5.2 expiring-border parity: the square's expiring colour now tints + -- the BORDER too (shared "turn red" look), and the border gets its own + -- thickness / alpha / animation overrides via ADApplyExpiringBorderState. + -- These flags mirror the icon's so the same trigger conditions apply. + local expiringAnimType = sq.dfAD_ExpiringAnimationType + local hasExpiringAnim = expiringAnimType and expiringAnimType ~= "NONE" + local hasExpiringThickness = sq.dfAD_ExpiringBorderSize + and sq.dfAD_ExpiringBorderSize ~= sq.dfAD_baseBorderSize + local hasExpiringAlpha = sq.dfAD_ExpiringBorderAlpha + and sq.dfAD_ExpiringBorderAlpha ~= 1 + -- Master enable gates the whole feature. + local masterEnabled = sq.dfAD_expiringFeatureEnabled ~= false + local anyExpiringFeature = masterEnabled and (expiringEnabled or expiringPulsate + or expiringWholeAlphaPulse or expiringBounce + or hasExpiringAnim or hasExpiringThickness or hasExpiringAlpha) if anyExpiringFeature then local ec = sq.dfAD_expiringColor local color = config.color local oc = {r = color and (color[1] or color.r) or 1, g = color and (color[2] or color.g) or 1, b = color and (color[3] or color.b) or 1} local applyColor = expiringEnabled + -- Border colour SNAPS at the threshold (the fill interpolates via the + -- curve): the border's OWN expiring colour when below + override on, + -- else its base colour. Separate from the fill's expiring colour so + -- the fill and border can differ on expiring. + local function borderTintFor(isExp) + if applyColor and isExp then + return sq.dfAD_ExpiringBorderColor or ec + end + return sq.dfAD_baseBorderColor or { r = 0, g = 0, b = 0, a = 1 } + end + local fireBorder = applyColor or hasExpiringAnim or hasExpiringThickness or hasExpiringAlpha RegisterExpiring(sq, { unit = frame.unit, auraInstanceID = auraData and auraData.auraInstanceID, @@ -2716,17 +3176,15 @@ function Indicators:UpdateSquare(frame, config, auraData, defaults, auraName, pr thresholdMode = sq.dfAD_expiringThresholdMode, color = ec, originalColor = oc, applyResult = function(el, result, entry) - -- applyResult only fires when colorCurve is set (i.e. applyColor = true) + -- applyResult only fires when colorCurve is set (applyColor true). if el.texture then el.texture:SetColorTexture(result.r, result.g, result.b, result.a or 1) end local oc2 = entry.originalColor local isExp = IsColorExpiring(result, oc2) - if el.adFillPulseFrame then - UpdatePulseState(el.adFillPulseFrame, isExp) - end - UpdateWholeAlphaPulseState(el, isExp) - UpdateBounceState(el, isExp) + if fireBorder then ADApplyExpiringBorderState(el, isExp, borderTintFor(isExp)) end + -- Anim effects: non-secret only (force-stopped on secret auras). + DriveExpiringEffects(el, result, isExp, el.adFillPulseFrame) end, applyManual = function(el, isExp, entry) if applyColor and el.texture then @@ -2738,6 +3196,7 @@ function Indicators:UpdateSquare(frame, config, auraData, defaults, auraName, pr el.texture:SetColorTexture(c.r or 1, c.g or 1, c.b or 1, 1) end end + if fireBorder then ADApplyExpiringBorderState(el, isExp, borderTintFor(isExp)) end if el.adFillPulseFrame then UpdatePulseState(el.adFillPulseFrame, isExp) end @@ -2760,6 +3219,9 @@ function Indicators:UpdateSquare(frame, config, auraData, defaults, auraName, pr end end + -- Expiring TINT (secret-safe, shared engine; self-gating). + SetupExpiringTint(sq.textOverlay or sq, "ARTWORK", sq, frame, auraData) + sq:Show() end @@ -2769,6 +3231,7 @@ function Indicators:HideUnusedSquares(frame, activeMap) for auraName, sq in pairs(map) do if not activeMap[auraName] then UnregisterExpiring(sq) + ClearExpiringTint(sq.textOverlay or sq) sq:Hide() -- Clear stale cooldown (matches bar cleanup pattern) if sq.cooldown then @@ -2857,6 +3320,9 @@ local function CreateADBar(frame, auraName) bar.dfAD_colorElapsed = 0 bar.dfAD_usedTimerDuration = false bar.dfAD_expiryCheckElapsed = 0 + -- Scratch ctx reused each frame for DF.Expiring:EvaluateManualColor (the + -- preview fill fallback) — avoids per-frame allocation. + local manualCtx = { base = {} } bar:SetScript("OnUpdate", function(self, elapsed) -- Expiration guard: if the aura is gone, hide the bar (#406) -- Throttled to ~1 FPS to avoid per-frame API calls @@ -2896,18 +3362,8 @@ local function CreateADBar(frame, auraName) self.duration:SetText(format("%.1f", remaining)) end if self.dfAD_durationColorByTime then - local r, g, b - if pct < 0.3 then - local t = pct / 0.3 - r, g, b = 1, 0.5 * t, 0 - elseif pct < 0.5 then - local t = (pct - 0.3) / 0.2 - r, g, b = 1, 0.5 + 0.5 * t, 0 - else - local t = (pct - 0.5) / 0.5 - r, g, b = 1 - t, 1, 0 - end - self.duration:SetTextColor(r, g, b, 1) + -- Shared colour-by-time ramp (single owner: DF.Expiring) + self.duration:SetTextColor(DF.Expiring:GradientColorAt(pct)) end end end @@ -2943,45 +3399,24 @@ local function CreateADBar(frame, auraName) end end - -- Manual color fallback for preview + -- Manual color fallback for preview — delegate the gradient + expiring + -- maths to DF.Expiring so it isn't hand-rolled here (live frames use the + -- secret-safe colour curve above). if not self.dfAD_usedTimerDuration then local dur = self.dfAD_duration local exp = self.dfAD_expirationTime if dur and exp and dur > 0 and exp > 0 then - local pct = min(1, max(0, exp - GetTime()) / dur) - local barR = self.dfAD_fillR or 1 - local barG = self.dfAD_fillG or 1 - local barB = self.dfAD_fillB or 1 - if self.dfAD_barColorByTime then - if pct < 0.3 then - local t = pct / 0.3 - barR, barG, barB = 1, 0.5 * t, 0 - elseif pct < 0.5 then - local t = (pct - 0.3) / 0.2 - barR, barG, barB = 1, 0.5 + 0.5 * t, 0 - else - local t = (pct - 0.5) / 0.5 - barR, barG, barB = 1 - t, 1, 0 - end - end - if self.dfAD_expiringEnabled and self.dfAD_expiringThreshold then - local isExp - if self.dfAD_expiringThresholdMode == "SECONDS" then - local remaining = max(0, exp - GetTime()) - isExp = remaining <= self.dfAD_expiringThreshold - else - isExp = pct <= (self.dfAD_expiringThreshold / 100) - end - if isExp then - local ec = self.dfAD_expiringColor - if ec then - barR = ec.r or 1 - barG = ec.g or 0.2 - barB = ec.b or 0.2 - end - end - end - self:SetStatusBarColor(barR, barG, barB, self.dfAD_fillA or 1) + local remaining = max(0, exp - GetTime()) + local ctx = manualCtx + ctx.base.r, ctx.base.g, ctx.base.b = + self.dfAD_fillR or 1, self.dfAD_fillG or 1, self.dfAD_fillB or 1 + ctx.colorByTime = self.dfAD_barColorByTime + ctx.expiringEnabled = self.dfAD_expiringEnabled + ctx.threshold = self.dfAD_expiringThreshold + ctx.thresholdMode = self.dfAD_expiringThresholdMode + ctx.expiringColor = self.dfAD_expiringColor + local r, g, b = DF.Expiring:EvaluateManualColor(ctx, remaining, dur) + self:SetStatusBarColor(r, g, b, self.dfAD_fillA or 1) end end end) @@ -2998,6 +3433,15 @@ local function GetOrCreateADBar(frame, auraName) return bar end +-- Stage 5.3a: lazily attach a unified DF.Border widget to a bar and hide the +-- legacy BackdropTemplate `bar.borderFrame`. Mirrors the icon/square helpers. +local function GetOrCreateADBarBorder(bar) + if bar.dfADBorder then return bar.dfADBorder end + bar.dfADBorder = DF.Border:New(bar, { frameLevelOffset = 0, layer = "BACKGROUND" }) + if bar.borderFrame then bar.borderFrame:Hide() end + return bar.dfADBorder +end + -- ============================================================ -- ConfigureBar: static config applied once per config change -- Sets size, orientation, texture, colors, color curve, border, @@ -3067,6 +3511,8 @@ function Indicators:ConfigureBar(frame, config, defaults, auraName, priority) bar.dfAD_expiringEnabled = expiringEnabled bar.dfAD_expiringThreshold = config.expiringThreshold or 30 bar.dfAD_expiringThresholdMode = config.expiringThresholdMode + bar.dfAD_expiringTintEnabled = config.expiringTintEnabled + bar.dfAD_expiringTintColor = config.expiringTintColor bar.dfAD_expiringColor = config.expiringColor or { r = 1, g = 0.2, b = 0.2 } -- Store base fill color for OnUpdate fallback @@ -3163,34 +3609,33 @@ function Indicators:ConfigureBar(frame, config, defaults, auraName, priority) end -- ======================================== - -- BORDER + -- BORDER (Stage 5.3 — unified DF.Border backend) + -- Canonical keys (ShowBorder / BorderSize / BorderInset) with legacy + -- fallback (showBorder / borderThickness / borderColor). The bar's border + -- sits OUTSIDE the StatusBar (the fill is never inset), so the band is + -- placed fully outward: spec.size = thickness, spec.inset = -(inset + + -- thickness) puts the ring's inner edge at the bar edge (Inset 0 = flush, + -- as before) and grows outward. BuildSpec carries Style / Texture / Colour + -- / Gradient / Shadow / Blend / Animation through, so Apply renders + + -- animates in one call. -- ======================================== - local showBorder = config.showBorder - if showBorder == nil then showBorder = true end - local borderThickness = config.borderThickness or 1 - - if bar.borderFrame then - if showBorder then - bar.borderFrame:ClearAllPoints() - bar.borderFrame:SetPoint("TOPLEFT", -borderThickness, borderThickness) - bar.borderFrame:SetPoint("BOTTOMRIGHT", borderThickness, -borderThickness) - if bar.borderFrame.SetBackdrop then - bar.borderFrame:SetBackdrop({ - edgeFile = "Interface\\Buttons\\WHITE8x8", - edgeSize = borderThickness, - }) - local borderColor = config.borderColor - if borderColor then - bar.borderFrame:SetBackdropBorderColor(borderColor[1] or borderColor.r or 0, borderColor[2] or borderColor.g or 0, borderColor[3] or borderColor.b or 0, borderColor[4] or borderColor.a or 1) - else - bar.borderFrame:SetBackdropBorderColor(0, 0, 0, 1) - end - end - bar.borderFrame:Show() - else - bar.borderFrame:Hide() - end + local borderEnabled = config.ShowBorder + if borderEnabled == nil then borderEnabled = config.showBorder end + if borderEnabled == nil then borderEnabled = true end + local borderThickness = config.BorderSize or config.borderThickness or 1 + local borderInset = config.BorderInset or config.borderInset or 0 + + local adBorder = GetOrCreateADBarBorder(bar) + local spec = DF.Border:BuildSpec(config, "") + spec.enabled = borderEnabled + spec.size = borderThickness + spec.inset = -(borderInset + borderThickness) + -- Legacy bar border was opaque black; fall back to it when no explicit + -- BorderColor (BuildSpec returns nil colour for unmigrated configs). + if not spec.color then + spec.color = { r = 0, g = 0, b = 0, a = 1 } end + DF.Border:Apply(adBorder, spec) -- Frame level: base from frame (not contentOverlay) + per-indicator level + small priority tiebreaker local level = config.frameLevel or (defaults and defaults.indicatorFrameLevel) or 2 @@ -3343,12 +3788,19 @@ function Indicators:UpdateBar(frame, config, auraData, defaults, auraName, prior local anchor = config.anchor or "BOTTOM" local offsetX = config.offsetX or 0 local offsetY = config.offsetY or 0 - -- Compensate for border overhang at frame edges - local showBorderForPos = config.showBorder - if showBorderForPos == nil then showBorderForPos = true end - offsetX, offsetY = AdjustOffsetForBorder(anchor, offsetX, offsetY, config.borderThickness or 1, showBorderForPos) - bar:ClearAllPoints() - bar:SetPoint(anchor, frame, anchor, offsetX, offsetY) + -- Position is the user's offset only — like the icon/square (Stage 5.3), + -- we no longer shift the bar by the border thickness, so changing the + -- border doesn't slide the whole bar. + -- Only re-anchor when the position changed (see UpdateIcon) so the preview's + -- per-frame refresh doesn't fight an active Bounce Translation. + if bar:GetNumPoints() == 0 or bar.dfAD_posAnchor ~= anchor + or bar.dfAD_posX ~= offsetX or bar.dfAD_posY ~= offsetY then + bar.dfAD_posAnchor, bar.dfAD_posX, bar.dfAD_posY = anchor, offsetX, offsetY + local b = bar.dfAD_basePos or {}; bar.dfAD_basePos = b + b.point, b.rel, b.relPoint, b.x, b.y = anchor, frame, anchor, offsetX, offsetY + bar:ClearAllPoints() + bar:SetPoint(anchor, frame, anchor, offsetX, offsetY) + end -- ======================================== -- COUNTDOWN DATA (drives bar fill) @@ -3601,6 +4053,10 @@ function Indicators:UpdateBar(frame, config, auraData, defaults, auraName, prior end end + -- Expiring TINT (secret-safe, shared engine; self-gating). Hosted on the + -- bar itself at OVERLAY so it sits above the fill. + SetupExpiringTint(bar, "OVERLAY", bar, frame, auraData) + bar:Show() end @@ -3609,6 +4065,7 @@ function Indicators:HideUnusedBars(frame, activeMap) if not map then return end for auraName, bar in pairs(map) do if not activeMap[auraName] then + ClearExpiringTint(bar) bar:Hide() -- Clear stale metadata so OnUpdate doesn't run with expired -- auraInstanceIDs causing stuck/corrupted bar state (#406) diff --git a/AuraDesigner/Options.lua b/AuraDesigner/Options.lua index 3fb516a2..41cfd72c 100644 --- a/AuraDesigner/Options.lua +++ b/AuraDesigner/Options.lua @@ -193,6 +193,173 @@ end -- Expose for Engine.lua and post-import use DF.MigrateAuraDesignerSpecScope = MigrateToSpecScoped +-- ============================================================ +-- STAGE 5.1b — ICON BORDER KEY MIGRATION +-- Renames the legacy per-aura icon border keys to the canonical +-- CreateBorderControls naming (matches every other unified-border +-- consumer in the addon): +-- borderEnabled → ShowBorder +-- borderThickness → BorderSize +-- borderInset → BorderInset (case-only rename) +-- +-- Walks every aura × every storage shape (typeKey-keyed sub-config +-- and the newer indicators[] array) for every spec. Idempotent — +-- only renames when the new key is nil, so a partially-migrated +-- config is safe. +-- +-- Scope: icon (Stage 5.1) + square (Stage 5.2). Bar (Stage 5.3) still +-- reuses some of the same legacy key names (borderThickness, borderInset), +-- so we stay type-scoped — only rename an instance's keys once that +-- indicator type has migrated to the unified backend. The icon and square +-- differ only in the enable key: icon used `borderEnabled`, square used +-- `showBorder`; both fold to canonical `ShowBorder`. +-- ============================================================ + +local function renameIconBorderKeys(t) + if type(t) ~= "table" then return end + if t.borderEnabled ~= nil and t.ShowBorder == nil then + t.ShowBorder, t.borderEnabled = t.borderEnabled, nil + end + if t.borderThickness ~= nil and t.BorderSize == nil then + t.BorderSize, t.borderThickness = t.borderThickness, nil + end + if t.borderInset ~= nil and t.BorderInset == nil then + t.BorderInset, t.borderInset = t.borderInset, nil + end + -- Stage 5.1d.2: legacy expiringPulsate (boolean) → ExpiringAnimationType + -- (string). expiringPulsate = true means the user wanted the AD legacy + -- alpha-fade pulse during expiring; that effect is now first-class as + -- DF_PULSATE. False just clears the boolean — the new key defaults to + -- "NONE" which means no expiring animation override. + if t.expiringPulsate ~= nil and t.ExpiringAnimationType == nil then + if t.expiringPulsate == true then + t.ExpiringAnimationType = "DF_PULSATE" + end + t.expiringPulsate = nil + end +end + +-- Square (Stage 5.2): border-key renames ONLY. The square's enable key is +-- `showBorder` (vs the icon's `borderEnabled`). Deliberately does NOT touch +-- `expiringPulsate` — on the square that's the FILL pulse, a different effect +-- from the icon's border DF_PULSATE, so it stays a boolean. +local function renameSquareBorderKeys(t) + if type(t) ~= "table" then return end + if t.showBorder ~= nil and t.ShowBorder == nil then + t.ShowBorder, t.showBorder = t.showBorder, nil + end + if t.borderThickness ~= nil and t.BorderSize == nil then + t.BorderSize, t.borderThickness = t.borderThickness, nil + end + if t.borderInset ~= nil and t.BorderInset == nil then + t.BorderInset, t.borderInset = t.borderInset, nil + end +end + +-- Bar (Stage 5.3): border-key renames. Enable key is `showBorder` (like the +-- square); the bar also carries a static `borderColor` table → canonical +-- `BorderColor`. The bar has no legacy inset key. +local function renameBarBorderKeys(t) + if type(t) ~= "table" then return end + if t.showBorder ~= nil and t.ShowBorder == nil then + t.ShowBorder, t.showBorder = t.showBorder, nil + end + if t.borderThickness ~= nil and t.BorderSize == nil then + t.BorderSize, t.borderThickness = t.borderThickness, nil + end + if t.borderColor ~= nil and t.BorderColor == nil then + t.BorderColor, t.borderColor = t.borderColor, nil + end +end + +-- Border-type indicator (Stage 5.4): its legacy `style` enum maps onto a +-- DF.Border style + animation combo. One-way, lossy (the 5 styles become +-- canonical Style/Animation combinations). Gated on `style` being present so +-- it runs once. +local function renameBorderTypeKeys(t) + if type(t) ~= "table" then return end + if t.style == nil or t.BorderStyle ~= nil then return end + local thickness = t.thickness or 2 + local inset = t.inset or 0 + local color = t.color or { r = 0, g = 0, b = 0, a = 1 } + local legacy = { Solid = "SOLID", Glow = "GLOW", Pulse = "SOLID" } + local style = legacy[t.style] or t.style or "SOLID" + t.ShowBorder = true + t.BorderInset = inset + t.BorderColor = color + if style == "GLOW" then + t.BorderStyle = "TEXTURE"; t.BorderTexture = "DF Glow"; t.BorderSize = thickness + elseif style == "DASHED" or style == "ANIMATED" then + t.BorderStyle = "SOLID"; t.BorderSize = 0 + t.BorderAnimationType = "DF_DASH" + t.BorderAnimationFrequency = (style == "ANIMATED") and 1 or 0 + t.BorderAnimationThickness = thickness + t.BorderAnimationColor = color + t.BorderAnimationInset = inset + elseif style == "CORNERS" then + t.BorderStyle = "SOLID"; t.BorderSize = 0 + t.BorderAnimationType = "CORNERS_ONLY" + t.BorderAnimationThickness = thickness + t.BorderAnimationColor = color + else -- SOLID (and anything unknown) + t.BorderStyle = "SOLID"; t.BorderSize = thickness + end + t.style = nil; t.thickness = nil; t.inset = nil; t.color = nil +end + +local function MigrateIconBorderKeysOnAuras(specAuras) + if type(specAuras) ~= "table" then return end + for _, auraCfg in pairs(specAuras) do + if type(auraCfg) == "table" then + -- Old shape: auraCfg. sub-config. + if auraCfg.icon then renameIconBorderKeys(auraCfg.icon) end + if auraCfg.square then renameSquareBorderKeys(auraCfg.square) end + if auraCfg.bar then renameBarBorderKeys(auraCfg.bar) end + if auraCfg.border then renameBorderTypeKeys(auraCfg.border) end + -- New shape: auraCfg.indicators[i] — each instance carries its + -- own border keys when the user has overridden defaults. + if auraCfg.indicators then + for _, ind in ipairs(auraCfg.indicators) do + if ind.type == "icon" then + renameIconBorderKeys(ind) + elseif ind.type == "square" then + renameSquareBorderKeys(ind) + elseif ind.type == "bar" then + renameBarBorderKeys(ind) + elseif ind.type == "border" then + renameBorderTypeKeys(ind) + end + end + end + end + end +end + +local function MigrateAuraDesignerIconBorderKeys(modeDb) + local adDB = modeDb and modeDb.auraDesigner + if not adDB or not adDB.auras then return end + + -- Detect shape: pre-spec-scoping (flat aura configs) vs spec-scoped. + -- Mirrors MigrateAuraDesignerToInstances' detection so we stay correct + -- whether the spec-scope migration ran before us or not. + for _, val in pairs(adDB.auras) do + if type(val) == "table" then + if val.priority ~= nil or val.indicators ~= nil or val.icon ~= nil then + -- Flat: adDB.auras is { auraName → auraCfg } + MigrateIconBorderKeysOnAuras(adDB.auras) + else + -- Spec-scoped: adDB.auras is { specKey → { auraName → auraCfg } } + for _, specAuras in pairs(adDB.auras) do + MigrateIconBorderKeysOnAuras(specAuras) + end + end + end + break -- Only check first entry for shape detection + end +end + +DF.MigrateAuraDesignerIconBorderKeys = MigrateAuraDesignerIconBorderKeys + local function GetAuraDesignerDB() local adDB = db.auraDesigner if adDB and (not adDB._specScopedV1 or not adDB._specScopedV2) then @@ -410,7 +577,14 @@ local function EnsureTypeConfig(auraName, typeKey) -- Size & appearance (from global defaults) size = gd.iconSize or 24, scale = gd.iconScale or 1.0, alpha = 1.0, -- Border - borderEnabled = true, borderThickness = 1, borderInset = 1, + -- Canonical border keys (Stage 5.1b/c). Legacy names were + -- borderEnabled / borderThickness / borderInset; migrated + -- via DF:MigrateAuraDesignerIconBorderKeys on ADDON_LOADED. + -- ShowBorder/BorderSize/BorderInset are stored on the + -- aura's icon sub-config; everything else (style, colour, + -- gradient, shadow, offset, blend) reads from TYPE_DEFAULTS + -- via proxy fall-through until the user overrides it. + ShowBorder = true, BorderSize = 1, BorderInset = 1, hideSwipe = false, -- Duration text showDuration = gd.showDuration ~= false, @@ -438,8 +612,8 @@ local function EnsureTypeConfig(auraName, typeKey) -- Appearance (from global defaults) size = gd.iconSize or 24, scale = gd.iconScale or 1.0, alpha = 1.0, color = {r = 1, g = 1, b = 1, a = 1}, - -- Border - showBorder = true, borderThickness = 1, borderInset = 1, + -- Border (canonical keys, Stage 5.2; legacy migrated on load) + ShowBorder = true, BorderSize = 1, BorderInset = 1, hideSwipe = false, -- Duration text showDuration = gd.showDuration ~= false, @@ -471,9 +645,9 @@ local function EnsureTypeConfig(auraName, typeKey) texture = "Interface\\TargetingFrame\\UI-StatusBar", fillColor = {r = 1, g = 1, b = 1, a = 1}, bgColor = {r = 0, g = 0, b = 0, a = 0.5}, - -- Border - showBorder = true, borderThickness = 1, - borderColor = {r = 0, g = 0, b = 0, a = 1}, + -- Border (canonical keys, Stage 5.3; legacy migrated on load) + ShowBorder = true, BorderSize = 1, BorderInset = 0, + BorderColor = {r = 0, g = 0, b = 0, a = 1}, -- Alpha alpha = 1.0, -- Bar color by time @@ -491,8 +665,11 @@ local function EnsureTypeConfig(auraName, typeKey) } elseif typeKey == "border" then auraCfg[typeKey] = { - style = "SOLID", color = {r = 1, g = 1, b = 1, a = 1}, - thickness = 2, inset = 0, + -- Border (canonical keys, Stage 5.4; legacy style/thickness/ + -- inset/color migrated on load) + ShowBorder = true, BorderStyle = "SOLID", BorderSize = 2, BorderInset = 0, + BorderColor = {r = 1, g = 1, b = 1, a = 1}, + drawAboveFrameBorder = true, expiringEnabled = false, expiringThreshold = 30, expiringThresholdMode = "PERCENT", expiringColor = {r = 1, g = 0.2, b = 0.2, a = 1}, expiringPulsate = false, @@ -554,7 +731,51 @@ local TYPE_DEFAULTS = { icon = { anchor = "TOPLEFT", offsetX = 0, offsetY = 0, size = 24, scale = 1.0, alpha = 1.0, - borderEnabled = true, borderThickness = 1, borderInset = 1, + -- Canonical border keys (Stage 5.1b/c). Legacy borderEnabled / + -- borderThickness / borderInset migrated on ADDON_LOADED via + -- DF:MigrateAuraDesignerIconBorderKeys. BorderColor defaults to + -- the pre-migration hardcoded translucent black so existing users + -- see no visual change. Style / Gradient* / Shadow* defaults seed + -- CreateBorderControls' dropdowns and pickers so they read sensible + -- values on first open. + ShowBorder = true, BorderSize = 1, BorderInset = 1, + BorderColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderOffsetX = 0, + BorderOffsetY = 0, + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + -- Animation defaults match Frame Border's Stage 3 defaults so the + -- behaviour of "pick PULSATE" reads the same across the addon. + -- BorderAnimationType = "NONE" means no continuous animation; the + -- spec.animation block is omitted by BuildSpec so Apply doesn't + -- start anything. Picking a non-NONE type surfaces the relevant + -- tunables (helper handles hide/show per effect). + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + -- 1 Hz default ≈ 1-second cycle, matching the legacy AD Pulsate + -- Border pulse rate. Frame Border / Defensive Icon use 0.25 which + -- reads as a slow gentle pulse at full-frame scale; at icon scale + -- (24px) the same rate looks like a static dim border because the + -- transitions are too gradual to perceive. + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, hideSwipe = false, hideIcon = false, showDuration = true, durationFont = "Friz Quadrata TT", durationScale = 1.0, durationOutline = "OUTLINE", @@ -569,7 +790,46 @@ local TYPE_DEFAULTS = { stackColor = {r = 1, g = 1, b = 1, a = 1}, expiringEnabled = false, expiringThreshold = 30, expiringThresholdMode = "PERCENT", expiringColor = {r = 1, g = 0.2, b = 0.2, a = 1}, - expiringPulsate = false, + -- Expiring Tint overlay (secret-safe). Default OFF, red — also feeds the + -- colour picker's Default button via the proxy's __dfDefaults = TYPE_DEFAULTS. + expiringTintEnabled = false, + expiringTintColor = {r = 1, g = 0.2, b = 0.2, a = 0.5}, -- #FF3333 @ 50% (matches expiring border red) + expiringPulsate = false, -- legacy; migrated to ExpiringAnimationType + -- Master enable for the whole Expiring feature. Default true so + -- existing configs are unaffected; turning it OFF disables every + -- expiring override (colour / thickness / alpha / animation / pulse / + -- bounce) regardless of their individual settings, and hides the rest + -- of the Expiring panel. + expiringFeatureEnabled = true, + -- Stage 5.1d.2 + parity: full Border Animation effect set as the value + -- the expiring callback swaps into spec.animation when remaining < + -- threshold. NONE = no animation override. The expiring animation + -- carries its OWN complete tunable set (colour, particles, thickness, + -- offset, …) independent of the base Border Animation — mirrors the + -- base defaults so the two panels read identically. + ExpiringAnimationType = "NONE", + ExpiringAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + ExpiringAnimationFrequency = 1, + ExpiringAnimationParticles = 8, + ExpiringAnimationLength = 8, + ExpiringAnimationThickness = 3, + ExpiringAnimationScale = 1, + ExpiringAnimationInset = 0, + ExpiringAnimationOffsetX = 0, + ExpiringAnimationOffsetY = 0, + ExpiringAnimationMask = false, + ExpiringAnimationSidesAxis = "HORIZONTAL", + ExpiringAnimationCornerLength = 10, + -- Stage 5.1d.3: per-state thickness + alpha overrides. Default to + -- 1 / 1 — same thickness as the base (1) and slightly more opaque + -- than the base alpha (0.8), so out of the box a user enabling + -- Expiring Color Override sees the border tick to fully opaque red + -- below threshold (subtle "more solid" feel). Move the sliders + -- higher / lower for stronger emphasis. Only take effect when the + -- expiring ticker is running (i.e. user has at least one expiring + -- feature on — colour override, animation, alpha pulse, or bounce). + ExpiringBorderSize = 1, + ExpiringBorderAlpha = 1, expiringWholeAlphaPulse = false, expiringBounce = false, frameLevel = 30, frameStrata = "INHERIT", showWhenMissing = false, missingDesaturate = false, @@ -578,7 +838,38 @@ local TYPE_DEFAULTS = { anchor = "TOPLEFT", offsetX = 0, offsetY = 0, size = 24, scale = 1.0, alpha = 1.0, color = {r = 1, g = 1, b = 1, a = 1}, - showBorder = true, borderThickness = 1, borderInset = 1, + -- Canonical border keys (Stage 5.2). Legacy showBorder / + -- borderThickness / borderInset migrated on ADDON_LOADED. BorderColor + -- defaults to opaque black, matching the square's pre-migration + -- hardcoded border so existing users see no change. The rest seed + -- CreateBorderControls' dropdowns / pickers on first open. + ShowBorder = true, BorderSize = 1, BorderInset = 1, + BorderColor = {r = 0, g = 0, b = 0, a = 1}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderOffsetX = 0, + BorderOffsetY = 0, + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, hideSwipe = false, hideIcon = false, showDuration = true, durationFont = "Friz Quadrata TT", durationScale = 1.0, durationOutline = "OUTLINE", @@ -591,9 +882,34 @@ local TYPE_DEFAULTS = { stackOutline = "OUTLINE", stackAnchor = "BOTTOMRIGHT", stackX = 0, stackY = 0, stackColor = {r = 1, g = 1, b = 1, a = 1}, + -- Master enable for the whole Expiring feature (Stage 5.2 — mirrors + -- the icon). Default true so existing configs are unaffected. + expiringFeatureEnabled = true, expiringEnabled = false, expiringThreshold = 30, expiringThresholdMode = "PERCENT", expiringColor = {r = 1, g = 0.2, b = 0.2, a = 1}, + expiringTintEnabled = false, + expiringTintColor = {r = 1, g = 0.2, b = 0.2, a = 0.5}, -- #FF3333 @ 50% (matches expiring border red) expiringPulsate = false, + -- Stage 5.2 expiring-border overrides (shared backend with the icon). + -- ExpiringBorderColor is SEPARATE from the fill's expiringColor — the + -- fill and border each get their own expiring tint. Defaults to the + -- same red so out of the box both "turn red", but they're independent. + ExpiringBorderColor = {r = 1, g = 0.2, b = 0.2, a = 1}, + ExpiringBorderSize = 1, + ExpiringBorderAlpha = 1, + ExpiringAnimationType = "NONE", + ExpiringAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + ExpiringAnimationFrequency = 1, + ExpiringAnimationParticles = 8, + ExpiringAnimationLength = 8, + ExpiringAnimationThickness = 3, + ExpiringAnimationScale = 1, + ExpiringAnimationInset = 0, + ExpiringAnimationOffsetX = 0, + ExpiringAnimationOffsetY = 0, + ExpiringAnimationMask = false, + ExpiringAnimationSidesAxis = "HORIZONTAL", + ExpiringAnimationCornerLength = 10, expiringWholeAlphaPulse = false, expiringBounce = false, frameLevel = 30, frameStrata = "INHERIT", showWhenMissing = false, @@ -605,12 +921,41 @@ local TYPE_DEFAULTS = { texture = "Interface\\TargetingFrame\\UI-StatusBar", fillColor = {r = 1, g = 1, b = 1, a = 1}, bgColor = {r = 0, g = 0, b = 0, a = 0.5}, - showBorder = true, borderThickness = 1, - borderColor = {r = 0, g = 0, b = 0, a = 1}, + -- Canonical border keys (Stage 5.3). Legacy showBorder / + -- borderThickness / borderColor migrated on ADDON_LOADED. BorderInset + -- defaults to 0 so the ring sits FLUSH outside the bar as before. + -- BorderColor defaults to opaque black (the bar's pre-migration look). + ShowBorder = true, BorderSize = 1, BorderInset = 0, + BorderColor = {r = 0, g = 0, b = 0, a = 1}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, alpha = 1.0, barColorByTime = false, expiringEnabled = false, expiringThreshold = 5, expiringColor = {r = 1, g = 0.2, b = 0.2, a = 1}, + expiringTintEnabled = false, + expiringTintColor = {r = 1, g = 0.2, b = 0.2, a = 0.5}, -- #FF3333 @ 50% (matches expiring border red) showDuration = true, durationFont = "Friz Quadrata TT", durationScale = 1.0, durationOutline = "OUTLINE", durationAnchor = "CENTER", durationX = 0, durationY = 0, @@ -621,6 +966,61 @@ local TYPE_DEFAULTS = { -- Frame-level types: mirror the inline literals in EnsureTypeConfig so the -- colour-picker Default button (and any other consumer of __dfDefaults) can -- resolve a default value for keys like "color" and "expiringColor". + -- Border-type (Stage 5.4): full canonical DF.Border defaults so + -- CreateBorderControls' dropdowns / pickers read sensible values. The + -- legacy style/thickness/inset/color are migrated on load. + border = { + ShowBorder = true, BorderSize = 2, BorderInset = 0, + BorderColor = {r = 1, g = 1, b = 1, a = 1}, + BorderStyle = "SOLID", + BorderBlendMode = "BLEND", + BorderOffsetX = 0, + BorderOffsetY = 0, + BorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + BorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + BorderGradientDirection = "HORIZONTAL", + BorderShadowEnabled = false, + BorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + BorderShadowSize = 1, + BorderShadowOffsetX = 1, + BorderShadowOffsetY = -1, + BorderAnimationType = "NONE", + BorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + BorderAnimationFrequency = 1, + BorderAnimationParticles = 8, + BorderAnimationLength = 8, + BorderAnimationThickness = 3, + BorderAnimationScale = 1, + BorderAnimationInset = 0, + BorderAnimationOffsetX = 0, + BorderAnimationOffsetY = 0, + BorderAnimationMask = false, + BorderAnimationSidesAxis = "HORIZONTAL", + BorderAnimationCornerLength = 10, + -- Draw above the frame's class border (parent+10) / aggro (parent+9). + drawAboveFrameBorder = true, + -- Expiring-border overrides (Stage 5.4 parity with icon/square). + expiringFeatureEnabled = true, + expiringEnabled = false, expiringThreshold = 30, expiringThresholdMode = "PERCENT", + expiringColor = {r = 1, g = 0.2, b = 0.2, a = 1}, + expiringPulsate = false, + ExpiringBorderSize = 2, + ExpiringBorderAlpha = 1, + ExpiringAnimationType = "NONE", + ExpiringAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + ExpiringAnimationFrequency = 1, + ExpiringAnimationParticles = 8, + ExpiringAnimationLength = 8, + ExpiringAnimationThickness = 3, + ExpiringAnimationScale = 1, + ExpiringAnimationInset = 0, + ExpiringAnimationOffsetX = 0, + ExpiringAnimationOffsetY = 0, + ExpiringAnimationMask = false, + ExpiringAnimationSidesAxis = "HORIZONTAL", + ExpiringAnimationCornerLength = 10, + showWhenMissing = false, + }, healthbar = { mode = "Replace", color = {r = 1, g = 1, b = 1, a = 1}, blend = 0.5, expiringEnabled = false, expiringThreshold = 30, expiringThresholdMode = "PERCENT", @@ -2013,17 +2413,10 @@ local function GetOrCreatePreviewCustomBorder(mockFrame, key) end local pool = mockFrame.dfPreviewCustomBorders if pool[key] then return pool[key] end - - local ch = CreateFrame("Frame", nil, mockFrame) - ch:SetAllPoints() - ch:SetFrameLevel(mockFrame:GetFrameLevel() + 4) -- Below shared border (+5) - ch:Hide() - ch.topLine = ch:CreateTexture(nil, "OVERLAY") - ch.bottomLine = ch:CreateTexture(nil, "OVERLAY") - ch.leftLine = ch:CreateTexture(nil, "OVERLAY") - ch.rightLine = ch:CreateTexture(nil, "OVERLAY") - pool[key] = ch - return ch + -- Stage 5.4: preview uses DF.Border (mirrors the runtime), below the + -- shared preview border (+5). + pool[key] = DF.Border:New(mockFrame, { frameLevelOffset = 4, layer = "OVERLAY" }) + return pool[key] end local function RefreshPreviewEffects() @@ -2031,14 +2424,14 @@ local function RefreshPreviewEffects() local mockFrame = framePreview.mockFrame if not mockFrame then return end - -- Reset shared border overlay - if framePreview.borderOverlay and DF.ApplyHighlightStyle then - DF.ApplyHighlightStyle(framePreview.borderOverlay, "NONE", 2, 0, 1, 1, 1, 1) + -- Reset shared border overlay (Stage 5.4: DF.Border — hide edges + anim) + if framePreview.borderOverlay then + DF.Border:Apply(framePreview.borderOverlay, { enabled = false }) end -- Reset custom border overlays if mockFrame.dfPreviewCustomBorders then for _, ch in pairs(mockFrame.dfPreviewCustomBorders) do - DF.ApplyHighlightStyle(ch, "NONE", 2, 0, 1, 1, 1, 1) + DF.Border:Apply(ch, { enabled = false }) end end if framePreview.healthFill then @@ -2060,29 +2453,20 @@ local function RefreshPreviewEffects() if type(auraCfg) ~= "table" then -- skip corrupted entries else - -- Border effect (uses highlight system for all 6 styles) - -- Mirrors live frame logic: shared borders use single overlay (first claim wins), - -- custom borders get independent per-aura overlays so multiple borders can stack. - if auraCfg.border and DF.ApplyHighlightStyle then - local clr = auraCfg.border.color or {r = 1, g = 1, b = 1, a = 1} - local thickness = auraCfg.border.thickness or 2 - local inset = auraCfg.border.inset or 0 - -- Migrate old style names (Solid→SOLID, Glow→GLOW, Pulse→SOLID) - local style = auraCfg.border.style or "SOLID" - if style == "Solid" then style = "SOLID" - elseif style == "Glow" then style = "GLOW" - elseif style == "Pulse" then style = "SOLID" end - + -- Border effect (Stage 5.4: rendered via DF.Border, mirroring the runtime). + -- Config is canonical (migrated on load); BuildSpec resolves Style / + -- animation / gradient / etc. Shared borders use a single overlay (first + -- claim wins); custom borders get independent per-aura overlays so multiple + -- can stack. + if auraCfg.border and auraCfg.border.ShowBorder ~= false then + local spec = DF.Border:BuildSpec(auraCfg.border, "") + if not spec.color then spec.color = { r = 1, g = 1, b = 1, a = 1 } end + spec.enabled = true if auraCfg.border.borderMode == "custom" then - -- Custom border: independent overlay per aura (can stack with shared + other custom) - local ch = GetOrCreatePreviewCustomBorder(mockFrame, auraName) - DF.ApplyHighlightStyle(ch, style, thickness, inset, - clr.r or 1, clr.g or 1, clr.b or 1, clr.a or 1) + DF.Border:Apply(GetOrCreatePreviewCustomBorder(mockFrame, auraName), spec) elseif not sharedBorderClaimed and framePreview.borderOverlay then - -- Shared border: first claim wins (matches live frame priority system) sharedBorderClaimed = true - DF.ApplyHighlightStyle(framePreview.borderOverlay, style, thickness, inset, - clr.r or 1, clr.g or 1, clr.b or 1, clr.a or 1) + DF.Border:Apply(framePreview.borderOverlay, spec) end end @@ -2446,16 +2830,47 @@ end local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOffset, layoutGroup, indicatorID) local proxy = optProxy or CreateProxy(auraName, typeKey) local contentWidth = width or 248 + -- widgets[] entries are {widget, height} so the reflow path can use + -- group.calculatedHeight (current after a LayoutChildren) while + -- non-group widgets fall back to the stored at-build-time height. local widgets = {} - local totalHeight = 10 + (yOffset or 0) -- top padding + optional offset + local startY = 10 + (yOffset or 0) -- top padding + optional offset + local totalHeight = startY local function AddWidget(widget, height) widget:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -totalHeight) if widget.SetWidth then widget:SetWidth(contentWidth - 10) end - tinsert(widgets, widget) + tinsert(widgets, { widget = widget, height = height or 30 }) totalHeight = totalHeight + (height or 30) end + -- Reflow all widgets in this BuildTypeContent's stack. When a group's + -- LayoutChildren updates its own height (e.g. Border's animation + -- widgets show/hide), the siblings below were anchored at FIXED y + -- positions based on the old height — they stay put, causing overlap + -- (group grew) or large gap (group shrank). Walk the list re-anchor + -- each widget at the running total height, reading the current + -- group.calculatedHeight for groups so the new layout flows correctly. + -- The host container's height is updated too so any parent scroll + -- range stays accurate. + parent.dfAD_ReflowWidgets = function() + local y = startY + for _, entry in ipairs(widgets) do + local w = entry.widget + local h + if w.calculatedHeight then + -- SettingsGroup tracks its current height after LayoutChildren. + h = w.calculatedHeight + else + h = entry.height + end + w:ClearAllPoints() + w:SetPoint("TOPLEFT", parent, "TOPLEFT", 5, -y) + y = y + h + end + parent:SetHeight(y) + end + local function AddGroup(header, buildFn, showSummary) local group = GUI:CreateSettingsGroup(parent, contentWidth - 10, { collapsible = true, @@ -2468,6 +2883,25 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff AddWidget(group, h) end + -- Lightweight subheader for inline section dividers inside a + -- SettingsGroup. Smaller and dimmer than GUI:CreateHeader (which is + -- for top-level group headers) — used in the Expiring section to + -- separate State Overrides from Icon Effects. Returned as a Frame + -- so it composes with g:AddWidget like every other widget. + local function CreateInlineSubheader(text) + local frame = CreateFrame("Frame", nil, parent) + frame:SetHeight(18) + local label = frame:CreateFontString(nil, "OVERLAY") + if GUI.SetSettingsFont then + GUI:SetSettingsFont(label, 8, "") + end + label:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 1) + label:SetText(text) + local c = GetThemeColor() + label:SetTextColor(c.r, c.g, c.b, 0.75) + return frame + end + -- ── COPY FROM (placed indicators only: icon, square, bar) ── if indicatorID and (typeKey == "icon" or typeKey == "square" or typeKey == "bar") then local copyContainer = CreateFrame("Frame", nil, parent) @@ -2608,6 +3042,71 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff -- Color picker callback shorthand — refreshes both the AD preview and live frames local function RPL() if RefreshPreviewLightweight then RefreshPreviewLightweight() end RefreshLiveFramesThrottled() end + -- Shared Expiring "State Overrides" panel for the BORDERED placed indicators + -- (icon / square / bar). These three blocks were near-identical; this + -- collapses them to one builder parameterised by the few real differences + -- (opts): dualColor (square's separate fill+border colours), alphaHandleKey + -- (which colour's alpha the slider edits), thicknessMax, durationPriority + -- (bar), and iconEffects {fillPulsate, wholeAlpha, bounce}. The master + -- enable, threshold row, State-Overrides rows, and the shared + -- CreateAnimationControls block are identical across all three. + -- (healthbar's Expiring is a different, border-less panel — not built here.) + -- AD's Expiring panel now renders through the SHARED GUI:CreateExpiringControls + -- (the same helper the standard buff aura icons use) — AD's design IS the + -- reference, so this is a thin adapter mapping AD's proxy keys + per-type + -- options (dualColor, alphaHandleKey, thicknessMax, durationPriority, + -- iconEffects) onto the shared helper. RPL = repaint; AuraDesigner_RefreshPage + -- = full rebuild (threshold-mode toggle needs it). + local function AddExpiringBorderGroup(opts) + opts = opts or {} + AddGroup(L["Expiring"], function(g) + GUI:CreateExpiringControls(g, proxy, { + parent = parent, + width = contentWidth - 10, + masterLabel = L["Enable Expiring"], + fullUpdate = RPL, + lightColors = RPL, + lightGeometry = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then parent.dfAD_ReflowWidgets() end + end, + refreshPage = function() DF:AuraDesigner_RefreshPage() end, + afterThreshold = opts.durationPriority and function(addGated) + local dpRow, dpH = CreateExpiringDurationPriorityRow(parent, auraName, typeKey, contentWidth - 10) + if dpRow then addGated(dpRow, dpH) end + end or nil, + keys = { + master = "expiringFeatureEnabled", + threshold = "expiringThreshold", + thresholdMode = "expiringThresholdMode", + colorOverride = "expiringEnabled", + color = "expiringColor", + borderColor = "ExpiringBorderColor", + alphaHandleColor = opts.alphaHandleKey or "expiringColor", + thickness = "ExpiringBorderSize", + animPrefix = "ExpiringAnimation", + fillPulsate = "expiringPulsate", + wholeAlpha = "expiringWholeAlphaPulse", + bounce = "expiringBounce", + tintEnable = "expiringTintEnabled", + tintColor = "expiringTintColor", + }, + include = { + threshold = true, + colorOverride = true, + dualColor = opts.dualColor, + alpha = true, + thickness = true, thicknessMin = 0, thicknessMax = opts.thicknessMax or 5, + animation = true, + iconEffects = opts.iconEffects, + tint = true, -- secret-safe; works on all auras + }, + lightTint = RPL, + }) + end) + end + if typeKey == "icon" then -- Position AddGroup(L["Position"], function(g) @@ -2647,12 +3146,68 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(desatCb, 28) if not proxy.showWhenMissing then desatCb:Hide() end end) - -- Border + -- Border (Stage 5.1c — unified controls via CreateBorderControls). + -- Show / Thickness / Inset are the same widgets as before; the helper + -- adds Style / Texture / Color / Gradient / Shadow / BlendMode / + -- Offset / Alpha on top. Animation, classColor, roleColor, and the + -- colorByTime checkbox are deliberately omitted — animation isn't + -- wired through AD's expiring system yet, class/role don't fit aura + -- indicators (the indicator's job is to show aura state, not unit + -- identity), and AD's Expiring section already covers "colour by + -- time remaining" implicitly through its own colour curve. AddGroup(L["Border"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], proxy, "borderEnabled"), 28) - g:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], 1, 5, 1, proxy, "borderThickness"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -3, 5, 1, proxy, "borderInset"), 54) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + animate = true, + }, + -- IMPORTANT: AD's per-aura proxy only triggers + -- RefreshLiveFramesThrottled + RefreshPreviewLightweight + -- on direct key assignment (proxy.X = v) via __newindex. + -- CreateColorPicker and the Border Alpha slider mutate + -- SUB-TABLE fields (proxy.BorderColor.a = v) which reads + -- through __index then writes to the returned table — no + -- __newindex fires, no refresh runs, and both the live + -- frame AND the AD preview window stay on the pre-edit + -- colour until /reload. + -- + -- RPL (defined above in BuildTypeContent) runs both the + -- preview refresh and the throttled live-frame refresh, so + -- colour-picker / alpha-slider / size-drag updates land + -- everywhere consistently within the 100ms debounce. + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + -- refreshStates re-evaluates hideOn on the Border group's + -- widgets and then reflows the sibling groups below in the + -- card body so the Expiring / Duration Text / Stack Count + -- groups slide up or down to track the Border group's new + -- height. Without the reflow, the Border group's internal + -- LayoutChildren updates its own height but the siblings + -- stay at fixed y positions — animation widgets surface + -- and overlap Expiring, or hide and leave a gap above + -- Expiring. dfAD_ReflowWidgets is set on the BuildTypeContent + -- parent and walks the whole widget stack. + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then + parent.dfAD_ReflowWidgets() + end + end, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) end) + -- Expiring (moved up next to Border — the border's expiring colour and + -- the per-icon effects all key off the same threshold, so grouping + -- them adjacent reads more naturally than burying Expiring at the + -- bottom of the panel.) + -- Icon: single Expiring Colour, Whole Alpha Pulse + Bounce effects. + AddExpiringBorderGroup({ + thicknessMax = 5, + iconEffects = { wholeAlpha = true, bounce = true }, + }) -- Duration Text AddGroup(L["Duration Text"], function(g) g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration Text"], proxy, "showDuration"), 28) @@ -2681,15 +3236,6 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(GUI:CreateSlider(parent, L["Stack Offset Y"], -150, 150, 1, proxy, "stackY"), 54) g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) end) - -- Expiring - AddGroup(L["Expiring"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Expiring Color Override"], proxy, "expiringEnabled"), 28) - g:AddWidget(CreateExpiringThresholdRow(parent, proxy, contentWidth - 10), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Expiring Color"], proxy, "expiringColor", true, RPL, RPL, true), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Pulsate Border"], proxy, "expiringPulsate"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Whole Alpha Pulse"], proxy, "expiringWholeAlphaPulse"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Bounce"], proxy, "expiringBounce"), 28) - end) elseif typeKey == "square" then -- Position @@ -2719,12 +3265,43 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff DF.AuraDesigner.Engine:ForceRefreshAllFrames() end), 28) end) - -- Border + -- Border (Stage 5.2 — unified controls via CreateBorderControls). + -- Same full toolkit as the icon's base border: Style / Texture / Colour + -- / Gradient / Shadow / Blend / Offset / Alpha + Animation. The + -- square's expiring system tints the FILL (not the border), so the + -- icon's expiring-border overrides are intentionally NOT added here. AddGroup(L["Border"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], proxy, "showBorder"), 28) - g:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], 1, 5, 1, proxy, "borderThickness"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -3, 5, 1, proxy, "borderInset"), 54) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + animate = true, + }, + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then + parent.dfAD_ReflowWidgets() + end + end, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) end) + -- Expiring (moved up next to Border — matches the icon indicator's + -- panel ordering. Border colour, fill pulsate, alpha pulse, and bounce + -- all key off the same threshold; grouping them adjacent to Border + -- reads more naturally than burying Expiring at the bottom.) + -- Square: separate fill + border Expiring colours (alpha handle edits the + -- BORDER colour); Fill Pulsate + Whole Alpha Pulse + Bounce effects. + AddExpiringBorderGroup({ + thicknessMax = 5, + dualColor = true, + alphaHandleKey = "ExpiringBorderColor", + iconEffects = { fillPulsate = true, wholeAlpha = true, bounce = true }, + }) -- Duration Text AddGroup(L["Duration Text"], function(g) g:AddWidget(GUI:CreateCheckbox(parent, L["Show Duration Text"], proxy, "showDuration"), 28) @@ -2753,15 +3330,6 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(GUI:CreateSlider(parent, L["Stack Offset Y"], -150, 150, 1, proxy, "stackY"), 54) g:AddWidget(GUI:CreateColorPicker(parent, L["Stack Text Color"], proxy, "stackColor", true, RPL, RPL, true), 28) end) - -- Expiring - AddGroup(L["Expiring"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Expiring Color Override"], proxy, "expiringEnabled"), 28) - g:AddWidget(CreateExpiringThresholdRow(parent, proxy, contentWidth - 10), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Expiring Color"], proxy, "expiringColor", true, RPL, RPL, true), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Fill Pulsate"], proxy, "expiringPulsate"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Whole Alpha Pulse"], proxy, "expiringWholeAlphaPulse"), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Bounce"], proxy, "expiringBounce"), 28) - end) elseif typeKey == "bar" then -- Position @@ -2804,11 +3372,30 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff g:AddWidget(GUI:CreateSlider(parent, L["Frame Level"], -10, 30, 1, proxy, "frameLevel"), 54) g:AddWidget(GUI:CreateDropdown(parent, L["Frame Strata"], FRAME_STRATA_OPTIONS, proxy, "frameStrata"), 54) end) - -- Border + -- Border (Stage 5.3 — unified controls via CreateBorderControls). + -- Full toolkit (Style / Texture / Colour / Gradient / Shadow / Blend / + -- Inset / Alpha + Animation). No offset (the bar has its own X/Y) and + -- no class/role (it's an aura bar, not unit identity). The bar's + -- expiring tints the FILL via its colour curve, so the icon/square + -- expiring-border overrides are intentionally not added here. AddGroup(L["Border"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], proxy, "showBorder"), 28) - g:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], 1, 4, 1, proxy, "borderThickness"), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], proxy, "borderColor", true, RPL, RPL, true), 28) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, blendMode = true, gradient = true, + shadow = true, alpha = true, animate = true, + }, + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then + parent.dfAD_ReflowWidgets() + end + end, + sizeMin = 1, sizeMax = 5, sizeStep = 1, + }) end) -- Expiring AddGroup(L["Expiring"], function(g) @@ -2833,25 +3420,47 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff end) elseif typeKey == "border" then - -- Appearance + -- Appearance — full DF.Border toolkit (Stage 5.4). The legacy 5 + -- styles are now combinations: Solid = Style SOLID; Glow = Style + -- TEXTURE + DF Glow; Dashed/Animated = Border Thickness 0 + DF Dash + -- (speed 0 / >0); Corners = Border Thickness 0 + Corners Only. + -- include offset too — this border covers the whole frame, so nudging + -- it can be useful. No class/role (it's an aura indicator). AddGroup(L["Appearance"], function(g) - g:AddWidget(GUI:CreateDropdown(parent, L["Style"], BORDER_STYLE_OPTIONS, proxy, "style"), 54) - g:AddWidget(GUI:CreateColorPicker(parent, L["Color"], proxy, "color", true, RPL, RPL, true), 28) - g:AddWidget(GUI:CreateSlider(parent, L["Thickness"], 1, 8, 1, proxy, "thickness"), 54) - g:AddWidget(GUI:CreateSlider(parent, L["Inset"], 0, 8, 1, proxy, "inset"), 54) + GUI:CreateBorderControls(g, proxy, "", { + parent = parent, + include = { + inset = true, offset = true, blendMode = true, + gradient = true, shadow = true, alpha = true, + animate = true, + }, + fullUpdate = RPL, + lightUpdate = RPL, + lightColors = RPL, + refreshStates = function() + g:LayoutChildren() + if parent.dfAD_ReflowWidgets then parent.dfAD_ReflowWidgets() end + end, + sizeMin = 0, sizeMax = 8, sizeStep = 1, + }) + -- Draw order: lift this border above the frame's own class/role + -- border so it fully covers it (on by default). Off tucks it back + -- underneath the frame border (the pre-5.4 stacking). + g:AddWidget(GUI:CreateCheckbox(parent, L["Draw above frame border"], proxy, "drawAboveFrameBorder", RPL), 28) g:AddWidget(GUI:CreateCheckbox(parent, L["Show When Missing"], proxy, "showWhenMissing", function() DF.AuraDesigner.Engine:ForceRefreshAllFrames() end), 28) end) - -- Expiring - AddGroup(L["Expiring"], function(g) - g:AddWidget(GUI:CreateCheckbox(parent, L["Expiring Color Override"], proxy, "expiringEnabled"), 28) - g:AddWidget(CreateExpiringThresholdRow(parent, proxy, contentWidth - 10), 54) - do local dpRow, dpH = CreateExpiringDurationPriorityRow(parent, auraName, typeKey, contentWidth - 10) - if dpRow then g:AddWidget(dpRow, dpH) end end - g:AddWidget(GUI:CreateColorPicker(parent, L["Expiring Color"], proxy, "expiringColor", true, RPL, RPL, true), 28) - g:AddWidget(GUI:CreateCheckbox(parent, L["Pulsate"], proxy, "expiringPulsate"), 28) - end) + -- Expiring — full parity with icon/square (Stage 5.4): master enable + + -- State Overrides (border thickness / colour / alpha / animation swap) + -- + the existing Pulsate. The Expiring Animation lets the border swap + -- effect below threshold (e.g. solid → marching DF Dash). + -- Bar: single Expiring Colour, thicker max (8), a duration-priority row, + -- and no Icon-Effects (bars don't pulse/bounce the whole icon). + AddExpiringBorderGroup({ + thicknessMax = 8, + durationPriority = true, + }) elseif typeKey == "healthbar" then -- Appearance @@ -3421,6 +4030,9 @@ local function CreateEnableBanner(parent) DF:AuraDesigner_RefreshPage() DF:InvalidateAuraLayout() DF:UpdateAllFrames() + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end end, function() -- Cancelled — revert checkbox self:SetChecked(false) @@ -3431,6 +4043,11 @@ local function CreateEnableBanner(parent) DF:AuraDesigner_RefreshPage() DF:InvalidateAuraLayout() DF:UpdateAllFrames() + -- Sync AD indicators to the now-disabled state — clears the leftover + -- indicators instead of leaving them frozen on screen until /reload. + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end end end) @@ -3755,16 +4372,9 @@ local function CreateFramePreview(parent, yOffset, rightPanelRef) container.hpText = hpText end - -- Border overlay (used when border effect is active) - -- Uses highlight-compatible structure so DF.ApplyHighlightStyle can render all 6 modes - container.borderOverlay = CreateFrame("Frame", nil, mockFrame) - container.borderOverlay:SetAllPoints() - container.borderOverlay:SetFrameLevel(mockFrame:GetFrameLevel() + 5) - container.borderOverlay.topLine = container.borderOverlay:CreateTexture(nil, "OVERLAY") - container.borderOverlay.bottomLine = container.borderOverlay:CreateTexture(nil, "OVERLAY") - container.borderOverlay.leftLine = container.borderOverlay:CreateTexture(nil, "OVERLAY") - container.borderOverlay.rightLine = container.borderOverlay:CreateTexture(nil, "OVERLAY") - container.borderOverlay:Hide() + -- Border overlay (used when border effect is active) — Stage 5.4: a + -- DF.Border widget covering the mock frame, mirroring the runtime. + container.borderOverlay = DF.Border:New(mockFrame, { frameLevelOffset = 5, layer = "OVERLAY" }) -- Click background — no-op in new UI (was used to deselect aura in old tile view) local bgClick = CreateFrame("Button", nil, mockFrame) @@ -5851,11 +6461,16 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) local parent = page.child -- ======================================== - -- REUSE: If mainFrame already exists and db hasn't changed (same mode), - -- just re-parent, show, and refresh. Avoids full teardown on resize. - -- A mode switch (Party↔Raid) changes db, so we must rebuild in that case. + -- REUSE: If mainFrame already exists, db hasn't changed (same mode) AND the + -- frame dimensions are unchanged, just re-parent, show, and refresh. A mode + -- switch (Party↔Raid) changes db; an auto-layout switch keeps the SAME db + -- reference but changes frameWidth/Height — both must force a full rebuild so + -- the preview mock resizes to the active layout's frame size. -- ======================================== - if mainFrame and prevDB == dbRef then + local _adFDB = (DF.GetDB and DF:GetDB((GUI and GUI.SelectedMode) or "party")) or {} + local _adW, _adH = _adFDB.frameWidth or 125, _adFDB.frameHeight or 64 + if mainFrame and prevDB == dbRef + and mainFrame.dfBuiltFrameW == _adW and mainFrame.dfBuiltFrameH == _adH then mainFrame:SetParent(parent) mainFrame:SetAllPoints() mainFrame:Show() @@ -5886,19 +6501,49 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) -- ======================================== mainFrame = CreateFrame("Frame", nil, parent) mainFrame:SetAllPoints() - - -- Override RefreshStates: Aura Designer uses its own layout system + -- Record the frame dims this build was made for, so the reuse-guard above can + -- detect an auto-layout switch (same db, different frameWidth/Height) and rebuild. + mainFrame.dfBuiltFrameW = _adW + mainFrame.dfBuiltFrameH = _adH + + -- Override RefreshStates: Aura Designer uses its own layout system. + -- + -- This hook gets called by anything that walks the GUI parent chain + -- looking for a page with RefreshStates+children — including + -- CreateInfoBanner's TriggerHostRelayout after every measure cycle. + -- AuraDesigner_RefreshPage is a heavyweight rebuild (destroys + + -- recreates every effect card on the active tab), so firing it + -- from a banner's auto-resize cascade meant: each new banner from + -- BuildEffectsTab triggered SetText → schedule DoRecomputeHeight → + -- TriggerHostRelayout → page:RefreshStates → AuraDesigner_RefreshPage + -- → SwitchTab → BuildEffectsTab → create more banners → repeat at + -- ~9 Hz, locking up the GUI the moment the perf-warning banner + -- surfaced (because picking an animation triggered the chain). + -- + -- The fix: only call AuraDesigner_RefreshPage when the page + -- dimensions actually changed. GUI window resize cases (the real + -- reason this hook exists) still rebuild; banner-cascade-as-noop + -- cases stop the loop. page.RefreshStates = function(self) local pageH = self:GetHeight() self.child:SetHeight(pageH) - if self.child and GUI.contentFrame then - self.child:SetWidth(GUI.contentFrame:GetWidth() - 30) + local newW = GUI.contentFrame and (GUI.contentFrame:GetWidth() - 30) or nil + if self.child and newW then + self.child:SetWidth(newW) end -- Keep parent scroll at 0 — only the right panel should scroll local parentScroll = self:GetParent() if parentScroll and parentScroll.SetVerticalScroll then parentScroll:SetVerticalScroll(0) end + -- Skip the heavyweight rebuild when nothing actually changed — + -- only fire it on genuine size transitions (window resize / tab + -- switch / first show). + if self._lastRefreshStatesH == pageH and self._lastRefreshStatesW == newW then + return + end + self._lastRefreshStatesH = pageH + self._lastRefreshStatesW = newW DF:AuraDesigner_RefreshPage() end diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cfa329..f97a9243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # DandersFrames Changelog +## [Unreleased] + +### New Features + +* (Frames) **Unified border system** — every border (frame, buff/debuff icons, aura bars, defensive icons, missing-buff, resource bar, pet frames, targeted spells) now runs through one engine with consistent **Style / Colour / Alpha / Gradient** controls. (by Krathe) +* (Borders) Added optional **border animations** — 10 effects (pulse, wipe, ripple, segment reveal, sides/corners-only, proc glow, dash, and more), available wherever a border is drawn. (by Krathe) +* (Icons) Status icons now use crisp **modern Blizzard atlas art** (ready check, summon, resurrect, phased, vehicle, main tank/assist, AFK), with automatic fallback to the legacy texture. (by Krathe) +* (Icons) Each status-icon section header now shows a **live preview** — the icon swatch, or its status text when "Show as Text" is on — greyed out when the icon is disabled. (by Krathe) +* (Icons) New **BG objective carrier icon** — lights up a friendly party/raid member carrying a battleground objective (flag or orb), so you can spot the carrier on your frames. (by Krathe) +* (Role Icon) **Custom role icons** — choose Blizzard, DF, or your own external texture per role (Tank / Healer / DPS). (by Krathe) +* (AFK Icon) Dedicated **Timer Text** controls for the elapsed-time counter (font, size, outline, colour, offset). The countdown is zero-padded `MM:SS`, left-justified and stays steady as it ticks. (by Krathe) +* (Fonts) Bundled **Roboto Mono** (SemiBold/Bold) — a monospaced option for perfectly static countdown text. (by Krathe) +* (Auto Layouts) Added `/df clearoverride ` to **remove a stuck per-layout override** directly — for overrides the settings UI can't reach (e.g. a pinned-players override while not in a raid). (by Krathe) + +### Improvements + +* (Performance) The expiring-border ticker now **throttles and staggers per entry** to cut overhead when many borders are expiring at once. (by Krathe) +* (Defaults) Tuned some new-profile defaults — buff icon sizing/spacing, stack-count offsets, Stack/Duration outline shadow, and a flush expiring-border inset. (by Krathe) +* (Reduced Max Health) The reduced-max-health bar's default colour is now a **translucent grey (50% @ ~80% alpha)** instead of opaque black, so it reads clearly on a dark health bar; profiles still on the old solid black are migrated automatically (a customised colour is left alone). (by Krathe) +* (Boss Debuffs) **Border Scale** can now go negative to hide the icon border, with a wider range, a step of 1, and an explanatory tip. (by Krathe) +* (Icons) Reorganised **every status-icon's settings into collapsible Settings / Appearance / Position boxes** (matching the Aura Designer layout), so each section is easier to scan. (by Krathe) +* (Icons) Status-icon font, size, colour and position changes now apply to **live frames instantly** — no `/reload`. (by Krathe) +* (Icons) Renamed **"Raid Target Icon" → "Target Marker Icon"**, and its header preview now shows the four common markers (square / cross / triangle / circle). (by Krathe) +* (Auto Layouts) The **override tooltip and `/df overrides` now read clearly** — each changed setting shows as a breadcrumb path with its value, only values that differ from global are listed, Text Designer elements show their names, and the override counts agree across the badge, status line and chat. (by Krathe) + +### Bug Fixes + +* (Range) The frame border (and other element borders) now reliably **fade out of range**, preserved across border re-renders. (by Krathe) +* (Defensive Icon) The defensive cooldown icon and its border now render **above auras** and stay co-planar with the icon. (by Krathe) +* (Role Icons) **Show Tank / Healer / DPS** toggles now apply live without a `/reload`, and are properly decoupled from the Hide-in-Combat gate. (by Krathe) +* (Aura Designer) Indicators are torn down when the Aura Designer is disabled, and re-applied on **profile swap**. (by Krathe) +* (Targeted Spells) The targeted list no longer appears in **test mode** when the feature is disabled. (by Krathe) +* (Aura Designer) The replace-mode health-bar highlight no longer **flickers** on phased or out-of-range units. (by Krathe) +* (Aura Designer) The replace-mode health-bar highlight no longer **bleeds over the frame border** when a unit is out of range. (by Krathe) +* (AFK Timer) The elapsed-time countdown no longer **shifts left/right** as it ticks. (by Krathe) +* (Test Mode) Replaced several test-mode buff/debuff preview icons that pointed at art removed in Midnight, so they no longer render blank. (by Krathe) +* (Text Designer) Text element edits now update **test-mode frames** live, not just real units. (by Krathe) +* (Designers) The Aura/Text Designer **preview now rebuilds to the frame size of the auto layout being edited**, instead of staying stuck at a previous layout's dimensions. (by Krathe) +* (Pinned Frames) A raid auto layout's pinned settings (pinned players, etc.) now **apply while editing/previewing that layout** and when it is active, instead of only inside an actual raid. (by Krathe) + ## [4.3.12] ### New Features diff --git a/Config.lua b/Config.lua index 25aff771..d912f332 100644 --- a/Config.lua +++ b/Config.lua @@ -27,6 +27,10 @@ local function RegisterCustomMedia() LSM:Register(LSM.MediaType.FONT, "DF Expressway", "Interface\\AddOns\\DandersFrames\\Fonts\\Expressway.ttf", ALL_LOCALES) LSM:Register(LSM.MediaType.FONT, "DF Roboto SemiBold", "Interface\\AddOns\\DandersFrames\\Fonts\\Roboto-SemiBold.ttf", ALL_LOCALES) LSM:Register(LSM.MediaType.FONT, "DF Roboto Bold", "Interface\\AddOns\\DandersFrames\\Fonts\\Roboto-Bold.ttf", ALL_LOCALES) + -- Monospaced (tabular) Roboto for timer/countdown text: equal-width digits so a + -- counting-down number does not shift left/right as the digit values change. + LSM:Register(LSM.MediaType.FONT, "DF Roboto Mono SemiBold", "Interface\\AddOns\\DandersFrames\\Fonts\\RobotoMono-SemiBold.ttf", ALL_LOCALES) + LSM:Register(LSM.MediaType.FONT, "DF Roboto Mono Bold", "Interface\\AddOns\\DandersFrames\\Fonts\\RobotoMono-Bold.ttf", ALL_LOCALES) -- Register custom statusbar textures LSM:Register(LSM.MediaType.STATUSBAR, "DF Flat", "Interface\\Buttons\\WHITE8x8") @@ -864,7 +868,7 @@ DF.PartyDefaults = { absorbBarReverse = false, absorbBarShowOvershield = false, absorbBarStrata = "MEDIUM", - absorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense", + absorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes", absorbBarWidth = 46, absorbBarX = 0, absorbBarY = 0, @@ -872,17 +876,25 @@ DF.PartyDefaults = { -- AFK Icon afkIconAlpha = 0.8, - afkIconAnchor = "BOTTOM", + afkIconAnchor = "CENTER", afkIconEnabled = true, afkIconFrameLevel = 0, afkIconHideInCombat = true, - afkIconScale = 1, - afkIconShowText = true, + afkIconScale = 0.7, + afkIconShowText = false, afkIconShowTimer = true, afkIconText = "AFK", - afkIconTextColor = {r = 1, g = 0.7725490927696228, b = 0.5411764979362488, a = 1}, + afkIconTextColor = {r = 1, g = 0.5, b = 0, a = 1}, + afkIconTimerColor = {r = 1, g = 0.5, b = 0, a = 1}, + -- afkIconTimerFont intentionally unset: the timer inherits the global status-icon + -- font. The countdown no longer wobbles because ApplyTimerTextSettings LEFT- + -- justifies it (the changing seconds sit on the right with nothing to push). A + -- monospace font is still selectable if perfectly zero movement is wanted. + afkIconTimerFontSize = 16, + afkIconTimerX = 0, + afkIconTimerY = 1, afkIconX = 0, - afkIconY = 2, + afkIconY = 0, -- Aggro Highlight aggroColorHighThreat = {r = 1, g = 1, b = 0.47}, @@ -921,13 +933,54 @@ DF.PartyDefaults = { missingHealthGradientAlpha = 0.8, missingHealthTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", - -- Border - borderColor = {r = 0, g = 0, b = 0, a = 1}, - borderSize = 1, - borderStyle = "SOLID", - borderTexture = "SOLID", - borderClassColor = false, - showFrameBorder = true, + -- Frame Border (canonical "frame" prefix; CreateBorderControls + BuildSpec + -- convention. Existing user configs with legacy keys (`borderSize`, + -- `showFrameBorder`, `borderClassColor`, etc.) are migrated to these via + -- DF:MigrateFrameBorderKeys on db load.) + frameBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + frameBorderAnimationCornerLength = 10, + frameBorderAnimationFrequency = 0.25, + frameBorderAnimationInset = 0, + frameBorderAnimationLength = 8, + frameBorderAnimationMask = false, + frameBorderAnimationOffsetX = 0, + frameBorderAnimationOffsetY = 0, + frameBorderAnimationParticles = 8, + frameBorderAnimationScale = 1, + frameBorderAnimationSidesAxis = "HORIZONTAL", + frameBorderAnimationThickness = 3, + frameBorderAnimationType = "NONE", + frameBorderBlendMode = "BLEND", + frameBorderColor = {r = 0, g = 0, b = 0, a = 1}, + frameBorderGradientDirection = "HORIZONTAL", + frameBorderGradientEnabled = false, + frameBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + frameBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + frameBorderInset = 0, + frameBorderOffsetX = 0, + frameBorderOffsetY = 0, + frameBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + frameBorderShadowEnabled = false, + frameBorderShadowOffsetX = 1, + frameBorderShadowOffsetY = -1, + frameBorderShadowSize = 1, + frameBorderSize = 1, + frameBorderStyle = "SOLID", + frameBorderTexture = "SOLID", + frameBorderUseClassColor = false, + frameShowBorder = true, + + -- ColorSource: which colour-resolver feeds the frame border. Replaces the + -- legacy frameBorderUseClassColor / UseRoleColor booleans (migrated on + -- db load by DF:MigrateFrameBorderKeys). + frameBorderColorSource = "STATIC", + -- Alpha slider used when ColorSource is CLASS or ROLE — the resolver + -- supplies RGB and this slider supplies alpha, since the picker (which + -- normally carries alpha) is hidden in those modes. + frameBorderAlpha = 1, + -- Role colours live at profile level (DF.db.roleColors), managed from the + -- Display → Colors page. DF:MigrateRoleBorderColors seeds defaults at the + -- profile level on first load; per-mode defaults intentionally absent. -- Boss Debuffs bossDebuffHighlight = true, @@ -960,9 +1013,38 @@ DF.PartyDefaults = { -- Buff settings buffAlpha = 1, buffAnchor = "BOTTOMRIGHT", - buffBorderEnabled = false, - buffBorderInset = 1, - buffBorderThickness = 1, + buffShowBorder = false, + buffBorderInset = 0, + buffBorderSize = 1, + -- Canonical border toolkit (Stage 5.5 Phase 2): plugs into BuildSpec + + -- CreateBorderControls. Colour alpha 0.8 preserves the legacy opacity. + buffBorderColor = {r = 0, g = 0, b = 0, a = 0.8}, + buffBorderStyle = "SOLID", + buffBorderTexture = "SOLID", + buffBorderBlendMode = "BLEND", + buffBorderOffsetX = 0, + buffBorderOffsetY = 0, + buffBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + buffBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + buffBorderGradientDirection = "HORIZONTAL", + buffBorderShadowEnabled = false, + buffBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + buffBorderShadowSize = 1, + buffBorderShadowOffsetX = 1, + buffBorderShadowOffsetY = -1, + buffBorderAnimationType = "NONE", + buffBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + buffBorderAnimationFrequency = 1, + buffBorderAnimationParticles = 8, + buffBorderAnimationLength = 8, + buffBorderAnimationThickness = 3, + buffBorderAnimationScale = 1, + buffBorderAnimationInset = 0, + buffBorderAnimationOffsetX = 0, + buffBorderAnimationOffsetY = 0, + buffBorderAnimationMask = false, + buffBorderAnimationSidesAxis = "HORIZONTAL", + buffBorderAnimationCornerLength = 10, buffClickThrough = true, buffClickThroughInCombatOnly = false, buffClickThroughKeybinds = true, @@ -978,16 +1060,31 @@ DF.PartyDefaults = { buffDurationHideAboveEnabled = false, buffDurationHideAboveThreshold = 10, buffDurationFont = "DF Roboto SemiBold", - buffDurationOutline = "SHADOW", + buffDurationOutline = "SHADOW;OUTLINE", buffDurationScale = 1.2000000476837, - buffDurationX = -2, + buffDurationX = 0, buffDurationY = 2, buffExpiringBorderColor = {r = 1, g = 0.50196081399918, b = 0, a = 1}, buffExpiringBorderColorByTime = false, buffExpiringBorderEnabled = true, - buffExpiringBorderInset = 1, + buffExpiringBorderInset = 0, buffExpiringBorderPulsate = true, buffExpiringBorderThickness = 2, + -- Expiring Animation (AD-style full toolkit) — replaces the legacy + -- buffExpiringBorderPulsate boolean (migrated: true -> DF_PULSATE). + buffExpiringBorderAnimationType = "DF_PULSATE", + buffExpiringBorderAnimationColor = {r = 1, g = 0.5, b = 0, a = 1}, + buffExpiringBorderAnimationFrequency = 2, + buffExpiringBorderAnimationParticles = 8, + buffExpiringBorderAnimationLength = 8, + buffExpiringBorderAnimationThickness = 3, + buffExpiringBorderAnimationScale = 1, + buffExpiringBorderAnimationInset = 0, + buffExpiringBorderAnimationOffsetX = 0, + buffExpiringBorderAnimationOffsetY = 0, + buffExpiringBorderAnimationMask = false, + buffExpiringBorderAnimationSidesAxis = "HORIZONTAL", + buffExpiringBorderAnimationCornerLength = 10, buffExpiringEnabled = true, buffExpiringThreshold = 30, buffExpiringThresholdMode = "PERCENT", @@ -1026,20 +1123,20 @@ DF.PartyDefaults = { buffHideSwipe = false, buffMax = 5, buffOffsetX = -1, - buffOffsetY = 3, - buffPaddingX = -2, - buffPaddingY = -2, + buffOffsetY = 5, + buffPaddingX = 2, + buffPaddingY = 2, buffScale = 1, buffShowCountdown = false, buffShowDuration = true, - buffSize = 24, + buffSize = 20, buffStackAnchor = "BOTTOMRIGHT", buffStackFont = "DF Roboto SemiBold", buffStackMinimum = 2, - buffStackOutline = "SHADOW", + buffStackOutline = "SHADOW;OUTLINE", buffStackScale = 1, - buffStackX = 0, - buffStackY = 0, + buffStackX = 2, + buffStackY = -1, buffWrap = 3, buffWrapOffsetX = 0, buffWrapOffsetY = 0, @@ -1094,9 +1191,38 @@ DF.PartyDefaults = { debuffBorderColorMagic = {r = 0.2, g = 0.6, b = 1}, debuffBorderColorNone = {r = 0, g = 0, b = 0, a = 1}, debuffBorderColorPoison = {r = 0, g = 0.6, b = 0}, - debuffBorderEnabled = true, - debuffBorderInset = 1, - debuffBorderThickness = 2, + debuffShowBorder = true, + debuffBorderInset = 0, + debuffBorderSize = 2, + -- Canonical border toolkit (Stage 5.5 Phase 2). Static colour, used when + -- debuffBorderColorByType is OFF (the by-type system overrides when on). + debuffBorderColor = {r = 0.8, g = 0, b = 0, a = 0.8}, + debuffBorderStyle = "SOLID", + debuffBorderTexture = "SOLID", + debuffBorderBlendMode = "BLEND", + debuffBorderOffsetX = 0, + debuffBorderOffsetY = 0, + debuffBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + debuffBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + debuffBorderGradientDirection = "HORIZONTAL", + debuffBorderShadowEnabled = false, + debuffBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + debuffBorderShadowSize = 1, + debuffBorderShadowOffsetX = 1, + debuffBorderShadowOffsetY = -1, + debuffBorderAnimationType = "NONE", + debuffBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + debuffBorderAnimationFrequency = 1, + debuffBorderAnimationParticles = 8, + debuffBorderAnimationLength = 8, + debuffBorderAnimationThickness = 3, + debuffBorderAnimationScale = 1, + debuffBorderAnimationInset = 0, + debuffBorderAnimationOffsetX = 0, + debuffBorderAnimationOffsetY = 0, + debuffBorderAnimationMask = false, + debuffBorderAnimationSidesAxis = "HORIZONTAL", + debuffBorderAnimationCornerLength = 10, debuffClickThrough = true, debuffClickThroughInCombatOnly = false, debuffClickThroughKeybinds = true, @@ -1111,7 +1237,7 @@ DF.PartyDefaults = { debuffDurationHideAboveEnabled = false, debuffDurationHideAboveThreshold = 10, debuffDurationFont = "DF Roboto SemiBold", - debuffDurationOutline = "SHADOW", + debuffDurationOutline = "SHADOW;OUTLINE", debuffDurationScale = 1, debuffDurationX = 0, debuffDurationY = 0, @@ -1131,18 +1257,18 @@ DF.PartyDefaults = { debuffHideSwipe = false, debuffMax = 5, debuffOffsetX = 1, - debuffOffsetY = 4, + debuffOffsetY = 5, debuffPaddingX = 2, debuffPaddingY = 2, debuffScale = 1, debuffShowAll = false, debuffShowCountdown = false, debuffShowDuration = false, - debuffSize = 18, + debuffSize = 20, debuffStackAnchor = "BOTTOMRIGHT", debuffStackFont = "DF Roboto SemiBold", debuffStackMinimum = 2, - debuffStackOutline = "SHADOW", + debuffStackOutline = "SHADOW;OUTLINE", debuffStackScale = 1, debuffStackX = 0, debuffStackY = 0, @@ -1169,8 +1295,36 @@ DF.PartyDefaults = { -- Defensive Icon defensiveIconAnchor = "CENTER", + defensiveIconBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + defensiveIconBorderAnimationCornerLength = 10, + defensiveIconBorderAnimationFrequency = 0.25, + defensiveIconBorderAnimationInset = 0, + defensiveIconBorderAnimationLength = 8, + defensiveIconBorderAnimationMask = false, + defensiveIconBorderAnimationOffsetX = 0, + defensiveIconBorderAnimationOffsetY = 0, + defensiveIconBorderAnimationParticles = 8, + defensiveIconBorderAnimationScale = 1, + defensiveIconBorderAnimationSidesAxis = "HORIZONTAL", + defensiveIconBorderAnimationThickness = 3, + defensiveIconBorderAnimationType = "NONE", + defensiveIconBorderBlendMode = "BLEND", defensiveIconBorderColor = {r = 0, g = 0.8, b = 0, a = 1}, + defensiveIconBorderGradientDirection = "HORIZONTAL", + defensiveIconBorderGradientEnabled = false, + defensiveIconBorderGradientEndColor = {r = 0, g = 0.4, b = 0.8, a = 1}, + defensiveIconBorderGradientStartColor = {r = 0, g = 0.8, b = 0, a = 1}, + defensiveIconBorderInset = 0, + defensiveIconBorderOffsetX = 0, + defensiveIconBorderOffsetY = 0, + defensiveIconBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + defensiveIconBorderShadowEnabled = false, + defensiveIconBorderShadowOffsetX = 1, + defensiveIconBorderShadowOffsetY = -1, + defensiveIconBorderShadowSize = 1, defensiveIconBorderSize = 2, + defensiveIconBorderStyle = "SOLID", + defensiveIconBorderTexture = "SOLID", defensiveIconClickThrough = true, defensiveIconClickThroughInCombatOnly = true, defensiveIconClickThroughKeybinds = true, @@ -1317,7 +1471,7 @@ DF.PartyDefaults = { healAbsorbBarOvershieldStyle = "SPARK", healAbsorbBarReverse = false, healAbsorbBarShowOvershield = false, - healAbsorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense", + healAbsorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes", healAbsorbBarWidth = 50, healAbsorbBarX = 0, healAbsorbBarY = -10, @@ -1359,7 +1513,7 @@ DF.PartyDefaults = { healthColorMediumWeight = 2, healthColorMode = "CLASS", healthFont = "DF Roboto SemiBold", - healthFontSize = 10, + healthFontSize = 11, healthOrientation = "HORIZONTAL", healthTextAbbreviate = true, healthTextAnchor = "CENTER", @@ -1376,7 +1530,7 @@ DF.PartyDefaults = { -- Reduced Max Health Bar reducedMaxHealthBlendMode = "BLEND", reducedMaxHealthClipHealthBar = true, - reducedMaxHealthColor = {r = 0, g = 0, b = 0, a = 1}, + reducedMaxHealthColor = {r = 0.502, g = 0.502, b = 0.502, a = 0.8039}, reducedMaxHealthEnabled = true, reducedMaxHealthTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes", @@ -1426,8 +1580,35 @@ DF.PartyDefaults = { missingBuffClassDetection = true, missingBuffHideFromBar = true, missingBuffIconAnchor = "CENTER", + missingBuffIconBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + missingBuffIconBorderAnimationCornerLength = 10, + missingBuffIconBorderAnimationFrequency = 0.25, + missingBuffIconBorderAnimationInset = 0, + missingBuffIconBorderAnimationLength = 8, + missingBuffIconBorderAnimationMask = false, + missingBuffIconBorderAnimationOffsetX = 0, + missingBuffIconBorderAnimationOffsetY = 0, + missingBuffIconBorderAnimationParticles = 8, + missingBuffIconBorderAnimationScale = 1, + missingBuffIconBorderAnimationSidesAxis = "HORIZONTAL", + missingBuffIconBorderAnimationThickness = 3, + missingBuffIconBorderAnimationType = "NONE", + missingBuffIconBorderBlendMode = "BLEND", missingBuffIconBorderColor = {r = 1, g = 0, b = 0, a = 1}, + missingBuffIconBorderGradientDirection = "HORIZONTAL", + missingBuffIconBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + missingBuffIconBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + missingBuffIconBorderInset = 0, + missingBuffIconBorderOffsetX = 0, + missingBuffIconBorderOffsetY = 0, + missingBuffIconBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + missingBuffIconBorderShadowEnabled = false, + missingBuffIconBorderShadowOffsetX = 1, + missingBuffIconBorderShadowOffsetY = -1, + missingBuffIconBorderShadowSize = 1, missingBuffIconBorderSize = 2, + missingBuffIconBorderStyle = "SOLID", + missingBuffIconBorderTexture = "SOLID", missingBuffIconDebug = false, missingBuffIconEnabled = false, missingBuffIconFrameLevel = 0, @@ -1454,7 +1635,7 @@ DF.PartyDefaults = { -- Name Text nameColorClass = false, nameFont = "DF Roboto SemiBold", - nameFontSize = 11, + nameFontSize = 12, nameTextAnchor = "TOP", nameTextColor = {r = 1, g = 1, b = 1, a = 1}, nameTextLength = 13, @@ -1485,8 +1666,33 @@ DF.PartyDefaults = { -- Personal Targeted Spells (Nameplate) personalTargetedSpellAlpha = 1, - personalTargetedSpellBorderColor = {r = 1, g = 0.3, b = 0}, + personalTargetedSpellBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + personalTargetedSpellBorderAnimationCornerLength = 10, + personalTargetedSpellBorderAnimationFrequency = 0.25, + personalTargetedSpellBorderAnimationInset = 0, + personalTargetedSpellBorderAnimationLength = 8, + personalTargetedSpellBorderAnimationMask = false, + personalTargetedSpellBorderAnimationOffsetX = 0, + personalTargetedSpellBorderAnimationOffsetY = 0, + personalTargetedSpellBorderAnimationParticles = 8, + personalTargetedSpellBorderAnimationScale = 1, + personalTargetedSpellBorderAnimationSidesAxis = "HORIZONTAL", + personalTargetedSpellBorderAnimationThickness = 3, + personalTargetedSpellBorderAnimationType = "NONE", + personalTargetedSpellBorderBlendMode = "BLEND", + personalTargetedSpellBorderColor = {r = 1, g = 0.3, b = 0, a = 1}, + personalTargetedSpellBorderGradientDirection = "HORIZONTAL", + personalTargetedSpellBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + personalTargetedSpellBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + personalTargetedSpellBorderInset = 0, + personalTargetedSpellBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + personalTargetedSpellBorderShadowEnabled = false, + personalTargetedSpellBorderShadowOffsetX = 1, + personalTargetedSpellBorderShadowOffsetY = -1, + personalTargetedSpellBorderShadowSize = 1, personalTargetedSpellBorderSize = 2, + personalTargetedSpellBorderStyle = "SOLID", + personalTargetedSpellBorderTexture = "SOLID", personalTargetedSpellDurationColor = {r = 1, g = 1, b = 1}, personalTargetedSpellDurationFont = "DF Roboto SemiBold", personalTargetedSpellDurationOutline = "SHADOW", @@ -1497,7 +1703,7 @@ DF.PartyDefaults = { personalTargetedSpellGrowth = "RIGHT", personalTargetedSpellHighlightColor = {r = 1, g = 0.8, b = 0}, personalTargetedSpellHighlightImportant = true, - personalTargetedSpellHighlightInset = 0, + personalTargetedSpellHighlightInset = 3, personalTargetedSpellHighlightSize = 3, personalTargetedSpellHighlightStyle = "glow", personalTargetedSpellImportantOnly = false, @@ -1526,7 +1732,20 @@ DF.PartyDefaults = { -- Pet Frames petAnchor = "BOTTOM", petBackgroundColor = {r = 0.9254902601242065, g = 0.9254902601242065, b = 0.9254902601242065, a = 0.800000011920929}, + petBorderBlendMode = "BLEND", petBorderColor = {r = 0, g = 0, b = 0, a = 1}, + petBorderGradientDirection = "HORIZONTAL", + petBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + petBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + petBorderInset = 0, + petBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + petBorderShadowEnabled = false, + petBorderShadowOffsetX = 1, + petBorderShadowOffsetY = -1, + petBorderShadowSize = 1, + petBorderSize = 1, + petBorderStyle = "SOLID", + petBorderTexture = "SOLID", petEnabled = false, petFrameHeight = 22, petFrameWidth = 130, @@ -1653,8 +1872,23 @@ DF.PartyDefaults = { resourceBarAnchor = "BOTTOM", resourceBarBackgroundColor = {r = 0, g = 0, b = 0, a = 0.80000001192093}, resourceBarBackgroundEnabled = true, + resourceBarBorderBlendMode = "BLEND", resourceBarBorderColor = {r = 0, g = 0, b = 0, a = 1}, + resourceBarBorderColorSource = "STATIC", resourceBarBorderEnabled = false, + resourceBarBorderGradientDirection = "HORIZONTAL", + resourceBarBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + resourceBarBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + resourceBarBorderInset = 0, + resourceBarBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + resourceBarBorderShadowEnabled = false, + resourceBarBorderShadowOffsetX = 1, + resourceBarBorderShadowOffsetY = -1, + resourceBarBorderShadowSize = 1, + resourceBarBorderSize = 1, + resourceBarBorderStyle = "SOLID", + resourceBarBorderTexture = "SOLID", + resourceBarShowBorder = false, resourceBarClassFilter = { DEATHKNIGHT = true, DEMONHUNTER = true, @@ -1684,7 +1918,7 @@ DF.PartyDefaults = { resourceBarSmooth = true, resourceBarWidth = 60, resourceBarX = 0, - resourceBarY = 0, + resourceBarY = 1, -- Class Power (Holy Power, Chi, Combo Points, etc. - player frame only) classPowerEnabled = false, @@ -1736,7 +1970,7 @@ DF.PartyDefaults = { roleIconExternalDPS = "", roleIconExternalHealer = "", roleIconExternalTank = "", - roleIconOnlyInCombat = false, + roleIconHideInCombat = false, roleIconScale = 1, roleIconShowDPS = true, roleIconShowHealer = true, @@ -1796,18 +2030,38 @@ DF.PartyDefaults = { -- Summon Icon summonIconAlpha = 1, - summonIconAnchor = "BOTTOM", + summonIconAnchor = "CENTER", summonIconEnabled = true, summonIconFrameLevel = 0, summonIconHideInCombat = false, summonIconScale = 1.5, - summonIconShowText = true, + summonIconShowText = false, summonIconTextAccepted = "Accepted", summonIconTextColor = {r = 0.6, g = 0.2, b = 1}, summonIconTextDeclined = "Declined", summonIconTextPending = "Summon", summonIconX = 0, - summonIconY = 9, + summonIconY = 0, + + -- BG Objective Carrier Icon (flag / orb carrier) + bgCarrierIconAlpha = 1, + bgCarrierIconAnchor = "CENTER", + bgCarrierIconEnabled = true, + bgCarrierIconFrameLevel = 0, + bgCarrierIconScale = 1, + bgCarrierIconShowText = false, + bgCarrierIconText = "FC", + bgCarrierIconTextColor = {r = 1, g = 0.82, b = 0}, + bgCarrierIconX = 0, + bgCarrierIconY = 0, + -- Combat icon (crossed swords shown when a unit is in combat) + combatIconAlpha = 1, + combatIconAnchor = "TOPLEFT", + combatIconEnabled = false, + combatIconFrameLevel = 0, + combatIconScale = 1, + combatIconX = 2, + combatIconY = -2, -- Targeted Spells (on-frame) targetedSpellAlpha = 1, @@ -1864,7 +2118,20 @@ DF.PartyDefaults = { -- by editing locale strings only. Party-mode only by design. -- Position uses an absolute mover (targetedListX/Y), not an anchor point. targetedListBackgroundAlpha = 0.5, + targetedListBorderBlendMode = "BLEND", targetedListBorderColor = {r = 0.18, g = 0.18, b = 0.18, a = 1}, + targetedListBorderGradientDirection = "HORIZONTAL", + targetedListBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + targetedListBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + targetedListBorderInset = 0, + targetedListBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + targetedListBorderShadowEnabled = false, + targetedListBorderShadowOffsetX = 1, + targetedListBorderShadowOffsetY = -1, + targetedListBorderShadowSize = 1, + targetedListBorderSize = 1, + targetedListBorderStyle = "SOLID", + targetedListBorderTexture = "SOLID", targetedListEnabled = false, targetedListFadeOutDuration = 0.25, targetedListFont = "DF Roboto SemiBold", @@ -1924,7 +2191,7 @@ DF.PartyDefaults = { targetedListDurationAnchor = "RIGHT", targetedListDurationAlign = "RIGHT", targetedListDurationFontSize = 17, - targetedListDurationX = -6, + targetedListDurationX = 30, targetedListDurationY = 0, targetedListInterruptTextAnchor = "CENTER", targetedListInterruptTextAlign = "CENTER", @@ -1959,7 +2226,7 @@ DF.PartyDefaults = { testShowPets = true, testShowReducedMaxHealth = true, testShowSelection = false, - testShowStatusIcons = false, + testShowStatusIcons = true, testShowTargetedSpell = false, testShowClassPower = true, testShowAuraDesigner = false, @@ -2186,7 +2453,7 @@ DF.RaidDefaults = { absorbBarReverse = false, absorbBarShowOvershield = false, absorbBarStrata = "MEDIUM", - absorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense", + absorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes", absorbBarWidth = 46, absorbBarX = 0, absorbBarY = 0, @@ -2194,17 +2461,25 @@ DF.RaidDefaults = { -- AFK Icon afkIconAlpha = 0.8, - afkIconAnchor = "BOTTOM", + afkIconAnchor = "CENTER", afkIconEnabled = true, afkIconFrameLevel = 0, afkIconHideInCombat = true, - afkIconScale = 1, - afkIconShowText = true, + afkIconScale = 0.7, + afkIconShowText = false, afkIconShowTimer = true, afkIconText = "AFK", - afkIconTextColor = {r = 1, g = 0.7725490927696228, b = 0.5411764979362488, a = 1}, + afkIconTextColor = {r = 1, g = 0.5, b = 0, a = 1}, + afkIconTimerColor = {r = 1, g = 0.5, b = 0, a = 1}, + -- afkIconTimerFont intentionally unset: the timer inherits the global status-icon + -- font. The countdown no longer wobbles because ApplyTimerTextSettings LEFT- + -- justifies it (the changing seconds sit on the right with nothing to push). A + -- monospace font is still selectable if perfectly zero movement is wanted. + afkIconTimerFontSize = 16, + afkIconTimerX = 0, + afkIconTimerY = 1, afkIconX = 0, - afkIconY = 2, + afkIconY = 0, -- Aggro Highlight aggroColorHighThreat = {r = 1, g = 1, b = 0.47}, @@ -2243,13 +2518,54 @@ DF.RaidDefaults = { missingHealthGradientAlpha = 0.8, missingHealthTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Minimalist", - -- Border - borderColor = {r = 0, g = 0, b = 0, a = 1}, - borderSize = 1, - borderStyle = "SOLID", - borderTexture = "SOLID", - borderClassColor = false, - showFrameBorder = true, + -- Frame Border (canonical "frame" prefix; CreateBorderControls + BuildSpec + -- convention. Existing user configs with legacy keys (`borderSize`, + -- `showFrameBorder`, `borderClassColor`, etc.) are migrated to these via + -- DF:MigrateFrameBorderKeys on db load.) + frameBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + frameBorderAnimationCornerLength = 10, + frameBorderAnimationFrequency = 0.25, + frameBorderAnimationInset = 0, + frameBorderAnimationLength = 8, + frameBorderAnimationMask = false, + frameBorderAnimationOffsetX = 0, + frameBorderAnimationOffsetY = 0, + frameBorderAnimationParticles = 8, + frameBorderAnimationScale = 1, + frameBorderAnimationSidesAxis = "HORIZONTAL", + frameBorderAnimationThickness = 3, + frameBorderAnimationType = "NONE", + frameBorderBlendMode = "BLEND", + frameBorderColor = {r = 0, g = 0, b = 0, a = 1}, + frameBorderGradientDirection = "HORIZONTAL", + frameBorderGradientEnabled = false, + frameBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + frameBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + frameBorderInset = 0, + frameBorderOffsetX = 0, + frameBorderOffsetY = 0, + frameBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + frameBorderShadowEnabled = false, + frameBorderShadowOffsetX = 1, + frameBorderShadowOffsetY = -1, + frameBorderShadowSize = 1, + frameBorderSize = 1, + frameBorderStyle = "SOLID", + frameBorderTexture = "SOLID", + frameBorderUseClassColor = false, + frameShowBorder = true, + + -- ColorSource: which colour-resolver feeds the frame border. Replaces the + -- legacy frameBorderUseClassColor / UseRoleColor booleans (migrated on + -- db load by DF:MigrateFrameBorderKeys). + frameBorderColorSource = "STATIC", + -- Alpha slider used when ColorSource is CLASS or ROLE — the resolver + -- supplies RGB and this slider supplies alpha, since the picker (which + -- normally carries alpha) is hidden in those modes. + frameBorderAlpha = 1, + -- Role colours live at profile level (DF.db.roleColors), managed from the + -- Display → Colors page. DF:MigrateRoleBorderColors seeds defaults at the + -- profile level on first load; per-mode defaults intentionally absent. -- Boss Debuffs bossDebuffHighlight = true, @@ -2282,9 +2598,38 @@ DF.RaidDefaults = { -- Buff settings buffAlpha = 1, buffAnchor = "BOTTOMRIGHT", - buffBorderEnabled = false, - buffBorderInset = 1, - buffBorderThickness = 1, + buffShowBorder = false, + buffBorderInset = 0, + buffBorderSize = 1, + -- Canonical border toolkit (Stage 5.5 Phase 2): plugs into BuildSpec + + -- CreateBorderControls. Colour alpha 0.8 preserves the legacy opacity. + buffBorderColor = {r = 0, g = 0, b = 0, a = 0.8}, + buffBorderStyle = "SOLID", + buffBorderTexture = "SOLID", + buffBorderBlendMode = "BLEND", + buffBorderOffsetX = 0, + buffBorderOffsetY = 0, + buffBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + buffBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + buffBorderGradientDirection = "HORIZONTAL", + buffBorderShadowEnabled = false, + buffBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + buffBorderShadowSize = 1, + buffBorderShadowOffsetX = 1, + buffBorderShadowOffsetY = -1, + buffBorderAnimationType = "NONE", + buffBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + buffBorderAnimationFrequency = 1, + buffBorderAnimationParticles = 8, + buffBorderAnimationLength = 8, + buffBorderAnimationThickness = 3, + buffBorderAnimationScale = 1, + buffBorderAnimationInset = 0, + buffBorderAnimationOffsetX = 0, + buffBorderAnimationOffsetY = 0, + buffBorderAnimationMask = false, + buffBorderAnimationSidesAxis = "HORIZONTAL", + buffBorderAnimationCornerLength = 10, buffClickThrough = true, buffClickThroughInCombatOnly = false, buffClickThroughKeybinds = true, @@ -2300,16 +2645,31 @@ DF.RaidDefaults = { buffDurationHideAboveEnabled = false, buffDurationHideAboveThreshold = 10, buffDurationFont = "DF Roboto SemiBold", - buffDurationOutline = "SHADOW", + buffDurationOutline = "SHADOW;OUTLINE", buffDurationScale = 1.2000000476837, - buffDurationX = -2, + buffDurationX = 0, buffDurationY = 2, buffExpiringBorderColor = {r = 1, g = 0.50196081399918, b = 0, a = 1}, buffExpiringBorderColorByTime = false, buffExpiringBorderEnabled = true, - buffExpiringBorderInset = 1, + buffExpiringBorderInset = 0, buffExpiringBorderPulsate = true, buffExpiringBorderThickness = 2, + -- Expiring Animation (AD-style full toolkit) — replaces the legacy + -- buffExpiringBorderPulsate boolean (migrated: true -> DF_PULSATE). + buffExpiringBorderAnimationType = "DF_PULSATE", + buffExpiringBorderAnimationColor = {r = 1, g = 0.5, b = 0, a = 1}, + buffExpiringBorderAnimationFrequency = 2, + buffExpiringBorderAnimationParticles = 8, + buffExpiringBorderAnimationLength = 8, + buffExpiringBorderAnimationThickness = 3, + buffExpiringBorderAnimationScale = 1, + buffExpiringBorderAnimationInset = 0, + buffExpiringBorderAnimationOffsetX = 0, + buffExpiringBorderAnimationOffsetY = 0, + buffExpiringBorderAnimationMask = false, + buffExpiringBorderAnimationSidesAxis = "HORIZONTAL", + buffExpiringBorderAnimationCornerLength = 10, buffExpiringEnabled = true, buffExpiringThreshold = 30, buffExpiringThresholdMode = "PERCENT", @@ -2348,20 +2708,20 @@ DF.RaidDefaults = { buffHideSwipe = false, buffMax = 5, buffOffsetX = -1, - buffOffsetY = 3, - buffPaddingX = -2, - buffPaddingY = -2, + buffOffsetY = 5, + buffPaddingX = 2, + buffPaddingY = 2, buffScale = 1, buffShowCountdown = false, buffShowDuration = true, - buffSize = 24, + buffSize = 20, buffStackAnchor = "BOTTOMRIGHT", buffStackFont = "DF Roboto SemiBold", buffStackMinimum = 2, - buffStackOutline = "SHADOW", + buffStackOutline = "SHADOW;OUTLINE", buffStackScale = 1, - buffStackX = 0, - buffStackY = 0, + buffStackX = 2, + buffStackY = -1, buffWrap = 3, buffWrapOffsetX = 0, buffWrapOffsetY = 0, @@ -2416,9 +2776,38 @@ DF.RaidDefaults = { debuffBorderColorMagic = {r = 0.2, g = 0.6, b = 1}, debuffBorderColorNone = {r = 0, g = 0, b = 0, a = 1}, debuffBorderColorPoison = {r = 0, g = 0.6, b = 0}, - debuffBorderEnabled = true, - debuffBorderInset = 1, - debuffBorderThickness = 2, + debuffShowBorder = true, + debuffBorderInset = 0, + debuffBorderSize = 2, + -- Canonical border toolkit (Stage 5.5 Phase 2). Static colour, used when + -- debuffBorderColorByType is OFF (the by-type system overrides when on). + debuffBorderColor = {r = 0.8, g = 0, b = 0, a = 0.8}, + debuffBorderStyle = "SOLID", + debuffBorderTexture = "SOLID", + debuffBorderBlendMode = "BLEND", + debuffBorderOffsetX = 0, + debuffBorderOffsetY = 0, + debuffBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + debuffBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + debuffBorderGradientDirection = "HORIZONTAL", + debuffBorderShadowEnabled = false, + debuffBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + debuffBorderShadowSize = 1, + debuffBorderShadowOffsetX = 1, + debuffBorderShadowOffsetY = -1, + debuffBorderAnimationType = "NONE", + debuffBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + debuffBorderAnimationFrequency = 1, + debuffBorderAnimationParticles = 8, + debuffBorderAnimationLength = 8, + debuffBorderAnimationThickness = 3, + debuffBorderAnimationScale = 1, + debuffBorderAnimationInset = 0, + debuffBorderAnimationOffsetX = 0, + debuffBorderAnimationOffsetY = 0, + debuffBorderAnimationMask = false, + debuffBorderAnimationSidesAxis = "HORIZONTAL", + debuffBorderAnimationCornerLength = 10, debuffClickThrough = true, debuffClickThroughInCombatOnly = false, debuffClickThroughKeybinds = true, @@ -2433,7 +2822,7 @@ DF.RaidDefaults = { debuffDurationHideAboveThreshold = 10, debuffDurationFont = "DF Roboto SemiBold", debuffDurationAnchor = "CENTER", - debuffDurationOutline = "SHADOW", + debuffDurationOutline = "SHADOW;OUTLINE", debuffDurationScale = 1, debuffDurationX = 0, debuffDurationY = 0, @@ -2453,18 +2842,18 @@ DF.RaidDefaults = { debuffHideSwipe = false, debuffMax = 5, debuffOffsetX = 1, - debuffOffsetY = 4, + debuffOffsetY = 5, debuffPaddingX = 2, debuffPaddingY = 2, debuffScale = 1, debuffShowAll = false, debuffShowCountdown = false, debuffShowDuration = false, - debuffSize = 18, + debuffSize = 20, debuffStackAnchor = "BOTTOMRIGHT", debuffStackFont = "DF Roboto SemiBold", debuffStackMinimum = 2, - debuffStackOutline = "SHADOW", + debuffStackOutline = "SHADOW;OUTLINE", debuffStackScale = 1, debuffStackX = 0, debuffStackY = 0, @@ -2491,8 +2880,36 @@ DF.RaidDefaults = { -- Defensive Icon defensiveIconAnchor = "CENTER", + defensiveIconBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + defensiveIconBorderAnimationCornerLength = 10, + defensiveIconBorderAnimationFrequency = 0.25, + defensiveIconBorderAnimationInset = 0, + defensiveIconBorderAnimationLength = 8, + defensiveIconBorderAnimationMask = false, + defensiveIconBorderAnimationOffsetX = 0, + defensiveIconBorderAnimationOffsetY = 0, + defensiveIconBorderAnimationParticles = 8, + defensiveIconBorderAnimationScale = 1, + defensiveIconBorderAnimationSidesAxis = "HORIZONTAL", + defensiveIconBorderAnimationThickness = 3, + defensiveIconBorderAnimationType = "NONE", + defensiveIconBorderBlendMode = "BLEND", defensiveIconBorderColor = {r = 0, g = 0.8, b = 0, a = 1}, + defensiveIconBorderGradientDirection = "HORIZONTAL", + defensiveIconBorderGradientEnabled = false, + defensiveIconBorderGradientEndColor = {r = 0, g = 0.4, b = 0.8, a = 1}, + defensiveIconBorderGradientStartColor = {r = 0, g = 0.8, b = 0, a = 1}, + defensiveIconBorderInset = 0, + defensiveIconBorderOffsetX = 0, + defensiveIconBorderOffsetY = 0, + defensiveIconBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + defensiveIconBorderShadowEnabled = false, + defensiveIconBorderShadowOffsetX = 1, + defensiveIconBorderShadowOffsetY = -1, + defensiveIconBorderShadowSize = 1, defensiveIconBorderSize = 2, + defensiveIconBorderStyle = "SOLID", + defensiveIconBorderTexture = "SOLID", defensiveIconClickThrough = true, defensiveIconClickThroughInCombatOnly = true, defensiveIconClickThroughKeybinds = true, @@ -2639,7 +3056,7 @@ DF.RaidDefaults = { healAbsorbBarOvershieldStyle = "SPARK", healAbsorbBarReverse = false, healAbsorbBarShowOvershield = false, - healAbsorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes_Dense", + healAbsorbBarTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes", healAbsorbBarWidth = 50, healAbsorbBarX = 0, healAbsorbBarY = -10, @@ -2681,7 +3098,7 @@ DF.RaidDefaults = { healthColorMediumWeight = 2, healthColorMode = "CLASS", healthFont = "DF Roboto SemiBold", - healthFontSize = 10, + healthFontSize = 11, healthOrientation = "HORIZONTAL", healthTextAbbreviate = true, healthTextAnchor = "CENTER", @@ -2698,7 +3115,7 @@ DF.RaidDefaults = { -- Reduced Max Health Bar reducedMaxHealthBlendMode = "BLEND", reducedMaxHealthClipHealthBar = true, - reducedMaxHealthColor = {r = 0, g = 0, b = 0, a = 1}, + reducedMaxHealthColor = {r = 0.502, g = 0.502, b = 0.502, a = 0.8039}, reducedMaxHealthEnabled = true, reducedMaxHealthTexture = "Interface\\AddOns\\DandersFrames\\Media\\DF_Stripes", @@ -2748,8 +3165,35 @@ DF.RaidDefaults = { missingBuffClassDetection = true, missingBuffHideFromBar = true, missingBuffIconAnchor = "CENTER", + missingBuffIconBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + missingBuffIconBorderAnimationCornerLength = 10, + missingBuffIconBorderAnimationFrequency = 0.25, + missingBuffIconBorderAnimationInset = 0, + missingBuffIconBorderAnimationLength = 8, + missingBuffIconBorderAnimationMask = false, + missingBuffIconBorderAnimationOffsetX = 0, + missingBuffIconBorderAnimationOffsetY = 0, + missingBuffIconBorderAnimationParticles = 8, + missingBuffIconBorderAnimationScale = 1, + missingBuffIconBorderAnimationSidesAxis = "HORIZONTAL", + missingBuffIconBorderAnimationThickness = 3, + missingBuffIconBorderAnimationType = "NONE", + missingBuffIconBorderBlendMode = "BLEND", missingBuffIconBorderColor = {r = 1, g = 0, b = 0, a = 1}, + missingBuffIconBorderGradientDirection = "HORIZONTAL", + missingBuffIconBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + missingBuffIconBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + missingBuffIconBorderInset = 0, + missingBuffIconBorderOffsetX = 0, + missingBuffIconBorderOffsetY = 0, + missingBuffIconBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + missingBuffIconBorderShadowEnabled = false, + missingBuffIconBorderShadowOffsetX = 1, + missingBuffIconBorderShadowOffsetY = -1, + missingBuffIconBorderShadowSize = 1, missingBuffIconBorderSize = 2, + missingBuffIconBorderStyle = "SOLID", + missingBuffIconBorderTexture = "SOLID", missingBuffIconDebug = false, missingBuffIconEnabled = false, missingBuffIconFrameLevel = 0, @@ -2776,7 +3220,7 @@ DF.RaidDefaults = { -- Name Text nameColorClass = false, nameFont = "DF Roboto SemiBold", - nameFontSize = 11, + nameFontSize = 12, nameTextAnchor = "TOP", nameTextColor = {r = 1, g = 1, b = 1, a = 1}, nameTextLength = 13, @@ -2807,8 +3251,33 @@ DF.RaidDefaults = { -- Personal Targeted Spells (Nameplate) personalTargetedSpellAlpha = 1, - personalTargetedSpellBorderColor = {r = 1, g = 0.3, b = 0}, + personalTargetedSpellBorderAnimationColor = {r = 0.95, g = 0.95, b = 0.32, a = 1}, + personalTargetedSpellBorderAnimationCornerLength = 10, + personalTargetedSpellBorderAnimationFrequency = 0.25, + personalTargetedSpellBorderAnimationInset = 0, + personalTargetedSpellBorderAnimationLength = 8, + personalTargetedSpellBorderAnimationMask = false, + personalTargetedSpellBorderAnimationOffsetX = 0, + personalTargetedSpellBorderAnimationOffsetY = 0, + personalTargetedSpellBorderAnimationParticles = 8, + personalTargetedSpellBorderAnimationScale = 1, + personalTargetedSpellBorderAnimationSidesAxis = "HORIZONTAL", + personalTargetedSpellBorderAnimationThickness = 3, + personalTargetedSpellBorderAnimationType = "NONE", + personalTargetedSpellBorderBlendMode = "BLEND", + personalTargetedSpellBorderColor = {r = 1, g = 0.3, b = 0, a = 1}, + personalTargetedSpellBorderGradientDirection = "HORIZONTAL", + personalTargetedSpellBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + personalTargetedSpellBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + personalTargetedSpellBorderInset = 0, + personalTargetedSpellBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + personalTargetedSpellBorderShadowEnabled = false, + personalTargetedSpellBorderShadowOffsetX = 1, + personalTargetedSpellBorderShadowOffsetY = -1, + personalTargetedSpellBorderShadowSize = 1, personalTargetedSpellBorderSize = 2, + personalTargetedSpellBorderStyle = "SOLID", + personalTargetedSpellBorderTexture = "SOLID", personalTargetedSpellDurationColor = {r = 1, g = 1, b = 1}, personalTargetedSpellDurationFont = "DF Roboto SemiBold", personalTargetedSpellDurationOutline = "SHADOW", @@ -2819,7 +3288,7 @@ DF.RaidDefaults = { personalTargetedSpellGrowth = "RIGHT", personalTargetedSpellHighlightColor = {r = 1, g = 0.8, b = 0}, personalTargetedSpellHighlightImportant = true, - personalTargetedSpellHighlightInset = 0, + personalTargetedSpellHighlightInset = 3, personalTargetedSpellHighlightSize = 3, personalTargetedSpellHighlightStyle = "glow", personalTargetedSpellImportantOnly = false, @@ -2848,7 +3317,20 @@ DF.RaidDefaults = { -- Pet Frames petAnchor = "BOTTOM", petBackgroundColor = {r = 0.9254902601242065, g = 0.9254902601242065, b = 0.9254902601242065, a = 0.800000011920929}, + petBorderBlendMode = "BLEND", petBorderColor = {r = 0, g = 0, b = 0, a = 1}, + petBorderGradientDirection = "HORIZONTAL", + petBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + petBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + petBorderInset = 0, + petBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + petBorderShadowEnabled = false, + petBorderShadowOffsetX = 1, + petBorderShadowOffsetY = -1, + petBorderShadowSize = 1, + petBorderSize = 1, + petBorderStyle = "SOLID", + petBorderTexture = "SOLID", petEnabled = false, petFrameHeight = 22, petFrameWidth = 130, @@ -2975,8 +3457,23 @@ DF.RaidDefaults = { resourceBarAnchor = "BOTTOM", resourceBarBackgroundColor = {r = 0, g = 0, b = 0, a = 0.80000001192093}, resourceBarBackgroundEnabled = true, + resourceBarBorderBlendMode = "BLEND", resourceBarBorderColor = {r = 0, g = 0, b = 0, a = 1}, + resourceBarBorderColorSource = "STATIC", resourceBarBorderEnabled = false, + resourceBarBorderGradientDirection = "HORIZONTAL", + resourceBarBorderGradientEndColor = {r = 0.5, g = 0.5, b = 0.5, a = 1}, + resourceBarBorderGradientStartColor = {r = 0, g = 0, b = 0, a = 1}, + resourceBarBorderInset = 0, + resourceBarBorderShadowColor = {r = 0, g = 0, b = 0, a = 0.8}, + resourceBarBorderShadowEnabled = false, + resourceBarBorderShadowOffsetX = 1, + resourceBarBorderShadowOffsetY = -1, + resourceBarBorderShadowSize = 1, + resourceBarBorderSize = 1, + resourceBarBorderStyle = "SOLID", + resourceBarBorderTexture = "SOLID", + resourceBarShowBorder = false, resourceBarClassFilter = { DEATHKNIGHT = true, DEMONHUNTER = true, @@ -3006,7 +3503,7 @@ DF.RaidDefaults = { resourceBarSmooth = true, resourceBarWidth = 60, resourceBarX = 0, - resourceBarY = 0, + resourceBarY = 1, -- Class Power (Holy Power, Chi, Combo Points, etc. - player frame only) classPowerEnabled = false, @@ -3058,7 +3555,7 @@ DF.RaidDefaults = { roleIconExternalDPS = "", roleIconExternalHealer = "", roleIconExternalTank = "", - roleIconOnlyInCombat = false, + roleIconHideInCombat = false, roleIconScale = 1, roleIconShowDPS = true, roleIconShowHealer = true, @@ -3118,18 +3615,38 @@ DF.RaidDefaults = { -- Summon Icon summonIconAlpha = 1, - summonIconAnchor = "BOTTOM", + summonIconAnchor = "CENTER", summonIconEnabled = true, summonIconFrameLevel = 0, summonIconHideInCombat = false, summonIconScale = 1.5, - summonIconShowText = true, + summonIconShowText = false, summonIconTextAccepted = "Accepted", summonIconTextColor = {r = 0.6, g = 0.2, b = 1}, summonIconTextDeclined = "Declined", summonIconTextPending = "Summon", summonIconX = 0, - summonIconY = 9, + summonIconY = 0, + + -- BG Objective Carrier Icon (flag / orb carrier) + bgCarrierIconAlpha = 1, + bgCarrierIconAnchor = "CENTER", + bgCarrierIconEnabled = true, + bgCarrierIconFrameLevel = 0, + bgCarrierIconScale = 1, + bgCarrierIconShowText = false, + bgCarrierIconText = "FC", + bgCarrierIconTextColor = {r = 1, g = 0.82, b = 0}, + bgCarrierIconX = 0, + bgCarrierIconY = 0, + -- Combat icon (crossed swords shown when a unit is in combat) + combatIconAlpha = 1, + combatIconAnchor = "TOPLEFT", + combatIconEnabled = false, + combatIconFrameLevel = 0, + combatIconScale = 1, + combatIconX = 2, + combatIconY = -2, -- Targeted Spells (on-frame) targetedSpellAlpha = 1, @@ -3200,7 +3717,7 @@ DF.RaidDefaults = { testShowPets = true, testShowReducedMaxHealth = true, testShowSelection = false, - testShowStatusIcons = false, + testShowStatusIcons = true, testShowTargetedSpell = false, testShowClassPower = true, testShowAuraDesigner = false, diff --git a/Core.lua b/Core.lua index 40c658e4..cde7bd99 100644 --- a/Core.lua +++ b/Core.lua @@ -582,24 +582,22 @@ function DF:LightweightUpdatePowerBarSize() end -- Update only border thickness -function DF:LightweightUpdateBorderThickness() +-- Re-apply the frame border (size, style, texture, colour, show/hide) to every +-- live frame in the current mode. The full update path only re-styles party +-- frames (UpdateAllFrames -> ApplyFrameLayout); the raid path (UpdateRaidLayout) +-- only repositions headers, so border changes wouldn't reach live raid frames +-- without a reload. This mirrors LightweightUpdateBorderColor but reconfigures +-- the whole border via ApplyFrameBorder, so it covers both party and raid. +function DF:LightweightUpdateBorder() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] - if not db then return end - - local thickness = db.borderThickness or 1 - + if not db or not DF.ApplyFrameBorder then return end + local function UpdateBorder(frame) - if not frame or not frame.borderTextures then return end - for _, tex in pairs(frame.borderTextures) do - if tex.isVertical then - tex:SetWidth(thickness) - else - tex:SetHeight(thickness) - end - end + if not frame or not frame.border then return end + DF:ApplyFrameBorder(frame, db) end - + IterateFramesInMode(mode, UpdateBorder) end @@ -818,7 +816,7 @@ function DF:LightweightUpdateAuraPosition(auraType) local paddingY = auraType == "buff" and (db.buffPaddingY or 1) or (db.debuffPaddingY or 1) local wrap = auraType == "buff" and (db.buffWrap or 4) or (db.debuffWrap or 4) local growth = auraType == "buff" and (db.buffGrowth or "LEFT_UP") or (db.debuffGrowth or "RIGHT_UP") - local borderThickness = auraType == "buff" and (db.buffBorderThickness or 1) or (db.debuffBorderThickness or 1) + local borderThickness = auraType == "buff" and (db.buffBorderSize or 1) or (db.debuffBorderSize or 1) -- Apply pixel-perfect sizing to size and scale together, adjusting for border if db.pixelPerfect then @@ -1216,7 +1214,17 @@ function DF:LightweightUpdateDefensiveIcons() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] if not db then return end - + + -- Test mode owns multi-defensive layout (including the CENTER-growth + -- second pass), so re-anchoring the primary icon here without re-running + -- that pass would un-centre it and visually overlap icon 2. Delegate to + -- the full test render — it's what fires on slider drop anyway, just done + -- per drag tick too. + if (DF.testMode or DF.raidTestMode) and DF.UpdateAllTestDefensiveBar then + DF:UpdateAllTestDefensiveBar() + return + end + local size = db.defensiveIconSize or 24 local scale = db.defensiveIconScale or 1 local x = db.defensiveIconX or 0 @@ -1235,43 +1243,27 @@ function DF:LightweightUpdateDefensiveIcons() borderSize = DF:PixelPerfect(borderSize) end - local function UpdateIcon(frame) - if not frame or not frame.defensiveIcon then return end - local icon = frame.defensiveIcon - - -- Update size and scale - icon:SetSize(size, size) - icon:SetScale(scale) - - -- Update position - icon:ClearAllPoints() - icon:SetPoint(anchor, frame, anchor, x, y) - - -- Update border size - local showBorder = db.defensiveIconShowBorder ~= false - if showBorder and icon.texture then + -- Per-icon visual update: border + artwork inset + duration text. Anything + -- that's the same for the primary icon AND every multi-defensive bar icon + -- (sizes, fonts) lives here. Positioning and per-icon layout (which differs + -- across multi-bar slots) stays with UpdateAllDefensiveBars. + local showBorder = db.defensiveIconShowBorder ~= false + local artInset = showBorder and borderSize or 0 + + local function ApplyVisuals(icon) + if not icon then return end + if icon.border then + local spec = DF.Border:BuildSpec(db, "defensiveIcon", { iconMode = true }) + spec.enabled = showBorder + spec.size = borderSize -- already pixel-perfected above + DF.Border:Apply(icon.border, spec) + end + if icon.texture then icon.texture:ClearAllPoints() - icon.texture:SetPoint("TOPLEFT", borderSize, -borderSize) - icon.texture:SetPoint("BOTTOMRIGHT", -borderSize, borderSize) - - -- Update edge border sizes - if icon.borderLeft then icon.borderLeft:SetWidth(borderSize) end - if icon.borderRight then icon.borderRight:SetWidth(borderSize) end - if icon.borderTop then - icon.borderTop:SetHeight(borderSize) - icon.borderTop:ClearAllPoints() - icon.borderTop:SetPoint("TOPLEFT", borderSize, 0) - icon.borderTop:SetPoint("TOPRIGHT", -borderSize, 0) - end - if icon.borderBottom then - icon.borderBottom:SetHeight(borderSize) - icon.borderBottom:ClearAllPoints() - icon.borderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - icon.borderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - end + icon.texture:SetPoint("TOPLEFT", artInset, -artInset) + icon.texture:SetPoint("BOTTOMRIGHT", -artInset, artInset) end - - -- Find nativeCooldownText if not already found + if not icon.nativeCooldownText and icon.cooldown then local regions = {icon.cooldown:GetRegions()} for _, region in ipairs(regions) do @@ -1281,8 +1273,6 @@ function DF:LightweightUpdateDefensiveIcons() end end end - - -- Update duration text if it exists if icon.nativeCooldownText then local durationSize = 10 * durScale DF:SafeSetFont(icon.nativeCooldownText, durFont, durationSize, durOutline) @@ -1290,7 +1280,32 @@ function DF:LightweightUpdateDefensiveIcons() icon.nativeCooldownText:SetPoint("CENTER", icon, "CENTER", durX, durY) end end - + + local function UpdateIcon(frame) + if not frame or not frame.defensiveIcon then return end + local icon = frame.defensiveIcon + + -- Size / scale / position belong to the primary icon only; multi-bar + -- slots are laid out by UpdateAllDefensiveBars. + icon:SetSize(size, size) + icon:SetScale(scale) + icon:ClearAllPoints() + icon:SetPoint(anchor, frame, anchor, x, y) + + ApplyVisuals(icon) + + -- Multi-defensive bar icons share the same border + artwork + duration + -- styling as the primary. Without this loop the border slider only + -- updated the leftmost icon mid-drag and the rest stayed at the old + -- border size, which in test mode also caused a layout reflow that + -- temporarily lost one icon. + if frame.defensiveBarIcons then + for _, extraIcon in pairs(frame.defensiveBarIcons) do + ApplyVisuals(extraIcon) + end + end + end + IterateFramesInMode(mode, UpdateIcon) end @@ -1320,32 +1335,21 @@ function DF:LightweightUpdateMissingBuff() frame.missingBuffFrame:ClearAllPoints() frame.missingBuffFrame:SetPoint(anchor, frame, anchor, x, y) - -- Update border size (positions icon within border) + -- Border via unified DF.Border backend (Stage 4.1). BuildSpec + -- reads canonical missingBuffIcon* keys; we override size with + -- the locally pixel-perfected value. Icon insets by visible + -- border thickness so artwork doesn't overlap edges. + if frame.missingBuffBorder then + local spec = DF.Border:BuildSpec(db, "missingBuffIcon", { iconMode = true }) + spec.enabled = showBorder + spec.size = borderSize + DF.Border:Apply(frame.missingBuffBorder, spec) + end if frame.missingBuffIcon then + local artInset = showBorder and borderSize or 0 frame.missingBuffIcon:ClearAllPoints() - if showBorder then - frame.missingBuffIcon:SetPoint("TOPLEFT", borderSize, -borderSize) - frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", -borderSize, borderSize) - - -- Update edge border sizes - if frame.missingBuffBorderLeft then frame.missingBuffBorderLeft:SetWidth(borderSize) end - if frame.missingBuffBorderRight then frame.missingBuffBorderRight:SetWidth(borderSize) end - if frame.missingBuffBorderTop then - frame.missingBuffBorderTop:SetHeight(borderSize) - frame.missingBuffBorderTop:ClearAllPoints() - frame.missingBuffBorderTop:SetPoint("TOPLEFT", borderSize, 0) - frame.missingBuffBorderTop:SetPoint("TOPRIGHT", -borderSize, 0) - end - if frame.missingBuffBorderBottom then - frame.missingBuffBorderBottom:SetHeight(borderSize) - frame.missingBuffBorderBottom:ClearAllPoints() - frame.missingBuffBorderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - frame.missingBuffBorderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - end - else - frame.missingBuffIcon:SetPoint("TOPLEFT", 0, 0) - frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", 0, 0) - end + frame.missingBuffIcon:SetPoint("TOPLEFT", artInset, -artInset) + frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", -artInset, artInset) end end end @@ -1486,7 +1490,7 @@ function DF:LightweightUpdateAuraBorder(auraType) local iconsKey = auraType == "buff" and "buffIcons" or "debuffIcons" -- Regular border settings - local thickness = auraType == "buff" and (db.buffBorderThickness or 1) or (db.debuffBorderThickness or 1) + local thickness = auraType == "buff" and (db.buffBorderSize or 1) or (db.debuffBorderSize or 1) local inset = auraType == "buff" and (db.buffBorderInset or 0) or (db.debuffBorderInset or 0) -- Expiring border settings (buffs only) @@ -1501,46 +1505,17 @@ function DF:LightweightUpdateAuraBorder(auraType) if not frame or not frame[iconsKey] then return end for idx, icon in ipairs(frame[iconsKey]) do if icon then - -- Update regular border + -- Update regular border (DF.Border geometry via shared helper). + -- Gated on icon.border, so it only reconfigures an existing + -- (enabled) border — pass enabled = true. if icon.border then - icon.border:ClearAllPoints() - icon.border:SetPoint("TOPLEFT", -thickness + inset, thickness - inset) - icon.border:SetPoint("BOTTOMRIGHT", thickness - inset, -thickness + inset) + DF:ConfigureAuraIconBorder(icon, db, auraType, true) end - -- Update expiring border (buffs only) - store settings on icon + -- Update expiring border (buffs only) — re-configure the unified + -- DF.Border overlay (geometry/colour/style/animation) live. if auraType == "buff" then - icon.expiringBorderThickness = expiringThickness - icon.expiringBorderInset = expiringInset - - -- Update expiring border textures if they exist - if icon.expiringBorderTop then - if DF.debugSliderUpdates and idx == 1 then - print(" - Updating icon " .. idx .. " expiring border") - end - -- Set thickness - icon.expiringBorderTop:SetHeight(expiringThickness) - icon.expiringBorderBottom:SetHeight(expiringThickness) - icon.expiringBorderLeft:SetWidth(expiringThickness) - icon.expiringBorderRight:SetWidth(expiringThickness) - - -- Position with inset - icon.expiringBorderLeft:ClearAllPoints() - icon.expiringBorderLeft:SetPoint("TOPLEFT", icon, "TOPLEFT", expiringInset, -expiringInset) - icon.expiringBorderLeft:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", expiringInset, expiringInset) - - icon.expiringBorderRight:ClearAllPoints() - icon.expiringBorderRight:SetPoint("TOPRIGHT", icon, "TOPRIGHT", -expiringInset, -expiringInset) - icon.expiringBorderRight:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -expiringInset, expiringInset) - - icon.expiringBorderTop:ClearAllPoints() - icon.expiringBorderTop:SetPoint("TOPLEFT", icon.expiringBorderLeft, "TOPRIGHT", 0, 0) - icon.expiringBorderTop:SetPoint("TOPRIGHT", icon.expiringBorderRight, "TOPLEFT", 0, 0) - - icon.expiringBorderBottom:ClearAllPoints() - icon.expiringBorderBottom:SetPoint("BOTTOMLEFT", icon.expiringBorderLeft, "BOTTOMRIGHT", 0, 0) - icon.expiringBorderBottom:SetPoint("BOTTOMRIGHT", icon.expiringBorderRight, "BOTTOMLEFT", 0, 0) - end + DF:ConfigureExpiringBorder(icon, db, "buffExpiring") end end end @@ -1576,7 +1551,11 @@ function DF:LightweightUpdateFrameLevel(elementType) if level > 0 then frame.defensiveIcon:SetFrameLevel(frameBaseLevel + level) else - frame.defensiveIcon:SetFrameLevel(baseLevel + 15) + -- +26 keeps the defensive icon above the buff/debuff auras AND + -- their borders: an aura icon sits at contentOverlay+15 with its + -- DF.Border +10 on top (= +25), so +26 clears the whole aura. + -- The defensive is an important alert and shouldn't be obscured. + frame.defensiveIcon:SetFrameLevel(baseLevel + 26) end elseif elementType == "role" and frame.roleIcon then local level = db.roleIconFrameLevel or 0 @@ -1636,33 +1615,79 @@ function DF:GetClassColor(class) return RAID_CLASS_COLORS[class] or DEFAULT_CLASS_COLOR end --- Resolve the frame border colour: the static borderColor by default, or the --- unit's class colour (RGB) with the static colour's alpha when borderClassColor --- is enabled. Non-player / unknown-class units fall back to the static colour. --- Handles test frames via their fake class data. +-- Resolve the frame border colour: the static borderColor by default, or +-- (Stage 2.1+) the unit's class / role colour with its own alpha slider when +-- the canonical frameBorderColorSource picks one. Non-player / unknown-class +-- units fall back to the static colour. Handles test frames via fake class +-- and role data. Mirrors Border:BuildSpec so the lightweight live-update +-- path (LightweightUpdateBorderColor) renders identically to the full Apply +-- path on every drag tick of the colour picker / alpha slider. function DF:GetFrameBorderColor(frame, db) - local base = db.borderColor or DEFAULT_CLASS_COLOR + local base = db.frameBorderColor or DEFAULT_CLASS_COLOR local br, bg, bb, ba = base.r or 0, base.g or 0, base.b or 0, base.a or 1 - if not (frame and db.borderClassColor) then + + -- Resolve source the same way Border:BuildSpec does, so the lightweight + -- live-update path (LightweightUpdateBorderColor) renders identically to + -- the full Apply path. ColorSource is the canonical Stage 2 key; the + -- legacy booleans are honoured as fallback in case the migration shim + -- hasn't run yet for some code path. + local source = db.frameBorderColorSource + if not source then + if db.frameBorderUseClassColor then source = "CLASS" + elseif db.frameBorderUseRoleColor then source = "ROLE" + else source = "STATIC" end + end + if source == "STATIC" or not frame then return br, bg, bb, ba end - local class - if frame.dfIsTestFrame then - local testData = DF.GetTestUnitData and DF:GetTestUnitData(frame.index, frame.isRaidFrame) - class = testData and testData.class - elseif frame.unit and UnitExists(frame.unit) then - -- No UnitIsPlayer gate: class-based NPC party members (e.g. follower - -- dungeon companions) have a class token too, and the class-coloured - -- health bars colour them, so the border should match. Units with no - -- class token (RAID_CLASS_COLORS miss) fall back to the static colour. - class = select(2, UnitClass(frame.unit)) + -- CLASS / ROLE: RGB from the resolver, alpha from the picker's own alpha + -- component (frameBorderColor.a — same `ba` above). The unified Border + -- Alpha slider (Stage 2.4) edits this same component, so picker and + -- slider stay in sync automatically; no separate alpha key to read. + local a = ba + + if source == "CLASS" then + local class + if frame.dfIsTestFrame then + local testData = DF.GetTestUnitData and DF:GetTestUnitData(frame.index, frame.isRaidFrame) + class = testData and testData.class + elseif frame.unit and UnitExists(frame.unit) then + -- No UnitIsPlayer gate: class-based NPC party members (e.g. + -- follower dungeon companions) have a class token too. Units + -- with no class token fall back to the static colour. + class = select(2, UnitClass(frame.unit)) + end + if class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then + local c = DF:GetClassColor(class) + return c.r, c.g, c.b, a + end + return br, bg, bb, a + elseif source == "ROLE" then + local rc = DF.db and DF.db.roleColors + local role + if frame.dfIsTestFrame then + local testData = DF.GetTestUnitData and DF:GetTestUnitData(frame.index, frame.isRaidFrame) + role = testData and testData.role + elseif frame.unit and UnitExists(frame.unit) and UnitGroupRolesAssigned then + role = UnitGroupRolesAssigned(frame.unit) + -- UnitGroupRolesAssigned returns "NONE" outside instances where + -- roles aren't assigned (solo, world content). For the player, + -- fall back to spec role so role colour stays meaningful. Other + -- units expose no public spec API; they stay on picker fallback. + if (not role or role == "NONE") and UnitIsUnit and UnitIsUnit(frame.unit, "player") + and GetSpecialization and GetSpecializationRole then + local spec = GetSpecialization() + if spec then role = GetSpecializationRole(spec) end + end + end + local c = rc and role and role ~= "NONE" and (rc[role] or rc[string.lower(role)]) + if c then + return c.r or br, c.g or bg, c.b or bb, a + end + return br, bg, bb, a end - if class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then - local c = DF:GetClassColor(class) - return c.r, c.g, c.b, ba - end return br, bg, bb, ba end @@ -2008,17 +2033,19 @@ function DF:LightweightUpdateBorderColor() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] if not db then return end - + local function UpdateFrame(frame) if not frame or not frame.border then return end -- Route through SetBorderColor so it recolours whichever mode (solid - -- edges or texture backdrop) is currently active. Resolved per-frame so - -- class-coloured borders pick up each unit's colour. + -- edges or texture backdrop) is currently active. Resolved per-frame + -- via GetFrameBorderColor so class / role colours pick up each + -- unit's resolved colour, and the dedicated frameBorderAlpha slider + -- is honoured on every drag tick. if frame.border.SetBorderColor then frame.border:SetBorderColor(DF:GetFrameBorderColor(frame, db)) end end - + IterateFramesInMode(mode, UpdateFrame) end @@ -2191,20 +2218,15 @@ function DF:LightweightUpdateExpiringBorderColor() local db = DF.db[mode] if not db then return end - local color = db.buffExpiringBorderColor or {r = 1, g = 0.5, b = 0, a = 1} - local function UpdateIcons(frame) if not frame or not frame.buffIcons then return end for _, icon in ipairs(frame.buffIcons) do - if icon and icon.expiringBorderTop then - icon.expiringBorderTop:SetColorTexture(color.r, color.g, color.b, color.a or 1) - icon.expiringBorderBottom:SetColorTexture(color.r, color.g, color.b, color.a or 1) - icon.expiringBorderLeft:SetColorTexture(color.r, color.g, color.b, color.a or 1) - icon.expiringBorderRight:SetColorTexture(color.r, color.g, color.b, color.a or 1) - end + -- Re-apply the unified expiring border so a live colour-picker change + -- repaints the static colour (and keeps style/animation in sync). + DF:ConfigureExpiringBorder(icon, db, "buffExpiring") end end - + IterateFramesInMode(mode, UpdateIcons) end @@ -2233,26 +2255,19 @@ function DF:LightweightUpdateMissingBuffBorderColor() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] if not db then return end - - local color = db.missingBuffIconBorderColor or {r = 1, g = 0, b = 0, a = 1} - + local function UpdateIcon(frame) - if frame then - if frame.missingBuffBorderLeft then - frame.missingBuffBorderLeft:SetColorTexture(color.r, color.g, color.b, color.a or 1) - end - if frame.missingBuffBorderRight then - frame.missingBuffBorderRight:SetColorTexture(color.r, color.g, color.b, color.a or 1) - end - if frame.missingBuffBorderTop then - frame.missingBuffBorderTop:SetColorTexture(color.r, color.g, color.b, color.a or 1) - end - if frame.missingBuffBorderBottom then - frame.missingBuffBorderBottom:SetColorTexture(color.r, color.g, color.b, color.a or 1) - end + if frame and frame.missingBuffBorder then + -- Route through BuildSpec + Apply (Stage 4.1) so the colour + -- pick respects ColorSource / gradient / etc. The full-render + -- path in Frames/Icons.lua does the same thing — keeping the + -- live drag-update consistent so dragging the picker on a + -- gradient or class-coloured border updates correctly. + DF.Border:Apply(frame.missingBuffBorder, + DF.Border:BuildSpec(db, "missingBuffIcon", { iconMode = true })) end end - + IterateFramesInMode(mode, UpdateIcon) end @@ -2261,36 +2276,49 @@ function DF:LightweightUpdateDefensiveIconColors() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] if not db then return end - + + -- Same test-mode delegation as LightweightUpdateDefensiveIcons: the test + -- render owns multi-defensive layout; touching individual icons here can + -- leave the primary anchored away from the centred-layout position. + if (DF.testMode or DF.raidTestMode) and DF.UpdateAllTestDefensiveBar then + DF:UpdateAllTestDefensiveBar() + return + end + local borderColor = db.defensiveIconBorderColor or {r = 0, g = 0, b = 0, a = 1} local durationColor = db.defensiveIconDurationColor or {r = 1, g = 1, b = 1} - local function UpdateIcon(frame) - if not frame or not frame.defensiveIcon then return end - local icon = frame.defensiveIcon - - -- Update border colors (edge borders) - if icon.borderLeft then - icon.borderLeft:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) - end - if icon.borderRight then - icon.borderRight:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) - end - if icon.borderTop then - icon.borderTop:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) + local function ApplyColors(icon, unit) + if not icon then return end + if icon.border then + -- ctx.unit lets the Class/Role resolvers fire on the live update + -- path. ctx.frame additionally lets test frames preview + -- Class/Role via GetTestUnitData (Stage 4.0). spec.color is NOT + -- overridden — BuildSpec resolves it via the ColorSource per + -- unit, so a static override here would clobber CLASS/ROLE. + local spec = DF.Border:BuildSpec(db, "defensiveIcon", { + unit = unit, + frame = icon.unitFrame, + iconMode = true, + }) + DF.Border:Apply(icon.border, spec) end - if icon.borderBottom then - icon.borderBottom:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) + -- Skip duration recolour when colorByTime is active — RenderDefensiveBarIcon owns it then. + if not db.defensiveIconDurationColorByTime and icon.nativeCooldownText then + icon.nativeCooldownText:SetTextColor(durationColor.r, durationColor.g, durationColor.b, 1) end - - -- Update duration text color (skip when colorByTime is active — RenderDefensiveBarIcon handles it) - if not db.defensiveIconDurationColorByTime then - if icon.nativeCooldownText then - icon.nativeCooldownText:SetTextColor(durationColor.r, durationColor.g, durationColor.b, 1) + end + + local function UpdateIcon(frame) + if not frame or not frame.defensiveIcon then return end + ApplyColors(frame.defensiveIcon, frame.unit) + if frame.defensiveBarIcons then + for _, extraIcon in pairs(frame.defensiveBarIcons) do + ApplyColors(extraIcon, frame.unit) end end end - + IterateFramesInMode(mode, UpdateIcon) end @@ -2329,21 +2357,19 @@ function DF:LightweightUpdateResourceBarBorder() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] if not db then return end - - local enabled = db.resourceBarBorderEnabled - local borderColor = db.resourceBarBorderColor or {r = 0, g = 0, b = 0, a = 1} - + local function UpdateFrame(frame) if not frame or not frame.dfPowerBar or not frame.dfPowerBar.border then return end - local border = frame.dfPowerBar.border - if enabled then - border:Show() - border:SetBackdropBorderColor(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) - else - border:Hide() - end + -- Route through BuildSpec + Apply (Stage 4.2) so the live drag- + -- update path renders identically to ApplyResourceBarLayout. + -- ctx.unit / ctx.frame let Class / Role resolvers fire. + DF.Border:Apply(frame.dfPowerBar.border, + DF.Border:BuildSpec(db, "resourceBar", { + unit = frame.unit, + frame = frame, + })) end - + IterateFramesInMode(mode, UpdateFrame) end @@ -2352,14 +2378,16 @@ function DF:LightweightUpdateResourceBarBorderColor() local mode = DF.GUI and DF.GUI.SelectedMode or "party" local db = DF.db[mode] if not db then return end - - local borderColor = db.resourceBarBorderColor or {r = 0, g = 0, b = 0, a = 1} - + local function UpdateFrame(frame) if not frame or not frame.dfPowerBar or not frame.dfPowerBar.border then return end - frame.dfPowerBar.border:SetBackdropBorderColor(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) + DF.Border:Apply(frame.dfPowerBar.border, + DF.Border:BuildSpec(db, "resourceBar", { + unit = frame.unit, + frame = frame, + })) end - + IterateFramesInMode(mode, UpdateFrame) end @@ -2479,7 +2507,7 @@ function DF:LightweightUpdateDebuffBorderColors() if not inTestMode then return end -- Skip if borders not enabled or not using color by type - if db.debuffBorderEnabled == false or db.debuffBorderColorByType == false then + if db.debuffShowBorder == false or db.debuffBorderColorByType == false then return end @@ -2501,7 +2529,7 @@ function DF:LightweightUpdateDebuffBorderColors() -- Get the debuff type stored on the icon (only set in test mode) local debuffType = icon.debuffType local color = colors[debuffType] or defaultColor - icon.border:SetColorTexture(color.r, color.g, color.b, 0.8) + icon.border:SetColor(color.r, color.g, color.b, 0.8) end end end @@ -3035,6 +3063,121 @@ eventFrame:RegisterEvent("PLAYER_TALENT_UPDATE") -- Fires when talents change eventFrame:RegisterEvent("UNIT_PET") -- Fires when a pet is summoned/dismissed eventFrame:RegisterEvent("PLAYER_UPDATE_RESTING") -- Fires when entering/leaving rested area +-- One-shot copy of legacy Frame Border saved-variable keys to the canonical +-- `frame*Border*` naming the unified DF.Border / CreateBorderControls helpers +-- expect. Called per-mode from ADDON_LOADED. Idempotent: if the new key +-- already exists in the profile we leave it (the user has saved with the new +-- key); otherwise we adopt the legacy value. Legacy keys are NOT deleted so +-- the migration can be safely re-run and old profiles stay readable by a +-- previous addon version if the user rolls back. +function DF:MigrateFrameBorderKeys(modeDb) + if not modeDb then return end + local function adopt(newKey, oldKey) + if modeDb[newKey] == nil and modeDb[oldKey] ~= nil then + modeDb[newKey] = modeDb[oldKey] + end + end + adopt("frameShowBorder", "showFrameBorder") + adopt("frameBorderSize", "borderSize") + adopt("frameBorderColor", "borderColor") + adopt("frameBorderStyle", "borderStyle") + adopt("frameBorderTexture", "borderTexture") + adopt("frameBorderUseClassColor","borderClassColor") + + -- ColorSource (single segmented key) supersedes the independent + -- UseClassColor / UseRoleColor booleans. Copy whichever was true into + -- the new key; leave the booleans intact so an old client can still + -- read them. + if modeDb.frameBorderColorSource == nil then + if modeDb.frameBorderUseClassColor then modeDb.frameBorderColorSource = "CLASS" + elseif modeDb.frameBorderUseRoleColor then modeDb.frameBorderColorSource = "ROLE" + end + end + + -- Gradient was previously an independent boolean (`BorderGradientEnabled`) + -- that overlaid on top of Style; Stage 2.3 folded it into Style as a + -- third option so the user can't get conflicting "Solid + Class Color + + -- Gradient" combinations. Adopt: if the old boolean is true and the + -- style isn't already explicitly set to TEXTURE (which would be a + -- deliberate other-mode choice), promote to "GRADIENT". Old boolean is + -- left in place for rollback safety. + local function adoptGradientStyle(prefix) + local styleKey = prefix .. "BorderStyle" + local enabledKey = prefix .. "BorderGradientEnabled" + if modeDb[enabledKey] == true and modeDb[styleKey] ~= "TEXTURE" + and modeDb[styleKey] ~= "GRADIENT" then + modeDb[styleKey] = "GRADIENT" + end + end + adoptGradientStyle("frame") + adoptGradientStyle("defensiveIcon") +end + +-- Move the role-border colour set from per-mode storage (Stage 2 default +-- placement) up to profile level under DF.db.roleColors so the global Colors +-- settings page can manage them alongside class colours. Idempotent: only +-- adopts a mode-level value into profile-level when profile-level doesn't +-- already have one set, and only seeds defaults when neither exists. Called +-- once per ADDON_LOADED after both modes have been migrated. +function DF:MigrateRoleBorderColors() + if not DF.db then return end + if not DF.db.roleColors then DF.db.roleColors = {} end + local rc = DF.db.roleColors + + local DEFAULTS = { + TANK = {r = 0.20, g = 0.55, b = 0.95, a = 1}, + HEALER = {r = 0.20, g = 0.80, b = 0.30, a = 1}, + DAMAGER = {r = 0.85, g = 0.20, b = 0.20, a = 1}, + } + + -- Adopt from whichever mode-level set was customised first. + local sources = { DF.db.party, DF.db.raid } + local function adopt(role, modeKey) + if rc[role] then return end + for _, m in ipairs(sources) do + if m and m[modeKey] then rc[role] = m[modeKey]; return end + end + rc[role] = DEFAULTS[role] + end + adopt("TANK", "roleBorderColorTank") + adopt("HEALER", "roleBorderColorHealer") + adopt("DAMAGER", "roleBorderColorDamager") +end + +-- Adopt the legacy `resourceBarBorderEnabled` boolean into the canonical +-- `resourceBarShowBorder` key the unified DF.Border helper expects. Same +-- pattern as MigrateFrameBorderKeys — idempotent, leaves the legacy key +-- in place for rollback safety. Stage 4.2. +function DF:MigrateResourceBarBorderKeys(modeDb) + if not modeDb then return end + if modeDb.resourceBarShowBorder == nil and modeDb.resourceBarBorderEnabled ~= nil then + modeDb.resourceBarShowBorder = modeDb.resourceBarBorderEnabled + end +end + +-- Aura icon borders: rename the legacy buff/debuff keys to the canonical +-- ShowBorder / BorderSize so they plug into BuildSpec + CreateBorderControls +-- (Stage 5.5 Phase 2 — full border toolkit for buff/debuff). Same idempotent, +-- leaves-the-legacy-key pattern as MigrateFrameBorderKeys. +function DF:MigrateAuraBorderKeys(modeDb) + if not modeDb then return end + for _, p in ipairs({ "buff", "debuff" }) do + if modeDb[p .. "ShowBorder"] == nil and modeDb[p .. "BorderEnabled"] ~= nil then + modeDb[p .. "ShowBorder"] = modeDb[p .. "BorderEnabled"] + end + if modeDb[p .. "BorderSize"] == nil and modeDb[p .. "BorderThickness"] ~= nil then + modeDb[p .. "BorderSize"] = modeDb[p .. "BorderThickness"] + end + end + -- Expiring border: the legacy single Pulsate bool becomes the unified + -- Expiring Animation type (true -> DF Pulsate, false -> None). Only seed + -- when an old key exists and the new one hasn't been set yet, so existing + -- configs keep their pulse and new profiles use their own default. + if modeDb.buffExpiringBorderAnimationType == nil and modeDb.buffExpiringBorderPulsate ~= nil then + modeDb.buffExpiringBorderAnimationType = modeDb.buffExpiringBorderPulsate and "DF_PULSATE" or "NONE" + end +end + -- The handler body is stored on DF as _MainEventDispatcher so the profiler -- can swap it for an instrumented version at runtime. The frame's actual -- script is a thin trampoline that calls through DF — re-binding takes @@ -3182,8 +3325,37 @@ DF._MainEventDispatcher = function(self, event, arg1) if not DF.db.raid then DF.db.raid = DF:DeepCopy(DF.RaidDefaults) end -- Ensure raidAutoProfiles exists in current profile - if not DF.db.raidAutoProfiles then - DF.db.raidAutoProfiles = DF:DeepCopy(DF.RaidAutoProfilesDefaults) + if not DF.db.raidAutoProfiles then + DF.db.raidAutoProfiles = DF:DeepCopy(DF.RaidAutoProfilesDefaults) + end + + -- Migrate legacy Frame Border keys (borderSize / showFrameBorder / + -- borderColor / borderStyle / borderTexture / borderClassColor / + -- frameBorderUseClassColor / frameBorderUseRoleColor) to the canonical + -- `frame*Border*` naming + new frameBorderColorSource segmented key. + -- One-shot copy per mode: if a new key already exists we leave it + -- (user has already saved with the new key); otherwise we adopt the + -- old value. + if DF.MigrateFrameBorderKeys then + DF:MigrateFrameBorderKeys(DF.db.party) + DF:MigrateFrameBorderKeys(DF.db.raid) + end + -- Resource Bar: resourceBarBorderEnabled → resourceBarShowBorder + -- (Stage 4.2 wire-up to the unified DF.Border helper). + if DF.MigrateResourceBarBorderKeys then + DF:MigrateResourceBarBorderKeys(DF.db.party) + DF:MigrateResourceBarBorderKeys(DF.db.raid) + end + -- Aura icons: buff/debuffBorderEnabled → ShowBorder, BorderThickness → + -- BorderSize (Stage 5.5 Phase 2 — full toolkit for buff/debuff borders). + if DF.MigrateAuraBorderKeys then + DF:MigrateAuraBorderKeys(DF.db.party) + DF:MigrateAuraBorderKeys(DF.db.raid) + end + -- Promote role border colours from per-mode storage to profile-level + -- DF.db.roleColors so the global Colors settings page manages them. + if DF.MigrateRoleBorderColors then + DF:MigrateRoleBorderColors() end -- Ensure classColors table exists (shared across party/raid) @@ -3366,6 +3538,60 @@ DF._MainEventDispatcher = function(self, event, arg1) end end + -- Recolour the Reduced Max Health bar off solid black. + -- The old shipped default was opaque black {0,0,0,1}, which reads as empty + -- space on a dark bar. Flip any profile still on one of our prior defaults + -- — that black, OR the short-lived in-development grey #757575CB — to the + -- new #808080CD (50% grey @ ~80% alpha). One-time per mode (flag) so a + -- later deliberate colour choice isn't reverted; non-matching (customised) + -- colours are left alone. (The #757575CB branch only matters to in-dev + -- testers; no released build ever shipped that value.) + local function recolorReducedMaxHealth(modeDb) + if modeDb and not modeDb._reducedMaxHealthRecolorV2 then + local c = modeDb.reducedMaxHealthColor + local isOldBlack = c and c.r == 0 and c.g == 0 and c.b == 0 and c.a == 1 + local isDevGrey = c and c.r == 0.4588 and c.g == 0.4588 and c.b == 0.4588 and c.a == 0.7961 + if isOldBlack or isDevGrey then + modeDb.reducedMaxHealthColor = { r = 0.502, g = 0.502, b = 0.502, a = 0.8039 } + end + modeDb._reducedMaxHealthRecolorV2 = true + end + end + for _, mode in ipairs({"party", "raid"}) do + recolorReducedMaxHealth(DF.db[mode]) + end + if DandersFramesDB_v2 and DandersFramesDB_v2.profiles then + for _, profile in pairs(DandersFramesDB_v2.profiles) do + for _, mode in ipairs({"party", "raid"}) do + recolorReducedMaxHealth(profile[mode]) + end + end + end + + -- The split test-mode toggles "Status / Ready" (testShowStatusIcons) and + -- "Role / Leader" (testShowIcons) merged into one "Icons" toggle keyed on + -- testShowStatusIcons (now default on). Flip existing profiles that were on + -- the old default (status off) to on once, so role/leader icons don't vanish + -- in test mode. One-time per mode (flag); a later deliberate off isn't reverted. + local function mergeIconsToggle(modeDb) + if modeDb and not modeDb._iconsToggleMergeV1 then + if modeDb.testShowStatusIcons == false then + modeDb.testShowStatusIcons = true + end + modeDb._iconsToggleMergeV1 = true + end + end + for _, mode in ipairs({"party", "raid"}) do + mergeIconsToggle(DF.db[mode]) + end + if DandersFramesDB_v2 and DandersFramesDB_v2.profiles then + for _, profile in pairs(DandersFramesDB_v2.profiles) do + for _, mode in ipairs({"party", "raid"}) do + mergeIconsToggle(profile[mode]) + end + end + end + -- Migrate the legacy `groupLabelShadow` (duplicate-fontstring shadow) into -- the new composite outline encoding from PR #115. If the user previously -- had the legacy shadow on, prepend "SHADOW;" to groupLabelOutline so they @@ -3669,6 +3895,22 @@ DF._MainEventDispatcher = function(self, event, arg1) end end + -- Stage 5.1b: rename per-aura icon border keys to canonical + -- ShowBorder / BorderSize / BorderInset. Idempotent; safe to + -- run on already-migrated configs. Defined in + -- AuraDesigner/Options.lua; load order guarantees that file + -- has registered DF.MigrateAuraDesignerIconBorderKeys by here. + if DF.MigrateAuraDesignerIconBorderKeys then + DF:MigrateAuraDesignerIconBorderKeys(DF.db.party) + DF:MigrateAuraDesignerIconBorderKeys(DF.db.raid) + if DandersFramesDB_v2 and DandersFramesDB_v2.profiles then + for _, profile in pairs(DandersFramesDB_v2.profiles) do + DF:MigrateAuraDesignerIconBorderKeys(profile.party) + DF:MigrateAuraDesignerIconBorderKeys(profile.raid) + end + end + end + -- Force auraSourceMode to DIRECT for all existing profiles (v4.2.x) -- One-time migration: sets flag so the popup only shows once. if DandersFramesDB_v2 and DandersFramesDB_v2.profiles then @@ -3852,6 +4094,54 @@ DF._MainEventDispatcher = function(self, event, arg1) end end + -- AFK text colour: the AFK text was previously hardcoded orange and + -- afkIconTextColor (its colour picker) was ignored. The picker is now + -- live; convert profiles still on the old peachy default to the orange + -- the text actually showed, so there's no visible change. + local function MigrateAFKTextColor(modeDb) + local c = modeDb and modeDb.afkIconTextColor + if type(c) == "table" + and math.abs((c.g or 0) - 0.7725490927696228) < 0.0001 + and math.abs((c.b or 0) - 0.5411764979362488) < 0.0001 then + modeDb.afkIconTextColor = { r = 1, g = 0.5, b = 0, a = 1 } + end + end + for _, mode in ipairs({"party", "raid"}) do + MigrateAFKTextColor(DF.db[mode]) + end + if DandersFramesDB_v2 and DandersFramesDB_v2.profiles then + for _, profile in pairs(DandersFramesDB_v2.profiles) do + for _, mode in ipairs({"party", "raid"}) do + MigrateAFKTextColor(profile[mode]) + end + end + end + + -- AFK timer font: an earlier build force-stamped the monospace timer font + -- onto every profile to stop the countdown wobble. The wobble is actually + -- fixed by LEFT-justifying the timer (see ApplyTimerTextSettings) — the + -- mono font is no longer needed or defaulted. Clear that stamp ONCE so the + -- timer goes back to inheriting the global font; guard with a flag so a + -- deliberate mono choice made later is not wiped on the next reload. + if DandersFramesDB_v2 and not DandersFramesDB_v2.afkTimerMonoUnstamped then + local function UnstampAFKTimerFont(modeDb) + if modeDb and modeDb.afkIconTimerFont == "DF Roboto Mono SemiBold" then + modeDb.afkIconTimerFont = nil + end + end + for _, mode in ipairs({"party", "raid"}) do + UnstampAFKTimerFont(DF.db[mode]) + end + if DandersFramesDB_v2.profiles then + for _, profile in pairs(DandersFramesDB_v2.profiles) do + for _, mode in ipairs({"party", "raid"}) do + UnstampAFKTimerFont(profile[mode]) + end + end + end + DandersFramesDB_v2.afkTimerMonoUnstamped = true + end + -- v4.3.4: One-time forced upgrade of "dandersframes" mode users to -- "both" (Hybrid). Hybrid covers boss debuffs via Blizzard's -- container overlay, which DandersFrames-only mode misses entirely. @@ -4207,8 +4497,22 @@ DF._MainEventDispatcher = function(self, event, arg1) SLASH_DANDERSFRAMES1 = "/df" SLASH_DANDERSFRAMES2 = "/dandersframes" SlashCmdList["DANDERSFRAMES"] = function(msg) + local rawMsg = msg or "" msg = msg and msg:lower() or "" - + + -- "/df clearoverride " — remove a stuck auto-layout + -- override from the target layout. Parsed from the raw message so the + -- key keeps its original case (override keys are mixed-case). + local firstWord, restRaw = rawMsg:match("^%s*(%S+)%s*(.-)%s*$") + if firstWord and (firstWord:lower() == "clearoverride" or firstWord:lower() == "clearoverrides") then + if DF.AutoProfilesUI and DF.AutoProfilesUI.ClearOverrideCommand then + DF.AutoProfilesUI:ClearOverrideCommand(restRaw ~= "" and restRaw or nil) + else + print("|cff00ff00DandersFrames:|r Auto profiles module not loaded.") + end + return + end + if msg == "unlock" then if DF.UnlockFrames then DF:UnlockFrames() end elseif msg == "lock" then @@ -5256,7 +5560,17 @@ function DF:FullProfileRefresh() if DF.UpdateRaidLayout then DF:UpdateRaidLayout() end - + + -- Re-apply Aura Designer indicators from the new profile. AD indicators are + -- built from the live config and version-gated, so on a profile swap they + -- keep the previous profile's look until /reload. ForceRefreshAllFrames + -- bumps adConfigVersion (forces every indicator to reconfigure) and + -- pre-warms all frames' indicators. Safe here — FullProfileRefresh already + -- bailed out above if in combat. + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then + DF.AuraDesigner.Engine:ForceRefreshAllFrames() + end + -- === REFRESH FLATRAIDFRAMES IF ACTIVE === if DF.FlatRaidFrames then if DF.FlatRaidFrames.initialized then diff --git a/DandersFrames.toc b/DandersFrames.toc index 6624eb5f..0f33f475 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -31,6 +31,7 @@ Libs\AceSerializer-3.0\AceSerializer-3.0.lua Libs\LibDataBroker-1.1\LibDataBroker-1.1.lua Libs\LibDBIcon-1.0\LibDBIcon-1.0.lua Libs\LibSharedMedia-3.0\LibSharedMedia-3.0.lua +Libs\LibCustomGlow-1.0\LibCustomGlow-1.0.xml Libs\LibDeflate\LibDeflate.lua Libs\LibSerialize\LibSerialize.lua @@ -85,6 +86,8 @@ WizardBuilder.lua # Frame System Frames\Core.lua Frames\Colors.lua +Frames\Border.lua +Frames\Expiring.lua Frames\Create.lua Frames\Headers.lua Frames\Update.lua diff --git a/ExportCategories.lua b/ExportCategories.lua index 77f05459..e6d37b96 100644 --- a/ExportCategories.lua +++ b/ExportCategories.lua @@ -138,9 +138,9 @@ DF.ExportCategories = { "reducedMaxHealthEnabled", "reducedMaxHealthTexture", - -- Border - "borderSize", - + -- Frame Border thickness (rest of frameBorder* keys live in `other`) + "frameBorderSize", + -- Class Color Alpha "classColorAlpha", @@ -168,8 +168,23 @@ DF.ExportCategories = { "resourceBarClassFilter", "resourceBarBackgroundEnabled", "resourceBarBackgroundColor", - "resourceBarBorderEnabled", + "resourceBarBorderBlendMode", "resourceBarBorderColor", + "resourceBarBorderColorSource", + "resourceBarBorderEnabled", + "resourceBarBorderGradientDirection", + "resourceBarBorderGradientEndColor", + "resourceBarBorderGradientStartColor", + "resourceBarBorderInset", + "resourceBarBorderShadowColor", + "resourceBarBorderShadowEnabled", + "resourceBarBorderShadowOffsetX", + "resourceBarBorderShadowOffsetY", + "resourceBarBorderShadowSize", + "resourceBarBorderSize", + "resourceBarBorderStyle", + "resourceBarBorderTexture", + "resourceBarShowBorder", "resourceBarFrameLevel", -- Class Power (player frame pips) @@ -313,8 +328,8 @@ DF.ExportCategories = { "buffStackX", "buffStackY", "buffStackMinimum", - "buffBorderEnabled", - "buffBorderThickness", + "buffShowBorder", + "buffBorderSize", "buffBorderInset", "buffExpiringEnabled", "buffExpiringThreshold", @@ -324,7 +339,20 @@ DF.ExportCategories = { "buffExpiringBorderColorByTime", "buffExpiringBorderThickness", "buffExpiringBorderInset", - "buffExpiringBorderPulsate", + "buffExpiringBorderPulsate", -- legacy; migrated to AnimationType, kept for old-export compatibility + "buffExpiringBorderAnimationType", + "buffExpiringBorderAnimationColor", + "buffExpiringBorderAnimationFrequency", + "buffExpiringBorderAnimationParticles", + "buffExpiringBorderAnimationLength", + "buffExpiringBorderAnimationThickness", + "buffExpiringBorderAnimationScale", + "buffExpiringBorderAnimationInset", + "buffExpiringBorderAnimationOffsetX", + "buffExpiringBorderAnimationOffsetY", + "buffExpiringBorderAnimationMask", + "buffExpiringBorderAnimationSidesAxis", + "buffExpiringBorderAnimationCornerLength", "buffExpiringTintEnabled", "buffExpiringTintColor", @@ -370,8 +398,8 @@ DF.ExportCategories = { "debuffStackX", "debuffStackY", "debuffStackMinimum", - "debuffBorderEnabled", - "debuffBorderThickness", + "debuffShowBorder", + "debuffBorderSize", "debuffBorderInset", "debuffBorderColorByType", "debuffBorderColorNone", @@ -529,7 +557,8 @@ DF.ExportCategories = { "afkIconTextColor", "vehicleIconTextColor", "raidRoleIconTextColor", - + "bgCarrierIconTextColor", + -- Role Icon "showRoleIcon", "roleIconAnchor", @@ -546,7 +575,7 @@ DF.ExportCategories = { "roleIconHideTank", "roleIconHideHealer", "roleIconHideDPS", - "roleIconOnlyInCombat", + "roleIconHideInCombat", "roleIconHideOnlyInCombat", "roleIconExternalTank", "roleIconExternalHealer", @@ -646,7 +675,13 @@ DF.ExportCategories = { "afkIconShowText", "afkIconText", "afkIconShowTimer", - + "afkIconTimerFont", + "afkIconTimerFontSize", + "afkIconTimerOutline", + "afkIconTimerColor", + "afkIconTimerX", + "afkIconTimerY", + -- Vehicle Icon "vehicleIconEnabled", "vehicleIconAnchor", @@ -673,7 +708,27 @@ DF.ExportCategories = { "raidRoleIconShowText", "raidRoleIconTextTank", "raidRoleIconTextAssist", - + + -- BG Objective Carrier Icon + "bgCarrierIconEnabled", + "bgCarrierIconAnchor", + "bgCarrierIconX", + "bgCarrierIconY", + "bgCarrierIconScale", + "bgCarrierIconAlpha", + "bgCarrierIconFrameLevel", + "bgCarrierIconShowText", + "bgCarrierIconText", + + -- Combat Icon + "combatIconEnabled", + "combatIconAnchor", + "combatIconX", + "combatIconY", + "combatIconScale", + "combatIconAlpha", + "combatIconFrameLevel", + -- Defensive Icon "defensiveIconEnabled", "defensiveIconAnchor", @@ -683,8 +738,36 @@ DF.ExportCategories = { "defensiveIconSize", "defensiveIconFrameLevel", "defensiveIconShowBorder", + "defensiveIconBorderAnimationColor", + "defensiveIconBorderAnimationCornerLength", + "defensiveIconBorderAnimationFrequency", + "defensiveIconBorderAnimationInset", + "defensiveIconBorderAnimationLength", + "defensiveIconBorderAnimationMask", + "defensiveIconBorderAnimationOffsetX", + "defensiveIconBorderAnimationOffsetY", + "defensiveIconBorderAnimationParticles", + "defensiveIconBorderAnimationScale", + "defensiveIconBorderAnimationSidesAxis", + "defensiveIconBorderAnimationThickness", + "defensiveIconBorderAnimationType", + "defensiveIconBorderBlendMode", "defensiveIconBorderColor", + "defensiveIconBorderGradientDirection", + "defensiveIconBorderGradientEnabled", + "defensiveIconBorderGradientEndColor", + "defensiveIconBorderGradientStartColor", + "defensiveIconBorderInset", + "defensiveIconBorderOffsetX", + "defensiveIconBorderOffsetY", + "defensiveIconBorderShadowColor", + "defensiveIconBorderShadowEnabled", + "defensiveIconBorderShadowOffsetX", + "defensiveIconBorderShadowOffsetY", + "defensiveIconBorderShadowSize", "defensiveIconBorderSize", + "defensiveIconBorderStyle", + "defensiveIconBorderTexture", "defensiveIconShowDuration", "defensiveIconDurationFont", "defensiveIconDurationScale", @@ -770,8 +853,33 @@ DF.ExportCategories = { "personalTargetedSpellScale", "personalTargetedSpellAlpha", "personalTargetedSpellShowBorder", + "personalTargetedSpellBorderAnimationColor", + "personalTargetedSpellBorderAnimationCornerLength", + "personalTargetedSpellBorderAnimationFrequency", + "personalTargetedSpellBorderAnimationInset", + "personalTargetedSpellBorderAnimationLength", + "personalTargetedSpellBorderAnimationMask", + "personalTargetedSpellBorderAnimationOffsetX", + "personalTargetedSpellBorderAnimationOffsetY", + "personalTargetedSpellBorderAnimationParticles", + "personalTargetedSpellBorderAnimationScale", + "personalTargetedSpellBorderAnimationSidesAxis", + "personalTargetedSpellBorderAnimationThickness", + "personalTargetedSpellBorderAnimationType", + "personalTargetedSpellBorderBlendMode", "personalTargetedSpellBorderColor", + "personalTargetedSpellBorderGradientDirection", + "personalTargetedSpellBorderGradientEndColor", + "personalTargetedSpellBorderGradientStartColor", + "personalTargetedSpellBorderInset", + "personalTargetedSpellBorderShadowColor", + "personalTargetedSpellBorderShadowEnabled", + "personalTargetedSpellBorderShadowOffsetX", + "personalTargetedSpellBorderShadowOffsetY", + "personalTargetedSpellBorderShadowSize", "personalTargetedSpellBorderSize", + "personalTargetedSpellBorderStyle", + "personalTargetedSpellBorderTexture", "personalTargetedSpellShowSwipe", "personalTargetedSpellShowDuration", "personalTargetedSpellDurationFont", @@ -852,8 +960,35 @@ DF.ExportCategories = { "missingBuffIconScale", "missingBuffIconFrameLevel", "missingBuffIconShowBorder", + "missingBuffIconBorderAnimationColor", + "missingBuffIconBorderAnimationCornerLength", + "missingBuffIconBorderAnimationFrequency", + "missingBuffIconBorderAnimationInset", + "missingBuffIconBorderAnimationLength", + "missingBuffIconBorderAnimationMask", + "missingBuffIconBorderAnimationOffsetX", + "missingBuffIconBorderAnimationOffsetY", + "missingBuffIconBorderAnimationParticles", + "missingBuffIconBorderAnimationScale", + "missingBuffIconBorderAnimationSidesAxis", + "missingBuffIconBorderAnimationThickness", + "missingBuffIconBorderAnimationType", + "missingBuffIconBorderBlendMode", "missingBuffIconBorderColor", + "missingBuffIconBorderGradientDirection", + "missingBuffIconBorderGradientEndColor", + "missingBuffIconBorderGradientStartColor", + "missingBuffIconBorderInset", + "missingBuffIconBorderOffsetX", + "missingBuffIconBorderOffsetY", + "missingBuffIconBorderShadowColor", + "missingBuffIconBorderShadowEnabled", + "missingBuffIconBorderShadowOffsetX", + "missingBuffIconBorderShadowOffsetY", + "missingBuffIconBorderShadowSize", "missingBuffIconBorderSize", + "missingBuffIconBorderStyle", + "missingBuffIconBorderTexture", "missingBuffCheckStamina", "missingBuffCheckIntellect", "missingBuffCheckAttackPower", @@ -881,9 +1016,45 @@ DF.ExportCategories = { -- OTHER - Aggro, selection, range, tooltips, pets, misc -- =========================================== other = { - -- Border - "showFrameBorder", - "borderColor", + -- Frame Border (canonical "frame" prefix) + "frameBorderAlpha", + "frameBorderAnimationColor", + "frameBorderAnimationCornerLength", + "frameBorderAnimationFrequency", + "frameBorderAnimationInset", + "frameBorderAnimationLength", + "frameBorderAnimationMask", + "frameBorderAnimationOffsetX", + "frameBorderAnimationOffsetY", + "frameBorderAnimationParticles", + "frameBorderAnimationScale", + "frameBorderAnimationSidesAxis", + "frameBorderAnimationThickness", + "frameBorderAnimationType", + "frameBorderBlendMode", + "frameBorderColorSource", + "frameBorderColor", + "frameBorderGradientDirection", + "frameBorderGradientEnabled", + "frameBorderGradientEndColor", + "frameBorderGradientStartColor", + "frameBorderInset", + "frameBorderOffsetX", + "frameBorderOffsetY", + "frameBorderShadowColor", + "frameBorderShadowEnabled", + "frameBorderShadowOffsetX", + "frameBorderShadowOffsetY", + "frameBorderShadowSize", + "frameBorderStyle", + "frameBorderTexture", + "frameBorderUseClassColor", + "frameShowBorder", + + -- Role border colours (shared across consumers via include.roleColor) + "roleBorderColorDamager", + "roleBorderColorHealer", + "roleBorderColorTank", -- Aggro Highlight "aggroHighlightMode", @@ -1002,7 +1173,20 @@ DF.ExportCategories = { "petOffsetY", "petTexture", "petShowBorder", + "petBorderBlendMode", "petBorderColor", + "petBorderGradientDirection", + "petBorderGradientEndColor", + "petBorderGradientStartColor", + "petBorderInset", + "petBorderShadowColor", + "petBorderShadowEnabled", + "petBorderShadowOffsetX", + "petBorderShadowOffsetY", + "petBorderShadowSize", + "petBorderSize", + "petBorderStyle", + "petBorderTexture", "petBackgroundColor", "petHealthBgColor", "petHealthColorMode", diff --git a/Features/Auras.lua b/Features/Auras.lua index b505905a..d2ecb403 100644 --- a/Features/Auras.lua +++ b/Features/Auras.lua @@ -326,49 +326,12 @@ auraTimerGroup:SetScript("OnLoop", function() elseif icon.expiringTint then icon.expiringTint:Hide() end - - -- Border - if icon.expiringBorderAlphaContainer and icon.expiringBorderEnabled then - icon.expiringBorderAlphaContainer:Show() - - if icon.expiringBorderColorByTime then - if icon.expiringBorderAlphaContainer.SetAlphaFromBoolean then - icon.expiringBorderAlphaContainer:SetAlphaFromBoolean(hasExpiration, 1, 0) - else - icon.expiringBorderAlphaContainer:SetAlpha(1) - end - - if not DF.expiringBorderColorCurve then - local curve = C_CurveUtil.CreateColorCurve() - curve:SetType(Enum.LuaCurveType.Linear) - curve:AddPoint(0, CreateColor(1, 0, 0, 1)) - curve:AddPoint(0.3, CreateColor(1, 0.5, 0, 1)) - curve:AddPoint(0.5, CreateColor(1, 1, 0, 1)) - curve:AddPoint(1, CreateColor(0, 1, 0, 1)) - DF.expiringBorderColorCurve = curve - end - - local colorResult = durationObj:EvaluateRemainingPercent(DF.expiringBorderColorCurve) - if colorResult and colorResult.GetRGBA and icon.expiringBorderTop then - icon.expiringBorderTop:SetColorTexture(colorResult:GetRGBA()) - icon.expiringBorderBottom:SetColorTexture(colorResult:GetRGBA()) - icon.expiringBorderLeft:SetColorTexture(colorResult:GetRGBA()) - icon.expiringBorderRight:SetColorTexture(colorResult:GetRGBA()) - end - else - if icon.expiringBorderAlphaContainer.SetAlphaFromBoolean then - icon.expiringBorderAlphaContainer:SetAlphaFromBoolean(hasExpiration, expiringAlpha, 0) - else - icon.expiringBorderAlphaContainer:SetAlpha(expiringAlpha) - end - end - - if icon.expiringBorderPulsate and icon.expiringBorderPulse and not icon.expiringBorderPulse:IsPlaying() then - icon.expiringBorderPulse:Play() - end - elseif icon.expiringBorderAlphaContainer then - icon.expiringBorderAlphaContainer:Hide() - end + + -- (Expiring BORDER is no longer driven here — it + -- registers with the shared DF.Expiring engine in + -- UpdateAuraIconsDirect and is driven by that one + -- ticker. This timer keeps only the tint above, + -- which is buff-specific.) end end end @@ -2289,6 +2252,122 @@ function DF:CollectDebuffs(unit, maxAuras) return CollectDebuffs_Blizzard(unit, maxAuras) end +-- ============================================================ +-- BUFF EXPIRING BORDER — shared DF.Expiring engine glue +-- +-- The buff expiring border registers the buff ICON with DF.Expiring (the same +-- engine AD's indicators use). The engine's ~3 FPS ticker evaluates the curve +-- and calls these callbacks; they drive the secret-safe visibility gate +-- (icon.expiringBorderGate alpha) and, in Color-by-Time mode, the border colour. +-- Registering the icon (not the gate) lets the engine auto-clean on icon:Hide() +-- exactly like AD. The gate stays the secret-safe primitive because the engine's +-- own hideWhenNotExpiring path is manual/preview-only (not secret-safe). +-- Defined before both UpdateAuraIcons_Enhanced and UpdateAuraIconsDirect so both +-- display paths can reference these module locals. +-- ============================================================ + +-- Cached expiring curves (shared with the tint path in the aura timer). +local function GetBuffExpiringCurve(icon) + if not (C_CurveUtil and C_CurveUtil.CreateColorCurve) then return nil end + if icon.expiringBorderColorByTime then + -- Color-by-Time: red→green over the whole duration (alpha always 1, so + -- the gate stays visible the whole time and only the colour shifts). + if not DF.expiringBorderColorCurve then + local curve = C_CurveUtil.CreateColorCurve() + curve:SetType(Enum.LuaCurveType.Linear) + curve:AddPoint(0, CreateColor(1, 0, 0, 1)) + curve:AddPoint(0.3, CreateColor(1, 0.5, 0, 1)) + curve:AddPoint(0.5, CreateColor(1, 1, 0, 1)) + curve:AddPoint(1, CreateColor(0, 1, 0, 1)) + DF.expiringBorderColorCurve = curve + end + return DF.expiringBorderColorCurve + end + -- Static colour: a VISIBILITY step curve — alpha 1 below threshold, 0 above. + -- The painted border colour stays put; the gate alpha (= this curve's alpha) + -- shows/hides it. Cached by threshold+mode (shared with the tint path). + local threshold = icon.expiringThreshold or 30 + local useSeconds = icon.expiringThresholdMode == "SECONDS" + DF.expiringCurves = DF.expiringCurves or {} + local cacheKey = (useSeconds and "s" or "p") .. threshold + if not DF.expiringCurves[cacheKey] then + local curve = C_CurveUtil.CreateColorCurve() + curve:SetType(Enum.LuaCurveType.Step) + if useSeconds then + curve:AddPoint(0, CreateColor(1, 1, 1, 1)) + curve:AddPoint(threshold, CreateColor(0, 0, 0, 0)) + curve:AddPoint(600, CreateColor(0, 0, 0, 0)) + else + curve:AddPoint(0, CreateColor(1, 1, 1, 1)) + curve:AddPoint(threshold / 100, CreateColor(0, 0, 0, 0)) + curve:AddPoint(1, CreateColor(0, 0, 0, 0)) + end + DF.expiringCurves[cacheKey] = curve + end + return DF.expiringCurves[cacheKey] +end + +-- API path: engine hands us the curve result (colour/alpha may be SECRET). +local function BuffExpiringApplyResult(icon, result, entry) + local gate = icon.expiringBorderGate + if not gate or not result.GetRGBA then return end + if entry.colorByTime then + local eb = icon.expiringBorder + -- solidOnly border → SetColor is a bare SetColorTexture, secret-safe. + if eb and eb.SetColor then eb:SetColor(result:GetRGBA()) end + if gate.SetAlphaFromBoolean then + gate:SetAlphaFromBoolean(icon.hasExpiration, 1, 0) + else + gate:SetAlpha(1) + end + else + -- Visibility curve: result alpha is the (secret) show/hide signal. + if gate.SetAlphaFromBoolean then + gate:SetAlphaFromBoolean(icon.hasExpiration, select(4, result:GetRGBA()), 0) + else + gate:SetAlpha(select(4, result:GetRGBA())) + end + end +end + +-- Preview/test path (non-secret): isExp is a plain bool. +local function BuffExpiringApplyManual(icon, isExp, entry) + local gate = icon.expiringBorderGate + if not gate then return end + if entry.colorByTime then + gate:SetAlpha(1) + else + gate:SetAlpha(isExp and 1 or 0) + end +end + +-- Register / refresh a buff icon's expiring border on the shared engine, or +-- unregister when expiring is off. Reuses icon.expiringEntry (no per-update +-- allocation). Engine auto-cleans the registry when icon:IsShown() is false. +local function UpdateBuffExpiringRegistration(icon, unit, auraInstanceID) + if icon.expiringBorderEnabled and icon.expiringBorderGate then + local entry = icon.expiringEntry + if not entry then entry = {}; icon.expiringEntry = entry end + entry.unit = unit + entry.auraInstanceID = auraInstanceID + entry.threshold = icon.expiringThreshold or 30 + entry.colorByTime = icon.expiringBorderColorByTime + -- Color-by-Time colours red→green over the FULL duration via a percent + -- curve, so it must always evaluate by percent regardless of the user's + -- threshold mode. The static visibility curve honours the real mode. + entry.thresholdMode = entry.colorByTime and "PERCENT" or icon.expiringThresholdMode + entry.duration = icon.auraDuration + entry.expirationTime = icon.expirationTime + entry.colorCurve = GetBuffExpiringCurve(icon) + entry.applyResult = BuffExpiringApplyResult + entry.applyManual = BuffExpiringApplyManual + DF.Expiring:Register(icon, entry) + elseif icon.expiringEntry then + DF.Expiring:Unregister(icon) + if icon.expiringBorderGate then icon.expiringBorderGate:SetAlpha(0) end + end +end + -- ============================================================ -- ENHANCED AURA ICON UPDATE -- ============================================================ @@ -2410,6 +2489,12 @@ function DF:UpdateAuraIcons_Enhanced(frame, icons, auraType, maxAuras) end end + -- Expiring border (BUFF only): register/refresh on the shared + -- DF.Expiring engine now that unit/aura/duration are known. + if auraType == "BUFF" then + UpdateBuffExpiringRegistration(icon, unit, auraInstanceID) + end + -- Set cooldown SafeSetCooldown(icon.cooldown, auraData, unit) @@ -2463,64 +2548,48 @@ function DF:UpdateAuraIcons_Enhanced(frame, icons, auraType, maxAuras) -- colored border showing through faded icon texture local unitDeadOrOffline = UnitIsDeadOrGhost(unit) or not UnitIsConnected(unit) - -- Set border color (normal border, not expiring) - only if we control borders - local borderEnabled = (auraType == "DEBUFF" and db.debuffBorderEnabled ~= false) or (auraType ~= "DEBUFF" and db.buffBorderEnabled ~= false) - if borderEnabled and not masqueBorderControl then - if auraType == "DEBUFF" and not unitDeadOrOffline then - -- Use custom dispel type colors if enabled, via color curve API - -- Only for living units - dead units can't be dispelled so colored border is meaningless - if db.debuffBorderColorByType ~= false and auraInstanceID and C_UnitAuras and C_UnitAuras.GetAuraDispelTypeColor and C_CurveUtil and C_CurveUtil.CreateColorCurve then - -- Build or get cached debuff border color curve - DF.debuffBorderCurve = DF.debuffBorderCurve or nil - if not DF.debuffBorderCurve then - local curve = C_CurveUtil.CreateColorCurve() - curve:SetType(Enum.LuaCurveType.Step) - - -- Dispel type enum values from wago.tools/db2/SpellDispelType - -- None = 0, Magic = 1, Curse = 2, Disease = 3, Poison = 4, Enrage = 9, Bleed = 11 - local noneColor = db.debuffBorderColorNone or {r = 0.8, g = 0.0, b = 0.0} - local magicColor = db.debuffBorderColorMagic or {r = 0.2, g = 0.6, b = 1.0} - local curseColor = db.debuffBorderColorCurse or {r = 0.6, g = 0.0, b = 1.0} - local diseaseColor = db.debuffBorderColorDisease or {r = 0.6, g = 0.4, b = 0.0} - local poisonColor = db.debuffBorderColorPoison or {r = 0.0, g = 0.6, b = 0.0} - local bleedColor = db.debuffBorderColorBleed or {r = 1.0, g = 0.0, b = 0.0} - - curve:AddPoint(0, CreateColor(noneColor.r, noneColor.g, noneColor.b, 1.0)) -- None - curve:AddPoint(1, CreateColor(magicColor.r, magicColor.g, magicColor.b, 1.0)) -- Magic - curve:AddPoint(2, CreateColor(curseColor.r, curseColor.g, curseColor.b, 1.0)) -- Curse - curve:AddPoint(3, CreateColor(diseaseColor.r, diseaseColor.g, diseaseColor.b, 1.0)) -- Disease - curve:AddPoint(4, CreateColor(poisonColor.r, poisonColor.g, poisonColor.b, 1.0)) -- Poison - curve:AddPoint(9, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) -- Enrage - curve:AddPoint(11, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) -- Bleed - - DF.debuffBorderCurve = curve - end - - -- Get color from API - local borderColor = C_UnitAuras.GetAuraDispelTypeColor(unit, auraInstanceID, DF.debuffBorderCurve) - if borderColor then - local r, g, b = 0.8, 0, 0 - if borderColor.GetRGBA then - r, g, b = borderColor:GetRGB() - elseif borderColor.r then - r, g, b = borderColor.r, borderColor.g, borderColor.b - end - icon.border:SetColorTexture(r, g, b, 1.0) - else - -- Fallback to none color - local c = db.debuffBorderColorNone or {r = 0.8, g = 0, b = 0} - icon.border:SetColorTexture(c.r, c.g, c.b, 1.0) + -- Border colour: only the debuff colour-by-type case recolours per- + -- update (secret dispel-type colour); static borders carry their + -- colour/style/animation from ConfigureAuraIconBorder (BuildSpec). + local borderEnabled = (auraType == "DEBUFF" and db.debuffShowBorder ~= false) or (auraType ~= "DEBUFF" and db.buffShowBorder ~= false) + if borderEnabled and not masqueBorderControl and icon.border then + if auraType == "DEBUFF" and db.debuffBorderColorByType ~= false and not unitDeadOrOffline + and auraInstanceID and C_UnitAuras and C_UnitAuras.GetAuraDispelTypeColor and C_CurveUtil and C_CurveUtil.CreateColorCurve then + if not DF.debuffBorderCurve then + local curve = C_CurveUtil.CreateColorCurve() + curve:SetType(Enum.LuaCurveType.Step) + local noneColor = db.debuffBorderColorNone or {r = 0.8, g = 0.0, b = 0.0} + local magicColor = db.debuffBorderColorMagic or {r = 0.2, g = 0.6, b = 1.0} + local curseColor = db.debuffBorderColorCurse or {r = 0.6, g = 0.0, b = 1.0} + local diseaseColor = db.debuffBorderColorDisease or {r = 0.6, g = 0.4, b = 0.0} + local poisonColor = db.debuffBorderColorPoison or {r = 0.0, g = 0.6, b = 0.0} + local bleedColor = db.debuffBorderColorBleed or {r = 1.0, g = 0.0, b = 0.0} + curve:AddPoint(0, CreateColor(noneColor.r, noneColor.g, noneColor.b, 1.0)) + curve:AddPoint(1, CreateColor(magicColor.r, magicColor.g, magicColor.b, 1.0)) + curve:AddPoint(2, CreateColor(curseColor.r, curseColor.g, curseColor.b, 1.0)) + curve:AddPoint(3, CreateColor(diseaseColor.r, diseaseColor.g, diseaseColor.b, 1.0)) + curve:AddPoint(4, CreateColor(poisonColor.r, poisonColor.g, poisonColor.b, 1.0)) + curve:AddPoint(9, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) + curve:AddPoint(11, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) + DF.debuffBorderCurve = curve + end + local borderColor = C_UnitAuras.GetAuraDispelTypeColor(unit, auraInstanceID, DF.debuffBorderCurve) + if borderColor then + local r, g, b = 0.8, 0, 0 + if borderColor.GetRGBA then + r, g, b = borderColor:GetRGB() + elseif borderColor.r then + r, g, b = borderColor.r, borderColor.g, borderColor.b end + icon.border:SetColor(r, g, b, 1.0) else - -- Color by type disabled or API not available - use default red - icon.border:SetColorTexture(0.8, 0, 0, 1.0) + local c = db.debuffBorderColorNone or {r = 0.8, g = 0, b = 0} + icon.border:SetColor(c.r, c.g, c.b, 1.0) end - else - icon.border:SetColorTexture(0, 0, 0, 1.0) -- Black for buffs and dead/offline debuffs + icon.border:SetAlpha(0.8) end - icon.border:SetAlpha(0.8) icon.border:Show() - elseif not masqueBorderControl then + elseif not masqueBorderControl and icon.border then icon.border:Hide() end -- When masqueBorderControl is true, border visibility is handled by ApplyAuraLayout @@ -2591,12 +2660,7 @@ function DF:UpdateAuraIcons_Enhanced(frame, icons, auraType, maxAuras) icon.auraDuration = nil if icon.duration then icon.duration:Hide() end if icon.expiringTint then icon.expiringTint:Hide() end - if icon.expiringBorderAlphaContainer then - icon.expiringBorderAlphaContainer:Hide() - if icon.expiringBorderPulse and icon.expiringBorderPulse:IsPlaying() then - icon.expiringBorderPulse:Stop() - end - end + if icon.expiringBorderGate then icon.expiringBorderGate:SetAlpha(0) end icon:Hide() end @@ -2644,12 +2708,7 @@ function DF:UpdateAuraIconsDirect(frame, icons, auraType, maxAuras) icon.auraDuration = nil if icon.duration then icon.duration:Hide() end if icon.expiringTint then icon.expiringTint:Hide() end - if icon.expiringBorderAlphaContainer then - icon.expiringBorderAlphaContainer:Hide() - if icon.expiringBorderPulse and icon.expiringBorderPulse:IsPlaying() then - icon.expiringBorderPulse:Stop() - end - end + if icon.expiringBorderGate then icon.expiringBorderGate:SetAlpha(0) end icon:Hide() end local countKey = auraType == "BUFF" and "buffDisplayedCount" or "debuffDisplayedCount" @@ -2669,12 +2728,7 @@ function DF:UpdateAuraIconsDirect(frame, icons, auraType, maxAuras) icon.auraDuration = nil if icon.duration then icon.duration:Hide() end if icon.expiringTint then icon.expiringTint:Hide() end - if icon.expiringBorderAlphaContainer then - icon.expiringBorderAlphaContainer:Hide() - if icon.expiringBorderPulse and icon.expiringBorderPulse:IsPlaying() then - icon.expiringBorderPulse:Stop() - end - end + if icon.expiringBorderGate then icon.expiringBorderGate:SetAlpha(0) end icon:Hide() end local countKey = auraType == "BUFF" and "buffDisplayedCount" or "debuffDisplayedCount" @@ -2719,7 +2773,7 @@ function DF:UpdateAuraIconsDirect(frame, icons, auraType, maxAuras) local masqueBorderControl = db.masqueBorderControl and DF.Masque and masqueActive -- Pre-fetch: border enabled (once per call) - local borderEnabled = (auraType == "DEBUFF" and db.debuffBorderEnabled ~= false) or (auraType ~= "DEBUFF" and db.buffBorderEnabled ~= false) + local borderEnabled = (auraType == "DEBUFF" and db.debuffShowBorder ~= false) or (auraType ~= "DEBUFF" and db.buffShowBorder ~= false) -- Pre-fetch: dead/offline state (once per call, not per icon) local unitDeadOrOffline = UnitIsDeadOrGhost(unit) or not UnitIsConnected(unit) @@ -2855,6 +2909,12 @@ function DF:UpdateAuraIconsDirect(frame, icons, auraType, maxAuras) end end + -- Expiring border (BUFF only): register/refresh on the shared + -- DF.Expiring engine now that unit/aura/duration are known. + if auraType == "BUFF" then + UpdateBuffExpiringRegistration(icon, unit, auraInstanceID) + end + -- Set cooldown SafeSetCooldown(icon.cooldown, auraData, unit) @@ -2891,54 +2951,52 @@ function DF:UpdateAuraIconsDirect(frame, icons, auraType, maxAuras) end end - -- Border color (normal, not expiring) - if borderEnabled and not masqueBorderControl then - if auraType == "DEBUFF" and not unitDeadOrOffline then - if db.debuffBorderColorByType ~= false and C_UnitAuras.GetAuraDispelTypeColor and C_CurveUtil and C_CurveUtil.CreateColorCurve then - if not DF.debuffBorderCurve then - local curve = C_CurveUtil.CreateColorCurve() - curve:SetType(Enum.LuaCurveType.Step) - - local noneColor = db.debuffBorderColorNone or {r = 0.8, g = 0.0, b = 0.0} - local magicColor = db.debuffBorderColorMagic or {r = 0.2, g = 0.6, b = 1.0} - local curseColor = db.debuffBorderColorCurse or {r = 0.6, g = 0.0, b = 1.0} - local diseaseColor = db.debuffBorderColorDisease or {r = 0.6, g = 0.4, b = 0.0} - local poisonColor = db.debuffBorderColorPoison or {r = 0.0, g = 0.6, b = 0.0} - local bleedColor = db.debuffBorderColorBleed or {r = 1.0, g = 0.0, b = 0.0} - - curve:AddPoint(0, CreateColor(noneColor.r, noneColor.g, noneColor.b, 1.0)) - curve:AddPoint(1, CreateColor(magicColor.r, magicColor.g, magicColor.b, 1.0)) - curve:AddPoint(2, CreateColor(curseColor.r, curseColor.g, curseColor.b, 1.0)) - curve:AddPoint(3, CreateColor(diseaseColor.r, diseaseColor.g, diseaseColor.b, 1.0)) - curve:AddPoint(4, CreateColor(poisonColor.r, poisonColor.g, poisonColor.b, 1.0)) - curve:AddPoint(9, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) - curve:AddPoint(11, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) - - DF.debuffBorderCurve = curve - end + -- Border colour: only the debuff colour-by-type case recolours + -- per-update (secret dispel-type colour). Static borders + -- (buffs, debuffs with colour-by-type off) carry their colour + -- /style/animation from ConfigureAuraIconBorder (BuildSpec). + if borderEnabled and not masqueBorderControl and icon.border then + if auraType == "DEBUFF" and db.debuffBorderColorByType ~= false and not unitDeadOrOffline + and C_UnitAuras.GetAuraDispelTypeColor and C_CurveUtil and C_CurveUtil.CreateColorCurve then + if not DF.debuffBorderCurve then + local curve = C_CurveUtil.CreateColorCurve() + curve:SetType(Enum.LuaCurveType.Step) + + local noneColor = db.debuffBorderColorNone or {r = 0.8, g = 0.0, b = 0.0} + local magicColor = db.debuffBorderColorMagic or {r = 0.2, g = 0.6, b = 1.0} + local curseColor = db.debuffBorderColorCurse or {r = 0.6, g = 0.0, b = 1.0} + local diseaseColor = db.debuffBorderColorDisease or {r = 0.6, g = 0.4, b = 0.0} + local poisonColor = db.debuffBorderColorPoison or {r = 0.0, g = 0.6, b = 0.0} + local bleedColor = db.debuffBorderColorBleed or {r = 1.0, g = 0.0, b = 0.0} + + curve:AddPoint(0, CreateColor(noneColor.r, noneColor.g, noneColor.b, 1.0)) + curve:AddPoint(1, CreateColor(magicColor.r, magicColor.g, magicColor.b, 1.0)) + curve:AddPoint(2, CreateColor(curseColor.r, curseColor.g, curseColor.b, 1.0)) + curve:AddPoint(3, CreateColor(diseaseColor.r, diseaseColor.g, diseaseColor.b, 1.0)) + curve:AddPoint(4, CreateColor(poisonColor.r, poisonColor.g, poisonColor.b, 1.0)) + curve:AddPoint(9, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) + curve:AddPoint(11, CreateColor(bleedColor.r, bleedColor.g, bleedColor.b, 1.0)) + + DF.debuffBorderCurve = curve + end - local borderColor = C_UnitAuras.GetAuraDispelTypeColor(unit, auraInstanceID, DF.debuffBorderCurve) - if borderColor then - local r, g, b = 0.8, 0, 0 - if borderColor.GetRGBA then - r, g, b = borderColor:GetRGB() - elseif borderColor.r then - r, g, b = borderColor.r, borderColor.g, borderColor.b - end - icon.border:SetColorTexture(r, g, b, 1.0) - else - local c = db.debuffBorderColorNone or {r = 0.8, g = 0, b = 0} - icon.border:SetColorTexture(c.r, c.g, c.b, 1.0) + local borderColor = C_UnitAuras.GetAuraDispelTypeColor(unit, auraInstanceID, DF.debuffBorderCurve) + if borderColor then + local r, g, b = 0.8, 0, 0 + if borderColor.GetRGBA then + r, g, b = borderColor:GetRGB() + elseif borderColor.r then + r, g, b = borderColor.r, borderColor.g, borderColor.b end + icon.border:SetColor(r, g, b, 1.0) else - icon.border:SetColorTexture(0.8, 0, 0, 1.0) + local c = db.debuffBorderColorNone or {r = 0.8, g = 0, b = 0} + icon.border:SetColor(c.r, c.g, c.b, 1.0) end - else - icon.border:SetColorTexture(0, 0, 0, 1.0) + icon.border:SetAlpha(0.8) end - icon.border:SetAlpha(0.8) icon.border:Show() - elseif not masqueBorderControl then + elseif not masqueBorderControl and icon.border then icon.border:Hide() end @@ -3012,12 +3070,7 @@ function DF:UpdateAuraIconsDirect(frame, icons, auraType, maxAuras) icon.auraDuration = nil if icon.duration then icon.duration:Hide() end if icon.expiringTint then icon.expiringTint:Hide() end - if icon.expiringBorderAlphaContainer then - icon.expiringBorderAlphaContainer:Hide() - if icon.expiringBorderPulse and icon.expiringBorderPulse:IsPlaying() then - icon.expiringBorderPulse:Stop() - end - end + if icon.expiringBorderGate then icon.expiringBorderGate:SetAlpha(0) end icon:Hide() end diff --git a/Features/ElementAppearance.lua b/Features/ElementAppearance.lua index 2c9dc5fd..d5f8dd6d 100644 --- a/Features/ElementAppearance.lua +++ b/Features/ElementAppearance.lua @@ -928,16 +928,12 @@ function DF:UpdateMissingBuffAppearance(frame) if db.oorEnabled then local oorAlpha = db.oorMissingBuffAlpha or 0.5 ApplyOORAlpha(frame.missingBuffIcon, inRange, alpha, oorAlpha) - ApplyOORAlpha(frame.missingBuffBorderLeft, inRange, alpha, oorAlpha) - ApplyOORAlpha(frame.missingBuffBorderRight, inRange, alpha, oorAlpha) - ApplyOORAlpha(frame.missingBuffBorderTop, inRange, alpha, oorAlpha) - ApplyOORAlpha(frame.missingBuffBorderBottom, inRange, alpha, oorAlpha) + -- Border is the unified DF.Border frame now (was 4 edge textures pre- + -- migration); fade the whole border frame like frame.border / icon.border. + ApplyOORAlpha(frame.missingBuffBorder, inRange, alpha, oorAlpha) else frame.missingBuffIcon:SetAlpha(alpha) - if frame.missingBuffBorderLeft then frame.missingBuffBorderLeft:SetAlpha(alpha) end - if frame.missingBuffBorderRight then frame.missingBuffBorderRight:SetAlpha(alpha) end - if frame.missingBuffBorderTop then frame.missingBuffBorderTop:SetAlpha(alpha) end - if frame.missingBuffBorderBottom then frame.missingBuffBorderBottom:SetAlpha(alpha) end + if frame.missingBuffBorder then frame.missingBuffBorder:SetAlpha(alpha) end end end @@ -1059,10 +1055,7 @@ function DF:UpdateDefensiveIconAppearance(frame) if db.oorEnabled then local oorAlpha = db.oorDefensiveIconAlpha or 0.5 ApplyOORAlpha(icon.texture, inRange, alpha, oorAlpha) - ApplyOORAlpha(icon.borderLeft, inRange, alpha, oorAlpha) - ApplyOORAlpha(icon.borderRight, inRange, alpha, oorAlpha) - ApplyOORAlpha(icon.borderTop, inRange, alpha, oorAlpha) - ApplyOORAlpha(icon.borderBottom, inRange, alpha, oorAlpha) + ApplyOORAlpha(icon.border, inRange, alpha, oorAlpha) ApplyOORAlpha(icon.cooldown, inRange, alpha, oorAlpha) ApplyOORAlpha(icon.count, inRange, alpha, oorAlpha) @@ -1070,20 +1063,14 @@ function DF:UpdateDefensiveIconAppearance(frame) if frame.defensiveBarIcons then for _, extraIcon in pairs(frame.defensiveBarIcons) do ApplyOORAlpha(extraIcon.texture, inRange, alpha, oorAlpha) - ApplyOORAlpha(extraIcon.borderLeft, inRange, alpha, oorAlpha) - ApplyOORAlpha(extraIcon.borderRight, inRange, alpha, oorAlpha) - ApplyOORAlpha(extraIcon.borderTop, inRange, alpha, oorAlpha) - ApplyOORAlpha(extraIcon.borderBottom, inRange, alpha, oorAlpha) + ApplyOORAlpha(extraIcon.border, inRange, alpha, oorAlpha) ApplyOORAlpha(extraIcon.cooldown, inRange, alpha, oorAlpha) ApplyOORAlpha(extraIcon.count, inRange, alpha, oorAlpha) end end else if icon.texture then icon.texture:SetAlpha(alpha) end - if icon.borderLeft then icon.borderLeft:SetAlpha(alpha) end - if icon.borderRight then icon.borderRight:SetAlpha(alpha) end - if icon.borderTop then icon.borderTop:SetAlpha(alpha) end - if icon.borderBottom then icon.borderBottom:SetAlpha(alpha) end + if icon.border then icon.border:SetAlpha(alpha) end if icon.cooldown then icon.cooldown:SetAlpha(alpha) end if icon.count then icon.count:SetAlpha(alpha) end @@ -1091,10 +1078,7 @@ function DF:UpdateDefensiveIconAppearance(frame) if frame.defensiveBarIcons then for _, extraIcon in pairs(frame.defensiveBarIcons) do if extraIcon.texture then extraIcon.texture:SetAlpha(alpha) end - if extraIcon.borderLeft then extraIcon.borderLeft:SetAlpha(alpha) end - if extraIcon.borderRight then extraIcon.borderRight:SetAlpha(alpha) end - if extraIcon.borderTop then extraIcon.borderTop:SetAlpha(alpha) end - if extraIcon.borderBottom then extraIcon.borderBottom:SetAlpha(alpha) end + if extraIcon.border then extraIcon.border:SetAlpha(alpha) end if extraIcon.cooldown then extraIcon.cooldown:SetAlpha(alpha) end if extraIcon.count then extraIcon.count:SetAlpha(alpha) end end @@ -1145,6 +1129,23 @@ function DF:UpdateAuraDesignerAppearance(frame) local inRange = GetInRange(frame) + -- Keep the AD tint/replace overlay inset off the frame border. This is also + -- done in ApplyHealthBar, but that only runs on an aura (re)apply — a range + -- transition doesn't re-run it, so without re-anchoring here the overlay kept + -- its full-frame extent and showed over the border until the next aura tick. + -- This pass runs on the range change, so the inset lands immediately. + local _ov = frame.dfAD and frame.dfAD.tintOverlay + if _ov and frame.healthBar then + local _bi = (frame.border and frame.border:IsShown() and db.frameBorderSize) or 0 + _ov:ClearAllPoints() + if _bi > 0 then + _ov:SetPoint("TOPLEFT", frame.healthBar, "TOPLEFT", _bi, -_bi) + _ov:SetPoint("BOTTOMRIGHT", frame.healthBar, "BOTTOMRIGHT", -_bi, _bi) + else + _ov:SetAllPoints(frame.healthBar) + end + end + if db.oorEnabled then local oorAlpha = db.oorAuraDesignerAlpha or 0.2 @@ -1315,6 +1316,12 @@ function DF:UpdateAllElementAppearances(frame) DF:UpdateFrameAppearance(frame) -- Update each element + -- AD appearance first: it writes healthbarEffectiveBlend (the OOR-aware bar + -- alpha) that UpdateHealthBarAppearance reads below. Running it afterwards left a + -- one-tick lag where, on first going out of range, the underlying replace-mode + -- bar texture kept its in-range (full) alpha for a frame while the border had + -- already faded — so the AD colour briefly bled through the border. + DF:UpdateAuraDesignerAppearance(frame) DF:UpdateHealthBarAppearance(frame) DF:UpdateMissingHealthBarAppearance(frame) DF:UpdateBackgroundAppearance(frame) @@ -1338,7 +1345,6 @@ function DF:UpdateAllElementAppearances(frame) DF:UpdateHealPredictionBarAppearance(frame) DF:UpdateDefensiveIconAppearance(frame) DF:UpdateTargetedSpellAppearance(frame) - DF:UpdateAuraDesignerAppearance(frame) -- Class power pips (player frame only): reparent/alpha for health fade (party or raid player frame) if DF.UpdateClassPowerAlpha and (frame == DF.playerFrame or (frame.unit and frame.isRaidFrame and UnitIsUnit(frame.unit, "player"))) then DF.UpdateClassPowerAlpha() diff --git a/Features/PinnedFrames.lua b/Features/PinnedFrames.lua index a55dc074..714fe6e3 100644 --- a/Features/PinnedFrames.lua +++ b/Features/PinnedFrames.lua @@ -46,15 +46,31 @@ end -- UTILITY FUNCTIONS -- ============================================================ +-- Auto layouts are raid-scoped: while one is being EDITED (live preview is written +-- into DF._realRaidDB) or is the ACTIVE runtime profile (overlay in DF.raidOverrides), +-- the pinned config must come from the RAID profile even when we're not physically in +-- a raid. Otherwise the layout's per-set overrides (players, etc. — see +-- PINNED_OVERRIDABLE / ApplyRuntimeProfile) silently read the party profile and the +-- frames look empty during edit/preview. Normal play (no layout edited/active) is +-- unchanged — it still follows actual group state. +local function UseRaidPinnedProfile() + if IsInRaid() then return true end + local apu = DF.AutoProfilesUI + if apu and ((apu.IsEditing and apu:IsEditing()) or apu.activeRuntimeProfile) then + return true + end + return false +end + -- Get pinned frames config for actual current mode local function GetPinnedDB() - local db = IsInRaid() and DF:GetRaidDB() or DF:GetDB() + local db = UseRaidPinnedProfile() and DF:GetRaidDB() or DF:GetDB() return db and db.pinnedFrames end -- Get the current actual mode (not cached) local function GetActualMode() - return IsInRaid() and "raid" or "party" + return UseRaidPinnedProfile() and "raid" or "party" end -- Get a specific set's config diff --git a/Features/TargetedSpells.lua b/Features/TargetedSpells.lua index 86c41f22..860a5328 100644 --- a/Features/TargetedSpells.lua +++ b/Features/TargetedSpells.lua @@ -2099,35 +2099,9 @@ local function CreatePersonalIcon(index) iconFrame:SetHitRectInsets(10000, 10000, 10000, 10000) icon.iconFrame = iconFrame - -- Border textures - 4 edge borders (consistent with other icons) - local defBorderSize = 2 - local borderLeft = iconFrame:CreateTexture(nil, "BACKGROUND") - borderLeft:SetPoint("TOPLEFT", 0, 0) - borderLeft:SetPoint("BOTTOMLEFT", 0, 0) - borderLeft:SetWidth(defBorderSize) - borderLeft:SetColorTexture(1, 0.3, 0, 1) - icon.borderLeft = borderLeft - - local borderRight = iconFrame:CreateTexture(nil, "BACKGROUND") - borderRight:SetPoint("TOPRIGHT", 0, 0) - borderRight:SetPoint("BOTTOMRIGHT", 0, 0) - borderRight:SetWidth(defBorderSize) - borderRight:SetColorTexture(1, 0.3, 0, 1) - icon.borderRight = borderRight - - local borderTop = iconFrame:CreateTexture(nil, "BACKGROUND") - borderTop:SetPoint("TOPLEFT", defBorderSize, 0) - borderTop:SetPoint("TOPRIGHT", -defBorderSize, 0) - borderTop:SetHeight(defBorderSize) - borderTop:SetColorTexture(1, 0.3, 0, 1) - icon.borderTop = borderTop - - local borderBottom = iconFrame:CreateTexture(nil, "BACKGROUND") - borderBottom:SetPoint("BOTTOMLEFT", defBorderSize, 0) - borderBottom:SetPoint("BOTTOMRIGHT", -defBorderSize, 0) - borderBottom:SetHeight(defBorderSize) - borderBottom:SetColorTexture(1, 0.3, 0, 1) - icon.borderBottom = borderBottom + -- Border via the unified DF.Border backend (Stage 4.4). + -- ApplyPersonalIconSettings drives BuildSpec + Apply on each update. + icon.border = DF.Border:New(iconFrame) -- Important spell highlight frame - set frame level ABOVE iconFrame so it renders on top local highlightFrame = CreateFrame("Frame", nil, iconFrame) @@ -2139,7 +2113,12 @@ local function CreatePersonalIcon(index) highlightFrame:SetHitRectInsets(10000, 10000, 10000, 10000) icon.highlightFrame = highlightFrame - -- Icon texture - positioned with inset for border + -- Icon texture - positioned with default 2px inset so it lines up + -- with the border at creation time. ApplyPersonalIconSettings + -- recomputes the inset from the db's BorderSize on every render + -- (via the shared artInset path), so this value only matters for + -- the brief moment between creation and first Apply. + local defBorderSize = 2 local texture = iconFrame:CreateTexture(nil, "ARTWORK") texture:SetPoint("TOPLEFT", defBorderSize, -defBorderSize) texture:SetPoint("BOTTOMRIGHT", -defBorderSize, defBorderSize) @@ -2366,68 +2345,28 @@ local function ApplyPersonalIconSettings(icon, db, spellID) end end - -- Border - 4 edge textures (consistent with other icons) - if showBorder then - if icon.borderLeft then - icon.borderLeft:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, 1) - icon.borderLeft:SetWidth(borderSize) - icon.borderLeft:Show() - end - if icon.borderRight then - icon.borderRight:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, 1) - icon.borderRight:SetWidth(borderSize) - icon.borderRight:Show() - end - if icon.borderTop then - icon.borderTop:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, 1) - icon.borderTop:SetHeight(borderSize) - icon.borderTop:ClearAllPoints() - icon.borderTop:SetPoint("TOPLEFT", borderSize, 0) - icon.borderTop:SetPoint("TOPRIGHT", -borderSize, 0) - icon.borderTop:Show() - end - if icon.borderBottom then - icon.borderBottom:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, 1) - icon.borderBottom:SetHeight(borderSize) - icon.borderBottom:ClearAllPoints() - icon.borderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - icon.borderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - icon.borderBottom:Show() - end - - -- Adjust icon texture position for border - if icon.icon then - icon.icon:ClearAllPoints() - icon.icon:SetPoint("TOPLEFT", icon.iconFrame, "TOPLEFT", borderSize, -borderSize) - icon.icon:SetPoint("BOTTOMRIGHT", icon.iconFrame, "BOTTOMRIGHT", -borderSize, borderSize) - end - - -- Adjust cooldown to match - if icon.cooldown then - icon.cooldown:ClearAllPoints() - icon.cooldown:SetPoint("TOPLEFT", icon.iconFrame, "TOPLEFT", borderSize, -borderSize) - icon.cooldown:SetPoint("BOTTOMRIGHT", icon.iconFrame, "BOTTOMRIGHT", -borderSize, borderSize) - end - else - -- Hide all border edges - if icon.borderLeft then icon.borderLeft:Hide() end - if icon.borderRight then icon.borderRight:Hide() end - if icon.borderTop then icon.borderTop:Hide() end - if icon.borderBottom then icon.borderBottom:Hide() end - - -- Full size icon when no border - if icon.icon then - icon.icon:ClearAllPoints() - icon.icon:SetPoint("TOPLEFT", icon.iconFrame, "TOPLEFT", 0, 0) - icon.icon:SetPoint("BOTTOMRIGHT", icon.iconFrame, "BOTTOMRIGHT", 0, 0) - end - - -- Adjust cooldown to match - if icon.cooldown then - icon.cooldown:ClearAllPoints() - icon.cooldown:SetPoint("TOPLEFT", icon.iconFrame, "TOPLEFT", 0, 0) - icon.cooldown:SetPoint("BOTTOMRIGHT", icon.iconFrame, "BOTTOMRIGHT", 0, 0) - end + -- Border via unified DF.Border backend (Stage 4.4). BuildSpec reads + -- canonical personalTargetedSpell* keys; we override size with the + -- locally pixel-perfected value. Icon + cooldown inset by visible + -- border thickness so artwork doesn't overlap the border edges (or + -- sits flush with the icon frame when the border is off). + if icon.border then + local spec = DF.Border:BuildSpec(db, "personalTargetedSpell", { iconMode = true }) + spec.enabled = showBorder + spec.size = borderSize + DF.Border:Apply(icon.border, spec) + end + + local artInset = showBorder and borderSize or 0 + if icon.icon then + icon.icon:ClearAllPoints() + icon.icon:SetPoint("TOPLEFT", icon.iconFrame, "TOPLEFT", artInset, -artInset) + icon.icon:SetPoint("BOTTOMRIGHT", icon.iconFrame, "BOTTOMRIGHT", -artInset, artInset) + end + if icon.cooldown then + icon.cooldown:ClearAllPoints() + icon.cooldown:SetPoint("TOPLEFT", icon.iconFrame, "TOPLEFT", artInset, -artInset) + icon.cooldown:SetPoint("BOTTOMRIGHT", icon.iconFrame, "BOTTOMRIGHT", -artInset, artInset) end -- Cooldown swipe @@ -4373,16 +4312,9 @@ local function TargetedList_BuildBar(parent) bg:SetColorTexture(0, 0, 0, 0.6) bar.bg = bg - -- Border (backdrop-template frame). Visibility + color applied - -- by TargetedList_ApplyBarAppearance. - local border = CreateFrame("Frame", nil, bar, "BackdropTemplate") - border:SetAllPoints(bar) - border:SetBackdrop({ - edgeFile = "Interface\\Buttons\\WHITE8x8", - edgeSize = 1, - }) - border:SetBackdropBorderColor(0, 0, 0, 1) - bar.border = border + -- Border via the unified DF.Border backend (Stage 4.5). + -- TargetedList_ApplyBarAppearance drives BuildSpec + Apply per render. + bar.border = DF.Border:New(bar) -- Icon — anchored dynamically by ApplyBarAppearance so its -- position (LEFT/RIGHT) and zoom state can change at runtime. @@ -4613,14 +4545,12 @@ local function TargetedList_ApplyBarAppearance(bar, db) local bgAlpha = db.targetedListBackgroundAlpha or 0.6 bar.bg:SetColorTexture(0, 0, 0, bgAlpha) - -- ----- Border show/hide + color ----- - local showBorder = db.targetedListShowBorder ~= false - if showBorder then - bar.border:Show() - local bc = db.targetedListBorderColor or {r=0, g=0, b=0, a=1} - bar.border:SetBackdropBorderColor(bc.r or 0, bc.g or 0, bc.b or 0, bc.a or 1) - else - bar.border:Hide() + -- Border via unified DF.Border backend (Stage 4.5). BuildSpec reads + -- canonical targetedList* keys; consumer doesn't override anything + -- (no pixel-perfect on Targeted List bars — bars are positioned by + -- screen-anchored container, not by the frame-pixel grid). + if bar.border then + DF.Border:Apply(bar.border, DF.Border:BuildSpec(db, "targetedList")) end -- ----- Font (all text elements share one font + outline setting) ----- @@ -5947,10 +5877,11 @@ function DF:LightweightUpdateTargetedListBorderColor() if not TargetedList_IsGateOpen() then return end local db = DF.db and DF.db.party if not db then return end - local bc = db.targetedListBorderColor or {r=0, g=0, b=0, a=1} + -- Route through BuildSpec + Apply (Stage 4.5) so the live drag-update + -- path renders identically to TargetedList_ApplyBarAppearance. for _, bar in pairs(casterToBar) do - if bar.border and bar.border:IsShown() then - bar.border:SetBackdropBorderColor(bc.r, bc.g, bc.b, bc.a or 1) + if bar.border then + DF.Border:Apply(bar.border, DF.Border:BuildSpec(db, "targetedList")) end end end diff --git a/Fonts/RobotoMono-Bold.ttf b/Fonts/RobotoMono-Bold.ttf new file mode 100644 index 00000000..bef439f3 Binary files /dev/null and b/Fonts/RobotoMono-Bold.ttf differ diff --git a/Fonts/RobotoMono-SemiBold.ttf b/Fonts/RobotoMono-SemiBold.ttf new file mode 100644 index 00000000..b828c3ae Binary files /dev/null and b/Fonts/RobotoMono-SemiBold.ttf differ diff --git a/Frames/Bars.lua b/Frames/Bars.lua index be099902..5fd7f43c 100644 --- a/Frames/Bars.lua +++ b/Frames/Bars.lua @@ -128,8 +128,8 @@ function DF:ApplyResourceBarLayout(frame) -- Account for frame border inset (matches other bar calculations) local borderInset = 0 - if db.showFrameBorder ~= false then - borderInset = db.borderSize or 1 + if db.frameShowBorder ~= false then + borderInset = db.frameBorderSize or 1 end if isVertical then @@ -189,15 +189,14 @@ function DF:ApplyResourceBarLayout(frame) end end - -- Border visibility and color + -- 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 - if db.resourceBarBorderEnabled then - bar.border:Show() - local borderC = db.resourceBarBorderColor or {r = 0, g = 0, b = 0, a = 1} - bar.border:SetBackdropBorderColor(borderC.r, borderC.g, borderC.b, borderC.a or 1) - else - bar.border:Hide() - end + 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 @@ -336,8 +335,8 @@ local function AbsorbLayoutStateChanged(frame, db) if s.colR ~= col.r or s.colG ~= col.g or s.colB ~= col.b or s.colA ~= (col.a or 0.7) then return true end -- Border settings (affect inset calculations for attached/overlay modes) - if s.showFrameBorder ~= (db.showFrameBorder ~= false) then return true end - if s.borderSize ~= (db.borderSize or 1) then return true end + if s.frameShowBorder ~= (db.frameShowBorder ~= false) then return true end + if s.frameBorderSize ~= (db.frameBorderSize or 1) then return true end -- Floating-mode specific if s.orientation ~= (db.absorbBarOrientation or "HORIZONTAL") then return true end @@ -391,8 +390,8 @@ local function CacheAbsorbLayoutState(frame, db) s.oorAlpha = db.oorAbsorbBarAlpha or 0.5 local col = db.absorbBarColor or DEFAULT_ABSORB_COLOR s.colR, s.colG, s.colB, s.colA = col.r, col.g, col.b, col.a or 0.7 - s.showFrameBorder = db.showFrameBorder ~= false - s.borderSize = db.borderSize or 1 + s.frameShowBorder = db.frameShowBorder ~= false + s.frameBorderSize = db.frameBorderSize or 1 s.orientation = db.absorbBarOrientation or "HORIZONTAL" s.width = db.absorbBarWidth or 50 s.height = db.absorbBarHeight or 6 @@ -930,8 +929,8 @@ function DF:UpdateAbsorb(frame, testIndex) local healthOrient = db.healthOrientation or "HORIZONTAL" local inset = 0 - if db.showFrameBorder ~= false then - inset = frame.dfReducedMaxHealthClipping and 0 or (db.borderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there + if db.frameShowBorder ~= false then + inset = frame.dfReducedMaxHealthClipping and 0 or (db.frameBorderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there end local barWidth = frame.healthBar:GetWidth() - (inset * 2) @@ -1030,8 +1029,8 @@ function DF:UpdateAbsorb(frame, testIndex) local healthOrient = db.healthOrientation or "HORIZONTAL" local inset = 0 - if db.showFrameBorder ~= false then - inset = frame.dfReducedMaxHealthClipping and 0 or (db.borderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there + if db.frameShowBorder ~= false then + inset = frame.dfReducedMaxHealthClipping and 0 or (db.frameBorderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there end local barWidth = frame.healthBar:GetWidth() - (inset * 2) @@ -1186,8 +1185,8 @@ function DF:UpdateAbsorb(frame, testIndex) -- Use explicit points instead of SetAllPoints to ensure proper clipping -- Inset by border size if frame border is enabled to avoid overlap local inset = 0 - if db.showFrameBorder ~= false then - inset = frame.dfReducedMaxHealthClipping and 0 or (db.borderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there + if db.frameShowBorder ~= false then + inset = frame.dfReducedMaxHealthClipping and 0 or (db.frameBorderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there end customBar:SetPoint("TOPLEFT", frame.healthBar, "TOPLEFT", inset, -inset) customBar:SetPoint("BOTTOMRIGHT", frame.healthBar, "BOTTOMRIGHT", -inset, inset) @@ -1521,8 +1520,8 @@ function DF:UpdateHealAbsorb(frame, testIndex) local healthOrient = db.healthOrientation or "HORIZONTAL" local inset = 0 - if db.showFrameBorder ~= false then - inset = frame.dfReducedMaxHealthClipping and 0 or (db.borderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there + if db.frameShowBorder ~= false then + inset = frame.dfReducedMaxHealthClipping and 0 or (db.frameBorderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there end local barWidth = frame.healthBar:GetWidth() - (inset * 2) @@ -1578,8 +1577,8 @@ function DF:UpdateHealAbsorb(frame, testIndex) -- Use explicit points instead of SetAllPoints to ensure proper clipping -- Inset by border size if frame border is enabled to avoid overlap local inset = 0 - if db.showFrameBorder ~= false then - inset = frame.dfReducedMaxHealthClipping and 0 or (db.borderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there + if db.frameShowBorder ~= false then + inset = frame.dfReducedMaxHealthClipping and 0 or (db.frameBorderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there end bar:ClearAllPoints() bar:SetPoint("TOPLEFT", frame.healthBar, "TOPLEFT", inset, -inset) @@ -1999,8 +1998,8 @@ function DF:UpdateHealPrediction(frame, testIndex) local healthOrient = db.healthOrientation or "HORIZONTAL" local inset = 0 - if db.showFrameBorder ~= false then - inset = frame.dfReducedMaxHealthClipping and 0 or (db.borderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there + if db.frameShowBorder ~= false then + inset = frame.dfReducedMaxHealthClipping and 0 or (db.frameBorderSize or 1) -- 0 when clipped: the clip edge is internal, no frame border there end -- For test mode, we can use calculated positions @@ -2214,7 +2213,7 @@ function DF:UpdateRoleIcon(frame, source) -- Debug (use /df debugrole to enable) if DF.debugRoleIcons then - print("|cff00ffffDF ROLE:|r", frame.unit, "role=", role, "onlyInCombat=", db.roleIconOnlyInCombat, "InCombat=", inCombat) + print("|cff00ffffDF ROLE:|r", frame.unit, "role=", role, "hideInCombat=", db.roleIconHideInCombat, "InCombat=", inCombat) end if role == "NONE" then @@ -2222,39 +2221,34 @@ function DF:UpdateRoleIcon(frame, source) return end - -- Determine if we should apply show settings - -- If "Show All Roles Out of Combat" is checked, role filters only apply during combat - -- Out of combat, all role icons show regardless of individual filter settings - local applySettings = true - if db.roleIconOnlyInCombat and not inCombat then - applySettings = false -- Out of combat, show all icons + -- Per-role visibility filter (global — which roles ever show an icon). + local shouldShow = false + if role == "TANK" then + shouldShow = db.roleIconShowTank ~= false + elseif role == "HEALER" then + shouldShow = db.roleIconShowHealer ~= false + elseif role == "DAMAGER" then + shouldShow = db.roleIconShowDPS ~= false end - - local shouldShow = true - if applySettings then - -- Respect individual show settings - if role == "TANK" then - shouldShow = db.roleIconShowTank ~= false - elseif role == "HEALER" then - shouldShow = db.roleIconShowHealer ~= false - elseif role == "DAMAGER" then - shouldShow = db.roleIconShowDPS ~= false - end + + -- Hide-in-combat timing gate — independent of the role filter. Refreshed on + -- combat transitions because Core's PLAYER_REGEN handlers call + -- UpdateAllRoleIcons. + if db.roleIconHideInCombat and inCombat then + shouldShow = false end - + -- Debug if DF.debugRoleIcons then - print("|cff00ffffDF ROLE:|r applySettings=", applySettings, "shouldShow=", shouldShow) + print("|cff00ffffDF ROLE:|r shouldShow=", shouldShow, "hideInCombat=", db.roleIconHideInCombat, "inCombat=", inCombat) end - + if not shouldShow then frame.roleIcon:Hide() return end - local tex, l, r, t, b = DF:GetRoleIconTexture(db, role) - frame.roleIcon.texture:SetTexture(tex) - frame.roleIcon.texture:SetTexCoord(l, r, t, b) + DF:SetIconTextureOrAtlas(frame.roleIcon.texture, DF:GetRoleIconTexture(db, role)) frame.roleIcon:Show() @@ -2449,10 +2443,10 @@ function DF:UpdateReadyCheckIcon(frame) local readyCheckStatus = GetReadyCheckStatus(frame.unit) if readyCheckStatus == "ready" then - frame.readyCheckIcon.texture:SetTexture("Interface\\RaidFrame\\ReadyCheck-Ready") + DF:SetUpgradedStatusIcon(frame.readyCheckIcon.texture, "Interface\\RaidFrame\\ReadyCheck-Ready") frame.readyCheckIcon:Show() elseif readyCheckStatus == "notready" then - frame.readyCheckIcon.texture:SetTexture("Interface\\RaidFrame\\ReadyCheck-NotReady") + DF:SetUpgradedStatusIcon(frame.readyCheckIcon.texture, "Interface\\RaidFrame\\ReadyCheck-NotReady") frame.readyCheckIcon:Show() elseif readyCheckStatus == "waiting" then -- Check if player is AFK while waiting (enhanced ready check) @@ -2466,9 +2460,9 @@ function DF:UpdateReadyCheckIcon(frame) if afkAccessible and isAFK then -- AFK state - show not ready icon (they likely won't respond) - frame.readyCheckIcon.texture:SetTexture("Interface\\RaidFrame\\ReadyCheck-NotReady") + DF:SetUpgradedStatusIcon(frame.readyCheckIcon.texture, "Interface\\RaidFrame\\ReadyCheck-NotReady") else - frame.readyCheckIcon.texture:SetTexture("Interface\\RaidFrame\\ReadyCheck-Waiting") + DF:SetUpgradedStatusIcon(frame.readyCheckIcon.texture, "Interface\\RaidFrame\\ReadyCheck-Waiting") end frame.readyCheckIcon:Show() else diff --git a/Frames/Border.lua b/Frames/Border.lua new file mode 100644 index 00000000..b9dc31d1 --- /dev/null +++ b/Frames/Border.lua @@ -0,0 +1,1483 @@ +local addonName, DF = ... + +-- ============================================================ +-- UNIFIED BORDER BACKEND (DF.Border) +-- +-- One border widget used across the addon so every border shares the same +-- capabilities and code path. A widget supports two render modes behind a +-- single colour API: +-- * Solid (default): four ColorTexture edges — pixel-perfect. +-- * Texture: a BackdropTemplate child using a LibSharedMedia border edgeFile. +-- +-- Usage: +-- local b = DF.Border:New(parent[, opts]) -- create the widget once +-- DF.Border:Apply(b, spec) -- (re)configure from a spec +-- b:SetColor(r, g, b, a) -- live recolour (routes to mode) +-- +-- The frame border is the first consumer; `frame.border` keeps the same shape +-- (top/bottom/left/right edges, a lazily-created `bd` backdrop child, and a +-- :SetBorderColor alias) so existing callers are unaffected. +-- +-- FUTURE (later phases) — the spec is intentionally open for: inset, shadow, +-- gradient, and glow (LibCustomGlow). Only the current frame-border feature set +-- (enabled / style / texture / size / colour) is implemented here for now. +-- ============================================================ + +local CreateFrame = CreateFrame +local ipairs = ipairs + +DF.Border = DF.Border or {} +local Border = DF.Border + +-- Create a border widget anchored to `parent` (or opts.anchorTo). +-- opts: +-- anchorTo frame to cover (default: parent) +-- frameLevelOffset level above parent (default: 10) +-- layer texture draw layer for the solid edges (default: "BORDER") +-- solidOnly hot-path SOLID border that never uses a gradient. Skips the +-- SetGradient/CreateColor gradient-clear in both Apply (SOLID) +-- and SetColor, so live recolours are a bare SetColorTexture — +-- cheap AND safe for secret-tinted colours (e.g. debuff +-- dispel-type colours), where CreateColor()/comparisons would +-- taint. Do NOT set for borders that can switch to GRADIENT. +function Border:New(parent, opts) + opts = opts or {} + local border = CreateFrame("Frame", nil, parent) + border._solidOnly = opts.solidOnly and true or false + -- Remember anchorTo on the widget so :Apply can re-anchor when an offsetX/Y + -- is supplied (SetAllPoints below is the offsetX=offsetY=0 default; :Apply + -- replaces it with two SetPoint calls translated by the offset). + border.anchorTo = opts.anchorTo or parent + border:SetAllPoints(border.anchorTo) + border:SetFrameLevel(parent:GetFrameLevel() + (opts.frameLevelOffset or 10)) + + local layer = opts.layer or "BORDER" + border.top = border:CreateTexture(nil, layer) + border.top:SetPoint("TOPLEFT", 0, 0) + border.top:SetPoint("TOPRIGHT", 0, 0) + border.bottom = border:CreateTexture(nil, layer) + border.bottom:SetPoint("BOTTOMLEFT", 0, 0) + border.bottom:SetPoint("BOTTOMRIGHT", 0, 0) + border.left = border:CreateTexture(nil, layer) + border.left:SetPoint("TOPLEFT", 0, 0) + border.left:SetPoint("BOTTOMLEFT", 0, 0) + border.right = border:CreateTexture(nil, layer) + border.right:SetPoint("TOPRIGHT", 0, 0) + border.right:SetPoint("BOTTOMRIGHT", 0, 0) + + -- Recolour whichever mode is currently active (used by live colour updates, + -- aggro/threat/dispel overlays, etc.). + border.SetColor = function(self, r, g, b, a) + a = a or 1 + if self.activeTexture then + if self.bd then self.bd:SetBackdropBorderColor(r, g, b, a) end + else + local bm = self._blendMode or "BLEND" + local edges = { self.top, self.bottom, self.left, self.right } + -- Clear any prior gradient — SetColorTexture does NOT reset it, so a + -- leftover gradient (set when the border was first painted, even in + -- SOLID mode) would tint the recolour and wash it out. Paint a + -- solid gradient of the new colour first (same pattern Apply uses). + -- solidOnly borders never set a gradient (Apply skips it too), so we + -- skip this — keeps the recolour a bare SetColorTexture, which is + -- both cheaper and safe for secret-tinted colours (CreateColor on a + -- secret value taints execution). + if not self._solidOnly and CreateColor then + local solid = CreateColor(r, g, b, a) + for _, e in ipairs(edges) do + if e.SetGradient then e:SetGradient("HORIZONTAL", solid, solid) end + end + end + for _, e in ipairs(edges) do + e:SetColorTexture(r, g, b, a) + e:SetBlendMode(bm) + end + end + end + -- Back-compat alias: existing frame-border consumers call :SetBorderColor. + border.SetBorderColor = border.SetColor + + return border +end + +-- Resolve a colour from either an array {r,g,b,a} or a keyed {r=,g=,b=,a=} +-- table, so consumers can pass whichever they already store. +local function readColor(color) + if not color then return 0, 0, 0, 1 end + return color[1] or color.r or 0, + color[2] or color.g or 0, + color[3] or color.b or 0, + color[4] or color.a or 1 +end + +-- Build a ready-to-Apply spec from a dbTable using the canonical key naming +-- mirror of CreateBorderControls: prefix .. "BorderSize" / "BorderStyle" / +-- "BorderGradientStartColor" etc. Each consumer's Apply call site collapses +-- to `DF.Border:Apply(border, DF.Border:BuildSpec(db, prefix))` (with optional +-- post-hoc overrides like a locally pixel-perfected size). Missing keys fall +-- back to sensible defaults — same defaults the Config blocks would seed. +-- +-- ctx (optional, Stage 2): { unit, auraInstanceID, remaining, totalDuration, +-- timeMode = "SECONDS"|"PERCENT", timeCurve, roleColors }. When a colour- +-- resolver toggle is enabled in db (`UseClassColor` / `UseRoleColor` / +-- `ColorByTime` / `ColorByType`), BuildSpec resolves the colour via the +-- matching Border:Resolve* helper. Priority order (most specific wins): +-- type > time > class > role > static spec.color +-- Resolvers silently fall through when their required ctx is missing, so a +-- consumer that only knows the unit can still flip on classColor without +-- worrying about time/type ctx. +function Border:BuildSpec(dbTable, prefix, ctx) + if not dbTable or not prefix then return {} end + local function k(suffix) return prefix .. suffix end + + -- Style is the top-level choice: SOLID | GRADIENT | TEXTURE. + -- GRADIENT owns its own colours (start/end pickers) so the colour-source + -- resolver chain is skipped for it — applying class/role/time/type tinting + -- on top of a gradient produced visual conflicts (you'd pick "class + -- colour" then watch the gradient stomp it). The model is: one style → + -- one colour expression. + local style = dbTable[k("BorderStyle")] or "SOLID" + + -- Resolve colour. Static `BorderColor` is the fallback for every + -- resolver, so flipping the source back to STATIC restores the picker + -- colour without the consumer doing anything. + -- + -- `BorderColorSource` ("STATIC" | "CLASS" | "ROLE") replaces the + -- previous independent boolean toggles (UseClassColor / UseRoleColor). The + -- old keys are migrated on db load (MigrateFrameBorderKeys); we still + -- honour them here as a fallback in case migration hasn't run for some + -- code path yet. ColorByTime / ColorByType remain independent and stack + -- ON TOP of the source — they override during aura state, then drop back + -- to whichever source the user picked. + local fallbackColor = dbTable[k("BorderColor")] + local color = fallbackColor + local source = dbTable[k("BorderColorSource")] + if not source then + if dbTable[k("BorderUseClassColor")] then source = "CLASS" + elseif dbTable[k("BorderUseRoleColor")] then source = "ROLE" + else source = "STATIC" end + end + if ctx and style ~= "GRADIENT" then + if dbTable[k("BorderColorByType")] and ctx.unit and ctx.auraInstanceID then + local r, g, b, a = self:ResolveTypeColor(ctx.unit, ctx.auraInstanceID, fallbackColor) + color = { r = r, g = g, b = b, a = a } + elseif dbTable[k("BorderColorByTime")] and ctx.timeCurve and ctx.remaining and ctx.totalDuration then + local r, g, b, a = self:ResolveTimeColor(ctx.timeCurve, ctx.remaining, ctx.totalDuration, ctx.timeMode, fallbackColor) + color = { r = r, g = g, b = b, a = a } + elseif source == "CLASS" and (ctx.unit or ctx.frame) then + -- Resolver supplies RGB from the class colour; alpha comes from + -- the picker (`BorderColor.a`). The Border Alpha slider + -- (when the consumer opts into include.alpha) edits the SAME + -- key, so picker and slider stay in sync automatically. + -- ctx.frame lets test frames look up class via GetTestUnitData + -- (Stage 4.0 — defensive icon test-mode preview). + local r, g, b, _ = self:ResolveClassColor(ctx.unit, fallbackColor, ctx.frame) + local a = (fallbackColor and (fallbackColor.a or fallbackColor[4])) or 1 + color = { r = r, g = g, b = b, a = a } + elseif source == "ROLE" and (ctx.unit or ctx.frame) then + -- Role colours live at DF.db.roleColors (profile-level, shared with + -- the Colors settings page). Consumer can still override via + -- ctx.roleColors if it has a special-case set. Alpha from the + -- picker, same reasoning as CLASS. + local rc = ctx.roleColors or (DF.db and DF.db.roleColors) + if rc then + local r, g, b, _ = self:ResolveRoleColor(ctx.unit, fallbackColor, rc, ctx.frame) + local a = (fallbackColor and (fallbackColor.a or fallbackColor[4])) or 1 + color = { r = r, g = g, b = b, a = a } + end + end + end + + local spec = { + enabled = dbTable[k("ShowBorder")] ~= false, + style = style, + texture = dbTable[k("BorderTexture")], + size = dbTable[k("BorderSize")] or 1, + color = color, + inset = dbTable[k("BorderInset")] or 0, + offsetX = dbTable[k("BorderOffsetX")] or 0, + offsetY = dbTable[k("BorderOffsetY")] or 0, + blendMode = dbTable[k("BorderBlendMode")] or "BLEND", + pixelPerfect = dbTable.pixelPerfect, + } + -- Gradient is now a STYLE (selected via the Border Style dropdown) rather + -- than an independent toggle. The legacy `BorderGradientEnabled` + -- boolean is migrated to `BorderStyle = "GRADIENT"` on db load + -- (MigrateFrameBorderKeys / equivalent) but we still honour a stale + -- `true` here as a safety net in case the migration hasn't run on some + -- code path. + if style == "GRADIENT" or dbTable[k("BorderGradientEnabled")] then + spec.style = "GRADIENT" + spec.gradient = { + enabled = true, + startColor = dbTable[k("BorderGradientStartColor")], + endColor = dbTable[k("BorderGradientEndColor")], + direction = dbTable[k("BorderGradientDirection")] or "HORIZONTAL", + } + end + if dbTable[k("BorderShadowEnabled")] then + spec.shadow = { + enabled = true, + color = dbTable[k("BorderShadowColor")], + size = dbTable[k("BorderShadowSize")] or 1, + offsetX = dbTable[k("BorderShadowOffsetX")] or 0, + offsetY = dbTable[k("BorderShadowOffsetY")] or 0, + } + end + -- Animation (Stage 3): LCG-backed glow effects. spec.animation is set only + -- when the consumer picked a non-NONE type — Apply uses presence to drive + -- StartAnimation, absence to drive StopAnimation. Tunables map 1:1 to + -- LCG.PixelGlow_Start / AutoCastGlow_Start / ButtonGlow_Start args, with + -- sensible defaults applied at Start time. + local animType = dbTable[k("BorderAnimationType")] + if animType and animType ~= "NONE" then + spec.animation = { + type = animType, + color = dbTable[k("BorderAnimationColor")], + frequency = dbTable[k("BorderAnimationFrequency")], + particles = dbTable[k("BorderAnimationParticles")], + length = dbTable[k("BorderAnimationLength")], + thickness = dbTable[k("BorderAnimationThickness")], + scale = dbTable[k("BorderAnimationScale")], + inset = dbTable[k("BorderAnimationInset")], + offsetX = dbTable[k("BorderAnimationOffsetX")], + offsetY = dbTable[k("BorderAnimationOffsetY")], + mask = dbTable[k("BorderAnimationMask")], + sidesAxis = dbTable[k("BorderAnimationSidesAxis")], + cornerLength = dbTable[k("BorderAnimationCornerLength")], + } + end + -- Icon consumers (ctx.iconMode) frame the art with an OUTWARD band — the + -- opposite of the inward convention frame outlines / status bars use. Route + -- through the shared icon-geometry helper so every icon border reads the same + -- (AD icon/square, aura icons, defensive / missing-buff / targeted-spell). + if ctx and ctx.iconMode then + self:IconGeometry(spec, spec.size, spec.inset) + end + return spec +end + +-- ============================================================ +-- ICON BORDER GEOMETRY (shared convention) +-- One geometry model for every icon-shaped consumer — AD icon/square, buff/ +-- debuff aura icons, and the defensive / missing-buff / targeted-spell icons — +-- so they all read identically: a `thickness`-wide band that FRAMES the art, +-- nudged OUTWARD by BorderInset (spec.inset = -inset), with the art inset by +-- the thickness when the border is on. (Frame outlines and status bars keep +-- the inward BuildSpec convention — a different, correct family.) +-- ============================================================ + +-- Stamp the icon geometry onto an already-built spec (from BuildSpec or a +-- hand-built table). Mutates + returns spec. +function Border:IconGeometry(spec, thickness, borderInset) + spec.size = thickness + spec.inset = -(borderInset or 0) + return spec +end + +-- Inset an icon's art/texture so the band frames it: by `thickness` when the +-- border is enabled, 0 when it's off (art fills the slot). +function Border:SetIconArtInset(texture, thickness, enabled) + if not texture then return end + local i = (enabled and thickness) or 0 + texture:ClearAllPoints() + texture:SetPoint("TOPLEFT", i, -i) + texture:SetPoint("BOTTOMRIGHT", -i, i) +end + +-- ============================================================ +-- COLOUR RESOLVERS (Stage 2) +-- Reusable per-element colour computations consumers can opt into via toggle +-- keys (`BorderUseClassColor`, `BorderColorByTime`, etc.). +-- Each returns r,g,b,a and falls back to `fallback` when context is missing or +-- resolution doesn't yield a colour. `fallback` accepts the same {r,g,b,a} or +-- {r=,g=,b=,a=} shape that the rest of DF.Border uses. +-- ============================================================ + +-- Class colour of `unit`, with `fallback`'s alpha preserved (the colour +-- picker's alpha shouldn't change when the toggle flips to class colour). +-- Optional 3rd arg `frame`: if it has dfIsTestFrame=true, the class is +-- pulled from the test data (DF:GetTestUnitData) instead of UnitClass(unit). +-- This lets test mode preview Class colour correctly even though test +-- frames don't have real unit IDs. Live frames go through the unit path. +function Border:ResolveClassColor(unit, fallback, frame) + local fr, fg, fb, fa = readColor(fallback) + + local classToken + if frame and frame.dfIsTestFrame then + local testData = DF.GetTestUnitData and DF:GetTestUnitData(frame.index, frame.isRaidFrame) + classToken = testData and testData.class + elseif unit and UnitExists and UnitExists(unit) then + classToken = select(2, UnitClass(unit)) + end + + if classToken and DF.GetClassColor then + local c = DF:GetClassColor(classToken) + if c then return c.r or fr, c.g or fg, c.b or fb, fa end + end + return fr, fg, fb, fa +end + +-- Role colour from a shared {TANK=, HEALER=, DAMAGER=} table, with fallback +-- alpha preserved. roleColors is typically `{tank = db.roleBorderColorTank, +-- healer = db.roleBorderColorHealer, damager = db.roleBorderColorDamager}` +-- supplied by the caller from the global db block. Optional 4th arg `frame`: +-- mirrors ResolveClassColor — test frames go through GetTestUnitData, +-- live frames through UnitGroupRolesAssigned. +function Border:ResolveRoleColor(unit, fallback, roleColors, frame) + local fr, fg, fb, fa = readColor(fallback) + if not roleColors then return fr, fg, fb, fa end + + local role + if frame and frame.dfIsTestFrame then + local testData = DF.GetTestUnitData and DF:GetTestUnitData(frame.index, frame.isRaidFrame) + role = testData and testData.role + elseif unit and UnitExists and UnitExists(unit) and UnitGroupRolesAssigned then + role = UnitGroupRolesAssigned(unit) + -- UnitGroupRolesAssigned returns "NONE" outside instances where roles + -- aren't assigned (solo, world content, open-world groups). For the + -- player, fall back to the spec role so role colour is meaningful + -- regardless of group context. Other units expose no public spec API, + -- so they stay on the picker fallback when role is NONE. + if (not role or role == "NONE") and UnitIsUnit and UnitIsUnit(unit, "player") + and GetSpecialization and GetSpecializationRole then + local spec = GetSpecialization() + if spec then role = GetSpecializationRole(spec) end + end + end + + local c = role and role ~= "NONE" and (roleColors[role] or roleColors[string.lower(role)]) + if c then return c.r or fr, c.g or fg, c.b or fb, fa end + return fr, fg, fb, fa +end + +-- Colour-by-time-remaining via a C_CurveUtil colour curve. Caller supplies the +-- pre-built curve (e.g. DF.expiringCurves[...]). totalDuration > 0 required +-- so we can pass either a remaining-percent (curve expects [0,1]) or a +-- remaining-duration (curve expects seconds) — `mode` picks which API to call. +function Border:ResolveTimeColor(curve, remaining, totalDuration, mode, fallback) + local fr, fg, fb, fa = readColor(fallback) + if not curve or not remaining or not totalDuration or totalDuration <= 0 then + return fr, fg, fb, fa + end + -- Curves return ColorMixins via EvaluateRemainingDuration / Percent. The + -- two helpers exist on the curve object directly (Midnight 12.0+). + local result + if mode == "SECONDS" and curve.EvaluateRemainingDuration then + result = curve:EvaluateRemainingDuration(remaining) + elseif curve.EvaluateRemainingPercent then + local pct = remaining / totalDuration + if pct < 0 then pct = 0 elseif pct > 1 then pct = 1 end + result = curve:EvaluateRemainingPercent(pct) + end + if result and result.GetRGBA then + local r, g, b, a = result:GetRGBA() + return r or fr, g or fg, b or fb, a or fa + end + return fr, fg, fb, fa +end + +-- Dispel-type colour for a debuff, via C_UnitAuras.GetAuraDispelTypeColor. +-- Lazy-builds `DF.debuffBorderCurve` from C_CurveUtil if it isn't already +-- present (Auras.lua / Dispel.lua build the same one independently today; +-- this serves as a shared lazy fallback). +function Border:ResolveTypeColor(unit, auraInstanceID, fallback) + local fr, fg, fb, fa = readColor(fallback) + if not unit or not auraInstanceID or not C_UnitAuras or not C_UnitAuras.GetAuraDispelTypeColor then + return fr, fg, fb, fa + end + if not DF.debuffBorderCurve and C_CurveUtil and C_CurveUtil.CreateColorCurve then + -- Same curve spec the buff/debuff aura system uses (extracted later if + -- we need to expose customisation; for now a sensible default). + DF.debuffBorderCurve = C_CurveUtil.CreateColorCurve({ + { 0, CreateColor(0.6, 0.0, 0.0, 1) }, + { 1, CreateColor(0.6, 0.0, 0.0, 1) }, + }) + end + if not DF.debuffBorderCurve then return fr, fg, fb, fa end + local result = C_UnitAuras.GetAuraDispelTypeColor(unit, auraInstanceID, DF.debuffBorderCurve) + if result and result.GetRGBA then + local r, g, b, a = result:GetRGBA() + return r or fr, g or fg, b or fb, fa -- keep fallback alpha (picker controls it) + end + return fr, fg, fb, fa +end + +-- ============================================================ +-- ANIMATIONS (Stage 3) +-- +-- spec.animation = { type, color, frequency, particles, length, thickness, +-- scale, cornerLength, sidesAxis }. `type` is the only required field; +-- the rest fall back to per-effect defaults. +-- +-- Effects split into three implementation families: +-- +-- 1. LCG-driven glows — target border.anchorTo (the unit frame) so the +-- glow reads as "this unit is highlighted" rather than "this thin 1px +-- strip is highlighted". +-- "PULSATE" → LCG.PixelGlow_Start pixel-art ring of N particles +-- "CHASE" → LCG.AutoCastGlow_Start rotating particle ring +-- "FLASH" → LCG.ButtonGlow_Start Blizzard button-glow pulse +-- "PROC" → LCG.ProcGlow_Start Blizzard proc start+loop flash +-- +-- 2. Custom OnUpdate animators — operate directly on the 4 edge textures +-- by modulating SetAlpha each frame. No LCG involved. Tick functions +-- live in `customTicks` below; the shared driver frame is created +-- lazily via ensureDriver(border). +-- "WIPE" sweep a bright highlight clockwise around perimeter +-- "RIPPLE" all edges pulse alpha with per-edge phase offsets +-- "SEGMENT_REVEAL" edges fade in sequentially top→right→bottom→left +-- +-- 3. Static shape modes — no animation, just a different render layout +-- held for as long as the type is active. +-- "SIDES_ONLY" hide one perpendicular edge pair (axis option) +-- "CORNERS_ONLY" show only short pieces at each of the 4 corners +-- (lazy-creates 4 extra textures so each corner has +-- a horizontal + a vertical short piece) +-- +-- "NONE" silently stops any running effect +-- +-- Stop semantics: Apply ALWAYS calls StopAnimation first to clear any prior +-- effect before starting a new one (avoids leaving a stale Pulsate running +-- under a freshly-started Chase, or stale CORNERS_ONLY textures visible +-- under a freshly-started WIPE). Idempotent for the no-active-anim case. +-- ============================================================ + +local function getLCG() + return LibStub and LibStub("LibCustomGlow-1.0", true) +end + +-- Lazy-create the shared OnUpdate driver for custom animations. +local function ensureDriver(border) + if border.animDriver then return border.animDriver end + local d = CreateFrame("Frame", nil, border) + d.elapsed = 0 + border.animDriver = d + return d +end + +-- Reset all four edges to fully opaque. Called from StopAnimation so the +-- next Apply pass renders normally; custom animators set non-1 alpha values +-- that would otherwise persist on the edges (relevant for SIDES_ONLY, which +-- modulates edge alpha directly rather than via overlays). +local function resetEdgeAlphas(border) + local edges = { border.top, border.bottom, border.left, border.right } + for _, e in ipairs(edges) do + if e then e:SetAlpha(1) end + end +end + +-- ===== ANIMATION OVERLAYS ===== +-- For the OnUpdate-driven custom effects (WIPE / RIPPLE / SEGMENT_REVEAL) we +-- render 4 dedicated overlay textures that sit immediately OUTSIDE the +-- border's outer edge — top overlay above the border's top, bottom below, +-- left to the left of the border's left, right to the right. The overlays +-- have their own thickness (anim.thickness) and colour (anim.color), so the +-- effect's visibility is INDEPENDENT of the border's own thickness. This +-- matches user expectation that picking "Wipe" at borderSize 1 still +-- produces an obvious sweeping highlight. +-- +-- Overlays live on the OVERLAY draw layer so they render above the border +-- itself (BORDER layer in :New) and any shadow. Width is extended by +-- `thickness` at each end of the horizontal overlays so the corners join +-- cleanly with the vertical overlays without visible gaps. + +-- Forward declaration. ensureAnimRect's body lives below the overlay setup +-- (where the inset/offset documentation reads more naturally next to the +-- overlay code that uses it). Declared up here so the closures in +-- setupAnimOverlay / applyCornersOnly / StartAnimation see the local +-- binding rather than falling through to a global lookup that returns nil. +local ensureAnimRect + +local function ensureAnimOverlay(border) + if border.animOverlay then return border.animOverlay end + local o = {} + o.top = border:CreateTexture(nil, "OVERLAY") + o.bottom = border:CreateTexture(nil, "OVERLAY") + o.left = border:CreateTexture(nil, "OVERLAY") + o.right = border:CreateTexture(nil, "OVERLAY") + border.animOverlay = o + return o +end + +local function setupAnimOverlay(border, anim) + local o = ensureAnimOverlay(border) + local th = anim.thickness or 2 + if th < 1 then th = 1 end + local rect = ensureAnimRect(border, anim.inset, anim.offsetX, anim.offsetY) + + -- Anchor each overlay just outside the animRect's matching edge, with + -- ends extended by `th` so the corners visually overlap rather than + -- showing 4 disjoint stripes with gaps. animRect carries the inset / + -- offset adjustments, so the overlay positioning composes with the + -- border's own offset without each overlay needing its own offset + -- arithmetic. + o.top:ClearAllPoints() + o.top:SetPoint("BOTTOMLEFT", rect, "TOPLEFT", -th, 0) + o.top:SetPoint("BOTTOMRIGHT", rect, "TOPRIGHT", th, 0) + o.top:SetHeight(th) + + o.bottom:ClearAllPoints() + o.bottom:SetPoint("TOPLEFT", rect, "BOTTOMLEFT", -th, 0) + o.bottom:SetPoint("TOPRIGHT", rect, "BOTTOMRIGHT", th, 0) + o.bottom:SetHeight(th) + + o.left:ClearAllPoints() + o.left:SetPoint("TOPRIGHT", rect, "TOPLEFT", 0, th) + o.left:SetPoint("BOTTOMRIGHT", rect, "BOTTOMLEFT", 0, -th) + o.left:SetWidth(th) + + o.right:ClearAllPoints() + o.right:SetPoint("TOPLEFT", rect, "TOPRIGHT", 0, th) + o.right:SetPoint("BOTTOMLEFT", rect, "BOTTOMRIGHT", 0, -th) + o.right:SetWidth(th) + + local r, g, b, a = readColor(anim.color or { r = 0.95, g = 0.95, b = 0.32, a = 1 }) + for _, e in ipairs({ o.top, o.bottom, o.left, o.right }) do + e:SetColorTexture(r, g, b, a) + e:SetAlpha(0) -- tick functions raise alpha as the effect plays + e:Show() + end + return o +end + +local function hideAnimOverlay(border) + if not border.animOverlay then return end + for _, e in pairs(border.animOverlay) do e:Hide() end +end + +-- 8-piece corner overlay set for CORNERS_ONLY. Lazy-created and parented to +-- the border on the OVERLAY draw layer (above the regular border edges). +-- Two textures per corner — a horizontal piece extending inward along the +-- top/bottom edge, and a vertical piece extending inward along the +-- left/right edge. +local function ensureCornerOverlays(border) + if border.cornerOverlays then return border.cornerOverlays end + local co = {} + local names = { "tlh", "tlv", "trh", "trv", "blh", "blv", "brh", "brv" } + for _, n in ipairs(names) do + co[n] = border:CreateTexture(nil, "OVERLAY") + end + border.cornerOverlays = co + return co +end + +local function hideCornerOverlays(border) + if not border.cornerOverlays then return end + for _, e in pairs(border.cornerOverlays) do e:Hide() end +end + +-- ===== DF_DASH (dashed / marching-ants border) ===== +-- Ported from the unit-frame highlight system (Features/Highlights.lua) so +-- DF.Border can render a dashed border — static OR marching. One effect: the +-- Animation Frequency is the march SPEED (0 = static "dashed", >0 = animated). +-- Draws a pool of dash textures per edge on the OVERLAY layer; the dashes use +-- the animation's own colour / thickness / inset, so a dashes-ONLY look is the +-- base Border Thickness 0 plus this effect. +local DF_DASH_LEN = 6 +local DF_DASH_GAP = 6 +local DF_DASH_PATTERN = DF_DASH_LEN + DF_DASH_GAP +local DF_DASH_SPEED = 20 -- px/sec at frequency 1 (matches the highlight) + +local function ensureDashPool(border) + if border.dashPool then return border.dashPool end + local function makeEdge(n) + local t = {} + for i = 1, n do + local d = border:CreateTexture(nil, "OVERLAY") + d:SetColorTexture(1, 1, 1, 1) + d:Hide() + t[i] = d + end + return t + end + border.dashPool = { + top = makeEdge(24), bottom = makeEdge(24), + left = makeEdge(24), right = makeEdge(24), + } + return border.dashPool +end + +local function hideDashPool(border) + if not border.dashPool then return end + for _, edge in pairs(border.dashPool) do + for _, d in ipairs(edge) do d:Hide() end + end +end + +local function drawDashEdgeH(border, dashes, isTop, edgeOffset, width, th, inset, r, g, b, a) + local numDashes = math.ceil(width / DF_DASH_PATTERN) + 2 + for i = numDashes + 1, #dashes do dashes[i]:Hide() end + local startPos = -(edgeOffset % DF_DASH_PATTERN) + for i = 1, numDashes do + local dashStart = startPos + (i - 1) * DF_DASH_PATTERN + local visStart = math.max(0, dashStart) + local visEnd = math.min(width, dashStart + DF_DASH_LEN) + local d = dashes[i] + if d and visEnd > visStart then + d:ClearAllPoints() + d:SetSize(visEnd - visStart, th) + if isTop then + d:SetPoint("TOPLEFT", border, "TOPLEFT", inset + visStart, -inset) + else + d:SetPoint("BOTTOMLEFT", border, "BOTTOMLEFT", inset + visStart, inset) + end + d:SetColorTexture(r, g, b, a) + d:Show() + elseif d then + d:Hide() + end + end +end + +local function drawDashEdgeV(border, dashes, isRight, edgeOffset, height, th, inset, r, g, b, a) + local numDashes = math.ceil(height / DF_DASH_PATTERN) + 2 + for i = numDashes + 1, #dashes do dashes[i]:Hide() end + local startPos = -(edgeOffset % DF_DASH_PATTERN) + for i = 1, numDashes do + local dashStart = startPos + (i - 1) * DF_DASH_PATTERN + local visStart = math.max(0, dashStart) + local visEnd = math.min(height, dashStart + DF_DASH_LEN) + local d = dashes[i] + if d and visEnd > visStart then + d:ClearAllPoints() + d:SetSize(th, visEnd - visStart) + if isRight then + d:SetPoint("TOPRIGHT", border, "TOPRIGHT", -inset, -inset - visStart) + else + d:SetPoint("TOPLEFT", border, "TOPLEFT", inset, -inset - visStart) + end + d:SetColorTexture(r, g, b, a) + d:Show() + elseif d then + d:Hide() + end + end +end + +-- Redraw all four edges' dashes at a marching offset (counter-clockwise: +-- bottom → left → top → right, matching the highlight system). +local function drawDashes(border, offset, th, inset, r, g, b, a) + local pool = ensureDashPool(border) + local fw, fh = border:GetWidth(), border:GetHeight() + if not fw or not fh or fw <= 0 or fh <= 0 then return end + local width = fw - inset * 2 + local height = fh - inset * 2 + if width <= 0 or height <= 0 then return end + drawDashEdgeH(border, pool.bottom, false, offset, width, th, inset, r, g, b, a) + drawDashEdgeV(border, pool.left, false, width + offset, height, th, inset, r, g, b, a) + drawDashEdgeH(border, pool.top, true, width + height - offset, width, th, inset, r, g, b, a) + drawDashEdgeV(border, pool.right, true, 2 * width + height - offset, height, th, inset, r, g, b, a) +end + +-- Shared positioning rectangle for animation effects: anchored to the +-- border itself (so animations follow the border's own offset/inset) and +-- adjusted by anim.inset / anim.offsetX / anim.offsetY for animation- +-- specific positioning. All three families route through this: +-- - LCG glows (Pulsate / Chase / Flash) use animRect as their LCG target, +-- so the glow renders at this rectangle's geometry. +-- - Overlays (Wipe / Ripple / Segment Reveal / Sides Only / Corners Only) +-- anchor to animRect instead of border directly. +-- This makes Inset / Offset X / Offset Y consistent with the border's own +-- equivalent controls — same mental model, same sign conventions. +-- +-- Inset sign: positive = INWARD (smaller rect, animation closer to centre); +-- negative = OUTWARD (larger rect, animation further from centre). +-- Matches Border Inset semantics. The previous "Extent" parameter was an +-- outward-only inset (Inset = -Extent). +-- (forward-declared above with `local ensureAnimRect` so callers earlier in +-- the file resolve through the local binding.) +function ensureAnimRect(border, inset, offsetX, offsetY) + inset = inset or 0 + offsetX = offsetX or 0 + offsetY = offsetY or 0 + if not border.animRect then + border.animRect = CreateFrame("Frame", nil, border) + end + local f = border.animRect + f:ClearAllPoints() + f:SetPoint("TOPLEFT", border, "TOPLEFT", inset + offsetX, -inset + offsetY) + f:SetPoint("BOTTOMRIGHT", border, "BOTTOMRIGHT", -inset + offsetX, inset + offsetY) + f:Show() + return f +end + +-- ===== CUSTOM ONUPDATE TICKS ===== +-- Each tick function receives (border, anim, elapsed) and modulates the 4 +-- edge SetAlpha values. Period defaults to anim.frequency-derived; a +-- frequency of 0 / nil produces a sensible 2-second cycle. + +local function tickPeriod(anim, default) + local f = anim.frequency + if not f or f == 0 then return default end + return 1 / f +end + +-- All three OnUpdate-driven custom effects modulate the OVERLAY textures +-- created by setupAnimOverlay (separate from the border's own edges), so +-- their visibility is independent of borderSize. The border underneath +-- stays unchanged while the animation plays on top of / outside it. + +local customTicks = {} + +-- WIPE: a bright "highlight" peak travels around the perimeter clockwise. +-- Each overlay has a centre-phase (0 / 0.25 / 0.5 / 0.75); its alpha is a +-- base level plus a triangular pulse that peaks when the cycle phase t +-- matches the overlay's centre. Wraps cleanly via circular distance. +customTicks.WIPE = function(border, anim, elapsed) + local o = border.animOverlay; if not o then return end + local period = tickPeriod(anim, 2) + local t = (elapsed % period) / period + local base, peak = 0.0, 1.0 + local function pulse(c) + local d = math.abs(t - c) + if d > 0.5 then d = 1 - d end + local p = math.max(0, 1 - d * 4) + return base + (peak - base) * p + end + if o.top then o.top:SetAlpha(pulse(0)) end + if o.right then o.right:SetAlpha(pulse(0.25)) end + if o.bottom then o.bottom:SetAlpha(pulse(0.5)) end + if o.left then o.left:SetAlpha(pulse(0.75)) end +end + +-- RIPPLE: all overlays pulse alpha sinusoidally with phase offsets so the +-- ripple appears to spread outward from the top in both rotational +-- directions. WIPE has a sharp travelling peak; RIPPLE has a smoother +-- "breathing" pattern across all four overlays. +customTicks.RIPPLE = function(border, anim, elapsed) + local o = border.animOverlay; if not o then return end + local period = tickPeriod(anim, 1.5) + local t = (elapsed % period) / period + local base, amp = 0.2, 0.8 + local twoPi = 2 * math.pi + local function wave(phase) return base + amp * (0.5 + 0.5 * math.sin(twoPi * (t + phase))) end + if o.top then o.top:SetAlpha(wave(0)) end + if o.right then o.right:SetAlpha(wave(0.25)) end + if o.bottom then o.bottom:SetAlpha(wave(0.5)) end + if o.left then o.left:SetAlpha(wave(0.25)) end -- mirrors right +end + +-- SEGMENT_REVEAL: overlays fade in one at a time (top → right → bottom → +-- left) over the period, then all fade out together in the last 15% of +-- the cycle before looping. +customTicks.SEGMENT_REVEAL = function(border, anim, elapsed) + local o = border.animOverlay; if not o then return end + local period = tickPeriod(anim, 2.5) + local t = (elapsed % period) / period + local order = { o.top, o.right, o.bottom, o.left } + local revealSegment = 0.8 + local fadeStart = 0.85 + local perEdge = revealSegment / 4 + for i, e in ipairs(order) do + if e then + local segStart = (i - 1) * perEdge + if t < segStart then + e:SetAlpha(0) + elseif t >= fadeStart then + local fade = (t - fadeStart) / (1 - fadeStart) + e:SetAlpha(math.max(0, 1 - fade)) + else + local local_t = (t - segStart) / perEdge + e:SetAlpha(math.min(1, local_t)) + end + end + end +end + +-- ===== STATIC SHAPE MODES ===== + +-- SIDES_ONLY: reveal the overlay textures (anim.thickness, anim.color) on +-- one perpendicular pair only. The underlying border edges stay at full +-- alpha so the user's border is still visible underneath. Earlier rev +-- modulated SetAlpha on the edges themselves, but at borderSize 1 the +-- visible result was nearly nothing; using overlays makes the effect +-- visible regardless of border thickness. +local function applySidesOnly(border, anim) + local o = setupAnimOverlay(border, anim) + local axis = anim.sidesAxis or "HORIZONTAL" + if axis == "HORIZONTAL" then + o.top:SetAlpha(1); o.bottom:SetAlpha(1) + o.left:SetAlpha(0); o.right:SetAlpha(0) + else + o.top:SetAlpha(0); o.bottom:SetAlpha(0) + o.left:SetAlpha(1); o.right:SetAlpha(1) + end +end + +-- CORNERS_ONLY: 8 overlay pieces — 2 per corner (one horizontal extending +-- inward from the corner along the top/bottom edge, one vertical +-- extending inward along the left/right edge). Anchored just outside the +-- border itself (matches setupAnimOverlay's pattern) so thickness +-- (anim.thickness) is independent of borderSize. anim.cornerLength +-- controls how far each piece extends along its edge; default 8 pixels. +local function applyCornersOnly(border, anim) + local co = ensureCornerOverlays(border) + local th = anim.thickness or 2 + if th < 1 then th = 1 end + local length = anim.cornerLength + if not length or length <= 0 then length = 8 end + local rect = ensureAnimRect(border, anim.inset, anim.offsetX, anim.offsetY) + + local r, g, b, a = readColor(anim.color or { r = 0.95, g = 0.95, b = 0.32, a = 1 }) + local function paint(e) + e:SetColorTexture(r, g, b, a) + e:SetAlpha(1) + e:Show() + end + + -- All 8 corner pieces anchor to animRect (which carries inset/offset), + -- not directly to border — matches the setupAnimOverlay pattern. + co.tlh:ClearAllPoints() + co.tlh:SetPoint("BOTTOMLEFT", rect, "TOPLEFT", -th, 0) + co.tlh:SetSize(length + th, th) + paint(co.tlh) + co.tlv:ClearAllPoints() + co.tlv:SetPoint("TOPRIGHT", rect, "TOPLEFT", 0, th) + co.tlv:SetSize(th, length + th) + paint(co.tlv) + + co.trh:ClearAllPoints() + co.trh:SetPoint("BOTTOMRIGHT", rect, "TOPRIGHT", th, 0) + co.trh:SetSize(length + th, th) + paint(co.trh) + co.trv:ClearAllPoints() + co.trv:SetPoint("TOPLEFT", rect, "TOPRIGHT", 0, th) + co.trv:SetSize(th, length + th) + paint(co.trv) + + co.blh:ClearAllPoints() + co.blh:SetPoint("TOPLEFT", rect, "BOTTOMLEFT", -th, 0) + co.blh:SetSize(length + th, th) + paint(co.blh) + co.blv:ClearAllPoints() + co.blv:SetPoint("BOTTOMRIGHT", rect, "BOTTOMLEFT", 0, -th) + co.blv:SetSize(th, length + th) + paint(co.blv) + + co.brh:ClearAllPoints() + co.brh:SetPoint("TOPRIGHT", rect, "BOTTOMRIGHT", th, 0) + co.brh:SetSize(length + th, th) + paint(co.brh) + co.brv:ClearAllPoints() + co.brv:SetPoint("BOTTOMLEFT", rect, "BOTTOMRIGHT", 0, -th) + co.brv:SetSize(th, length + th) + paint(co.brv) +end + +-- Stop every LCG glow we might have started AND tear down any custom +-- animator state. Cheap: each Stop is a no-op when its glow frame isn't +-- present; the driver Hide is a no-op when no driver exists. +function Border:StopAnimation(border) + if not border then return end + local LCG = getLCG() + if LCG then + local key = "DFBorder" + -- Stop on BOTH the raw anchor AND the animRect wrapper since either + -- could have been the last LCG target. Each Stop is a cheap no-op + -- when its glow frame isn't present. (`glowExtent` is the legacy + -- field from the pre-rename revision and is checked for users + -- mid-upgrade who might still have a glow running on the old frame.) + local anchor = border.anchorTo or border + local function stopAll(t) + if LCG.PixelGlow_Stop then LCG.PixelGlow_Stop(t, key) end + if LCG.AutoCastGlow_Stop then LCG.AutoCastGlow_Stop(t, key) end + if LCG.ButtonGlow_Stop then LCG.ButtonGlow_Stop(t) end + if LCG.ProcGlow_Stop then LCG.ProcGlow_Stop(t, key) end + end + stopAll(anchor) + if border.animRect then stopAll(border.animRect) end + if border.glowExtent then stopAll(border.glowExtent) end + end + if border.animDriver then + border.animDriver:SetScript("OnUpdate", nil) + border.animDriver:Hide() + border.animDriver.elapsed = 0 + end + -- Hide all overlay sets from prior animation passes. The cornerExtras + -- field is from a previous-rev CORNERS_ONLY implementation; we keep + -- the Hide-loop for backward compat on profiles where the field was + -- already populated, then mark it nil so it's not referenced again. + hideAnimOverlay(border) + hideCornerOverlays(border) + hideDashPool(border) + if border.cornerExtras then + for _, e in ipairs(border.cornerExtras) do e:Hide() end + border.cornerExtras = nil + end + border.cornersOnlyActive = nil + resetEdgeAlphas(border) + -- DF_PULSATE modulates the container frame's alpha (not per-edge); restore + -- the container alpha to 1 so a NONE / different effect renders at full + -- opacity -- but ONLY when such an animation was actually running. + -- + -- The container alpha is ALSO the carrier for the range system's + -- out-of-range fade (ApplyOORAlpha -> border:SetAlpha / SetAlphaFromBoolean + -- on the wrapper, in element-specific OOR mode). Apply() ends EVERY + -- non-animated render in StopAnimation, so resetting the alpha + -- unconditionally clobbered that OOR fade: out-of-range borders flashed to + -- full opacity on each re-render -- most visibly in the burst of relayouts + -- when joining a raid whose members are in another zone -- until the next + -- range tick re-dimmed them. DF_PULSATE is the only effect that touches the + -- wrapper alpha (every other effect uses per-edge alpha / overlays / LCG + -- glow), and activeAnimation still holds the prior effect here (it's cleared + -- just below), so gate the reset on it. + if border.activeAnimation == "DF_PULSATE" and border.SetAlpha then + border:SetAlpha(1) + end + border.activeAnimation = nil + border._animHash = nil -- ensure the next StartAnimation runs the full path +end + +-- Build a comparable hash of the animation spec so StartAnimation can no-op +-- when called with the same config the border is already running. Consumer +-- refresh paths (AD's RefreshLiveFramesThrottled bumps adConfigVersion → next +-- UpdateFrame calls Configure on every visible AD-enabled frame → Apply on +-- every border → StartAnimation) fire many times per second. Without this +-- dedupe, every call ran StopAnimation which reset the OnUpdate driver's +-- elapsed counter to 0 — DF_PULSATE in particular got stuck near phase 0 +-- (visibly: a dim border that never pulsed back up to full alpha). +local function animSpecHash(anim) + if not anim then return "nil" end + local c = anim.color + local cr = (c and (c.r or c[1])) or "_" + local cg = (c and (c.g or c[2])) or "_" + local cb = (c and (c.b or c[3])) or "_" + local ca = (c and (c.a or c[4])) or "_" + return table.concat({ + tostring(anim.type), + tostring(anim.frequency), tostring(anim.particles), + tostring(anim.length), tostring(anim.thickness), + tostring(anim.scale), + tostring(anim.inset), tostring(anim.offsetX), tostring(anim.offsetY), + tostring(anim.mask), + tostring(anim.sidesAxis), tostring(anim.cornerLength), + tostring(cr), tostring(cg), tostring(cb), tostring(ca), + }, "|") +end + +function Border:StartAnimation(border, spec) + if not border or not spec or not spec.animation then + self:StopAnimation(border); return + end + local anim = spec.animation + if not anim.type or anim.type == "NONE" then + self:StopAnimation(border); return + end + + -- No-op when the same animation is already running with the same spec. + -- Prevents redundant Stop+Start cycles from resetting elapsed-based + -- effects mid-cycle. Cleared by StopAnimation so a NONE → effect + -- transition (or any genuine spec change) still goes through the full + -- restart path below. + local newHash = animSpecHash(anim) + if border._animHash == newHash then return end + + -- DF_PULSATE retune-in-place: the spec changed, but if a DF Pulsate is + -- already running on this border, NEVER tear it down — just update its + -- period. A frequency change (or any unrelated spec churn from a + -- consumer's refresh loop) then adjusts the pulse SPEED only. This avoids + -- two flicker sources: + -- * StopAnimation sets border:SetAlpha(1) — a one-frame flash to full + -- bright before the driver's OnUpdate resumes. + -- * The OnUpdate accumulates PHASE (not absolute elapsed), so changing + -- the period changes how fast the phase advances but never makes the + -- phase value jump — the fade is never clipped or restarted mid-cycle. + if anim.type == "DF_PULSATE" and border.activeAnimation == "DF_PULSATE" then + local rawFreq = (anim.frequency and anim.frequency > 0) and anim.frequency or 1 + border._dfPulsatePeriod = 2 / rawFreq + border._animHash = newHash + return + end + -- Always clear before starting — see "Stop semantics" in the section + -- header above. StopAnimation NILs border._animHash, so the hash MUST be + -- stamped AFTER it — otherwise every full start leaves the hash nil and the + -- next Apply (AD re-applies ~3×/sec via the expiring ticker) mismatches and + -- restarts the effect, making LCG glows (PROC etc.) flash over and over. + self:StopAnimation(border) + border._animHash = newHash + + -- LCG-driven effects (PULSATE / CHASE / FLASH). Glow target is the + -- shared animRect (positioned by anim.inset / anim.offsetX/Y), so glow + -- inset/offset works the same way as overlay inset/offset. Pulsate's + -- `mask` (the dark backing card) is OFF by default now — earlier rev + -- passed `true` unconditionally, which produced a visible dark square + -- behind the particle ring that users didn't want. + local LCG = getLCG() + if LCG and (anim.type == "PULSATE" or anim.type == "CHASE" or anim.type == "FLASH" or anim.type == "PROC") then + local target = ensureAnimRect(border, anim.inset, anim.offsetX, anim.offsetY) + local key = "DFBorder" + local color + if anim.color then + local r, g, b, a = readColor(anim.color) + color = { r, g, b, a } + end + -- The Animation Frequency slider can now reach 0 (so DF_DASH can be + -- static). LCG glows treat 0 as invalid, so pass nil → LCG uses its + -- own default rate for these effects. + local freq = (anim.frequency and anim.frequency > 0) and anim.frequency or nil + if anim.type == "PULSATE" then + -- PixelGlow `border` arg: false → no outer mask. anim.mask = true + -- restores the backing card for users who want that look. + local mask = anim.mask and true or false + LCG.PixelGlow_Start(target, color, anim.particles, freq, + anim.length, anim.thickness, 0, 0, mask, key) + elseif anim.type == "CHASE" then + LCG.AutoCastGlow_Start(target, color, anim.particles, freq, + anim.scale, 0, 0, key) + elseif anim.type == "FLASH" then + LCG.ButtonGlow_Start(target, color, freq) + elseif anim.type == "PROC" then + -- ProcGlow takes an options table; map frequency → duration + -- (1/freq = seconds-per-cycle) so its slider behaves like the + -- other effects' Frequency control (cycles per second). + local duration = (anim.frequency and anim.frequency > 0) + and (1 / anim.frequency) or 1 + LCG.ProcGlow_Start(target, { + color = color, + duration = duration, + startAnim = true, + key = key, + }) + end + border.activeAnimation = anim.type + return + end + + -- Custom OnUpdate effects — render their own overlay textures, so the + -- effect's visibility doesn't depend on the border's own thickness. + local tick = customTicks[anim.type] + if tick then + setupAnimOverlay(border, anim) + local d = ensureDriver(border) + d.elapsed = 0 + d:Show() + d:SetScript("OnUpdate", function(self, dt) + self.elapsed = (self.elapsed or 0) + dt + tick(border, anim, self.elapsed) + end) + border.activeAnimation = anim.type + return + end + + -- DF Pulsate: soft alpha fade pulse on the border's 4 edges. Distinct + -- from the LCG-driven Pulsate (which surrounds the border with a + -- particle ring) — DF_PULSATE keeps the border itself visible and just + -- fades its opacity smoothly between 0.05 and 1.0. Inherited from + -- AD's legacy expiring border pulse; exposed as a first-class animation + -- type so it works as either a continuous Border Animation OR as the + -- value the new Expiring Animation dropdown will swap in below + -- threshold (Stage 5.1d.2+). Uses ensureDriver's OnUpdate frame; on + -- StopAnimation the existing resetEdgeAlphas() restores the edges + -- back to alpha 1 so the next render is clean. + if anim.type == "DF_PULSATE" then + -- Frequency mapping is per-type. LCG glow types interpret frequency + -- as cycles-per-second of a particle animation; that maps 1:1 to the + -- slider. DF_PULSATE is a gentle alpha fade and reads better at + -- ~half that rate, so we use period = 2 / freq. Result: + -- slider 0.5 → 4 s cycle (slow, ambient) + -- slider 1.0 → 2 s cycle (matches the old AD legacy pulse rate) + -- slider 2.0 → 1 s cycle (snappy) + -- slider 4.0 → 0.5 s cycle (urgent) + -- Users still get the full slider range; the scale just shifts so the + -- default settles on a comfortable 2-second cycle. + local rawFreq = (anim.frequency and anim.frequency > 0) and anim.frequency or 1 + -- Store period as a FIELD (not a closure upvalue) so the retune-in-place + -- path at the top of StartAnimation can change the pulse speed on the + -- already-running driver without re-SetScript'ing. + border._dfPulsatePeriod = 2 / rawFreq + local d = ensureDriver(border) + d:Show() + -- Advance a PHASE accumulator in [0,1) by dt/period each frame rather + -- than deriving phase from absolute elapsed. Two consequences: + -- * Changing the period (frequency) only changes how fast the phase + -- advances — the phase value itself stays continuous, so the fade + -- never jumps or clips when the user drags Frequency. + -- * The phase persists on the border across genuine restarts, so a + -- NONE→DF_PULSATE or other→DF_PULSATE transition resumes the pulse + -- from where it left off instead of snapping to the dim trough. + -- wave = (1 - cos(2π·phase)) / 2 is a smooth 0→1→0 (full→low→full) + -- curve with zero-slope endpoints, so each cycle blends seamlessly + -- into the next with no visible seam at the loop point. + d:SetScript("OnUpdate", function(self, dt) + local p = border._dfPulsatePeriod or 2 + local ph = ((border._dfPulsatePhase or 0) + dt / p) % 1 + border._dfPulsatePhase = ph + local wave = (1 - math.cos(ph * 2 * math.pi)) * 0.5 + -- Fade between 0.05 (dim trough) and 1.0 (full) — a gentle pulse. + border:SetAlpha(0.05 + 0.95 * wave) + end) + border.activeAnimation = anim.type + return + end + + -- DF Dash: a dashed border, static or marching. Animation Frequency is the + -- march SPEED — 0 = static ("dashed"), > 0 = marching ants ("animated"). + -- Dashes use the animation's own colour / thickness / inset (so a + -- dashes-only look = base Border Thickness 0 + this effect). + if anim.type == "DF_DASH" then + -- Store the dash params as FIELDS so RecolorActive can recolour a + -- running DF_DASH in place (the expiring ticker recolours ~3×/sec; a + -- restart would tear down + redraw every dash each tick). + local r, g, b, a = readColor(anim.color or { r = 0.95, g = 0.95, b = 0.32, a = 1 }) + border._dfDashTh = math.max(1, anim.thickness or 2) + border._dfDashInset = anim.inset or 0 + border._dfDashR, border._dfDashG, border._dfDashB, border._dfDashA = r, g, b, a + local rawFreq = anim.frequency or 0 + local marchSpeed = (rawFreq and rawFreq > 0) and (rawFreq * DF_DASH_SPEED) or 0 + if marchSpeed > 0 then + -- Marching: OnUpdate advances the offset, reading colour/size from + -- the fields so a live recolour is picked up next tick. elapsed + -- persists across restarts so a spec change doesn't snap the ants. + local d = ensureDriver(border) + d.elapsed = border._dfDashElapsed or 0 + d:Show() + d:SetScript("OnUpdate", function(self, dt) + self.elapsed = (self.elapsed or 0) + dt + border._dfDashElapsed = self.elapsed + local offset = (self.elapsed * marchSpeed) % DF_DASH_PATTERN + drawDashes(border, offset, border._dfDashTh, border._dfDashInset, + border._dfDashR, border._dfDashG, border._dfDashB, border._dfDashA) + end) + else + -- Static: draw once, no driver (cheaper). + drawDashes(border, 0, border._dfDashTh, border._dfDashInset, r, g, b, a) + end + border.activeAnimation = anim.type + return + end + + -- Static shape modes — also render via overlays (not the border edges + -- themselves) so they're visible at borderSize 1. + if anim.type == "SIDES_ONLY" then + applySidesOnly(border, anim) + border.activeAnimation = anim.type + elseif anim.type == "CORNERS_ONLY" then + applyCornersOnly(border, anim) + border.activeAnimation = anim.type + end +end + +-- Recolour the border AND whatever animation is currently running, WITHOUT a +-- restart. The expiring ticker calls this ~3×/sec; routing through +-- StartAnimation would re-hash, Stop (tearing down every dash / overlay) and +-- redraw each tick. Recolours: base edges (via SetColor), DF_DASH dashes +-- (field + live textures), CORNERS_ONLY / SIDES_ONLY corner-overlay textures, +-- and the WIPE/RIPPLE/SEGMENT_REVEAL overlays. LCG glows (Pulsate/Chase/ +-- Flash/Proc) can't be recoloured live by LCG, so they keep their colour (the +-- expiring tint still applies to the edges underneath). +function Border:RecolorActive(border, r, g, b, a) + if not border then return end + a = a or 1 + if border.SetColor then border:SetColor(r, g, b, a) end + local active = border.activeAnimation + if active == "DF_DASH" then + border._dfDashR, border._dfDashG, border._dfDashB, border._dfDashA = r, g, b, a + if border.dashPool then + for _, edge in pairs(border.dashPool) do + for _, d in ipairs(edge) do + if d:IsShown() then d:SetColorTexture(r, g, b, a) end + end + end + end + elseif active == "CORNERS_ONLY" or active == "SIDES_ONLY" then + if border.cornerOverlays then + for _, e in pairs(border.cornerOverlays) do e:SetColorTexture(r, g, b, a) end + end + if border.animOverlay then + for _, e in pairs(border.animOverlay) do e:SetColorTexture(r, g, b, a) end + end + elseif border.animOverlay then + for _, e in pairs(border.animOverlay) do e:SetColorTexture(r, g, b, a) end + end +end + +-- (Re)configure a border widget from a spec. +-- spec: +-- enabled false hides the border entirely (default: true) +-- style "SOLID" | "GRADIENT" | "TEXTURE" (default: "SOLID"). +-- GRADIENT and TEXTURE are mutually exclusive presentations of +-- the border — the GUI exposes them all in a single Border +-- Style dropdown so only one can be active at a time. +-- texture LibSharedMedia border key (used only in TEXTURE style) +-- size edge thickness / backdrop edgeSize (default: 1) +-- color {r,g,b,a} or {r=,g=,b=,a=}; alpha lives in the colour +-- inset signed pixels: positive moves edges INSIDE the parent's +-- bounds; negative moves them outside. Default 0 (edges flush +-- with parent corners as set up in :New). Honoured only in +-- the SOLID 4-edge mode — backdrop-template mode anchors the +-- backdrop child via SetPoint(-1,1)/(1,-1) implicitly. +-- offsetX signed pixels: translates the WHOLE border widget along the +-- X axis (positive = right). Independent of `inset`, which +-- changes the border's relationship to its own bounds. +-- offsetY signed pixels: translates the WHOLE border widget along the +-- Y axis (positive = up, matching WoW UI convention used by +-- other DF offset sliders). Works in both SOLID and TEXTURE +-- modes because we translate the widget itself, not the edges. +-- blendMode "BLEND" (default) | "ADD" | "DISABLE" | "MOD" — Blizzard +-- texture blend modes. Applied per-edge in SOLID mode. TEXTURE +-- mode renders through a BackdropTemplate whose edge textures +-- aren't directly accessible to SetBlendMode, so the value is +-- silently ignored there. +-- gradient Optional. { enabled = true, startColor, endColor, +-- direction = "HORIZONTAL"|"VERTICAL" }. When enabled, the two +-- edges parallel to the gradient axis use Texture:SetGradient; +-- the two perpendicular edges paint as solid startColor (one +-- side) and endColor (the other), so the overall border reads +-- as one continuous gradient across the unit. SOLID mode only; +-- TEXTURE mode ignores. When disabled/missing, spec.color is +-- used as a normal solid border. +-- shadow Optional. { enabled = true, color, size, offsetX, offsetY }. +-- A solid 4-edge ring rendered one frameLevel below the +-- border itself, translated by (offsetX, offsetY) relative to +-- the border's own anchorTo. Independent of border mode: a +-- textured border still gets a solid shadow ring behind it. +-- The shadow widget is lazy-created on first use and reused +-- thereafter; spec.shadow nil/disabled simply hides it. +-- pixelPerfect snap size and inset to whole screen pixels +function Border:Apply(border, spec) + if not border then return end + spec = spec or {} + local edges = { border.top, border.bottom, border.left, border.right } + + -- Translate the whole border widget by (offsetX, offsetY). Two opposite + -- corners fully constrain a rectangle in WoW, so two SetPoint calls suffice + -- and idempotently replace :New's SetAllPoints when offsets are zero. + local offsetX = spec.offsetX or 0 + local offsetY = spec.offsetY or 0 + if border.anchorTo then + border:ClearAllPoints() + border:SetPoint("TOPLEFT", border.anchorTo, "TOPLEFT", offsetX, offsetY) + border:SetPoint("BOTTOMRIGHT", border.anchorTo, "BOTTOMRIGHT", offsetX, offsetY) + end + + -- Hidden border: hide both modes (after the offset re-anchor so a later + -- :Apply that re-enables the border picks up the same translation). + if spec.enabled == false then + for _, e in ipairs(edges) do if e then e:Hide() end end + if border.bd then border.bd:Hide() end + border.activeTexture = nil + -- Tear down any running glow when the border is hidden, otherwise + -- the LCG glow keeps rendering around the unit with no visible + -- border underneath it. + self:StopAnimation(border) + return + end + + local size = spec.size or 1 + local inset = spec.inset or 0 + if spec.pixelPerfect and DF.PixelPerfect then + size = DF:PixelPerfect(size) + if inset ~= 0 then inset = DF:PixelPerfect(inset) end + end + local cr, cg, cb, ca = readColor(spec.color) + + -- Style drives the render path: SOLID (4 colour edges), GRADIENT (4 edges + -- with two carrying SetGradient and two solid in the start/end colours), + -- TEXTURE (BackdropTemplate edgeFile). TEXTURE silently falls back to + -- SOLID if the LSM key can't be resolved, so the border never vanishes. + local style = spec.style or "SOLID" + local texture = spec.texture + local edgeFile = (style == "TEXTURE" and texture and texture ~= "" and texture ~= "SOLID" and DF.GetBorderTexturePath) + and DF:GetBorderTexturePath(texture) or nil + + if not edgeFile then + -- SOLID or GRADIENT — both render via the 4-edge mode. Texture mode + -- silently degrades to SOLID here when the LSM key isn't resolvable. + border.activeTexture = nil + if border.bd then border.bd:Hide() end + + -- Re-anchor edges so inset takes effect (and so going inset != 0 → 0 + -- restores the flush layout). Done on every Apply: it's four cheap + -- SetPoint pairs and avoids a "needs ClearAllPoints first time only" + -- footgun. The corner overlap pattern (top/bottom span the full width; + -- left/right are inset by `size` at top/bottom) matches :New's defaults. + border.top:ClearAllPoints() + border.top:SetPoint("TOPLEFT", inset, -inset) + border.top:SetPoint("TOPRIGHT", -inset, -inset) + border.top:SetHeight(size) + + border.bottom:ClearAllPoints() + border.bottom:SetPoint("BOTTOMLEFT", inset, inset) + border.bottom:SetPoint("BOTTOMRIGHT", -inset, inset) + border.bottom:SetHeight(size) + + border.left:ClearAllPoints() + border.left:SetPoint("TOPLEFT", inset, -inset - size) + border.left:SetPoint("BOTTOMLEFT", inset, inset + size) + border.left:SetWidth(size) + + border.right:ClearAllPoints() + border.right:SetPoint("TOPRIGHT", -inset, -inset - size) + border.right:SetPoint("BOTTOMRIGHT", -inset, inset + size) + border.right:SetWidth(size) + + local blendMode = spec.blendMode or "BLEND" + -- Remember it so SetColor (live recolour, e.g. expiring / OOR) can + -- re-assert it — SetColorTexture can drop a non-default blend mode. + border._blendMode = blendMode + local gradient = spec.gradient + if style == "GRADIENT" and gradient and CreateColor then + -- Two parallel edges carry the gradient via SetGradient; the two + -- perpendicular edges are painted in pure startColor / endColor + -- so the four edges read as one continuous gradient. + local sr, sg, sb, sa = readColor(gradient.startColor) + local er, eg, eb, ea = readColor(gradient.endColor) + local startMixin = CreateColor(sr, sg, sb, sa) + local endMixin = CreateColor(er, eg, eb, ea) + local direction = gradient.direction or "HORIZONTAL" + + -- Treat every edge — gradient-bearing OR solid cap — through the + -- SAME two-call pattern: SetColorTexture(white) base, then + -- SetGradient with the stops. For a solid cap, the stops are the + -- same colour twice, which renders solid. This avoids the + -- order-dependent SetColorTexture↔SetGradient interaction that + -- left stale gradient state visible when swapping directions in + -- the GUI (visible as "side caps with a horizontal gradient" in + -- VERTICAL mode after the user had been on HORIZONTAL). + local solidStart = CreateColor(sr, sg, sb, sa) + local solidEnd = CreateColor(er, eg, eb, ea) + + for _, e in ipairs(edges) do + e:SetColorTexture(1, 1, 1, 1) + end + + if direction == "HORIZONTAL" then + -- WoW HORIZONTAL: min = LEFT, max = RIGHT. start→end naturally + -- maps to left→right, no swap. + border.top:SetGradient( "HORIZONTAL", startMixin, endMixin) + border.bottom:SetGradient("HORIZONTAL", startMixin, endMixin) + border.left:SetGradient( "HORIZONTAL", solidStart, solidStart) + border.right:SetGradient( "HORIZONTAL", solidEnd, solidEnd) + else + -- WoW VERTICAL: min = BOTTOM, max = TOP. The user picked + -- start expecting it at the TOP of the gradient, so the + -- arguments are swapped relative to HORIZONTAL — endMixin + -- as min (bottom), startMixin as max (top). + border.top:SetGradient( "VERTICAL", solidStart, solidStart) + border.bottom:SetGradient("VERTICAL", solidEnd, solidEnd) + border.left:SetGradient( "VERTICAL", endMixin, startMixin) + border.right:SetGradient( "VERTICAL", endMixin, startMixin) + end + for _, e in ipairs(edges) do + e:SetBlendMode(blendMode) + e:Show() + end + else + -- Clear any leftover gradient state from a prior gradient-mode call + -- before reverting to solid. Setting a constant-colour gradient is + -- the reliable cross-version way to do this; SetColorTexture alone + -- can leave the previous min/max colour interpolation in place on + -- some Blizzard texture pipelines. + -- solidOnly borders never enter the GRADIENT branch, so there's + -- nothing to clear — skip it so the edges carry no gradient and a + -- later bare-SetColorTexture recolour stays clean and secret-safe. + if not border._solidOnly and CreateColor then + local solid = CreateColor(cr, cg, cb, ca) + for _, e in ipairs(edges) do + if e.SetGradient then e:SetGradient("HORIZONTAL", solid, solid) end + end + end + for _, e in ipairs(edges) do + e:SetColorTexture(cr, cg, cb, ca) + e:SetBlendMode(blendMode) + e:Show() + end + end + else + -- Texture mode: a BackdropTemplate child with the LSM border edgeFile. + -- spec.blendMode is intentionally ignored here — see doc above. + for _, e in ipairs(edges) do if e then e:Hide() end end + if not border.bd then + border.bd = CreateFrame("Frame", nil, border, "BackdropTemplate") + border.bd:SetAllPoints(border) + end + local bd = border.bd + bd:SetBackdrop({ edgeFile = edgeFile, edgeSize = (size > 0 and size) or 1 }) + bd:SetBackdropBorderColor(cr, cg, cb, ca) + bd:Show() + border.activeTexture = texture + end + + -- Drop shadow: solid 4-edge ring, lazy-created, parented next to the + -- border. Within the border's frame level, the BACKGROUND draw layer + -- puts the shadow behind the BORDER-layer edge textures — so the + -- shadow reads as "behind the border" without needing a lower frame + -- level. Earlier rev used border.level - 1 here, but that broke for + -- StatusBar consumers (Resource Bar) where the bar's own statusbar + -- texture sits at the bar's frame level and the shadow at bar.level + -- ended up rendering BEHIND the opaque bar fill — invisible on + -- in-range units, only peeking through when the bar's alpha dropped + -- on OOR. Matching border.level lifts the shadow above the bar fill + -- on all consumers without affecting Frame Border (its parent has + -- no fill texture). + local shadow = spec.shadow + if shadow and shadow.enabled then + local sf = border.shadow + if not sf then + sf = CreateFrame("Frame", nil, border:GetParent() or border) + sf.top = sf:CreateTexture(nil, "BACKGROUND") + sf.bottom = sf:CreateTexture(nil, "BACKGROUND") + sf.left = sf:CreateTexture(nil, "BACKGROUND") + sf.right = sf:CreateTexture(nil, "BACKGROUND") + border.shadow = sf + end + -- Re-sync the frame level every Apply because the border's level + -- can be changed by consumer code AFTER Border:New (Resource Bar + -- does this in ApplyResourceBarLayout). One-shot-at-creation + -- left shadow stale at the pre-override level. + sf:SetFrameLevel(border:GetFrameLevel()) + + local shadowSize = shadow.size or 1 + local shadowOX = shadow.offsetX or 0 + local shadowOY = shadow.offsetY or 0 + if spec.pixelPerfect and DF.PixelPerfect then + shadowSize = DF:PixelPerfect(shadowSize) + end + local shr, shg, shb, sha = readColor(shadow.color) + + -- Anchor the shadow widget to the border's own bounds + shadow offset. + sf:ClearAllPoints() + sf:SetPoint("TOPLEFT", border, "TOPLEFT", shadowOX, shadowOY) + sf:SetPoint("BOTTOMRIGHT", border, "BOTTOMRIGHT", shadowOX, shadowOY) + + -- Layout the four shadow edges (same pattern as solid border edges). + sf.top:ClearAllPoints() + sf.top:SetPoint("TOPLEFT", 0, 0) + sf.top:SetPoint("TOPRIGHT", 0, 0) + sf.top:SetHeight(shadowSize) + sf.top:SetColorTexture(shr, shg, shb, sha) + + sf.bottom:ClearAllPoints() + sf.bottom:SetPoint("BOTTOMLEFT", 0, 0) + sf.bottom:SetPoint("BOTTOMRIGHT", 0, 0) + sf.bottom:SetHeight(shadowSize) + sf.bottom:SetColorTexture(shr, shg, shb, sha) + + sf.left:ClearAllPoints() + sf.left:SetPoint("TOPLEFT", 0, -shadowSize) + sf.left:SetPoint("BOTTOMLEFT", 0, shadowSize) + sf.left:SetWidth(shadowSize) + sf.left:SetColorTexture(shr, shg, shb, sha) + + sf.right:ClearAllPoints() + sf.right:SetPoint("TOPRIGHT", 0, -shadowSize) + sf.right:SetPoint("BOTTOMRIGHT", 0, shadowSize) + sf.right:SetWidth(shadowSize) + sf.right:SetColorTexture(shr, shg, shb, sha) + + sf:Show() + elseif border.shadow then + border.shadow:Hide() + end + + -- Animation: presence of spec.animation drives Start, absence drives + -- Stop. Stop is also called when the border is hidden (spec.enabled + -- false handled earlier returns before this point), so re-disabling the + -- border tears down any running glow. + if spec.animation then + self:StartAnimation(border, spec) + else + self:StopAnimation(border) + end +end diff --git a/Frames/Core.lua b/Frames/Core.lua index 7e926747..f9d84535 100644 --- a/Frames/Core.lua +++ b/Frames/Core.lua @@ -651,6 +651,15 @@ local BLIZZARD_ROLE_COORDS = { DAMAGER = {0.296875, 0.59375, 0.296875, 0.65}, } +-- Modern micro role atlases — sharper than the legacy PORTRAITROLES texcoord +-- crop. Preferred when present; GetRoleIconTexture falls back to the legacy +-- texture below so older / edge-case clients still render. +local BLIZZARD_ROLE_ATLAS = { + TANK = "UI-LFG-RoleIcon-Tank-Micro", + HEALER = "UI-LFG-RoleIcon-Healer-Micro", + DAMAGER = "UI-LFG-RoleIcon-DPS-Micro", +} + function DF:GetRoleIconTexture(db, role) local style = db.roleIconStyle or "BLIZZARD" @@ -676,8 +685,79 @@ function DF:GetRoleIconTexture(db, role) if style == "CUSTOM" then return ROLE_ICON_TEXTURES[role], 0, 1, 0, 1 else - -- BLIZZARD + -- BLIZZARD — prefer the modern micro role atlas, fall back to the legacy + -- portrait-roles texture + texcoords when the atlas isn't available. + local atlas = BLIZZARD_ROLE_ATLAS[role] + if atlas and C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(atlas) then + return atlas -- atlas name, no texcoords + end local c = BLIZZARD_ROLE_COORDS[role] return "Interface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES", c[1], c[2], c[3], c[4] end end + +-- Sets a texture region from EITHER a Blizzard atlas name or a texture file +-- path, preferring the atlas (sharper, modern) when the name resolves and +-- falling back to the file path otherwise. Mirrors oUF's approach so status +-- icons render crisply on current clients but never break on older ones. +-- value : atlas name OR texture path +-- l,r,t,b : optional tex coords, applied only on the texture-file path +function DF:SetIconTextureOrAtlas(region, value, l, r, t, b) + if not region or not value then return end + if C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(value) then + region:SetAtlas(value) + else + region:SetTexture(value) + if l then + region:SetTexCoord(l, r, t, b) + else + region:SetTexCoord(0, 1, 0, 1) + end + end +end + +-- Legacy raid-frame status icon textures → their modern atlas equivalents. +-- The atlas versions (used by Blizzard's own raid frames) are sharper; we keep +-- the legacy path as the lookup key AND the fallback so nothing breaks if an +-- atlas is ever absent. +local LEGACY_STATUS_ICON_ATLAS = { + ["Interface\\RaidFrame\\ReadyCheck-Ready"] = "UI-LFG-ReadyMark-Raid", + ["Interface\\RaidFrame\\ReadyCheck-NotReady"] = "UI-LFG-DeclineMark-Raid", + ["Interface\\RaidFrame\\ReadyCheck-Waiting"] = "UI-LFG-PendingMark-Raid", + ["Interface\\RaidFrame\\Raid-Icon-SummonPending"] = "RaidFrame-Icon-SummonPending", + ["Interface\\RaidFrame\\Raid-Icon-SummonAccepted"] = "RaidFrame-Icon-SummonAccepted", + ["Interface\\RaidFrame\\Raid-Icon-SummonDeclined"] = "RaidFrame-Icon-SummonDeclined", + ["Interface\\RaidFrame\\Raid-Icon-Rez"] = "RaidFrame-Icon-Rez", + ["Interface\\TargetingFrame\\UI-PhasingIcon"] = "RaidFrame-Icon-Phasing", + ["Interface\\LFGFrame\\LFG-Eye"] = "RaidFrame-Icon-LFR", + ["Interface\\FriendsFrame\\StatusIcon-Away"] = "characterupdate_clock-icon", + ["Interface\\Vehicles\\UI-Vehicles-Raid-Icon"] = "RaidFrame-Icon-Vehicle", + ["Interface\\GroupFrame\\UI-Group-MainTankIcon"] = "RaidFrame-Icon-MainTank", + ["Interface\\GroupFrame\\UI-Group-MainAssistIcon"] = "RaidFrame-Icon-MainAssist", +} + +-- Render a known legacy status-icon texture as its modern atlas (sharper), +-- falling back to the legacy file when the atlas isn't available. Owns the +-- texcoord state, so call sites must NOT add a trailing SetTexCoord — passing +-- the legacy path as the single source of truth is enough. +function DF:SetUpgradedStatusIcon(region, legacyTexture) + if not region or not legacyTexture then return end + local atlas = LEGACY_STATUS_ICON_ATLAS[legacyTexture] + if atlas and C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(atlas) then + region:SetAtlas(atlas) + else + region:SetTexture(legacyTexture) + region:SetTexCoord(0, 1, 0, 1) + end +end + +-- Icon-section header previews (options) register a refresher here. Hooked +-- frame-update functions call DF:RefreshIconPreviews so previews track live +-- setting changes (enable/text toggles). Each refresher self-guards on its +-- section being visible, so this is nearly free when options are closed. +DF.iconPreviewRefreshers = {} +function DF:RefreshIconPreviews() + local list = self.iconPreviewRefreshers + if not list then return end + for i = 1, #list do list[i]() end +end diff --git a/Frames/Create.lua b/Frames/Create.lua index d956218f..edb960d3 100755 --- a/Frames/Create.lua +++ b/Frames/Create.lua @@ -40,103 +40,35 @@ DFBindingTooltipTextLeft1:SetFontObject(GameTooltipText) -- ============================================================ -- FRAME BORDER WIDGET --- frame.border supports two modes sharing one SetBorderColor API: --- * Solid (default): four ColorTexture edges — pixel-perfect, unchanged. --- * Texture: a BackdropTemplate child using a LibSharedMedia border edgeFile. --- DF:ApplyFrameBorder reconfigures the active mode from the db; recolour --- consumers (live colour update, etc.) call frame.border:SetBorderColor and it --- routes to whichever mode is active. +-- The frame border is built on the unified DF.Border backend (Frames/Border.lua). +-- frame.border keeps its established shape: top/bottom/left/right edges, a lazy +-- `bd` backdrop child for Texture style, and a :SetBorderColor method that +-- routes to whichever mode is active (used by live colour / aggro / dispel +-- overlays). These thin wrappers translate the frame DB into a border spec. -- ============================================================ function DF:CreateFrameBorder(frame, db) - local border = CreateFrame("Frame", nil, frame) - border:SetAllPoints() - border:SetFrameLevel(frame:GetFrameLevel() + 10) - - border.top = border:CreateTexture(nil, "BORDER") - border.top:SetPoint("TOPLEFT", 0, 0) - border.top:SetPoint("TOPRIGHT", 0, 0) - border.bottom = border:CreateTexture(nil, "BORDER") - border.bottom:SetPoint("BOTTOMLEFT", 0, 0) - border.bottom:SetPoint("BOTTOMRIGHT", 0, 0) - border.left = border:CreateTexture(nil, "BORDER") - border.left:SetPoint("TOPLEFT", 0, 0) - border.left:SetPoint("BOTTOMLEFT", 0, 0) - border.right = border:CreateTexture(nil, "BORDER") - border.right:SetPoint("TOPRIGHT", 0, 0) - border.right:SetPoint("BOTTOMRIGHT", 0, 0) - - -- Recolour whichever mode is currently active (used by live colour updates, - -- aggro/threat/dispel overlays, etc.). - border.SetBorderColor = function(self, r, g, b, a) - a = a or 1 - if self.activeTexture then - if self.bd then self.bd:SetBackdropBorderColor(r, g, b, a) end - else - self.top:SetColorTexture(r, g, b, a) - self.bottom:SetColorTexture(r, g, b, a) - self.left:SetColorTexture(r, g, b, a) - self.right:SetColorTexture(r, g, b, a) - end - end - - frame.border = border + frame.border = DF.Border:New(frame) DF:ApplyFrameBorder(frame, db) - return border + return frame.border end function DF:ApplyFrameBorder(frame, db) - local border = frame and frame.border - if not border then return end + if not frame or not frame.border then return end db = db or (DF.GetFrameDB and DF:GetFrameDB(frame)) if not db then return end - local edges = { border.top, border.bottom, border.left, border.right } - - -- Hidden border: hide both modes. - if db.showFrameBorder == false then - for _, e in ipairs(edges) do if e then e:Hide() end end - if border.bd then border.bd:Hide() end - border.activeTexture = nil - return - end - - local size = db.borderSize or 1 - if db.pixelPerfect and DF.PixelPerfect then size = DF:PixelPerfect(size) end - local cr, cg, cb, ca = DF:GetFrameBorderColor(frame, db) - -- Only resolve an LSM edgeFile when the Texture style is selected; otherwise - -- fall through to the built-in solid four-edge border below. - local style = db.borderStyle or "SOLID" - local texture = db.borderTexture - local edgeFile = (style == "TEXTURE" and texture and texture ~= "" and texture ~= "SOLID" and DF.GetBorderTexturePath) - and DF:GetBorderTexturePath(texture) or nil - - if not edgeFile then - -- Solid mode (default), or a texture that couldn't be resolved — fall - -- back to solid so the border never silently vanishes. - border.activeTexture = nil - if border.bd then border.bd:Hide() end - border.top:SetHeight(size) - border.bottom:SetHeight(size) - border.left:SetWidth(size) - border.right:SetWidth(size) - for _, e in ipairs(edges) do - e:SetColorTexture(cr, cg, cb, ca) - e:Show() - end - else - -- Texture mode: a BackdropTemplate child with the LSM border edgeFile. - for _, e in ipairs(edges) do if e then e:Hide() end end - if not border.bd then - border.bd = CreateFrame("Frame", nil, border, "BackdropTemplate") - border.bd:SetAllPoints(border) - end - local bd = border.bd - bd:SetBackdrop({ edgeFile = edgeFile, edgeSize = (size > 0 and size) or 1 }) - bd:SetBackdropBorderColor(cr, cg, cb, ca) - bd:Show() - border.activeTexture = texture - end + -- ctx lets BuildSpec resolve class / role colours via Border:Resolve* + -- helpers when the source is CLASS or ROLE. Resolvers fall through to + -- the static frameBorderColor picker when ctx is missing. + -- ctx.frame is required for test-mode preview: test frames have no + -- real unit, but they carry dfIsTestFrame + index + isRaidFrame so + -- the resolvers can pull class/role from GetTestUnitData (Stage 4.0 + -- wired this for Defensive Icon; Frame Border was missed at the time). + DF.Border:Apply(frame.border, DF.Border:BuildSpec(db, "frame", { + unit = frame.unit, + frame = frame, + })) end local BINDING_SHORT_NAMES = { @@ -988,37 +920,22 @@ function DF:CreateFrameElementsExtended(frame, db) frame.missingBuffFrame:SetSize(24, 24) frame.missingBuffFrame:SetPoint("CENTER", frame, "CENTER", 0, 0) frame.missingBuffFrame:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 10) - + + -- Unified border (Stage 4.1 — replaces the hand-rolled 4-edge block). + -- DF.Border:Apply at render time owns size / colour / style / gradient / + -- shadow / animation per the missingBuffIcon* db keys. + -- frameLevelOffset 0: keep the border co-planar with the icon (like the AD + -- icons and alpha2, which drew the border on the icon frame itself). The + -- default +10 floats it ABOVE same-level aura icons while the art stays + -- below them — a visible layering split where the icon overlaps auras. + frame.missingBuffBorder = DF.Border:New(frame.missingBuffFrame, { frameLevelOffset = 0 }) + local mbBorderSize = 2 - frame.missingBuffBorderLeft = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderLeft:SetPoint("TOPLEFT", 0, 0) - frame.missingBuffBorderLeft:SetPoint("BOTTOMLEFT", 0, 0) - frame.missingBuffBorderLeft:SetWidth(mbBorderSize) - frame.missingBuffBorderLeft:SetColorTexture(1, 0, 0, 1) - - frame.missingBuffBorderRight = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderRight:SetPoint("TOPRIGHT", 0, 0) - frame.missingBuffBorderRight:SetPoint("BOTTOMRIGHT", 0, 0) - frame.missingBuffBorderRight:SetWidth(mbBorderSize) - frame.missingBuffBorderRight:SetColorTexture(1, 0, 0, 1) - - frame.missingBuffBorderTop = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderTop:SetPoint("TOPLEFT", mbBorderSize, 0) - frame.missingBuffBorderTop:SetPoint("TOPRIGHT", -mbBorderSize, 0) - frame.missingBuffBorderTop:SetHeight(mbBorderSize) - frame.missingBuffBorderTop:SetColorTexture(1, 0, 0, 1) - - frame.missingBuffBorderBottom = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderBottom:SetPoint("BOTTOMLEFT", mbBorderSize, 0) - frame.missingBuffBorderBottom:SetPoint("BOTTOMRIGHT", -mbBorderSize, 0) - frame.missingBuffBorderBottom:SetHeight(mbBorderSize) - frame.missingBuffBorderBottom:SetColorTexture(1, 0, 0, 1) - frame.missingBuffIcon = frame.missingBuffFrame:CreateTexture(nil, "ARTWORK") frame.missingBuffIcon:SetPoint("TOPLEFT", mbBorderSize, -mbBorderSize) frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", -mbBorderSize, mbBorderSize) frame.missingBuffIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) - + frame.missingBuffFrame:Hide() -- ======================================== @@ -1027,34 +944,23 @@ function DF:CreateFrameElementsExtended(frame, db) frame.defensiveIcon = CreateFrame("Frame", nil, frame.contentOverlay) frame.defensiveIcon:SetSize(24, 24) frame.defensiveIcon:SetPoint("CENTER", frame, "CENTER", 0, 0) - frame.defensiveIcon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 15) + -- +26 (not +15): sit above the buff/debuff auras AND their +25 borders, so + -- the defensive alert is never obscured. Core.lua's auto re-level matches. + frame.defensiveIcon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 26) frame.defensiveIcon:Hide() + -- Border built on the unified DF.Border backend (Frames/Border.lua). + -- Live re-style (size / colour / style / texture) goes through + -- DF.Border:Apply in Core.lua's LightweightUpdateDefensiveIcon* helpers. + -- frameLevelOffset 0: co-planar with the icon (matches the AD icons and + -- alpha2, which drew the defensive border on the icon frame's BACKGROUND). + -- The default +10 floated the border above same-level aura icons while the + -- art stayed below them — the visible layering split when it overlaps auras. + frame.defensiveIcon.border = DF.Border:New(frame.defensiveIcon, { frameLevelOffset = 0 }) + + -- Artwork is inset by the current border size; the Lightweight* helpers + -- re-anchor when borderSize changes. defBorderSize seeds the default. local defBorderSize = 2 - frame.defensiveIcon.borderLeft = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderLeft:SetPoint("TOPLEFT", 0, 0) - frame.defensiveIcon.borderLeft:SetPoint("BOTTOMLEFT", 0, 0) - frame.defensiveIcon.borderLeft:SetWidth(defBorderSize) - frame.defensiveIcon.borderLeft:SetColorTexture(0, 0.8, 0, 1) - - frame.defensiveIcon.borderRight = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderRight:SetPoint("TOPRIGHT", 0, 0) - frame.defensiveIcon.borderRight:SetPoint("BOTTOMRIGHT", 0, 0) - frame.defensiveIcon.borderRight:SetWidth(defBorderSize) - frame.defensiveIcon.borderRight:SetColorTexture(0, 0.8, 0, 1) - - frame.defensiveIcon.borderTop = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderTop:SetPoint("TOPLEFT", defBorderSize, 0) - frame.defensiveIcon.borderTop:SetPoint("TOPRIGHT", -defBorderSize, 0) - frame.defensiveIcon.borderTop:SetHeight(defBorderSize) - frame.defensiveIcon.borderTop:SetColorTexture(0, 0.8, 0, 1) - - frame.defensiveIcon.borderBottom = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderBottom:SetPoint("BOTTOMLEFT", defBorderSize, 0) - frame.defensiveIcon.borderBottom:SetPoint("BOTTOMRIGHT", -defBorderSize, 0) - frame.defensiveIcon.borderBottom:SetHeight(defBorderSize) - frame.defensiveIcon.borderBottom:SetColorTexture(0, 0.8, 0, 1) - frame.defensiveIcon.texture = frame.defensiveIcon:CreateTexture(nil, "ARTWORK") frame.defensiveIcon.texture:SetPoint("TOPLEFT", defBorderSize, -defBorderSize) frame.defensiveIcon.texture:SetPoint("BOTTOMRIGHT", -defBorderSize, defBorderSize) @@ -1160,17 +1066,11 @@ function DF:CreateFrameElementsExtended(frame, db) powerBg:SetColorTexture(0, 0, 0, 0.8) frame.dfPowerBar.bg = powerBg - -- Power bar border - local powerBorder = CreateFrame("Frame", nil, frame.dfPowerBar, "BackdropTemplate") - powerBorder:SetPoint("TOPLEFT", -1, 1) - powerBorder:SetPoint("BOTTOMRIGHT", 1, -1) - powerBorder:SetBackdrop({ - edgeFile = "Interface\\Buttons\\WHITE8x8", - edgeSize = 1, - }) - powerBorder:SetBackdropBorderColor(0, 0, 0, 1) - powerBorder:Hide() - frame.dfPowerBar.border = powerBorder + -- Power / Resource bar border via the unified DF.Border backend + -- (Stage 4.2). ApplyResourceBarLayout in Frames/Bars.lua drives + -- BuildSpec + Apply on each update; border anchorTo defaults to the + -- bar itself so it surrounds the resource bar's bounds. + frame.dfPowerBar.border = DF.Border:New(frame.dfPowerBar) -- ======================================== -- ABSORB BAR @@ -1670,38 +1570,20 @@ function DF:CreateUnitFrame(unit, index, isRaid) frame.missingBuffFrame:SetSize(24, 24) frame.missingBuffFrame:SetPoint("CENTER", frame, "CENTER", 0, 0) frame.missingBuffFrame:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 10) - - -- Create actual edge borders instead of a background + + -- Unified border (Stage 4.1 — replaces the hand-rolled 4-edge block). + -- frameLevelOffset 0: keep the border co-planar with the icon (like the AD + -- icons and alpha2, which drew the border on the icon frame itself). The + -- default +10 floats it ABOVE same-level aura icons while the art stays + -- below them — a visible layering split where the icon overlaps auras. + frame.missingBuffBorder = DF.Border:New(frame.missingBuffFrame, { frameLevelOffset = 0 }) + local borderSize = 2 - frame.missingBuffBorderLeft = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderLeft:SetPoint("TOPLEFT", 0, 0) - frame.missingBuffBorderLeft:SetPoint("BOTTOMLEFT", 0, 0) - frame.missingBuffBorderLeft:SetWidth(borderSize) - frame.missingBuffBorderLeft:SetColorTexture(1, 0, 0, 1) - - frame.missingBuffBorderRight = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderRight:SetPoint("TOPRIGHT", 0, 0) - frame.missingBuffBorderRight:SetPoint("BOTTOMRIGHT", 0, 0) - frame.missingBuffBorderRight:SetWidth(borderSize) - frame.missingBuffBorderRight:SetColorTexture(1, 0, 0, 1) - - frame.missingBuffBorderTop = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderTop:SetPoint("TOPLEFT", borderSize, 0) - frame.missingBuffBorderTop:SetPoint("TOPRIGHT", -borderSize, 0) - frame.missingBuffBorderTop:SetHeight(borderSize) - frame.missingBuffBorderTop:SetColorTexture(1, 0, 0, 1) - - frame.missingBuffBorderBottom = frame.missingBuffFrame:CreateTexture(nil, "BACKGROUND") - frame.missingBuffBorderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - frame.missingBuffBorderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - frame.missingBuffBorderBottom:SetHeight(borderSize) - frame.missingBuffBorderBottom:SetColorTexture(1, 0, 0, 1) - frame.missingBuffIcon = frame.missingBuffFrame:CreateTexture(nil, "ARTWORK") frame.missingBuffIcon:SetPoint("TOPLEFT", borderSize, -borderSize) frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", -borderSize, borderSize) frame.missingBuffIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) - + frame.missingBuffFrame:Hide() -- ======================================== @@ -1710,35 +1592,24 @@ function DF:CreateUnitFrame(unit, index, isRaid) frame.defensiveIcon = CreateFrame("Frame", nil, frame.contentOverlay) frame.defensiveIcon:SetSize(24, 24) frame.defensiveIcon:SetPoint("CENTER", frame, "CENTER", 0, 0) - frame.defensiveIcon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 15) + -- +26 (not +15): sit above the buff/debuff auras AND their +25 borders, so + -- the defensive alert is never obscured. Core.lua's auto re-level matches. + frame.defensiveIcon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 26) frame.defensiveIcon:Hide() -- Create actual edge borders instead of a background + -- Border built on the unified DF.Border backend (Frames/Border.lua). + -- Live re-style (size / colour / style / texture) goes through + -- DF.Border:Apply in Core.lua's LightweightUpdateDefensiveIcon* helpers. + -- frameLevelOffset 0: co-planar with the icon (matches the AD icons and + -- alpha2, which drew the defensive border on the icon frame's BACKGROUND). + -- The default +10 floated the border above same-level aura icons while the + -- art stayed below them — the visible layering split when it overlaps auras. + frame.defensiveIcon.border = DF.Border:New(frame.defensiveIcon, { frameLevelOffset = 0 }) + + -- Artwork is inset by the current border size; the Lightweight* helpers + -- re-anchor when borderSize changes. defBorderSize seeds the default. local defBorderSize = 2 - frame.defensiveIcon.borderLeft = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderLeft:SetPoint("TOPLEFT", 0, 0) - frame.defensiveIcon.borderLeft:SetPoint("BOTTOMLEFT", 0, 0) - frame.defensiveIcon.borderLeft:SetWidth(defBorderSize) - frame.defensiveIcon.borderLeft:SetColorTexture(0, 0.8, 0, 1) - - frame.defensiveIcon.borderRight = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderRight:SetPoint("TOPRIGHT", 0, 0) - frame.defensiveIcon.borderRight:SetPoint("BOTTOMRIGHT", 0, 0) - frame.defensiveIcon.borderRight:SetWidth(defBorderSize) - frame.defensiveIcon.borderRight:SetColorTexture(0, 0.8, 0, 1) - - frame.defensiveIcon.borderTop = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderTop:SetPoint("TOPLEFT", defBorderSize, 0) - frame.defensiveIcon.borderTop:SetPoint("TOPRIGHT", -defBorderSize, 0) - frame.defensiveIcon.borderTop:SetHeight(defBorderSize) - frame.defensiveIcon.borderTop:SetColorTexture(0, 0.8, 0, 1) - - frame.defensiveIcon.borderBottom = frame.defensiveIcon:CreateTexture(nil, "BACKGROUND") - frame.defensiveIcon.borderBottom:SetPoint("BOTTOMLEFT", defBorderSize, 0) - frame.defensiveIcon.borderBottom:SetPoint("BOTTOMRIGHT", -defBorderSize, 0) - frame.defensiveIcon.borderBottom:SetHeight(defBorderSize) - frame.defensiveIcon.borderBottom:SetColorTexture(0, 0.8, 0, 1) - frame.defensiveIcon.texture = frame.defensiveIcon:CreateTexture(nil, "ARTWORK") frame.defensiveIcon.texture:SetPoint("TOPLEFT", defBorderSize, -defBorderSize) frame.defensiveIcon.texture:SetPoint("BOTTOMRIGHT", -defBorderSize, defBorderSize) @@ -1852,17 +1723,11 @@ function DF:CreateUnitFrame(unit, index, isRaid) powerBg:SetColorTexture(0, 0, 0, 0.8) frame.dfPowerBar.bg = powerBg - -- Power bar border - local powerBorder = CreateFrame("Frame", nil, frame.dfPowerBar, "BackdropTemplate") - powerBorder:SetPoint("TOPLEFT", -1, 1) - powerBorder:SetPoint("BOTTOMRIGHT", 1, -1) - powerBorder:SetBackdrop({ - edgeFile = "Interface\\Buttons\\WHITE8x8", - edgeSize = 1, - }) - powerBorder:SetBackdropBorderColor(0, 0, 0, 1) - powerBorder:Hide() - frame.dfPowerBar.border = powerBorder + -- Power / Resource bar border via the unified DF.Border backend + -- (Stage 4.2). ApplyResourceBarLayout in Frames/Bars.lua drives + -- BuildSpec + Apply on each update; border anchorTo defaults to the + -- bar itself so it surrounds the resource bar's bounds. + frame.dfPowerBar.border = DF.Border:New(frame.dfPowerBar) -- ======================================== -- ABSORB BAR @@ -2367,12 +2232,12 @@ function DF:CreateAuraIcon(parent, index, auraType) local baseLevel = parent:GetFrameLevel() icon:SetFrameLevel(baseLevel + 40) - -- Border - use BACKGROUND layer so icon texture draws ON TOP of it - -- This creates a visible border around the edges where the icon doesn't cover - icon.border = icon:CreateTexture(nil, "BACKGROUND") - PixelUtil.SetPoint(icon.border, "TOPLEFT", icon, "TOPLEFT", -1, 1) - PixelUtil.SetPoint(icon.border, "BOTTOMRIGHT", icon, "BOTTOMRIGHT", 1, -1) - icon.border:SetColorTexture(0, 0, 0, 0.8) + -- Border — a unified DF.Border (solidOnly) is created LAZILY by + -- DF:ConfigureAuraIconBorder the first time this icon's border is enabled, + -- so disabled (e.g. default buff) borders allocate nothing. Stored as + -- `icon.border`; recoloured per aura update via icon.border:SetColor + -- (secret-safe). Show/Hide/SetAlpha/Masque-gate calls work on the frame. + icon.border = nil -- Normal texture - Masque expects this for proper button structure -- Using a 1x1 white pixel that's invisible by default (alpha 0) @@ -2417,68 +2282,30 @@ function DF:CreateAuraIcon(parent, index, auraType) icon.expiringTint:SetBlendMode("ADD") icon.expiringTint:Hide() - -- Expiring border uses two containers: - -- Outer container: alpha controlled by API (visibility: 0 or 1) - -- Inner container: alpha controlled by animation (pulsate: 0.3 to 1) - -- This prevents API SetAlpha from conflicting with animation - - icon.expiringBorderAlphaContainer = CreateFrame("Frame", nil, icon.textOverlay) - icon.expiringBorderAlphaContainer:SetAllPoints(icon) - icon.expiringBorderAlphaContainer:SetFrameLevel(icon.textOverlay:GetFrameLevel()) - icon.expiringBorderAlphaContainer:EnableMouse(false) -- Don't intercept mouse - icon.expiringBorderAlphaContainer:Hide() - - icon.expiringBorderContainer = CreateFrame("Frame", nil, icon.expiringBorderAlphaContainer) - icon.expiringBorderContainer:SetAllPoints(icon) - icon.expiringBorderContainer:SetFrameLevel(icon.expiringBorderAlphaContainer:GetFrameLevel()) - icon.expiringBorderContainer:EnableMouse(false) -- Don't intercept mouse - - -- Expiring border - use 4 edge textures for hollow rectangle effect - -- Left and Right are full height, Top and Bottom fit between them (no corner overlap) - local borderThickness = 2 - - icon.expiringBorderLeft = icon.expiringBorderContainer:CreateTexture(nil, "OVERLAY") - icon.expiringBorderLeft:SetPoint("TOPLEFT", icon, "TOPLEFT", -1, 1) - icon.expiringBorderLeft:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", -1, -1) - icon.expiringBorderLeft:SetWidth(borderThickness) - icon.expiringBorderLeft:SetColorTexture(1, 1, 1, 1) - - icon.expiringBorderRight = icon.expiringBorderContainer:CreateTexture(nil, "OVERLAY") - icon.expiringBorderRight:SetPoint("TOPRIGHT", icon, "TOPRIGHT", 1, 1) - icon.expiringBorderRight:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 1, -1) - icon.expiringBorderRight:SetWidth(borderThickness) - icon.expiringBorderRight:SetColorTexture(1, 1, 1, 1) - - -- Top and bottom fit between left and right edges (no corner overlap) - icon.expiringBorderTop = icon.expiringBorderContainer:CreateTexture(nil, "OVERLAY") - icon.expiringBorderTop:SetPoint("TOPLEFT", icon.expiringBorderLeft, "TOPRIGHT", 0, 0) - icon.expiringBorderTop:SetPoint("TOPRIGHT", icon.expiringBorderRight, "TOPLEFT", 0, 0) - icon.expiringBorderTop:SetHeight(borderThickness) - icon.expiringBorderTop:SetColorTexture(1, 1, 1, 1) - - icon.expiringBorderBottom = icon.expiringBorderContainer:CreateTexture(nil, "OVERLAY") - icon.expiringBorderBottom:SetPoint("BOTTOMLEFT", icon.expiringBorderLeft, "BOTTOMRIGHT", 0, 0) - icon.expiringBorderBottom:SetPoint("BOTTOMRIGHT", icon.expiringBorderRight, "BOTTOMLEFT", 0, 0) - icon.expiringBorderBottom:SetHeight(borderThickness) - icon.expiringBorderBottom:SetColorTexture(1, 1, 1, 1) - - -- Pulse animation for inner container (doesn't conflict with outer container's alpha) - icon.expiringBorderPulse = icon.expiringBorderContainer:CreateAnimationGroup() - icon.expiringBorderPulse:SetLooping("REPEAT") - - local fadeOut = icon.expiringBorderPulse:CreateAnimation("Alpha") - fadeOut:SetFromAlpha(1) - fadeOut:SetToAlpha(0.3) - fadeOut:SetDuration(0.5) - fadeOut:SetOrder(1) - fadeOut:SetSmoothing("IN_OUT") - - local fadeIn = icon.expiringBorderPulse:CreateAnimation("Alpha") - fadeIn:SetFromAlpha(0.3) - fadeIn:SetToAlpha(1) - fadeIn:SetDuration(0.5) - fadeIn:SetOrder(2) - fadeIn:SetSmoothing("IN_OUT") + -- Expiring border — unified DF.Border, AD-style feature set (Expiring Colour + -- Override + the full Expiring Animation: DF Pulsate / Dash / glow / …). + -- Replaces the legacy two-container + 4-edge + AnimationGroup pulse. + -- + -- Two-layer design (mirrors the legacy alphaContainer + container split): + -- * expiringBorderGate — a plain frame whose ALPHA carries the secret-safe + -- threshold/expiry visibility. The aura timer drives it via + -- SetAlphaFromBoolean(hasExpiration, expiringAlpha, 0) — the only way to + -- consume the secret-tainted expiry curve without tainting Lua flow. + -- * expiringBorder — the DF.Border itself, parented to the gate so the + -- gate's alpha multiplies the border (and any animation alpha) without + -- the animation fighting the visibility channel. + -- A separate overlay above the normal border (+6) so it can show even when + -- the normal buff border is off. solidOnly so the Color-by-Time curve can + -- recolour it per-tick with a secret colour (bare SetColorTexture, no taint); + -- ConfigureExpiringBorder re-creates it non-solidOnly when Color-by-Time is + -- off so the full style toolkit (gradient/texture) is available. + icon.expiringBorderGate = CreateFrame("Frame", nil, icon) + icon.expiringBorderGate:SetAllPoints(icon) + icon.expiringBorderGate:SetFrameLevel(icon:GetFrameLevel() + 6) + icon.expiringBorderGate:EnableMouse(false) + icon.expiringBorderGate:SetAlpha(0) + icon.expiringBorder = DF.Border:New(icon.expiringBorderGate, { solidOnly = true, frameLevelOffset = 0 }) + icon.expiringBorder:Hide() -- Stack count (on textOverlay, above cooldown) icon.count = icon.textOverlay:CreateFontString(nil, "OVERLAY") diff --git a/Frames/Expiring.lua b/Frames/Expiring.lua new file mode 100644 index 00000000..26de8c57 --- /dev/null +++ b/Frames/Expiring.lua @@ -0,0 +1,339 @@ +local addonName, DF = ... + +-- ============================================================ +-- DF.Expiring — SHARED EXPIRING ENGINE +-- +-- One registry + one ~3 FPS ticker that drives ANY element (border, text, +-- frame alpha, …) toward an "expiring" state below a duration threshold. The +-- engine is element-agnostic: each consumer supplies applyResult / applyManual +-- callbacks and (optionally) a Step colour curve, and the engine evaluates the +-- secret-safe Duration API on the consumer's behalf. +-- +-- This was originally AuraDesigner/Indicators.lua-local (RegisterExpiring / +-- BuildExpiringColorCurve / the OnUpdate ticker). Lifted here so AD's +-- indicators AND the standard buff expiring border share ONE engine instead of +-- each hand-rolling a ticker. The engine reads ONLY fields on the entryData +-- table passed to Register — no hidden module state — so consumers stay +-- decoupled (AD's "Show When Missing" pending-flag mechanism lives in +-- Indicators.lua and injects its fields into entryData before delegating here). +-- +-- entryData contract: +-- unit, auraInstanceID secret-safe duration source (real units) +-- duration, expirationTime preview/mock fallback (non-secret) +-- threshold, thresholdMode "PERCENT" (0-100) | "SECONDS" (1-60) +-- colorCurve optional Step curve → applyResult fires (API path) +-- applyResult(el, result, e) fires when colorCurve set; result is a ColorMixin +-- (result.r/g/b may be SECRET — use IsColorExpiring) +-- applyManual(el, isExp, e) fires on the preview path / when no colorCurve; +-- isExp is a plain bool +-- hideWhenNotExpiring opt: drive element visibility by expiring state +-- useShowHide opt: Show/Hide instead of SetAlpha +-- visibleAlpha, hiddenAlpha opt: alphas for the SetAlpha visibility path +-- ============================================================ + +local pairs = pairs +local GetTime = GetTime +local max = math.max +local issecretvalue = issecretvalue or function() return false end + +DF.Expiring = DF.Expiring or {} +local Expiring = DF.Expiring + +local expiringRegistry = {} + +-- Check if an interpolated colour result differs from the original colour. +-- result.r/g/b may be secret (tainted) values from EvaluateRemainingDuration/ +-- Percent; arithmetic on secret values throws. If tainted, the engine IS +-- interpolating → expiring. +function Expiring.IsColorExpiring(result, oc) + if issecretvalue(result.r) then return true end + return (math.abs(result.r - oc.r) > 0.01 + or math.abs(result.g - oc.g) > 0.01 + or math.abs(result.b - oc.b) > 0.01) +end + +-- Build a Step colour curve encoding two states: +-- Below threshold → expiringColor +-- At/above threshold → originalColor +-- thresholdMode: nil/"PERCENT" = percentage (0-100), "SECONDS" = seconds (1-60) +function Expiring:BuildColorCurve(threshold, expiringColor, originalColor, thresholdMode) + if not C_CurveUtil or not C_CurveUtil.CreateColorCurve then return nil end + local curve = C_CurveUtil.CreateColorCurve() + curve:SetType(Enum.LuaCurveType.Step) + local ecR = expiringColor.r or 1 + local ecG = expiringColor.g or 0.2 + local ecB = expiringColor.b or 0.2 + local ocR = originalColor.r or 1 + local ocG = originalColor.g or 1 + local ocB = originalColor.b or 1 + curve:AddPoint(0, CreateColor(ecR, ecG, ecB, 1)) + if thresholdMode == "SECONDS" then + -- Curve points in seconds for EvaluateRemainingDuration + curve:AddPoint(threshold, CreateColor(ocR, ocG, ocB, 1)) + curve:AddPoint(600, CreateColor(ocR, ocG, ocB, 1)) -- 10min cap + else + -- Curve points as decimal percentage for EvaluateRemainingPercent + curve:AddPoint(threshold / 100, CreateColor(ocR, ocG, ocB, 1)) + curve:AddPoint(1, CreateColor(ocR, ocG, ocB, 1)) + end + return curve +end + +-- Canonical "colour by time remaining" ramp: red → orange → yellow → green as +-- the remaining fraction climbs 0 → 1. Matches the Linear colour-curve points +-- (0,red)(0.3,orange)(0.5,yellow)(1,green) evaluated on the secret-safe live +-- path, so the manual (preview) result agrees with the curve result. +-- pct is a NON-secret 0-1 fraction. Returns r, g, b. +function Expiring:GradientColorAt(pct) + pct = pct or 0 + if pct < 0 then pct = 0 elseif pct > 1 then pct = 1 end + if pct < 0.3 then + return 1, 0.5 * (pct / 0.3), 0 + elseif pct < 0.5 then + return 1, 0.5 + 0.5 * ((pct - 0.3) / 0.2), 0 + else + return 1 - ((pct - 0.5) / 0.5), 1, 0 + end +end + +-- Manual (non-secret) evaluation of a fill colour that may combine the +-- colour-by-time gradient with an expiring-threshold override. This is the +-- preview/fallback twin of the C_CurveUtil colour curve built for the live +-- secret-safe path, keeping the gradient + threshold maths in ONE place so +-- consumers (e.g. the AD bar preview) don't hand-roll it. remaining/duration +-- must be NON-secret (preview auras only). +-- ctx: { base = {r,g,b}, colorByTime, expiringEnabled, threshold, +-- thresholdMode ("SECONDS"|nil/percent), expiringColor = {r,g,b} } +-- Returns r, g, b. +function Expiring:EvaluateManualColor(ctx, remaining, duration) + local base = ctx.base or ctx + local r, g, b = base.r or 1, base.g or 1, base.b or 1 + local pct = 0 + if duration and duration > 0 then + pct = remaining / duration + if pct < 0 then pct = 0 elseif pct > 1 then pct = 1 end + end + if ctx.colorByTime then + r, g, b = self:GradientColorAt(pct) + end + if ctx.expiringEnabled and ctx.threshold then + local isExp + if ctx.thresholdMode == "SECONDS" then + isExp = remaining <= ctx.threshold + else + isExp = pct <= (ctx.threshold / 100) + end + if isExp then + local ec = ctx.expiringColor + if ec then + r = ec.r or 1 + g = ec.g or 0.2 + b = ec.b or 0.2 + end + end + end + return r, g, b +end + +-- Build a Step VISIBILITY curve: alpha 1 below threshold, alpha 0 at/above. +-- Used to secret-safely gate an alpha-based element (a tint overlay) — the +-- result's alpha is fed straight to SetAlphaFromBoolean. Cached by mode+threshold. +local visibilityCurveCache = {} +function Expiring:BuildVisibilityCurve(threshold, thresholdMode) + if not C_CurveUtil or not C_CurveUtil.CreateColorCurve then return nil end + threshold = threshold or 30 + local seconds = thresholdMode == "SECONDS" + local key = (seconds and "s" or "p") .. threshold + if visibilityCurveCache[key] then return visibilityCurveCache[key] end + local curve = C_CurveUtil.CreateColorCurve() + curve:SetType(Enum.LuaCurveType.Step) + curve:AddPoint(0, CreateColor(1, 1, 1, 1)) -- below threshold: visible + if seconds then + curve:AddPoint(threshold, CreateColor(0, 0, 0, 0)) -- at/above: hidden + curve:AddPoint(600, CreateColor(0, 0, 0, 0)) + else + curve:AddPoint(threshold / 100, CreateColor(0, 0, 0, 0)) + curve:AddPoint(1, CreateColor(0, 0, 0, 0)) + end + visibilityCurveCache[key] = curve + return curve +end + +-- Secret-safe expiring TINT: a colour overlay that fades in below threshold. +-- The tint texture carries its colour+max-alpha via SetColorTexture, and the +-- engine gates its visibility via SetAlphaFromBoolean on a visibility curve — +-- so it works on SECRET buff/debuff auras (alpha-based, never branches on the +-- secret remaining-time). applyManual handles the non-secret preview path. +local function tintApplyResult(tex, result, entry) + if not result.GetRGBA then return end + local hasExp + if entry.unit and entry.auraInstanceID and C_UnitAuras and C_UnitAuras.DoesAuraHaveExpirationTime then + hasExp = C_UnitAuras.DoesAuraHaveExpirationTime(entry.unit, entry.auraInstanceID) + end + if tex.SetAlphaFromBoolean then + tex:SetAlphaFromBoolean(hasExp, select(4, result:GetRGBA()), 0) + else + tex:SetAlpha(select(4, result:GetRGBA())) + end +end + +local function tintApplyManual(tex, isExp, entry) + tex:SetAlpha(isExp and 1 or 0) +end + +-- Register / refresh / unregister a tint overlay texture for an element. +-- ctx: { unit, auraInstanceID, threshold, thresholdMode, duration, +-- expirationTime, enabled, color = {r,g,b,a} }. The texture's own alpha +-- (color.a) is the max tint strength; the curve gates 0↔1 on top of it. +function Expiring:UpdateTint(tex, ctx) + if not tex then return end + if not ctx or not ctx.enabled then + self:Unregister(tex) + tex:Hide() + return + end + local c = ctx.color or {} + local r = c.r or c[1] or 1 + local g = c.g or c[2] or 0 + local b = c.b or c[3] or 0 + local a = c.a or c[4] or 0.3 + tex:SetColorTexture(r, g, b, a) + tex:Show() + self:Register(tex, { + unit = ctx.unit, + auraInstanceID = ctx.auraInstanceID, + threshold = ctx.threshold, + thresholdMode = ctx.thresholdMode, + duration = ctx.duration, + expirationTime = ctx.expirationTime, + colorCurve = self:BuildVisibilityCurve(ctx.threshold, ctx.thresholdMode), + applyResult = tintApplyResult, + applyManual = tintApplyManual, + }) +end + +-- Evaluate one registry entry: API path (colour curve via the secret-safe +-- Duration API) with a preview fallback (manual pct), plus the optional +-- Show-When-Missing visibility toggle. Shared by Register (immediate eval) and +-- the ticker so they never drift. +local function EvaluateEntry(element, entry) + local applied = false + + -- API path: evaluate the colour curve on the real unit's Duration object. + if entry.colorCurve and entry.unit and entry.auraInstanceID + and C_UnitAuras and C_UnitAuras.GetAuraDuration then + local durationObj = C_UnitAuras.GetAuraDuration(entry.unit, entry.auraInstanceID) + if durationObj then + local result + if entry.thresholdMode == "SECONDS" and durationObj.EvaluateRemainingDuration then + result = durationObj:EvaluateRemainingDuration(entry.colorCurve) + elseif durationObj.EvaluateRemainingPercent then + result = durationObj:EvaluateRemainingPercent(entry.colorCurve) + end + if result and entry.applyResult then + entry.applyResult(element, result, entry) + applied = true + end + end + end + + -- Preview fallback: manual comparison against the threshold (non-secret). + if not applied then + local dur = entry.duration + local exp = entry.expirationTime + if dur and exp and not issecretvalue(dur) and not issecretvalue(exp) and dur > 0 then + local remaining = max(0, exp - GetTime()) + local isExpiring + if entry.thresholdMode == "SECONDS" then + isExpiring = remaining <= (entry.threshold or 10) + else + isExpiring = (remaining / dur) <= ((entry.threshold or 30) / 100) + end + if entry.applyManual then + entry.applyManual(element, isExpiring, entry) + end + elseif entry.applyManual then + -- duration=0 means permanent or synthetic (missing) aura — not expiring + entry.applyManual(element, false, entry) + end + end + + -- Show When Missing: toggle visibility based on expiring state. + -- Icons/squares use Hide()/Show() so OOR alpha restore won't undo us. + -- Borders use SetAlpha() since they're not in the OOR icon/square loop. + if entry.hideWhenNotExpiring then + local dur = entry.duration + local exp = entry.expirationTime + local isExp = false + if dur and exp and not issecretvalue(dur) and not issecretvalue(exp) and dur > 0 then + local rem = max(0, exp - GetTime()) + if entry.thresholdMode == "SECONDS" then + isExp = rem <= (entry.threshold or 10) + else + isExp = (rem / dur) <= ((entry.threshold or 30) / 100) + end + end + if entry.useShowHide then + if isExp then + element:Show() + element:SetAlpha(entry.visibleAlpha or 1) + else + element:Hide() + end + else + local notExpAlpha = entry.hiddenAlpha or 0 + element:SetAlpha(isExp and (entry.visibleAlpha or 1) or notExpAlpha) + end + end +end + +-- Per-entry re-evaluation cadence. 1.0s matches alpha2's effective 1 FPS-per- +-- icon rate (CPU-neutral vs the old aura timer); lower = snappier colour +-- response at more cost. The base ticker still wakes ~3 FPS, but each entry +-- only runs the (relatively expensive) Duration-curve evaluation when its own +-- interval has elapsed — so total evals/sec ≈ entries × (1/EVAL_INTERVAL). +local EVAL_INTERVAL = 1.0 +local staggerCounter = 0 + +-- Register an element for expiring updates. Evaluates immediately so the +-- caller's Apply ends with the correct colour/state (without this, Apply paints +-- the ORIGINAL colour and the ~3 FPS ticker overrides it later → visible +-- flicker on the first frame). +function Expiring:Register(element, entryData) + expiringRegistry[element] = entryData + EvaluateEntry(element, entryData) + -- Stagger the first throttled re-eval across [0.1, 1.0]×interval so a burst + -- of registrations (all auras appearing on combat start) doesn't land every + -- entry's evaluations on the same tick. Re-registration (aura refresh) + -- re-runs the immediate eval above, so freshness on change is preserved. + staggerCounter = (staggerCounter + 1) % 10 + entryData._nextEval = GetTime() + EVAL_INTERVAL * (0.1 + 0.1 * staggerCounter) +end + +function Expiring:Unregister(element) + if element then + expiringRegistry[element] = nil + end +end + +-- ~3 FPS shared ticker. One OnUpdate for every registered element across the +-- whole addon (AD indicators + buff expiring borders). +local expiringFrame = CreateFrame("Frame") +local expiringElapsed = 0 +expiringFrame:Show() -- CRITICAL: OnUpdate only fires on visible frames + +expiringFrame:SetScript("OnUpdate", function(_, elapsed) + expiringElapsed = expiringElapsed + elapsed + if expiringElapsed < 0.33 then return end -- base wake ~3 FPS + expiringElapsed = 0 + + local now = GetTime() + for element, entry in pairs(expiringRegistry) do + if not element:IsShown() then + expiringRegistry[element] = nil + elseif now >= (entry._nextEval or 0) then + EvaluateEntry(element, entry) + entry._nextEval = now + EVAL_INTERVAL + end + end +end) diff --git a/Frames/Headers.lua b/Frames/Headers.lua index 1ec9cfe7..012c66f6 100755 --- a/Frames/Headers.lua +++ b/Frames/Headers.lua @@ -9041,6 +9041,11 @@ headerChildEventFrame:SetScript("OnEvent", function(self, event, arg1) -- UNIT_PHASE / UNIT_FLAGS / UNIT_OTHER_PARTY_CHANGED: Update phased icon (cache-aware) if event == "UNIT_PHASE" or event == "UNIT_FLAGS" or event == "UNIT_OTHER_PARTY_CHANGED" then local unit = arg1 + -- Combat flag can flip on UNIT_FLAGS — refresh this unit's combat icon + if event == "UNIT_FLAGS" and unit and DF.UpdateCombatIcon then + local cFrame = DF.unitFrameMap and DF.unitFrameMap[unit] + if cFrame then DF:UpdateCombatIcon(cFrame) end + end if unit and DF.UpdatePhasedCacheForUnit then -- Update cache and main frame (only refreshes icon if cache value changed) DF:UpdatePhasedCacheForUnit(unit) diff --git a/Frames/Icons.lua b/Frames/Icons.lua index 5fb90261..a5ca1b7c 100644 --- a/Frames/Icons.lua +++ b/Frames/Icons.lua @@ -151,34 +151,15 @@ local function GetOrCreateDefensiveBarIcon(frame, index) -- Create a new icon frame cloned from the same pattern as Create.lua icon = CreateFrame("Frame", nil, frame.contentOverlay) icon:SetSize(24, 24) - icon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 15) + icon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 26) icon:Hide() - local borderSize = 2 - icon.borderLeft = icon:CreateTexture(nil, "BACKGROUND") - icon.borderLeft:SetPoint("TOPLEFT", 0, 0) - icon.borderLeft:SetPoint("BOTTOMLEFT", 0, 0) - icon.borderLeft:SetWidth(borderSize) - icon.borderLeft:SetColorTexture(0, 0.8, 0, 1) - - icon.borderRight = icon:CreateTexture(nil, "BACKGROUND") - icon.borderRight:SetPoint("TOPRIGHT", 0, 0) - icon.borderRight:SetPoint("BOTTOMRIGHT", 0, 0) - icon.borderRight:SetWidth(borderSize) - icon.borderRight:SetColorTexture(0, 0.8, 0, 1) - - icon.borderTop = icon:CreateTexture(nil, "BACKGROUND") - icon.borderTop:SetPoint("TOPLEFT", borderSize, 0) - icon.borderTop:SetPoint("TOPRIGHT", -borderSize, 0) - icon.borderTop:SetHeight(borderSize) - icon.borderTop:SetColorTexture(0, 0.8, 0, 1) - - icon.borderBottom = icon:CreateTexture(nil, "BACKGROUND") - icon.borderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - icon.borderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - icon.borderBottom:SetHeight(borderSize) - icon.borderBottom:SetColorTexture(0, 0.8, 0, 1) + -- Border on the unified DF.Border backend. RenderDefensiveBarIcon does the + -- live restyle via DF.Border:Apply on each update. frameLevelOffset 0 keeps + -- it co-planar with the icon (matches frame.defensiveIcon). + icon.border = DF.Border:New(icon, { frameLevelOffset = 0 }) + local borderSize = 2 icon.texture = icon:CreateTexture(nil, "ARTWORK") icon.texture:SetPoint("TOPLEFT", borderSize, -borderSize) icon.texture:SetPoint("BOTTOMRIGHT", -borderSize, borderSize) @@ -462,46 +443,31 @@ local function RenderDefensiveBarIcon(icon, unit, auraInstanceID, db, iconSize, end end - -- Border - if showBorder then - if icon.borderLeft then - icon.borderLeft:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a) - icon.borderLeft:SetWidth(borderSize) - icon.borderLeft:Show() - end - if icon.borderRight then - icon.borderRight:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a) - icon.borderRight:SetWidth(borderSize) - icon.borderRight:Show() - end - if icon.borderTop then - icon.borderTop:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a) - icon.borderTop:SetHeight(borderSize) - icon.borderTop:ClearAllPoints() - icon.borderTop:SetPoint("TOPLEFT", borderSize, 0) - icon.borderTop:SetPoint("TOPRIGHT", -borderSize, 0) - icon.borderTop:Show() - end - if icon.borderBottom then - icon.borderBottom:SetColorTexture(borderColor.r, borderColor.g, borderColor.b, borderColor.a) - icon.borderBottom:SetHeight(borderSize) - icon.borderBottom:ClearAllPoints() - icon.borderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - icon.borderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - icon.borderBottom:Show() - end - icon.texture:ClearAllPoints() - icon.texture:SetPoint("TOPLEFT", borderSize, -borderSize) - icon.texture:SetPoint("BOTTOMRIGHT", -borderSize, borderSize) - else - if icon.borderLeft then icon.borderLeft:Hide() end - if icon.borderRight then icon.borderRight:Hide() end - if icon.borderTop then icon.borderTop:Hide() end - if icon.borderBottom then icon.borderBottom:Hide() end - icon.texture:ClearAllPoints() - icon.texture:SetPoint("TOPLEFT", 0, 0) - icon.texture:SetPoint("BOTTOMRIGHT", 0, 0) - end + -- Border (unified DF.Border backend). BuildSpec reads the canonical db + -- keys; we override `enabled`/`size`/`color` with the locally-computed + -- values (already pixel-perfected, and color may come from the live + -- update path with overrides applied). ctx.unit feeds the Class/Role + -- colour resolvers (Stage 4.0 — defensive icons get class/role colour + -- so the user can see at a glance WHO used the defensive). + local artInset = showBorder and borderSize or 0 + if icon.border then + local spec = DF.Border:BuildSpec(db, "defensiveIcon", { + unit = unit, + frame = icon.unitFrame, -- lets test frames resolve Class/Role via test data + iconMode = true, -- outward icon-border geometry (shared) + }) + spec.enabled = showBorder + spec.size = borderSize + -- spec.color is NOT overridden: BuildSpec has already resolved it + -- per the ColorSource setting (STATIC / CLASS / ROLE), and a static + -- override here would clobber CLASS/ROLE picks. Pre-Stage-2 the + -- override was harmless because everything resolved to the static + -- db colour anyway. + DF.Border:Apply(icon.border, spec) + end + icon.texture:ClearAllPoints() + icon.texture:SetPoint("TOPLEFT", artInset, -artInset) + icon.texture:SetPoint("BOTTOMRIGHT", -artInset, artInset) icon:SetSize(iconSize, iconSize) icon:Show() @@ -900,60 +866,28 @@ function DF:UpdateMissingBuffIcon(frame, forceUpdate) -- Show the missing buff icon frame.missingBuffIcon:SetTexture(missingIcon) - -- Apply border if enabled + -- Border via unified DF.Border backend (Stage 4.1). BuildSpec reads + -- the canonical missingBuffIcon* keys; we override size with the + -- locally-pixel-perfected value. Icon insets by the visible border + -- thickness so the artwork doesn't overlap the border edges (or + -- sits flush with the frame when the border is off). local showBorder = db.missingBuffIconShowBorder ~= false - if showBorder then - -- PERF: Use module-level default instead of inline table - local bc = db.missingBuffIconBorderColor or DEFAULT_MISSING_BUFF_BORDER_COLOR - local borderSize = db.missingBuffIconBorderSize or 2 - - -- Apply pixel perfect to border size - if db.pixelPerfect then - borderSize = DF:PixelPerfect(borderSize) - end - - -- Set color on all border edges - if frame.missingBuffBorderLeft then - frame.missingBuffBorderLeft:SetColorTexture(bc.r, bc.g, bc.b, bc.a) - frame.missingBuffBorderLeft:SetWidth(borderSize) - frame.missingBuffBorderLeft:Show() - end - if frame.missingBuffBorderRight then - frame.missingBuffBorderRight:SetColorTexture(bc.r, bc.g, bc.b, bc.a) - frame.missingBuffBorderRight:SetWidth(borderSize) - frame.missingBuffBorderRight:Show() - end - if frame.missingBuffBorderTop then - frame.missingBuffBorderTop:SetColorTexture(bc.r, bc.g, bc.b, bc.a) - frame.missingBuffBorderTop:SetHeight(borderSize) - frame.missingBuffBorderTop:ClearAllPoints() - frame.missingBuffBorderTop:SetPoint("TOPLEFT", borderSize, 0) - frame.missingBuffBorderTop:SetPoint("TOPRIGHT", -borderSize, 0) - frame.missingBuffBorderTop:Show() - end - if frame.missingBuffBorderBottom then - frame.missingBuffBorderBottom:SetColorTexture(bc.r, bc.g, bc.b, bc.a) - frame.missingBuffBorderBottom:SetHeight(borderSize) - frame.missingBuffBorderBottom:ClearAllPoints() - frame.missingBuffBorderBottom:SetPoint("BOTTOMLEFT", borderSize, 0) - frame.missingBuffBorderBottom:SetPoint("BOTTOMRIGHT", -borderSize, 0) - frame.missingBuffBorderBottom:Show() - end - - -- Adjust icon position for border - frame.missingBuffIcon:ClearAllPoints() - frame.missingBuffIcon:SetPoint("TOPLEFT", borderSize, -borderSize) - frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", -borderSize, borderSize) - else - -- Hide all border edges - if frame.missingBuffBorderLeft then frame.missingBuffBorderLeft:Hide() end - if frame.missingBuffBorderRight then frame.missingBuffBorderRight:Hide() end - if frame.missingBuffBorderTop then frame.missingBuffBorderTop:Hide() end - if frame.missingBuffBorderBottom then frame.missingBuffBorderBottom:Hide() end - frame.missingBuffIcon:ClearAllPoints() - frame.missingBuffIcon:SetPoint("TOPLEFT", 0, 0) - frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", 0, 0) + local borderSize = db.missingBuffIconBorderSize or 2 + if db.pixelPerfect then + borderSize = DF:PixelPerfect(borderSize) end + + if frame.missingBuffBorder then + local spec = DF.Border:BuildSpec(db, "missingBuffIcon", { iconMode = true }) + spec.enabled = showBorder + spec.size = borderSize + DF.Border:Apply(frame.missingBuffBorder, spec) + end + + local artInset = showBorder and borderSize or 0 + frame.missingBuffIcon:ClearAllPoints() + frame.missingBuffIcon:SetPoint("TOPLEFT", artInset, -artInset) + frame.missingBuffIcon:SetPoint("BOTTOMRIGHT", -artInset, artInset) -- Apply positioning local scale = db.missingBuffIconScale or 1.5 @@ -1150,7 +1084,7 @@ function DF:UpdateDefensiveBar(frame) -- Frame level local frameLevel = db.defensiveIconFrameLevel or 0 if frameLevel == 0 then - icon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 15) + icon:SetFrameLevel(frame.contentOverlay:GetFrameLevel() + 26) else icon:SetFrameLevel(frame:GetFrameLevel() + frameLevel) end diff --git a/Frames/Pets.lua b/Frames/Pets.lua index 24ed7de7..65b2686e 100644 --- a/Frames/Pets.lua +++ b/Frames/Pets.lua @@ -55,15 +55,9 @@ function DF:CreatePetFrame(unit, ownerFrame, isRaid) frame.healthBar.bg:SetAllPoints() frame.healthBar.bg:SetColorTexture(0.2, 0.2, 0.2, 0.8) - -- Border - frame.border = CreateFrame("Frame", nil, frame, "BackdropTemplate") - frame.border:SetPoint("TOPLEFT", -1, 1) - frame.border:SetPoint("BOTTOMRIGHT", 1, -1) - frame.border:SetBackdrop({ - edgeFile = "Interface\\Buttons\\WHITE8x8", - edgeSize = 1, - }) - frame.border:SetBackdropBorderColor(0, 0, 0, 1) + -- Border via the unified DF.Border backend (Stage 4.3). + -- ApplyPetFrameStyle drives BuildSpec + Apply on each update. + frame.border = DF.Border:New(frame) -- Name text — do NOT use SetFont() directly; use SetFontObject so that -- later SafeSetFont calls with font families can properly override @@ -180,15 +174,9 @@ function DF:CreateTestPetFrame(unit, ownerTestFrame, isRaid) frame.healthBar.bg:SetAllPoints() frame.healthBar.bg:SetColorTexture(0.2, 0.2, 0.2, 0.8) - -- Border - frame.border = CreateFrame("Frame", nil, frame, "BackdropTemplate") - frame.border:SetPoint("TOPLEFT", -1, 1) - frame.border:SetPoint("BOTTOMRIGHT", 1, -1) - frame.border:SetBackdrop({ - edgeFile = "Interface\\Buttons\\WHITE8x8", - edgeSize = 1, - }) - frame.border:SetBackdropBorderColor(0, 0, 0, 1) + -- Border via the unified DF.Border backend (Stage 4.3). + -- ApplyPetFrameStyle drives BuildSpec + Apply on each update. + frame.border = DF.Border:New(frame) -- Name text — do NOT use SetFont() directly; use SafeSetFont or SetFontObject -- so that later SafeSetFont calls with font families can properly override @@ -498,13 +486,14 @@ function DF:ApplyPetFrameStyle(frame) local healthBgColor = db.petHealthBgColor or {r = 0.2, g = 0.2, b = 0.2, a = 0.8} frame.healthBar.bg:SetVertexColor(healthBgColor.r, healthBgColor.g, healthBgColor.b, healthBgColor.a or 0.8) - -- Border - if db.petShowBorder then - local borderColor = db.petBorderColor or {r = 0, g = 0, b = 0, a = 1} - frame.border:SetBackdropBorderColor(borderColor.r, borderColor.g, borderColor.b, borderColor.a or 1) - frame.border:Show() - else - frame.border:Hide() + -- Border via unified DF.Border backend (Stage 4.3). No ctx — Class / + -- Role colour deliberately not exposed on Pet Frame: UnitClass("pet") + -- returns the pet family (Beast / Felguard / etc.), not a class token + -- that maps to RAID_CLASS_COLORS, so the resolver wouldn't produce a + -- useful colour. Re-visit only if a per-class-of-owner feature + -- becomes worth its own resolver. + if frame.border then + DF.Border:Apply(frame.border, DF.Border:BuildSpec(db, "pet")) end -- Name text styling - use SafeSetFont like main frames diff --git a/Frames/StatusIcons.lua b/Frames/StatusIcons.lua index d3cd88a8..38294076 100644 --- a/Frames/StatusIcons.lua +++ b/Frames/StatusIcons.lua @@ -20,6 +20,8 @@ local GetReadyCheckStatus = GetReadyCheckStatus local GetPartyAssignment = GetPartyAssignment local GetRaidRosterInfo = GetRaidRosterInfo local IsInRaid = IsInRaid +local IsInInstance = IsInInstance +local UnitPvpClassification = UnitPvpClassification local InCombatLockdown = InCombatLockdown local CreateFrame = CreateFrame @@ -83,7 +85,7 @@ function DF:CreateStatusIcons(frame) -- ======================================== frame.summonIcon = CreateStatusIcon(overlay, 16) frame.summonIcon:SetPoint("CENTER", frame, "CENTER", 0, 0) - frame.summonIcon.texture:SetTexture("Interface\\RaidFrame\\Raid-Icon-SummonPending") + DF:SetUpgradedStatusIcon(frame.summonIcon.texture, "Interface\\RaidFrame\\Raid-Icon-SummonPending") frame.summonIcon.text:SetTextColor(0.6, 0.2, 1, 1) -- Purple for summon -- ======================================== @@ -91,7 +93,7 @@ function DF:CreateStatusIcons(frame) -- ======================================== frame.resurrectionIcon = CreateStatusIcon(overlay, 16) frame.resurrectionIcon:SetPoint("CENTER", frame, "CENTER", 0, 10) - frame.resurrectionIcon.texture:SetTexture("Interface\\RaidFrame\\Raid-Icon-Rez") + DF:SetUpgradedStatusIcon(frame.resurrectionIcon.texture, "Interface\\RaidFrame\\Raid-Icon-Rez") frame.resurrectionIcon.text:SetTextColor(0.2, 1, 0.2, 1) -- Green for res frame.resurrectionIcon.unitFrame = frame frame.resurrectionIcon:EnableMouse(true) @@ -131,8 +133,7 @@ function DF:CreateStatusIcons(frame) -- ======================================== frame.phasedIcon = CreateStatusIcon(overlay, 16) frame.phasedIcon:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -2, -2) - frame.phasedIcon.texture:SetTexture("Interface\\TargetingFrame\\UI-PhasingIcon") - frame.phasedIcon.texture:SetTexCoord(0.15625, 0.84375, 0.15625, 0.84375) + DF:SetUpgradedStatusIcon(frame.phasedIcon.texture, "Interface\\TargetingFrame\\UI-PhasingIcon") frame.phasedIcon.text:SetTextColor(0.5, 0.5, 1, 1) -- Blue-ish for phased -- ======================================== @@ -140,7 +141,7 @@ function DF:CreateStatusIcons(frame) -- ======================================== frame.afkIcon = CreateStatusIcon(overlay, 32) frame.afkIcon:SetPoint("CENTER", frame, "CENTER", 0, 0) - frame.afkIcon.texture:SetTexture("Interface\\FriendsFrame\\StatusIcon-Away") + DF:SetUpgradedStatusIcon(frame.afkIcon.texture, "Interface\\FriendsFrame\\StatusIcon-Away") DF:SafeSetFont(frame.afkIcon.text, nil, 12, "OUTLINE") frame.afkIcon.text:SetTextColor(1, 0.5, 0, 1) -- Orange for AFK -- Timer text (separate from main text, shown below/after) @@ -155,7 +156,7 @@ function DF:CreateStatusIcons(frame) -- ======================================== frame.vehicleIcon = CreateStatusIcon(overlay, 16) frame.vehicleIcon:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2) - frame.vehicleIcon.texture:SetTexture("Interface\\Vehicles\\UI-Vehicles-Raid-Icon") + DF:SetUpgradedStatusIcon(frame.vehicleIcon.texture, "Interface\\Vehicles\\UI-Vehicles-Raid-Icon") frame.vehicleIcon.text:SetTextColor(0.4, 0.8, 1, 1) -- Light blue for vehicle -- ======================================== @@ -165,7 +166,27 @@ function DF:CreateStatusIcons(frame) frame.raidRoleIcon:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 2) frame.raidRoleIcon.text:SetTextColor(1, 1, 0, 1) -- Yellow for raid role -- Texture set dynamically based on role - + + -- ======================================== + -- BG OBJECTIVE CARRIER ICON (flag / orb carrier) + -- ======================================== + frame.bgCarrierIcon = CreateStatusIcon(overlay, 18) + frame.bgCarrierIcon:SetPoint("CENTER", frame, "CENTER", 0, 0) + DF:SetUpgradedStatusIcon(frame.bgCarrierIcon.texture, "Interface\\Icons\\inv_bannerpvp_03") + frame.bgCarrierIcon.text:SetTextColor(1, 0.82, 0, 1) -- Gold for objective carrier + + -- ======================================== + -- COMBAT ICON (unit is in combat) + -- ======================================== + frame.combatIcon = CreateStatusIcon(overlay, 16) + frame.combatIcon:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) -- free corner (avoids the CENTER cluster + BG-carrier) + -- Classic crossed-swords combat glyph. UI-StateIcon is a sheet (swords + + -- resting); crop to the swords quadrant. Set directly (NOT SetUpgradedStatusIcon, + -- which would reset the texcoord to the full sheet). ApplyIconSettings leaves + -- texcoord alone, so this crop persists. + frame.combatIcon.texture:SetTexture("Interface\\CharacterFrame\\UI-StateIcon") + frame.combatIcon.texture:SetTexCoord(0.5, 1.0, 0, 0.49) + -- ======================================== -- CENTER STATUS ICON (DEPRECATED - backward compat) -- ======================================== @@ -173,6 +194,77 @@ function DF:CreateStatusIcons(frame) frame.centerStatusIcon:SetPoint("CENTER", frame, "CENTER", 0, 0) end +-- ============================================================ +-- HELPER: Apply the AFK-style timer text settings (font, size, outline, +-- colour, position). Dedicated Timer* keys take precedence, falling +-- back to the global status-icon font so existing profiles keep their look. +-- Shared by the live render (ApplyIconSettings) and Test Mode. +-- ============================================================ +function DF:ApplyTimerTextSettings(icon, db, prefix) + local t = icon and icon.timerText + if not t or not db or not prefix then return end + + local font = db[prefix .. "TimerFont"] or db.statusIconFont or "Fonts\\FRIZQT__.TTF" + local size = db[prefix .. "TimerFontSize"] or ((db.statusIconFontSize or 12) - 2) + local outline = db[prefix .. "TimerOutline"] or db.statusIconFontOutline or "OUTLINE" + -- outline may be a composed "SHADOW;" value; OutlineFlag strips the + -- shadow and returns the SetFont flag ("NONE" -> "" since SetFont rejects it). + local flag = DF:OutlineFlag(outline) + local actualOutline = (flag == "NONE") and "" or flag + local fontPath = (DF.GetFont and (DF:GetFont(font) or font)) or font + t:SetFont(fontPath, size, actualOutline) + + if DF:OutlineHasShadow(outline) then + local sc = db.fontShadowColor or { r = 0, g = 0, b = 0, a = 1 } + t:SetShadowOffset(db.fontShadowOffsetX or 1, db.fontShadowOffsetY or -1) + t:SetShadowColor(sc.r or 0, sc.g or 0, sc.b or 0, sc.a or 1) + else + t:SetShadowOffset(0, 0) + end + + local c = db[prefix .. "TimerColor"] or db[prefix .. "TextColor"] + if c then t:SetTextColor(c.r or 1, c.g or 1, c.b or 1, c.a or 1) end + + -- LEFT-justify the timer instead of centring it. CENTRE justify — even inside + -- a fixed-width box — re-measures the text's INK box each tick, and a narrow + -- "1" shrinks that ink box, so a centred string nudges left/right every second. + -- The seconds change each tick, so pin the LEFT edge and let the digits sit at + -- fixed offsets to the right. The time is also zero-padded to MM:SS (constant + -- char count, see FormatAFKTime), so the string width never changes either — + -- it stays put across the 9:59 -> 10:00 boundary too. Anchor the left edge + -- ~half a typical MM:SS left of centre so it reads centred under the icon. + local nudge = size * 1.2 -- ~half the width of a 5-char MM:SS timer + t:SetWidth(size * 6) + t:SetJustifyH("LEFT") + t:ClearAllPoints() + t:SetPoint("TOPLEFT", icon, "BOTTOM", (db[prefix .. "TimerX"] or 0) - nudge, db[prefix .. "TimerY"] or -1) +end + +-- ============================================================ +-- HELPER: Stable anchor for status text that updates rapidly (the AFK +-- "show as text" line, which bakes the ticking timer into the string). +-- CENTRE auto-measures the ink box every tick, so the changing timer nudges the +-- whole string left/right. LEFT-justify instead: pin the left edge (the constant +-- label) and let the changing timer dangle off the right. Cache the centring +-- nudge keyed by string LENGTH, so a constant-length string (the time is +-- zero-padded to MM:SS) only re-measures when the structure actually changes, +-- never per tick — which is what keeps it from wobbling. +-- ============================================================ +function DF:ApplyStableTextAnchor(fs, icon) + if not fs or not icon then return end + fs:SetJustifyH("LEFT") + local text = fs:GetText() or "" + if fs._dfStableLen ~= #text then + local w = fs:GetStringWidth() + if w and w > 0 then + fs._dfStableLen = #text + fs._dfStableNudge = w / 2 + end + end + fs:ClearAllPoints() + fs:SetPoint("LEFT", icon, "CENTER", -(fs._dfStableNudge or 0), 0) +end + -- ============================================================ -- HELPER: Apply icon positioning from settings -- ============================================================ @@ -201,22 +293,21 @@ local function ApplyIconSettings(icon, db, prefix) local fontSize = db.statusIconFontSize or 12 local outline = db.statusIconFontOutline or "OUTLINE" - -- Handle SHADOW and NONE outlines (WoW SetFont rejects "NONE") - local actualOutline = outline - if outline == "SHADOW" or outline == "NONE" then - actualOutline = "" - end - + -- outline may be a composed "SHADOW;" value; OutlineFlag strips + -- the shadow and returns the SetFont flag ("NONE" -> "" — SetFont rejects "NONE"). + local flag = DF:OutlineFlag(outline) + local actualOutline = (flag == "NONE") and "" or flag + -- Get font path from SharedMedia if available local fontPath = font if DF.GetFont then fontPath = DF:GetFont(font) or font end - + icon.text:SetFont(fontPath, fontSize, actualOutline) - + -- Apply shadow if needed - if outline == "SHADOW" then + if DF:OutlineHasShadow(outline) then local shadowX = db.fontShadowOffsetX or 1 local shadowY = db.fontShadowOffsetY or -1 local shadowColor = db.fontShadowColor or {r = 0, g = 0, b = 0, a = 1} @@ -233,39 +324,9 @@ local function ApplyIconSettings(icon, db, prefix) end end - -- Also apply to timer text if it exists (AFK icon) + -- Also apply to timer text if it exists (AFK icon) — dedicated controls. if icon.timerText then - local font = db.statusIconFont or "Fonts\\FRIZQT__.TTF" - local fontSize = (db.statusIconFontSize or 12) - 2 -- Slightly smaller for timer - local outline = db.statusIconFontOutline or "OUTLINE" - - local actualOutline = outline - if outline == "SHADOW" or outline == "NONE" then - actualOutline = "" - end - - local fontPath = font - if DF.GetFont then - fontPath = DF:GetFont(font) or font - end - - icon.timerText:SetFont(fontPath, fontSize, actualOutline) - - if outline == "SHADOW" then - local shadowX = db.fontShadowOffsetX or 1 - local shadowY = db.fontShadowOffsetY or -1 - local shadowColor = db.fontShadowColor or {r = 0, g = 0, b = 0, a = 1} - icon.timerText:SetShadowOffset(shadowX, shadowY) - icon.timerText:SetShadowColor(shadowColor.r or 0, shadowColor.g or 0, shadowColor.b or 0, shadowColor.a or 1) - else - icon.timerText:SetShadowOffset(0, 0) - end - - -- Timer text uses same color as main text - local textColor = db[prefix .. "TextColor"] - if textColor then - icon.timerText:SetTextColor(textColor.r or 1, textColor.g or 1, textColor.b or 1, 1) - end + DF:ApplyTimerTextSettings(icon, db, prefix) end end @@ -339,8 +400,7 @@ function DF:UpdateSummonIcon(frame) end if showIcon then - frame.summonIcon.texture:SetTexture(texture) - frame.summonIcon.texture:SetTexCoord(0, 1, 0, 1) + DF:SetUpgradedStatusIcon(frame.summonIcon.texture, texture) ApplyIconSettings(frame.summonIcon, db, "summonIcon") -- Show as text or icon based on setting @@ -411,7 +471,7 @@ function DF:UpdateResurrectionIcon(frame) resCache[unit] = 1 resTimer = resTimer or C_Timer.NewTicker(0.25, ResTimerCleanup) end - frame.resurrectionIcon.texture:SetTexture("Interface\\RaidFrame\\Raid-Icon-Rez") + DF:SetUpgradedStatusIcon(frame.resurrectionIcon.texture, "Interface\\RaidFrame\\Raid-Icon-Rez") frame.resurrectionIcon.texture:SetVertexColor(0, 1, 0, 1) ApplyIconSettings(frame.resurrectionIcon, db, "resurrectionIcon") frame.resurrectionIcon:Show() @@ -420,7 +480,7 @@ function DF:UpdateResurrectionIcon(frame) -- Was casting, now stopped → pending accept (yellow) -- Store timestamp so we can expire after 60s resCache[unit] = GetTime() - frame.resurrectionIcon.texture:SetTexture("Interface\\RaidFrame\\Raid-Icon-Rez") + DF:SetUpgradedStatusIcon(frame.resurrectionIcon.texture, "Interface\\RaidFrame\\Raid-Icon-Rez") frame.resurrectionIcon.texture:SetVertexColor(1, 1, 0, 0.75) ApplyIconSettings(frame.resurrectionIcon, db, "resurrectionIcon") frame.resurrectionIcon:Show() @@ -428,7 +488,7 @@ function DF:UpdateResurrectionIcon(frame) elseif resCache[unit] and resCache[unit] ~= 1 then -- Still showing pending accept (check not expired) if (GetTime() - resCache[unit]) <= RES_ACCEPT_TIMEOUT then - frame.resurrectionIcon.texture:SetTexture("Interface\\RaidFrame\\Raid-Icon-Rez") + DF:SetUpgradedStatusIcon(frame.resurrectionIcon.texture, "Interface\\RaidFrame\\Raid-Icon-Rez") frame.resurrectionIcon.texture:SetVertexColor(1, 1, 0, 0.75) ApplyIconSettings(frame.resurrectionIcon, db, "resurrectionIcon") frame.resurrectionIcon:Show() @@ -661,11 +721,9 @@ function DF:UpdatePhasedIcon(frame) -- cached == -1 means LFG (other party), anything else means phased local isLFG = (cached == -1) if isLFG and db.phasedIconShowLFGEye then - frame.phasedIcon.texture:SetTexture("Interface\\LFGFrame\\LFG-Eye") - frame.phasedIcon.texture:SetTexCoord(0.14, 0.235, 0.28, 0.47) + DF:SetUpgradedStatusIcon(frame.phasedIcon.texture, "Interface\\LFGFrame\\LFG-Eye") else - frame.phasedIcon.texture:SetTexture("Interface\\TargetingFrame\\UI-PhasingIcon") - frame.phasedIcon.texture:SetTexCoord(0.15625, 0.84375, 0.15625, 0.84375) + DF:SetUpgradedStatusIcon(frame.phasedIcon.texture, "Interface\\TargetingFrame\\UI-PhasingIcon") end ApplyIconSettings(frame.phasedIcon, db, "phasedIcon") ShowIconAsText(frame.phasedIcon, db.phasedIconText or "Phased", db.phasedIconShowText) @@ -693,12 +751,12 @@ end -- Format seconds as M:SS or H:MM:SS local function FormatAFKTime(seconds) if seconds < 3600 then - return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60) + return string.format("%02d:%02d", math.floor(seconds / 60), seconds % 60) else local hours = math.floor(seconds / 3600) local mins = math.floor((seconds % 3600) / 60) local secs = seconds % 60 - return string.format("%d:%02d:%02d", hours, mins, secs) + return string.format("%02d:%02d:%02d", hours, mins, secs) end end @@ -770,8 +828,8 @@ function DF:UpdateAFKIcon(frame) if frame.afkIcon.timerText then frame.afkIcon.timerText:Hide() end else statusText = db.afkIconText or "AFK" - -- Restore AFK's orange color (may have been overridden by DND branch) - frame.afkIcon.text:SetTextColor(1, 0.5, 0, 1) + -- AFK text colour comes from afkIconTextColor (applied by + -- ApplyIconSettings above); the DND branch overrides to red when DND. local showTimer = db.afkIconShowTimer ~= false -- Calculate timer if enabled @@ -796,6 +854,9 @@ function DF:UpdateAFKIcon(frame) end ShowIconAsText(frame.afkIcon, statusText, db.afkIconShowText) + if db.afkIconShowText and frame.afkIcon.text then + DF:ApplyStableTextAnchor(frame.afkIcon.text, frame.afkIcon) + end frame.afkIcon:Show() else frame.afkIcon:Hide() @@ -932,13 +993,12 @@ function DF:UpdateRaidRoleIcon(frame) if showIcon and role then local statusText = nil if role == "MAINTANK" then - frame.raidRoleIcon.texture:SetTexture("Interface\\GroupFrame\\UI-Group-MainTankIcon") + DF:SetUpgradedStatusIcon(frame.raidRoleIcon.texture, "Interface\\GroupFrame\\UI-Group-MainTankIcon") statusText = db.raidRoleIconTextTank or "MT" else - frame.raidRoleIcon.texture:SetTexture("Interface\\GroupFrame\\UI-Group-MainAssistIcon") + DF:SetUpgradedStatusIcon(frame.raidRoleIcon.texture, "Interface\\GroupFrame\\UI-Group-MainAssistIcon") statusText = db.raidRoleIconTextAssist or "MA" end - frame.raidRoleIcon.texture:SetTexCoord(0, 1, 0, 1) ApplyIconSettings(frame.raidRoleIcon, db, "raidRoleIcon") ShowIconAsText(frame.raidRoleIcon, statusText, db.raidRoleIconShowText) frame.raidRoleIcon:Show() @@ -947,19 +1007,108 @@ function DF:UpdateRaidRoleIcon(frame) end end +-- ============================================================ +-- BG OBJECTIVE CARRIER ICON +-- Lights up when the unit is carrying a battleground objective +-- (WSG/TP flag, Kotmogu orb, etc.). Detection uses +-- UnitPvpClassification — an official, non-secret API — so it does +-- NOT depend on Blizzard's compact raid frames being enabled, and +-- only ever queries the frame's own (friendly, in-group) unit. +-- UnitPvpClassification returns an Enum.PvPUnitClassification value +-- (or -1 outside objective PvP); we only render flags + orbs. +-- ============================================================ +local PVP_CARRIER_TEXTURES = { + [0] = "Interface\\Icons\\inv_bannerpvp_01", -- FlagCarrierHorde + [1] = "Interface\\Icons\\inv_bannerpvp_02", -- FlagCarrierAlliance + [2] = "Interface\\Icons\\inv_bannerpvp_03", -- FlagCarrierNeutral + [7] = 1119885, -- OrbCarrierBlue + [8] = 1119886, -- OrbCarrierGreen + [9] = 1119887, -- OrbCarrierOrange + [10] = 1119887, -- OrbCarrierPurple (reuse orb art) +} + +function DF:UpdateBGCarrierIcon(frame) + if not frame or not frame.unit or not frame.bgCarrierIcon then return end + + local db = DF:GetFrameDB(frame) + if not db or not db.bgCarrierIconEnabled then + frame.bgCarrierIcon:Hide() + return + end + + local unit = frame.unit + if not UnitExists(unit) or not UnitPvpClassification then + frame.bgCarrierIcon:Hide() + return + end + + local classification + pcall(function() classification = UnitPvpClassification(unit) end) + + -- Outside objective PvP this is -1 / nil. Guard secret values too. + if not canaccessvalue(classification) or type(classification) ~= "number" then + frame.bgCarrierIcon:Hide() + return + end + + local texture = PVP_CARRIER_TEXTURES[classification] + if not texture then + frame.bgCarrierIcon:Hide() + return + end + + DF:SetUpgradedStatusIcon(frame.bgCarrierIcon.texture, texture) + ApplyIconSettings(frame.bgCarrierIcon, db, "bgCarrierIcon") + ShowIconAsText(frame.bgCarrierIcon, db.bgCarrierIconText or "FC", db.bgCarrierIconShowText) + frame.bgCarrierIcon:Show() +end + +-- Combat icon — shows the crossed-swords glyph while the unit is in combat. +-- Detection is UnitAffectingCombat(unit); event-driven via UNIT_FLAGS (see +-- Headers.lua) plus the normal status-icon refresh. Secret-guarded for Midnight. +function DF:UpdateCombatIcon(frame) + if not frame or not frame.unit or not frame.combatIcon then return end + + local db = DF:GetFrameDB(frame) + if not db or not db.combatIconEnabled then + frame.combatIcon:Hide() + return + end + + local unit = frame.unit + if not UnitExists(unit) or not UnitAffectingCombat then + frame.combatIcon:Hide() + return + end + + local inCombat + pcall(function() inCombat = UnitAffectingCombat(unit) end) + + -- Hide unless we can read the value AND it's truthy (secret-safe). + if not canaccessvalue(inCombat) or not inCombat then + frame.combatIcon:Hide() + return + end + + ApplyIconSettings(frame.combatIcon, db, "combatIcon") + frame.combatIcon:Show() +end + -- ============================================================ -- UPDATE ALL STATUS ICONS FOR A FRAME -- Convenience function to update all icons at once -- ============================================================ function DF:UpdateAllStatusIcons(frame) if not frame then return end - + DF:UpdateSummonIcon(frame) DF:UpdateResurrectionIcon(frame) DF:UpdatePhasedIcon(frame) DF:UpdateAFKIcon(frame) DF:UpdateVehicleIcon(frame) DF:UpdateRaidRoleIcon(frame) + DF:UpdateBGCarrierIcon(frame) + DF:UpdateCombatIcon(frame) end -- ============================================================ @@ -967,29 +1116,19 @@ end -- Called when text mode settings change -- ============================================================ function DF:UpdateAllFramesStatusIcons() - -- Update party frames - if DF.partyHeader then - local children = {DF.partyHeader:GetChildren()} - for _, frame in pairs(children) do + -- Update all live frames via the proper iterator. GetChildren() on the secure + -- group headers returns template internals, NOT the unit buttons, so the old + -- GetChildren() loops here never reached live frames — status-icon font / size / + -- colour changes only applied after a /reload. IterateAllFrames uses + -- GetAttribute("child"..i), the same path the AFK ticker uses. + if DF.IterateAllFrames then + DF:IterateAllFrames(function(frame) if frame.unit then DF:UpdateAllStatusIcons(frame) end - end - end - - -- Update raid frames - for i = 1, 8 do - local header = DF["raidGroup" .. i] - if header then - local children = {header:GetChildren()} - for _, frame in pairs(children) do - if frame.unit then - DF:UpdateAllStatusIcons(frame) - end - end - end + end) end - + -- Also refresh test frames if in test mode if DF.testMode or DF.raidTestMode then DF:RefreshTestFrames() @@ -1051,7 +1190,7 @@ afkTickerFrame:SetScript("OnUpdate", function(self, elapsed) for i = 1, 40 do local frame = DF.testRaidFrames and DF.testRaidFrames[i] if frame and frame.afkIcon and frame.afkIcon:IsShown() then - local testData = DF:GetTestUnitData(i) + local testData = DF:GetTestUnitData(i, true) -- true = raid; without it this pulled PARTY data so raid AFK frames (3 & 15) read isAFK=false and never ticked if testData and testData.isAFK then DF:UpdateTestStatusIcons(frame, testData) end @@ -1060,6 +1199,42 @@ afkTickerFrame:SetScript("OnUpdate", function(self, elapsed) end end) +-- ============================================================ +-- BG CARRIER TICKER +-- UnitPvpClassification has no change event, so poll while in a +-- PvP instance and the icon is enabled. Cheap: only runs inside +-- battlegrounds / arenas, and only when enabled in party or raid. +-- ============================================================ +local bgCarrierTickerFrame = CreateFrame("Frame") +local bgCarrierInterval = 0.5 +local bgCarrierElapsed = 0 + +bgCarrierTickerFrame:SetScript("OnUpdate", function(self, elapsed) + bgCarrierElapsed = bgCarrierElapsed + elapsed + if bgCarrierElapsed < bgCarrierInterval then return end + bgCarrierElapsed = 0 + + -- Only relevant inside a PvP instance (battleground / arena / Blitz). + local inInstance, instanceType = IsInInstance() + if not inInstance or (instanceType ~= "pvp" and instanceType ~= "arena") then return end + + local partyDb = DF:GetDB() + local raidDb = DF:GetRaidDB() + local partyEnabled = partyDb and partyDb.bgCarrierIconEnabled + local raidEnabled = raidDb and raidDb.bgCarrierIconEnabled + if not partyEnabled and not raidEnabled then return end + + if DF.IterateAllFrames then + DF:IterateAllFrames(function(frame) + if not frame.unit or not frame.bgCarrierIcon then return end + local isParty = not frame.isRaidFrame + if (isParty and partyEnabled) or (not isParty and raidEnabled) then + DF:UpdateBGCarrierIcon(frame) + end + end) + end +end) + -- ============================================================ -- ENHANCED READY CHECK ICON -- Adds AFK state detection (4th state) @@ -1115,7 +1290,7 @@ function DF:UpdateReadyCheckIconEnhanced(frame) return end - frame.readyCheckIcon.texture:SetTexture(texture) + DF:SetUpgradedStatusIcon(frame.readyCheckIcon.texture, texture) -- Apply positioning local scale = db.readyCheckIconScale or 1.0 @@ -1183,9 +1358,7 @@ function DF:UpdateRoleIconEnhanced(frame) end -- Set texture based on style - local tex, l, r, t, b = DF:GetRoleIconTexture(db, role) - frame.roleIcon.texture:SetTexture(tex) - frame.roleIcon.texture:SetTexCoord(l, r, t, b) + DF:SetIconTextureOrAtlas(frame.roleIcon.texture, DF:GetRoleIconTexture(db, role)) frame.roleIcon:Show() diff --git a/Frames/Update.lua b/Frames/Update.lua index b1ffa185..456eafaa 100644 --- a/Frames/Update.lua +++ b/Frames/Update.lua @@ -1442,6 +1442,131 @@ function DF:ApplyFrameStyle(frame) end -- Apply layout settings to buff or debuff icons +-- ============================================================================ +-- Aura icon border (DF.Border) geometry — configure-once. +-- icon.border is a solidOnly DF.Border lazily created here on first enable. +-- This sets its band geometry + the icon-art inset; the aura hot path recolours +-- (icon.border:SetColor, secret-safe). Called from layout and the lightweight +-- slider path — never per aura update. +-- +-- Geometry mirrors the Aura Designer icon (AuraDesigner/Indicators.lua): the art +-- is inset by the border thickness and the band frames that ring, nudged outward +-- by BorderInset (spec.size = thickness, spec.inset = -inset, texture inset = +-- thickness). Identical model, so aura icons and AD icons read the same. +-- ============================================================================ +function DF:ConfigureAuraIconBorder(icon, db, prefix, enabled) + if not icon then return end + if not enabled then + -- Border off: full-size art; drop any existing border. Lazy — a disabled + -- icon never allocates a DF.Border. + DF.Border:SetIconArtInset(icon.texture, 0, false) + if icon.border and icon.border.SetColor then + DF.Border:Apply(icon.border, { enabled = false }) + end + return + end + local thickness = math.max(1, db[prefix .. "BorderSize"] or 1) + -- Debuff colour-by-type recolours per-update with a SECRET (dispel-type) + -- colour, which needs a solidOnly border (SOLID, no gradient) + the + -- per-update SetColor path. Everything else (buffs, debuffs with + -- colour-by-type OFF) is a STATIC-colour border: full toolkit, configure + -- once via BuildSpec, never recoloured. + local dynamic = (prefix == "debuff") and (db.debuffBorderColorByType ~= false) + -- (Re)create the border if its solidOnly mode no longer matches (the flag is + -- fixed at New; toggling colour-by-type flips the mode). + local border = icon.border + if not border or not border.SetColor or border._solidOnly ~= dynamic then + if border and border.Hide then border:Hide() end + border = DF.Border:New(icon, { solidOnly = dynamic, frameLevelOffset = 3 }) + icon.border = border + end + DF.Border:SetIconArtInset(icon.texture, thickness, true) + -- Full toolkit via BuildSpec (iconMode = outward geometry); style/gradient/ + -- texture/animation/colour/shadow all honoured for static borders. + local spec = DF.Border:BuildSpec(db, prefix, { iconMode = true }) + spec.enabled = true + spec.size = thickness + if dynamic then + -- A gradient/animation can't carry a per-tick secret colour — force SOLID; + -- the per-update recolour supplies the dispel-type colour. + spec.style = "SOLID"; spec.gradient = nil; spec.animation = nil + end + DF.Border:Apply(border, spec) +end + +-- Configure the unified expiring border overlay (BUFFS only) from the +-- `` key set (prefix = "buffExpiring": buffExpiringBorderEnabled / +-- Thickness / Inset / Color / ColorByTime / AnimationType / …). Configure-once +-- at layout: paints the static colour + geometry and STARTS the configured +-- animation; the aura timer then only raises/lowers the gate alpha (threshold +-- visibility) and, in Color-by-Time mode, recolours per-tick. The animation +-- runs continuously while configured (gate alpha hides it above threshold) — +-- same model the legacy pulse used. +function DF:ConfigureExpiringBorder(icon, db, prefix) + if not icon then return end + local gate = icon.expiringBorderGate + local eb = icon.expiringBorder + if not gate or not eb then return end + + -- The master "Enable Expiring Indicators" (Enabled, e.g. + -- buffExpiringEnabled) gates the WHOLE feature — the border only shows when + -- BOTH it and "Show Expiring Border" (BorderEnabled) are on. Mirrors + -- AD's expiringFeatureEnabled master. (nil = on, matching the default.) + local enabled = db[prefix .. "Enabled"] ~= false and db[prefix .. "BorderEnabled"] + icon.expiringBorderEnabled = enabled and true or false + local colorByTime = db[prefix .. "BorderColorByTime"] and true or false + icon.expiringBorderColorByTime = colorByTime + + if not enabled then + DF.Border:Apply(eb, { enabled = false }) -- hides edges + stops animation + if eb.Hide then eb:Hide() end + gate:SetAlpha(0) + -- Drop any live engine registration so the shared ticker stops driving + -- this icon's gate (the per-aura hot path re-registers when re-enabled). + if DF.Expiring then DF.Expiring:Unregister(icon) end + icon.expiringEntry = nil + return + end + + -- Color-by-Time recolours per-tick with a SECRET colour, which needs a + -- solidOnly border (bare SetColorTexture, no CreateColor/gradient). When + -- it's off the colour is static, so the full style toolkit is available. + -- The flag is fixed at New, so re-create when the mode flips. + if eb._solidOnly ~= colorByTime then + if eb.Hide then eb:Hide() end + eb = DF.Border:New(gate, { solidOnly = colorByTime, frameLevelOffset = 0 }) + icon.expiringBorder = eb + end + + local thickness = math.max(1, db[prefix .. "BorderThickness"] or 2) + if db.pixelPerfect then thickness = DF:PixelPerfect(thickness) end + -- Expiring inset already uses the outward-negative convention (matches + -- DF.Border spec.inset directly), so pass it through WITHOUT the iconMode + -- sign flip the normal aura border uses. + local inset = db[prefix .. "BorderInset"] or -1 + + -- Full toolkit (style/gradient/texture/animation) from the expiring keys. + local spec = DF.Border:BuildSpec(db, prefix) + spec.enabled = true + spec.size = thickness + spec.inset = inset + spec.color = db[prefix .. "BorderColor"] + if colorByTime then + -- Per-tick secret recolour can't be a two-stop gradient — force a SOLID + -- base; the timer supplies the duration-curve colour. + spec.style = "SOLID"; spec.gradient = nil + end + -- The inner border frame is created Hidden (Create.lua); show it so its edges + -- render and the animation driver runs. Visibility is governed by the GATE + -- alpha, not the frame's shown state, so this stays Shown while configured. + if eb.Show then eb:Show() end + DF.Border:Apply(eb, spec) -- paints edges + starts the configured animation + + -- Start hidden (gate alpha 0); the timer raises it when expiring so a + -- freshly-laid-out icon never flashes the expiring border. + gate:SetAlpha(0) +end + function DF:ApplyAuraLayout(frame, auraType) if not frame then return end -- When AD is enabled: skip buff layout only if showBuffs is off (AD replaces them). @@ -1515,11 +1640,7 @@ function DF:ApplyAuraLayout(frame, auraType) local expiringThreshold = 30 local expiringThresholdMode = "PERCENT" local expiringBorderEnabled = false - local expiringBorderColor = DEFAULT_EXPIRING_BORDER_COLOR local expiringBorderColorByTime = false - local expiringBorderPulsate = false - local expiringBorderThickness = 2 - local expiringBorderInset = -1 local expiringTintEnabled = false local expiringTintColor = DEFAULT_EXPIRING_TINT_COLOR @@ -1528,35 +1649,27 @@ function DF:ApplyAuraLayout(frame, auraType) expiringThreshold = db.buffExpiringThreshold or 30 expiringThresholdMode = db.buffExpiringThresholdMode or "PERCENT" expiringBorderEnabled = db.buffExpiringBorderEnabled or false - expiringBorderColor = db.buffExpiringBorderColor or DEFAULT_EXPIRING_BORDER_COLOR expiringBorderColorByTime = db.buffExpiringBorderColorByTime or false - expiringBorderPulsate = db.buffExpiringBorderPulsate or false - expiringBorderThickness = db.buffExpiringBorderThickness or 2 - expiringBorderInset = db.buffExpiringBorderInset or -1 expiringTintEnabled = db.buffExpiringTintEnabled or false expiringTintColor = db.buffExpiringTintColor or DEFAULT_EXPIRING_TINT_COLOR end -- Note: Debuffs don't use expiring indicators - their borders are used for debuff types - - -- Apply pixel-perfect sizing to expiring border thickness - if db.pixelPerfect and auraType == "BUFF" then - expiringBorderThickness = DF:PixelPerfect(expiringBorderThickness) - end - + -- (Expiring border pixel-perfect sizing is handled inside ConfigureExpiringBorder.) + -- Debuff border settings (use pre-calculated borderThickness if this is debuff type) - local debuffBorderThickness = auraType == "DEBUFF" and borderThickness or (db.debuffBorderThickness or 1) + local debuffBorderSize = auraType == "DEBUFF" and borderThickness or (db.debuffBorderSize or 1) local debuffBorderInset = db.debuffBorderInset or 1 -- Buff border settings (use pre-calculated borderThickness if this is buff type) - local buffBorderThickness = auraType == "BUFF" and borderThickness or (db.buffBorderThickness or 1) + local buffBorderSize = auraType == "BUFF" and borderThickness or (db.buffBorderSize or 1) local buffBorderInset = db.buffBorderInset or 1 -- Apply pixel-perfect sizing to the other type's border thickness (the current type was already done) if db.pixelPerfect then if auraType == "BUFF" then - debuffBorderThickness = DF:PixelPerfect(debuffBorderThickness) + debuffBorderSize = DF:PixelPerfect(debuffBorderSize) else - buffBorderThickness = DF:PixelPerfect(buffBorderThickness) + buffBorderSize = DF:PixelPerfect(buffBorderSize) end end @@ -1601,11 +1714,7 @@ function DF:ApplyAuraLayout(frame, auraType) icon.expiringThreshold = expiringThreshold icon.expiringThresholdMode = expiringThresholdMode icon.expiringBorderEnabled = expiringBorderEnabled - icon.expiringBorderColor = expiringBorderColor icon.expiringBorderColorByTime = expiringBorderColorByTime - icon.expiringBorderPulsate = expiringBorderPulsate - icon.expiringBorderThickness = expiringBorderThickness - icon.expiringBorderInset = expiringBorderInset icon.expiringTintEnabled = expiringTintEnabled icon.expiringTintColor = expiringTintColor @@ -1712,7 +1821,7 @@ function DF:ApplyAuraLayout(frame, auraType) -- Texture and layer reset (skip if Masque is actively skinning and controlling borders) if not (masqueActive and masqueBorderControl) then -- Get border thickness for icon texture inset calculation - local borderThickness = auraType == "DEBUFF" and debuffBorderThickness or buffBorderThickness + local borderThickness = auraType == "DEBUFF" and debuffBorderSize or buffBorderSize -- Ensure at least 1 pixel inset for visibility local textureInset = math.max(1, borderThickness) @@ -1732,55 +1841,33 @@ function DF:ApplyAuraLayout(frame, auraType) end end - -- Apply border thickness and inset (only if we control borders) - if icon.border and not (masqueActive and masqueBorderControl) then - local borderThickness = auraType == "DEBUFF" and debuffBorderThickness or buffBorderThickness - local borderInset = auraType == "DEBUFF" and debuffBorderInset or buffBorderInset - icon.border:ClearAllPoints() - icon.border:SetPoint("TOPLEFT", -borderThickness + borderInset, borderThickness - borderInset) - icon.border:SetPoint("BOTTOMRIGHT", borderThickness - borderInset, -borderThickness + borderInset) + -- Configure the border geometry via the shared helper (only if we + -- control borders). Lazy: the helper creates the DF.Border when the + -- border is enabled and restores full-size art when it's off. + if not (masqueActive and masqueBorderControl) then + local prefix = (auraType == "DEBUFF") and "debuff" or "buff" + -- MUST match the Auras hot-path borderEnabled expression exactly, + -- or the colour path could try to show a border we never created. + local borderEnabled = (auraType == "DEBUFF" and db.debuffShowBorder ~= false) + or (auraType ~= "DEBUFF" and db.buffShowBorder ~= false) + DF:ConfigureAuraIconBorder(icon, db, prefix, borderEnabled) end -- Expiring tint overlay if icon.expiringTint then icon.expiringTint:SetColorTexture(expiringTintColor.r, expiringTintColor.g, expiringTintColor.b, expiringTintColor.a) + -- The master "Enable Expiring Indicators" gates the tint too. The + -- aura timer only DRIVES the tint while the master is on, so hide + -- it here on layout (runs on the master toggle via UpdateAllFrames) + -- — otherwise a tint shown before the toggle would linger. + if not expiringEnabled then icon.expiringTint:Hide() end end - -- Expiring border (4 edge textures) - apply thickness and inset - if icon.expiringBorderTop then - local thickness = expiringBorderThickness - local inset = expiringBorderInset - - -- Only set static color if NOT in colorByTime mode (OnUpdate handles color in that mode) - if not expiringBorderColorByTime then - icon.expiringBorderTop:SetVertexColor(expiringBorderColor.r, expiringBorderColor.g, expiringBorderColor.b, expiringBorderColor.a or 1) - icon.expiringBorderBottom:SetVertexColor(expiringBorderColor.r, expiringBorderColor.g, expiringBorderColor.b, expiringBorderColor.a or 1) - icon.expiringBorderLeft:SetVertexColor(expiringBorderColor.r, expiringBorderColor.g, expiringBorderColor.b, expiringBorderColor.a or 1) - icon.expiringBorderRight:SetVertexColor(expiringBorderColor.r, expiringBorderColor.g, expiringBorderColor.b, expiringBorderColor.a or 1) - end - - -- Set thickness - icon.expiringBorderTop:SetHeight(thickness) - icon.expiringBorderBottom:SetHeight(thickness) - icon.expiringBorderLeft:SetWidth(thickness) - icon.expiringBorderRight:SetWidth(thickness) - - -- Position with inset (negative inset = outset) - icon.expiringBorderLeft:ClearAllPoints() - icon.expiringBorderLeft:SetPoint("TOPLEFT", icon, "TOPLEFT", inset, -inset) - icon.expiringBorderLeft:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", inset, inset) - - icon.expiringBorderRight:ClearAllPoints() - icon.expiringBorderRight:SetPoint("TOPRIGHT", icon, "TOPRIGHT", -inset, -inset) - icon.expiringBorderRight:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -inset, inset) - - icon.expiringBorderTop:ClearAllPoints() - icon.expiringBorderTop:SetPoint("TOPLEFT", icon.expiringBorderLeft, "TOPRIGHT", 0, 0) - icon.expiringBorderTop:SetPoint("TOPRIGHT", icon.expiringBorderRight, "TOPLEFT", 0, 0) - - icon.expiringBorderBottom:ClearAllPoints() - icon.expiringBorderBottom:SetPoint("BOTTOMLEFT", icon.expiringBorderLeft, "BOTTOMRIGHT", 0, 0) - icon.expiringBorderBottom:SetPoint("BOTTOMRIGHT", icon.expiringBorderRight, "BOTTOMLEFT", 0, 0) + -- Expiring border (BUFFS only) — unified DF.Border overlay, configured + -- once here (geometry/colour/style/animation). The aura timer drives + -- the gate alpha (threshold visibility) and Color-by-Time recolour. + if auraType == "BUFF" then + DF:ConfigureExpiringBorder(icon, db, "buffExpiring") end -- Cooldown swipe settings diff --git a/GUI/GUI.lua b/GUI/GUI.lua index ae449044..719034b5 100644 --- a/GUI/GUI.lua +++ b/GUI/GUI.lua @@ -299,8 +299,18 @@ function GUI:CreateCollapsibleSection(parent, text, defaultExpanded, width) local c = GetThemeColor() section.title:SetTextColor(c.r, c.g, c.b) section.title.UpdateTheme = function() - local nc = GetThemeColor() - section.title:SetTextColor(nc.r, nc.g, nc.b) + if section.previewDimmed then + section.title:SetTextColor(0.5, 0.5, 0.5) + else + local nc = GetThemeColor() + section.title:SetTextColor(nc.r, nc.g, nc.b) + end + end + -- Grey the header title when the section's feature is disabled (driven by + -- the preview wiring). Routes through UpdateTheme so theme changes respect it. + section.SetPreviewDimmed = function(self, dimmed) + self.previewDimmed = dimmed and true or false + self.title.UpdateTheme() end if not parent.ThemeListeners then parent.ThemeListeners = {} end table.insert(parent.ThemeListeners, section.title) @@ -356,6 +366,76 @@ function GUI:CreateCollapsibleSection(parent, text, defaultExpanded, width) widget.collapsibleSection = self end + -- Optional header preview thumbnails — a right-aligned row of small icon + -- swatches on the header bar, used to show the actual icon(s) a section + -- controls (e.g. the Role Icon section previews the Tank/Healer/DPS icons in + -- the currently selected style). Always visible on the header, so the page + -- reads as a gallery whether sections are expanded or collapsed. + -- + -- icons: array of entries, each EITHER an icon or a text label: + -- { texture = "atlas-or-path", coords = {l,r,t,b}?, desaturate = bool? } + -- { text = "MT", desaturate = bool? } + -- Icon entries are fixed-width swatches; text entries are sized to the + -- string. Entries flow right-to-left from the header's right edge so the + -- first entry sits leftmost. nil/empty clears the preview. + section.previewIcons = {} + section.SetPreviewIcons = function(self, icons) + local pool = self.previewIcons + local n = icons and #icons or 0 + local SIZE, GAP, RIGHT_INSET = 18, 4, -10 + local x = RIGHT_INSET + for i = n, 1, -1 do -- right-to-left so entry 1 ends up leftmost + local data = icons[i] + local slot = pool[i] + if not slot then + slot = CreateFrame("Frame", nil, self) + slot:SetHeight(SIZE) + slot.tex = slot:CreateTexture(nil, "OVERLAY") + slot.tex:SetAllPoints() + slot.fs = slot:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + slot.fs:SetAllPoints() + slot.fs:SetJustifyH("CENTER") + pool[i] = slot + end + local dim = data.desaturate and true or false + local w = SIZE + if data.text and data.text ~= "" then + slot.tex:Hide() + slot.fs:SetText(data.text) + if dim then + slot.fs:SetTextColor(0.5, 0.5, 0.5, 1) + elseif data.color then + slot.fs:SetTextColor(data.color.r or 1, data.color.g or 1, data.color.b or 1, data.color.a or 1) + else + slot.fs:SetTextColor(1, 0.82, 0, 1) + end + slot.fs:Show() + w = math.max(SIZE, (slot.fs:GetStringWidth() or 0) + 4) + else + slot.fs:Hide() + -- data.texture may be an atlas name or a texture path; the helper + -- prefers the atlas and falls back to the path (+ optional coords). + local co = data.coords + DF:SetIconTextureOrAtlas(slot.tex, data.texture, co and co[1], co and co[2], co and co[3], co and co[4]) + slot.tex:SetDesaturated(dim) + -- Optional per-entry inset: textures that fill their cell edge-to-edge + -- (e.g. raid-target markers) read bigger than the padded status-icon + -- atlases. data.inset shrinks the swatch to match. + local pad = data.inset or 0 + slot.tex:ClearAllPoints() + slot.tex:SetPoint("TOPLEFT", slot, "TOPLEFT", pad, -pad) + slot.tex:SetPoint("BOTTOMRIGHT", slot, "BOTTOMRIGHT", -pad, pad) + slot.tex:Show() + end + slot:SetWidth(w) + slot:ClearAllPoints() + slot:SetPoint("RIGHT", self, "RIGHT", x, 0) + slot:Show() + x = x - w - GAP + end + for i = n + 1, #pool do pool[i]:Hide() end + end + -- Hover effects clickArea:SetScript("OnEnter", function() section:SetBackdropColor(C_HOVER.r, C_HOVER.g, C_HOVER.b, 0.8) @@ -397,6 +477,10 @@ function GUI:CreateSettingsGroup(parent, width, opts) group.isSettingsGroup = true group.collapsible = opts.collapsible or false group.showSummary = opts.showSummary or false + -- Optional saved-state key override: lets several boxes share a standard + -- display header (e.g. "Appearance") while persisting collapse state under a + -- unique key (e.g. "afkIcon:Appearance"), so they don't toggle together. + group.collapseKey = opts.collapseKey group.collapsed = false -- Visual styling - subtle background and border @@ -426,26 +510,30 @@ function GUI:CreateSettingsGroup(parent, width, opts) barBg:SetColorTexture(1, 1, 1, 0.03) local barIcon = collapseBar:CreateTexture(nil, "OVERLAY") - barIcon:SetSize(8, 8) + barIcon:SetSize(12, 12) barIcon:SetPoint("CENTER", 0, 0) local mediaPath = "Interface\\AddOns\\DandersFrames\\Media\\Icons\\" - barIcon:SetTexture(mediaPath .. "chevron_right") - barIcon:SetVertexColor(1, 1, 1, 0.3) + -- "expand_more" is a down chevron; rotate 180° so it points UP — this bar + -- collapses the (expanded) section, so an up arrow reads correctly. + barIcon:SetTexture(mediaPath .. "expand_more") + barIcon:SetRotation(math.pi) + barIcon:SetVertexColor(1, 1, 1, 0.5) collapseBar:SetScript("OnEnter", function() barBg:SetColorTexture(1, 1, 1, 0.06) - barIcon:SetVertexColor(1, 1, 1, 0.6) + barIcon:SetVertexColor(1, 1, 1, 0.85) end) collapseBar:SetScript("OnLeave", function() barBg:SetColorTexture(1, 1, 1, 0.03) - barIcon:SetVertexColor(1, 1, 1, 0.3) + barIcon:SetVertexColor(1, 1, 1, 0.5) end) collapseBar:SetScript("OnClick", function() group.collapsed = true local headerText = group.headerWidget and group.headerWidget.text and group.headerWidget.text:GetText() - if headerText then + local stateKey = group.collapseKey or headerText + if stateKey then local saved = GUI:GetCollapsedGroups() - saved[headerText] = true + saved[stateKey] = true end if group.collapseArrow then group.collapseArrow:SetTexture(mediaPath .. "chevron_right") @@ -453,6 +541,8 @@ function GUI:CreateSettingsGroup(parent, width, opts) if DF.AuraDesigner_RefreshPage then DF:AuraDesigner_RefreshPage() end + local pageChild = group:GetParent() + if pageChild and pageChild.RefreshStates then pageChild.RefreshStates() end if group.onCollapseChanged then group.onCollapseChanged(group) end end) @@ -476,8 +566,9 @@ function GUI:CreateSettingsGroup(parent, width, opts) -- Resolve collapsed state: default to expanded unless saved state says collapsed local headerText = widget.text:GetText() + local stateKey = self.collapseKey or headerText local savedStates = GUI:GetCollapsedGroups() - if headerText and savedStates[headerText] then + if stateKey and savedStates[stateKey] then self.collapsed = true else self.collapsed = false @@ -510,15 +601,19 @@ function GUI:CreateSettingsGroup(parent, width, opts) widget:SetScript("OnMouseDown", function() self.collapsed = not self.collapsed -- Persist collapsed state to SavedVariables - if headerText then + if stateKey then local saved = GUI:GetCollapsedGroups() - saved[headerText] = self.collapsed or nil -- only store true, remove when expanded + saved[stateKey] = self.collapsed or nil -- only store true, remove when expanded end arrow:SetTexture(self.collapsed and (mediaPath .. "chevron_right") or (mediaPath .. "expand_more")) - -- Refresh the page to recalculate layout + -- Refresh the page to recalculate layout. The Aura Designer page + -- has its own refresh; BuildPage pages (icons, frame settings…) + -- expose RefreshStates on the group's parent (self.child). if DF.AuraDesigner_RefreshPage then DF:AuraDesigner_RefreshPage() end + local pageChild = self:GetParent() + if pageChild and pageChild.RefreshStates then pageChild.RefreshStates() end if self.onCollapseChanged then self.onCollapseChanged(self) end end) @@ -746,12 +841,16 @@ end -- dbTable/dbKey: reads/writes the selected value -- callback: called after a selection change -- totalWidth: total container width (buttons divide it evenly with small gaps) -function GUI:CreateSegmentedButtonGroup(parent, options, dbTable, dbKey, callback, totalWidth) +function GUI:CreateSegmentedButtonGroup(parent, options, dbTable, dbKey, callback, totalWidth, minBtnWidthOpt) local container = CreateFrame("Frame", nil, parent) totalWidth = totalWidth or 560 local btnHeight = 38 -- compact modern height: label + subtitle fit snugly local gap = 4 - local minBtnWidth = 110 -- below this, buttons wrap to next row + -- minBtnWidth governs when buttons wrap. The default suits 2-3 segment + -- groups with full-word labels in the standard ~560px settings panels; + -- caller can pass a smaller value when packing more / shorter segments + -- into a narrower group (e.g. a 260px border-controls column). + local minBtnWidth = minBtnWidthOpt or 110 container:SetSize(totalWidth, btnHeight) local n = #options @@ -1029,6 +1128,23 @@ function GUI:CreateInfoBanner(parent, opts) end end g:LayoutChildren() + -- Also bubble up to the page so its column layout sees the + -- group's new calculatedHeight. Without this, sibling groups + -- in the same column stay anchored to the OLD bottom of this + -- group, and the group's backdrop (now taller) visibly + -- overshoots past those siblings' anchor — rendering as an + -- empty rectangle of group backdrop above the next group. + -- Hit when an animation type is first selected in a border + -- panel: banner appears, async recompute grows the group, + -- next group below stays put, gap shows. + local p = g:GetParent() + while p do + if type(p.RefreshStates) == "function" and p.children then + p:RefreshStates() + return + end + p = p:GetParent() + end return end -- Otherwise, walk up to find a host page. @@ -1053,9 +1169,28 @@ function GUI:CreateInfoBanner(parent, opts) end local pending = false + -- Set whenever a RecomputeHeight() request was deferred because the + -- banner was invisible. Cleared once a real recompute runs after the + -- banner becomes visible. OnShow checks this flag to decide whether to + -- trigger a fresh recompute when the widget surfaces. + local deferredWhileHidden = false local function DoRecomputeHeight() pending = false if recomputing then return end + -- Skip when the banner is hidden — GetStringHeight on a hidden + -- FontString returns an unreliable value (width depends on the + -- parent's layout having run, and LayoutChildren doesn't SetWidth + -- on hidden widgets), and the resulting SetHeight + Trigger­Host­ + -- Relayout cascade costs real work proportional to the host + -- SettingsGroup's widget count. For consumers that mount banners + -- behind hideOn predicates that default to true (animation perf + -- warning at type=NONE) this used to fire one cascade per banner + -- at every GUI open — N indicator cards × ~25-widget group × + -- proxy-backed dbTable in Aura Designer = sustained lockup. + if not banner:IsVisible() then + deferredWhileHidden = true + return + end local h = math.ceil(MeasureContent()) -- Chrome: 13 px top (icon at -10, text nudged -3) + 9 px bottom = 22 px. local newH = math.max(opts.minHeight or 28, h + 22) @@ -1094,9 +1229,35 @@ function GUI:CreateInfoBanner(parent, opts) end end - banner:SetScript("OnSizeChanged", function() - RecomputeHeight() - end) + -- opts.staticHeight: skip ALL recompute machinery (no OnSizeChanged + -- binding, no OnShow re-measure, no DoRecomputeHeight cascade). + -- For consumers whose text never changes after construction AND who + -- can predict a sensible fixed height up front (e.g. animation perf + -- warning). Avoids the SetHeight → OnSizeChanged → TriggerHostRelayout + -- → g:LayoutChildren feedback loop that, in container layouts where + -- LayoutChildren re-fires SetWidth on every pass (Aura Designer's + -- indicator card body), drops FPS the moment the banner surfaces. + if not opts.staticHeight then + -- Only width changes affect the wrapped string height — height + -- changes (which our own SetHeight inside DoRecomputeHeight triggers) + -- don't. Filtering on width breaks part of the feedback loop, but + -- doesn't help when the host layout fires OnSizeChanged per frame + -- with same-or-different widths (some scroll-frame containers do). + local lastMeasuredWidth + banner:SetScript("OnSizeChanged", function(self, w, _) + if w == lastMeasuredWidth then return end + lastMeasuredWidth = w + RecomputeHeight() + end) + banner:SetScript("OnShow", function() + if deferredWhileHidden then + deferredWhileHidden = false + cachedH = nil + lastMeasuredWidth = nil + RecomputeHeight() + end + end) + end banner._RecomputeHeight = RecomputeHeight function banner:SetIconTexture(path) @@ -2395,7 +2556,7 @@ function GUI:CreateInput(parent, label, width) end -- CreateEditBox: Text input with db binding (for settings like custom text) -function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width) +function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width, placeholder) local frame = CreateFrame("Frame", nil, parent) frame:SetSize(width or 180, 44) @@ -2476,6 +2637,25 @@ function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width) end) editbox:SetScript("OnEditFocusLost", SaveValue) + -- Optional placeholder: greyed example text shown while the box is empty + -- and unfocused. Purely cosmetic — never written to the db. + if placeholder and placeholder ~= "" then + local ph = editbox:CreateFontString(nil, "ARTWORK", "DFFontHighlightSmall") + ph:SetPoint("LEFT", 5, 0) + ph:SetPoint("RIGHT", -5, 0) + ph:SetJustifyH("LEFT") + ph:SetText(placeholder) + ph:SetTextColor(C_TEXT_DIM.r, C_TEXT_DIM.g, C_TEXT_DIM.b, 0.55) + local function UpdatePlaceholder() + ph:SetShown(not editbox:HasFocus() and editbox:GetText() == "") + end + editbox.UpdatePlaceholder = UpdatePlaceholder + editbox:HookScript("OnTextChanged", UpdatePlaceholder) + editbox:HookScript("OnEditFocusGained", UpdatePlaceholder) + editbox:HookScript("OnEditFocusLost", UpdatePlaceholder) + UpdatePlaceholder() + end + -- Refresh override indicators on show frame:SetScript("OnShow", function() if dbTable and dbKey then @@ -2484,13 +2664,22 @@ function GUI:CreateEditBox(parent, label, dbTable, dbKey, callback, width) if frame.UpdateOverrideIndicators then frame:UpdateOverrideIndicators(dbTable and dbTable[dbKey]) end + if editbox.UpdatePlaceholder then editbox.UpdatePlaceholder() end end) - + frame.EditBox = editbox return frame end -function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, callback, lightweightUpdate, usePreviewMode) +-- customGet / customSet (optional, matches CreateDropdown's pattern): when +-- provided, the slider routes its reads and writes through these functions +-- instead of dbTable[dbKey] directly. Used by widgets whose underlying value +-- lives inside a nested table (e.g. Border Alpha → BorderColor.a), +-- where the plain `dbTable[dbKey] = v` path can't express the nesting. +-- Consumers that pass customSet typically pass dbKey = nil so the +-- auto-profile override system doesn't track a key that doesn't exist at the +-- top level of dbTable. +function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, callback, lightweightUpdate, usePreviewMode, customGet, customSet) local container = CreateFrame("Frame", nil, parent) container:SetSize(260, 50) @@ -2612,6 +2801,19 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c end end + -- Wrapper for both pathways: customGet/Set when provided, dbTable[dbKey] + -- otherwise. Centralising this avoids a sprinkling of `if customGet then` + -- across every place the slider touches its value. + local function ReadValue() + if customGet then return customGet() end + if dbTable then return dbTable[dbKey] end + return nil + end + local function WriteValue(v) + if customSet then return customSet(v) end + if dbTable then dbTable[dbKey] = v end + end + local function UpdateValue(val) val = val or minVal suppressCallback = true @@ -2629,7 +2831,7 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c slider:SetScript("OnMouseDown", function(self, button) if button == "LeftButton" then isDragging = true - local funcName = lightweightUpdate and (dbKey .. " lightweight") or nil + local funcName = lightweightUpdate and ((dbKey or label or "slider") .. " lightweight") or nil DF:OnSliderDragStart(lightweightUpdate, funcName, sliderUsePreviewMode) end end) @@ -2647,12 +2849,13 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c end) slider:SetScript("OnShow", function() - if dbTable then UpdateValue(dbTable[dbKey]) end + local v = ReadValue() + if v ~= nil then UpdateValue(v) end end) - + slider:SetScript("OnValueChanged", function(self, value) if suppressCallback then return end - if not dbTable then return end + if not (dbTable or customSet) then return end if step >= 1 then value = math.floor(value + 0.5) else @@ -2660,7 +2863,7 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c end -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, value) then if not input:HasFocus() then input:SetText(FormatValue(value)) end UpdateFill() @@ -2668,7 +2871,7 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c return end - dbTable[dbKey] = value + WriteValue(value) -- If editing a profile, also set the override if DF.AutoProfilesUI and DF.AutoProfilesUI:IsEditing() and dbKey then @@ -2693,7 +2896,7 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c val = math.max(minVal, math.min(maxVal, val)) -- Runtime override protection: redirect to baseline, skip refresh - if GUI.SelectedMode == "raid" and DF.AutoProfilesUI + if dbKey and GUI.SelectedMode == "raid" and DF.AutoProfilesUI and DF.AutoProfilesUI:HandleRuntimeWrite(dbKey, val) then self:SetText(FormatValue(val)) suppressCallback = true @@ -2705,7 +2908,7 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c return end - dbTable[dbKey] = val + WriteValue(val) suppressCallback = true slider:SetValue(val) suppressCallback = false @@ -2734,17 +2937,18 @@ function GUI:CreateSlider(parent, label, minVal, maxVal, step, dbTable, dbKey, c -- Guaranteed full update (SetValue may not fire OnValueChanged if value didn't change) DF:UpdateAll() else - UpdateValue(dbTable[dbKey]) + local v = ReadValue(); if v ~= nil then UpdateValue(v) end end self:ClearFocus() end) - + input:SetScript("OnEscapePressed", function(self) - UpdateValue(dbTable[dbKey]) + local v = ReadValue(); if v ~= nil then UpdateValue(v) end self:ClearFocus() end) - - if dbTable then UpdateValue(dbTable[dbKey]) end + + local initial = ReadValue() + if initial ~= nil then UpdateValue(initial) end -- SEARCH: Register this setting with slider metadata if DF.Search and dbKey and type(dbKey) == "string" then @@ -3190,7 +3394,7 @@ end local OUTLINE_FLAG_ORDER = { "NONE", "OUTLINE", "THICKOUTLINE", "MONOCHROME", "MONOCHROME, OUTLINE", "MONOCHROME, THICKOUTLINE" } -function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback) +function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback, inheritKey) local options = { NONE = L["None"], OUTLINE = L["Outline"], @@ -3200,8 +3404,8 @@ function GUI:CreateOutlineDropdown(parent, label, dbTable, dbKey, callback) ["MONOCHROME, THICKOUTLINE"] = L["Monochrome Thick Outline"], _order = OUTLINE_FLAG_ORDER, } - local get = function() return DF:OutlineFlag(dbTable[dbKey]) end - local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey])) end + local get = function() return DF:OutlineFlag(dbTable[dbKey] or (inheritKey and dbTable[inheritKey])) end + local set = function(flag) dbTable[dbKey] = DF:ComposeOutline(flag, DF:OutlineHasShadow(dbTable[dbKey] or (inheritKey and dbTable[inheritKey]))) end return GUI:CreateDropdown(parent, label or L["Outline"], options, dbTable, dbKey, callback, get, set) end @@ -3211,6 +3415,752 @@ function GUI:CreateShadowCheckbox(parent, label, dbTable, dbKey, callback) return GUI:CreateCheckbox(parent, label or L["Shadow"], dbTable, dbKey, callback, get, set) end +-- ============================================================ +-- UNIFIED BORDER CONTROL SET +-- Drops the canonical Show / Style / Texture / Size / Colour controls plus +-- whichever optional Phase B controls the consumer opts into (offset, inset, +-- blendMode, gradient, shadow). Saved-variable keys are built from a single +-- camelCase `prefix` (e.g. "defensiveIcon" → "defensiveIconBorderSize"), so +-- consumers add one call instead of hand-rolling ~6-15 widgets each. +-- +-- Each opts.include flag is per-element: "tailor-made to what makes logical +-- sense" — the API exposes everything, but consumers opt in only to what fits +-- their element. Returns a table of widget references so the caller can add +-- per-element extras (dispel-type colour, pulsate, etc.) afterwards. +-- +-- opts = { +-- parent = the panel widget (e.g. self.child) — same first arg the +-- underlying CreateCheckbox/Slider/etc. take +-- include = { offset=, inset=, blendMode=, gradient=, shadow=, +-- classColor=, roleColor=, colorByTime=, colorByType= } +-- fullUpdate = callback for full re-render (drop / value-set) +-- lightUpdate = callback for slider-drag (size, offsets, shadow sliders) +-- lightColors = callback for live colour-picker preview +-- refreshStates = optional hook fired when Show/Gradient/Shadow toggles +-- change visibility of other widgets +-- hideWhen = optional predicate fn(db) → bool. When true, EVERY widget +-- (including the Show toggle itself) hides — used by +-- consumers whose border section sits inside a parent panel +-- with its own enable toggle (e.g. defensiveIconEnabled). +-- sizeMin / sizeMax / sizeStep = slider range overrides +-- offsetMin / offsetMax / offsetStep +-- } +-- ============================================================ +-- CreateAnimationControls — the Border Animation control set +-- (Type dropdown + every per-effect tunable), extracted so the base +-- Border Animation panel (CreateBorderControls / include.animate) AND +-- Aura Designer's Expiring Animation override render an IDENTICAL set of +-- widgets from ONE source. Add or remove an effect / tunable here and both +-- panels update together — no drift. +-- +-- group = SettingsGroup the widgets are added to +-- dbTable = db / proxy the widgets read & write +-- animPrefix = key namespace; widgets target dbTable[animPrefix .. suffix] +-- (base border: "BorderAnimation"; AD expiring: +-- "ExpiringAnimation") +-- opts: +-- parent = frame parent for the widgets +-- fullUpdate = heavy refresh callback (dropdown / slider-release / colour) +-- lightUpdate = light refresh callback (slider-drag) +-- lightColors = live colour-picker preview callback (needed for AD's +-- proxy, whose sub-table colour writes skip __newindex) +-- typeLabel = label for the Type dropdown +-- hideExtra = optional predicate; when true the WHOLE block hides +-- (the border panel folds the block under Show Border; +-- the always-visible Expiring override omits it) +-- onTypeChange = runs after the Type dropdown changes (re-layout / reflow) +-- perfBanner = show the per-border FPS warning banner (default true) +-- Returns the widget table (animationType, animationColor, … ) so the caller +-- can merge the handles into its own control table. +-- ============================================================ +function GUI:CreateAnimationControls(group, dbTable, animPrefix, opts) + opts = opts or {} + local parent = opts.parent + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local typeLabel = opts.typeLabel or L["Border Animation"] + local hideExtra = opts.hideExtra + local onTypeChange = opts.onTypeChange or function() end + local showPerfBanner = opts.perfBanner ~= false + + local function aKey(suffix) return animPrefix .. suffix end + local animTypeKey = aKey("Type") + local function animType() return dbTable[animTypeKey] or "NONE" end + local function extraOff() return (hideExtra and hideExtra()) or false end + local function animOff() return extraOff() or animType() == "NONE" end + + -- Sets of effect types each tunable applies to (truthiness on a + -- string-keyed set). Mirrors the per-effect parameter map — keep in + -- sync with StartAnimation's branches in Frames/Border.lua. + -- DF_DASH: Frequency = march SPEED (0 = static dashed), Thickness = dash + -- thickness, Inset = dash inset. + local hasFrequency = { PULSATE=1, DF_PULSATE=1, CHASE=1, FLASH=1, PROC=1, + WIPE=1, RIPPLE=1, SEGMENT_REVEAL=1, DF_DASH=1 } + local hasParticles = { PULSATE=1, CHASE=1 } + local hasThickness = { PULSATE=1, WIPE=1, RIPPLE=1, SEGMENT_REVEAL=1, + SIDES_ONLY=1, CORNERS_ONLY=1, DF_DASH=1 } + -- Inset / Offset apply to every non-NONE effect EXCEPT DF_PULSATE (which + -- modulates the border's own edges and has no separate animRect). + local hasPositioning = { PULSATE=1, CHASE=1, FLASH=1, PROC=1, WIPE=1, RIPPLE=1, + SEGMENT_REVEAL=1, SIDES_ONLY=1, CORNERS_ONLY=1, DF_DASH=1 } + local pulsateOnly = { PULSATE=1 } + local chaseOnly = { CHASE=1 } + local sidesOnly = { SIDES_ONLY=1 } + local cornersOnly = { CORNERS_ONLY=1 } + local function hideUnless(set) + return function() + if animOff() then return true end + return not set[animType()] + end + end + + local w = {} + + -- DF_PULSATE sits next to PULSATE so users compare them at a glance — + -- both "pulse" effects, but the LCG one renders a particle ring outside + -- the border while DF Pulsate fades the border's own edge alpha. + w.animationType = group:AddWidget(GUI:CreateDropdown(parent, typeLabel, + { + NONE = L["None"], + PULSATE = L["Pulsate"], + DF_PULSATE = L["DF Pulsate"], + CHASE = L["Chase"], + FLASH = L["Flash"], + PROC = L["Proc"], + WIPE = L["Wipe"], + RIPPLE = L["Ripple"], + SEGMENT_REVEAL = L["Segment Reveal"], + SIDES_ONLY = L["Sides Only"], + CORNERS_ONLY = L["Corners Only"], + DF_DASH = L["DF Dash"], + -- None first (the "off" option), then alphabetical by label. + _order = { "NONE", "CHASE", "CORNERS_ONLY", "DF_DASH", "DF_PULSATE", + "FLASH", "PROC", "PULSATE", "RIPPLE", "SEGMENT_REVEAL", + "SIDES_ONLY", "WIPE" }, + }, + dbTable, animTypeKey, onTypeChange), 55) + -- Type dropdown respects only the extra gate (e.g. Show Border). With no + -- extra gate (Expiring override) it's always visible. + w.animationType.hideOn = hideExtra or function() return false end + + -- Perf warning: animations run an OnUpdate (or LCG internal animation) + -- per active border, which adds up in 20-30 player raids. + if showPerfBanner then + local perfBanner = GUI:CreateInfoBanner(parent, { + tone = "warning", + text = L["Animations run per-border and may impact FPS in larger raids. Use sparingly on high-priority alerts."], + staticHeight = true, + minHeight = 56, + }) + w.animationPerfBanner = group:AddWidget(perfBanner, perfBanner.layoutHeight) + w.animationPerfBanner.hideOn = animOff + end + + -- Animation colour applies to every effect except DF_PULSATE (which + -- modulates the border's own edge alpha — no separate colour). lightColors + -- is threaded through so AD's proxy gets live preview while dragging. + w.animationColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Animation Colour"], + dbTable, aKey("Color"), true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.animationColor.hideOn = function() + return animOff() or animType() == "DF_PULSATE" + end + + -- Min 0: DF_DASH reads Frequency as march speed, so 0 = static dashed. + -- The LCG glows treat 0 as their default rate (clamped in StartAnimation), + -- and the OnUpdate effects fall back to a sensible default period at 0. + w.animationFrequency = group:AddWidget(GUI:CreateSlider(parent, L["Animation Frequency"], + 0, 4, 0.05, dbTable, aKey("Frequency"), + fullUpdate, lightUpdate, true), 55) + w.animationFrequency.hideOn = hideUnless(hasFrequency) + + w.animationParticles = group:AddWidget(GUI:CreateSlider(parent, L["Animation Particles"], + 1, 16, 1, dbTable, aKey("Particles"), + fullUpdate, lightUpdate, true), 55) + w.animationParticles.hideOn = hideUnless(hasParticles) + + w.animationLength = group:AddWidget(GUI:CreateSlider(parent, L["Animation Length"], + 1, 30, 1, dbTable, aKey("Length"), + fullUpdate, lightUpdate, true), 55) + w.animationLength.hideOn = hideUnless(pulsateOnly) + + w.animationThickness = group:AddWidget(GUI:CreateSlider(parent, L["Animation Thickness"], + 1, 12, 1, dbTable, aKey("Thickness"), + fullUpdate, lightUpdate, true), 55) + w.animationThickness.hideOn = hideUnless(hasThickness) + + w.animationScale = group:AddWidget(GUI:CreateSlider(parent, L["Animation Scale"], + 0.5, 3, 0.05, dbTable, aKey("Scale"), + fullUpdate, lightUpdate, true), 55) + w.animationScale.hideOn = hideUnless(chaseOnly) + + w.animationInset = group:AddWidget(GUI:CreateSlider(parent, L["Animation Inset"], + -50, 50, 1, dbTable, aKey("Inset"), + fullUpdate, lightUpdate, true), 55) + w.animationInset.hideOn = hideUnless(hasPositioning) + + w.animationOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset X"], + -50, 50, 1, dbTable, aKey("OffsetX"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetX.hideOn = hideUnless(hasPositioning) + + w.animationOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Animation Offset Y"], + -50, 50, 1, dbTable, aKey("OffsetY"), + fullUpdate, lightUpdate, true), 55) + w.animationOffsetY.hideOn = hideUnless(hasPositioning) + + w.animationMask = group:AddWidget(GUI:CreateCheckbox(parent, L["Pulsate Backing Frame"], + dbTable, aKey("Mask"), fullUpdate), 30) + w.animationMask.hideOn = hideUnless(pulsateOnly) + + w.animationSidesAxis = group:AddWidget(GUI:CreateDropdown(parent, L["Sides Axis"], + { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, + dbTable, aKey("SidesAxis"), fullUpdate), 55) + w.animationSidesAxis.hideOn = hideUnless(sidesOnly) + + w.animationCornerLength = group:AddWidget(GUI:CreateSlider(parent, L["Corner Length"], + 2, 40, 1, dbTable, aKey("CornerLength"), + fullUpdate, lightUpdate, true), 55) + w.animationCornerLength.hideOn = hideUnless(cornersOnly) + + return w +end + +-- ============================================================ +function GUI:CreateBorderControls(group, dbTable, prefix, opts) + opts = opts or {} + local parent = opts.parent + local include = opts.include or {} + local fullUpdate = opts.fullUpdate or function() end + local lightUpdate = opts.lightUpdate + local lightColors = opts.lightColors + local refreshStates = opts.refreshStates + local hideWhen = opts.hideWhen + + local sizeMin, sizeMax, sizeStep = opts.sizeMin or 0, opts.sizeMax or 8, opts.sizeStep or 1 + local offMin, offMax, offStep = opts.offsetMin or -50, opts.offsetMax or 50, opts.offsetStep or 1 + + local function key(suffix) return prefix .. suffix end + local showKey = key("ShowBorder") + -- The Show toggle only respects the parent-level hideWhen. Everything + -- else respects hideWhen OR the Show toggle being off. + -- + -- hideOn predicates IGNORE the table arg LayoutChildren passes (which is + -- always `DF.db[GUI.SelectedMode]`) and read from the captured `dbTable` + -- instead. For consumers whose dbTable == DF.db[mode] (Frame Border, + -- Defensive Icon, etc.) the two are identical so behaviour is unchanged. + -- For consumers with a different dbTable — notably Aura Designer's + -- per-aura proxy — this is the only way the visibility predicates see + -- the actual border state (e.g. proxy.BorderStyle, not the unrelated + -- DF.db.party.BorderStyle which doesn't exist). + local function hideShow() return hideWhen and hideWhen(dbTable) or false end + local function hideOff() return hideShow() or dbTable[showKey] == false end + + local w = {} + + w.show = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Border"], dbTable, showKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.show.hideOn = hideShow + + -- Slider label reads "Border Thickness" (more meaningful than "Size") but + -- the underlying db key stays `BorderSize` and spec.size in the + -- backend stays the same — purely a user-facing rename, no migration. + w.size = group:AddWidget(GUI:CreateSlider(parent, L["Border Thickness"], sizeMin, sizeMax, sizeStep, + dbTable, key("BorderSize"), fullUpdate, lightUpdate, true), 55) + w.size.hideOn = hideOff + + -- Gradient is a STYLE, not a separate toggle. When the consumer opts into + -- gradient via include.gradient, we expose GRADIENT as a third dropdown + -- option. Otherwise the dropdown is the original SOLID / TEXTURE pair. + local styleOptions = { SOLID = L["Solid"], TEXTURE = L["Texture"], + _order = { "SOLID", "TEXTURE" } } + if include.gradient then + styleOptions.GRADIENT = L["Gradient"] + -- Insert GRADIENT between SOLID and TEXTURE so the order reads + -- "simple colour → two colours → custom texture" in the dropdown. + styleOptions._order = { "SOLID", "GRADIENT", "TEXTURE" } + end + w.style = group:AddWidget(GUI:CreateDropdown(parent, L["Border Style"], + styleOptions, dbTable, key("BorderStyle"), function() + -- Match the frame border: pick the first LSM border when switching + -- to Texture without one configured. + if dbTable[key("BorderStyle")] == "TEXTURE" then + local list = DF.GetBorderList and DF:GetBorderList() or nil + local t = dbTable[key("BorderTexture")] + if list and (not t or t == "" or t == "SOLID") then + dbTable[key("BorderTexture")] = next(list) + end + end + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.style.hideOn = hideOff + + -- isGradient is declared up here so the Style-dependent widget cluster + -- (Texture under TEXTURE style, gradient pickers under GRADIENT style) + -- can sit immediately below the Style dropdown — the consequence of the + -- user's style choice reads top-to-bottom without scrolling past + -- unrelated inset / offset / blend controls first. + local function isGradient() return dbTable[key("BorderStyle")] == "GRADIENT" end + + w.texture = group:AddWidget(GUI:CreateDropdown(parent, L["Border Texture"], + DF:GetBorderList(), dbTable, key("BorderTexture"), fullUpdate), 55) + w.texture.hideOn = function() + return hideOff() or dbTable[key("BorderStyle")] ~= "TEXTURE" + end + + -- Gradient pickers — only visible under Style = GRADIENT. Grouped here + -- (between Texture and the Colour Source dropdown) so all style-dependent + -- widgets sit directly under the Style dropdown that controls them. + -- The standalone "Border Gradient" checkbox was removed when Style + -- absorbed it; Style is now the single source of truth so it's not + -- possible to pick "Solid + Class Color" then have a Gradient checkbox + -- stomp the class colour (the previous UX bug). Legacy + -- `BorderGradientEnabled = true` profiles are migrated to + -- `BorderStyle = "GRADIENT"` on db load. + if include.gradient then + local function gradHide() return hideOff() or not isGradient() end + + w.gradientStart = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient Start Colour"], + dbTable, key("BorderGradientStartColor"), true, fullUpdate), 35) + w.gradientStart.hideOn = gradHide + w.gradientEnd = group:AddWidget(GUI:CreateColorPicker(parent, L["Gradient End Colour"], + dbTable, key("BorderGradientEndColor"), true, fullUpdate), 35) + w.gradientEnd.hideOn = gradHide + w.gradientDirection = group:AddWidget(GUI:CreateDropdown(parent, L["Gradient Direction"], + { HORIZONTAL = L["Horizontal"], VERTICAL = L["Vertical"] }, + dbTable, key("BorderGradientDirection"), fullUpdate), 55) + w.gradientDirection.hideOn = gradHide + end + + -- Colour Source dropdown sits ABOVE the colour picker so the relationship + -- "source → resulting colour" reads top-to-bottom in the panel. The + -- options table is built dynamically: Static is always present; Class + -- and Role are added if the consumer opted in via the matching include. + -- Hidden in GRADIENT style — gradient owns its own colours, no resolver + -- chain applies (see Border:BuildSpec). + local sourceKey = key("BorderColorSource") + local hasSourceDropdown = include.classColor or include.roleColor + if hasSourceDropdown then + local sourceOptions = { STATIC = L["Static"], _order = { "STATIC" } } + if include.classColor then + sourceOptions.CLASS = L["Class"] + sourceOptions._order[#sourceOptions._order + 1] = "CLASS" + end + if include.roleColor then + sourceOptions.ROLE = L["Role"] + sourceOptions._order[#sourceOptions._order + 1] = "ROLE" + end + -- Default the source from the legacy boolean keys when first opened. + if dbTable[sourceKey] == nil then + if dbTable[key("BorderUseClassColor")] then dbTable[sourceKey] = "CLASS" + elseif dbTable[key("BorderUseRoleColor")] then dbTable[sourceKey] = "ROLE" + else dbTable[sourceKey] = "STATIC" end + end + w.colorSource = group:AddWidget(GUI:CreateDropdown(parent, L["Border Color Source"], + sourceOptions, dbTable, sourceKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 55) + w.colorSource.hideOn = function() return hideOff() or isGradient() end + end + + -- Static colour picker — only visible when source is STATIC (or when the + -- consumer didn't enable any resolver at all, so source doesn't exist). + -- Hidden in GRADIENT style (gradient uses its own start/end pickers). + w.color = group:AddWidget(GUI:CreateColorPicker(parent, L["Border Color"], dbTable, key("BorderColor"), + true, fullUpdate, lightColors, lightColors ~= nil), 35) + w.color.hideOn = function() + if hideOff() or isGradient() then return true end + if hasSourceDropdown then + local src = dbTable[sourceKey] or "STATIC" + return src ~= "STATIC" + end + return false + end + + -- Unified Border Alpha slider — opt-in via include.alpha. Reads / writes + -- the SAME alpha component the colour picker exposes + -- (BorderColor.a), so the slider is just a convenient handle for + -- the picker's alpha bar — no separate alpha key to migrate or keep in + -- sync. Visible in STATIC / CLASS / ROLE; hidden in GRADIENT (where the + -- two gradient pickers each carry their own alpha, and a single slider + -- has no obvious meaning). + if include.alpha then + -- Ensure the underlying colour table has an alpha component so the + -- slider doesn't read nil on first open. The picker also seeds .a but + -- we don't depend on widget-creation order. + local c = dbTable[key("BorderColor")] + if type(c) ~= "table" then + c = { r = 0, g = 0, b = 0, a = 1 } + dbTable[key("BorderColor")] = c + end + if c.a == nil then c.a = 1 end + + w.alpha = group:AddWidget(GUI:CreateSlider(parent, L["Border Alpha"], 0, 1, 0.05, + nil, nil, fullUpdate, lightColors or lightUpdate, true, + function() return dbTable[key("BorderColor")].a or 1 end, + function(v) dbTable[key("BorderColor")].a = v end), 55) + w.alpha.hideOn = function() return hideOff() or isGradient() end + end + + if include.inset then + w.inset = group:AddWidget(GUI:CreateSlider(parent, L["Border Inset"], -20, 20, 1, + dbTable, key("BorderInset"), fullUpdate, lightUpdate, true), 55) + w.inset.hideOn = hideOff + end + + if include.offset then + w.offsetX = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset X"], offMin, offMax, offStep, + dbTable, key("BorderOffsetX"), fullUpdate, lightUpdate, true), 55) + w.offsetX.hideOn = hideOff + w.offsetY = group:AddWidget(GUI:CreateSlider(parent, L["Border Offset Y"], offMin, offMax, offStep, + dbTable, key("BorderOffsetY"), fullUpdate, lightUpdate, true), 55) + w.offsetY.hideOn = hideOff + end + + if include.blendMode then + w.blendMode = group:AddWidget(GUI:CreateDropdown(parent, L["Border Blend Mode"], + { BLEND = L["Blend"], ADD = L["Add"], MOD = L["Mod"], DISABLE = L["Disable"] }, + dbTable, key("BorderBlendMode"), fullUpdate), 55) + w.blendMode.hideOn = hideOff + end + + if include.shadow then + local shadowOnKey = key("BorderShadowEnabled") + w.shadowEnabled = group:AddWidget(GUI:CreateCheckbox(parent, L["Border Shadow"], dbTable, shadowOnKey, function() + if refreshStates then refreshStates() end + fullUpdate() + end), 30) + w.shadowEnabled.hideOn = hideOff + local function shadowHide() return hideOff() or dbTable[shadowOnKey] == false end + + w.shadowColor = group:AddWidget(GUI:CreateColorPicker(parent, L["Shadow Colour"], + dbTable, key("BorderShadowColor"), true, fullUpdate), 35) + w.shadowColor.hideOn = shadowHide + w.shadowSize = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Size"], 0, 10, 1, + dbTable, key("BorderShadowSize"), fullUpdate, lightUpdate, true), 55) + w.shadowSize.hideOn = shadowHide + w.shadowOffsetX = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset X"], -10, 10, 1, + dbTable, key("BorderShadowOffsetX"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetX.hideOn = shadowHide + w.shadowOffsetY = group:AddWidget(GUI:CreateSlider(parent, L["Shadow Offset Y"], -10, 10, 1, + dbTable, key("BorderShadowOffsetY"), fullUpdate, lightUpdate, true), 55) + w.shadowOffsetY.hideOn = shadowHide + end + + -- ===== Animation (Stage 3) ===== + -- include.animate drops the full Border Animation control set (Type + -- dropdown + per-effect tunables, each with a hideOn keyed to the effect + -- it applies to). Built from the shared GUI:CreateAnimationControls so the + -- base panel and AD's Expiring override never drift. The whole block folds + -- under Show Border via hideExtra = hideOff. Widget handles are merged back + -- onto `w` so existing references (w.animationType, …) are preserved. + if include.animate then + local aw = GUI:CreateAnimationControls(group, dbTable, key("BorderAnimation"), { + parent = parent, + fullUpdate = fullUpdate, + lightUpdate = lightUpdate, + lightColors = lightColors, + typeLabel = L["Border Animation"], + hideExtra = hideOff, + onTypeChange = function() + if refreshStates then refreshStates() end + fullUpdate() + end, + }) + for k, v in pairs(aw) do w[k] = v end + end + + -- ===== Colour resolver toggles (Stage 2) ===== + -- These flip BorderColor's source from the static picker to a per-unit / + -- per-aura / per-tick computation. BuildSpec applies them in priority + -- order (type > time > class > role > static) when the consumer passes + -- ctx to BuildSpec. The static colour picker still controls the fallback + -- (when ctx is missing or the resolver yields nil). + + -- (Colour Source dropdown + Static colour picker + Alpha slider are wired + -- earlier, above the inset/offset/blendMode/gradient/shadow block, so the + -- relationship "source → colour" reads top-to-bottom in the panel.) + + if include.colorByTime then + w.colorByTime = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, key("BorderColorByTime"), fullUpdate), 30) + w.colorByTime.hideOn = hideOff + -- The actual colour curve picker is consumer-specific (e.g. AD's + -- existing expiring colour curve) and is added by the consumer + -- alongside this checkbox. + end + + if include.colorByType then + w.colorByType = group:AddWidget(GUI:CreateCheckbox(parent, L["Color by Aura Type"], dbTable, key("BorderColorByType"), fullUpdate), 30) + w.colorByType.hideOn = hideOff + end + + return w +end + +-- ============================================================ +-- EXPIRING CONTROLS (shared) — the Aura Designer expiring panel is the +-- reference design; this helper reproduces it EXACTLY (master enable → +-- Percent/Seconds toggle threshold → State Overrides → thickness / colour / +-- alpha / animation → optional extras) so EVERY expiring consumer (AD +-- icon/square/bar AND the standard buff aura icons) renders the same flow and +-- look. Per-consumer differences are `include.*` flags + an explicit `keys` +-- map (expiring DB key names diverge: AD uses `expiring*`/`Expiring*` on a +-- proxy, buff uses `buffExpiring*`), so a row simply HIDES when it doesn't apply +-- to that consumer — never a separate hand-built panel. +-- ============================================================ + +-- Small dim inline subheader (section divider inside a SettingsGroup), matching +-- AD's "State Overrides" / "Icon Effects" dividers. +function GUI:CreateExpiringSubheader(parent, text) + local frame = CreateFrame("Frame", nil, parent) + frame:SetHeight(18) + local label = frame:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(label, 8, "") + label:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 2, 1) + label:SetText(text) + local c = GetThemeColor() + label:SetTextColor(c.r, c.g, c.b, 0.75) + return frame +end + +-- Threshold slider + a compact Percent/Seconds TOGGLE BUTTON (AD's design). +-- The slider's label/range switch with the mode, so the row rebuilds the page +-- on toggle via opts.refreshPage. Keys are parameterised (thresholdKey / +-- thresholdModeKey) so any consumer's DB schema works. +function GUI:CreateExpiringThresholdRow(parent, dbTable, opts) + opts = opts or {} + local tKey = opts.thresholdKey + local mKey = opts.thresholdModeKey + local refresh = opts.refreshPage or function() end + local width = opts.width or 248 + local isSeconds = mKey and dbTable[mKey] == "SECONDS" + + local container = CreateFrame("Frame", nil, parent) + container:SetHeight(54) + container:SetWidth(width) + + local label, minV, maxV, step + if isSeconds then + label = L["Expiring Threshold (seconds)"] + minV, maxV, step = 1, 60, 1 + if tKey and dbTable[tKey] and dbTable[tKey] > 60 then dbTable[tKey] = 10 end + else + label = L["Expiring Threshold (%)"] + minV, maxV, step = 5, 100, 5 + if tKey and dbTable[tKey] and dbTable[tKey] < 5 then dbTable[tKey] = 30 end + end + + local slider = GUI:CreateSlider(container, label, minV, maxV, step, dbTable, tKey) + slider:SetPoint("TOPLEFT", 0, 0) + slider:SetWidth(width) + + local modeBtn = CreateFrame("Button", nil, container, "BackdropTemplate") + modeBtn:SetSize(56, 18) + modeBtn:SetPoint("BOTTOMRIGHT", slider, "TOPRIGHT", -10, 2) + modeBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Buttons\\WHITE8x8", edgeSize = 1, + }) + modeBtn:SetBackdropColor(0.14, 0.14, 0.17, 1) + modeBtn:SetBackdropBorderColor(0.30, 0.30, 0.35, 0.8) + + local modeText = modeBtn:CreateFontString(nil, "OVERLAY") + GUI:SetSettingsFont(modeText, 9, "") + modeText:SetPoint("CENTER", 0, 0) + modeText:SetText(isSeconds and L["Seconds"] or L["Percent"]) + modeText:SetTextColor(0.9, 0.9, 0.9) + + modeBtn:SetScript("OnEnter", function(self) + self:SetBackdropColor(0.18, 0.18, 0.22, 1) + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(L["Threshold Mode"]) + GameTooltip:AddLine(isSeconds and L["Currently: Seconds. Click for Percent."] or L["Currently: Percent. Click for Seconds."], 0.8, 0.8, 0.8, true) + GameTooltip:Show() + end) + modeBtn:SetScript("OnLeave", function(self) + self:SetBackdropColor(0.14, 0.14, 0.17, 1) + GameTooltip:Hide() + end) + modeBtn:SetScript("OnClick", function() + if not mKey then return end + if dbTable[mKey] == "SECONDS" then + dbTable[mKey] = "PERCENT" + if tKey then dbTable[tKey] = 30 end + else + dbTable[mKey] = "SECONDS" + if tKey then dbTable[tKey] = 10 end + end + refresh() + end) + + return container +end + +-- The full shared expiring panel. opts: +-- parent, fullUpdate, lightColors, lightGeometry, refreshStates, refreshPage, +-- width, masterLabel, colorLabel, +-- keys = { master, threshold, thresholdMode, borderEnable, colorByTime, +-- colorOverride, color, borderColor, alphaHandleColor, thickness, +-- inset, animPrefix, tintEnable, tintColor, +-- fillPulsate, wholeAlpha, bounce }, +-- include = { threshold, borderEnable, colorByTime, colorOverride, alpha, +-- dualColor, thickness, thicknessMin, thicknessMax, inset, +-- animation, tint, iconEffects = {fillPulsate,wholeAlpha,bounce} } +function GUI:CreateExpiringControls(group, dbTable, opts) + opts = opts or {} + local parent = opts.parent + local K = opts.keys or {} + local inc = opts.include or {} + local fullUpdate = opts.fullUpdate or function() end + local lightColors = opts.lightColors + local lightGeometry = opts.lightGeometry + local refreshStates = opts.refreshStates or function() end + local refreshPage = opts.refreshPage or function() end + + local w = {} + + -- Master gate (whole feature off) and the border-row gate (master off OR a + -- separate Show-Expiring-Border toggle off, when the consumer has one). + local function masterOff() + return (K.master and dbTable[K.master] == false) or false + end + local function borderOff() + if masterOff() then return true end + if K.borderEnable and dbTable[K.borderEnable] == false then return true end + return false + end + -- HIDE (not grey) rows that don't apply — the consumer's refreshStates + -- reflows the group so hidden rows collapse. + local function addGated(widget, h, gate) + widget.hideOn = gate or masterOff + return group:AddWidget(widget, h) + end + + if K.master then + w.master = group:AddWidget(GUI:CreateCheckbox(parent, opts.masterLabel or L["Enable Expiring"], dbTable, K.master, function() + -- Reflow (collapse/expand the gated rows) + repaint; no full page + -- rebuild — only the threshold-mode toggle needs refreshPage. + refreshStates(); fullUpdate() + end), 30) + end + + if inc.threshold ~= false and K.threshold then + addGated(GUI:CreateExpiringThresholdRow(parent, dbTable, { + thresholdKey = K.threshold, thresholdModeKey = K.thresholdMode, + width = opts.width, + refreshPage = function() refreshStates(); refreshPage() end, + }), 54) + end + + -- Consumer hook for an extra row directly under the threshold (e.g. AD bar's + -- duration-priority row). Receives addGated(widget, height[, gate]). + if opts.afterThreshold then opts.afterThreshold(addGated, masterOff) end + + if inc.borderEnable and K.borderEnable then + w.borderEnable = group:AddWidget(GUI:CreateCheckbox(parent, L["Show Expiring Border"], dbTable, K.borderEnable, function() + refreshStates(); fullUpdate() + end), 30) + w.borderEnable.hideOn = masterOff + end + + addGated(GUI:CreateExpiringSubheader(parent, L["State Overrides"]), 18, borderOff) + + if inc.thickness ~= false and K.thickness then + addGated(GUI:CreateSlider(parent, L["Expiring Border Thickness"], + inc.thicknessMin or 0, inc.thicknessMax or 5, 1, + dbTable, K.thickness, fullUpdate, lightGeometry, true), 55, borderOff) + end + + if inc.inset and K.inset then + addGated(GUI:CreateSlider(parent, L["Expiring Border Inset"], + -3, 3, 1, dbTable, K.inset, fullUpdate, lightGeometry, true), 55, borderOff) + end + + if inc.colorByTime and K.colorByTime then + w.colorByTime = addGated(GUI:CreateCheckbox(parent, L["Color by Time Remaining"], dbTable, K.colorByTime, function() + refreshStates(); fullUpdate() + end), 30, borderOff) + end + + if inc.colorOverride and K.colorOverride then + addGated(GUI:CreateCheckbox(parent, L["Expiring Color Override"], dbTable, K.colorOverride, fullUpdate), 30, borderOff) + end + + if K.color then + -- Single-colour consumers (icon / bar / buff) label it "Expiring Border + -- Color" to match the square's border picker; the square's dual case adds + -- a separate "Expiring Fill Color" above it. + local label = opts.colorLabel or (inc.dualColor and L["Expiring Fill Color"] or L["Expiring Border Color"]) + local cp = GUI:CreateColorPicker(parent, label, dbTable, K.color, true, fullUpdate, lightColors, lightColors ~= nil) + -- Hidden when the border is off OR (buff) Color-by-Time owns the colour. + cp.hideOn = function() + if borderOff() then return true end + if K.colorByTime and dbTable[K.colorByTime] then return true end + return false + end + group:AddWidget(cp, 35) + w.color = cp + end + + if inc.dualColor and K.borderColor then + addGated(GUI:CreateColorPicker(parent, L["Expiring Border Color"], dbTable, K.borderColor, true, fullUpdate, lightColors, lightColors ~= nil), 35, borderOff) + end + + if inc.alpha then + local alphaKey = K.alphaHandleColor or K.color + addGated(GUI:CreateSlider(parent, L["Expiring Border Alpha"], 0, 1, 0.05, nil, nil, fullUpdate, lightColors, true, + function() local c = dbTable[alphaKey]; return (c and (c.a or c[4])) or 1 end, + function(v) local c = dbTable[alphaKey]; if type(c) == "table" then c.a = v end end), 55, borderOff) + end + + if inc.animation ~= false and K.animPrefix then + local aw = GUI:CreateAnimationControls(group, dbTable, K.animPrefix, { + parent = parent, + fullUpdate = fullUpdate, + lightUpdate = lightGeometry, + lightColors = lightColors, + typeLabel = L["Expiring Animation"], + perfBanner = true, + hideExtra = borderOff, + onTypeChange = function() refreshStates() end, + }) + for k, v in pairs(aw) do w[k] = v end + end + + -- "Expiring Effects" — whole-element responses to the aura crossing its + -- threshold (anim effects + Tint), under ONE shared subheader so every + -- consumer reads the same. Rows appear per consumer via include flags. + local fx = inc.iconEffects + local hasTint = inc.tint and K.tintEnable + if fx or hasTint then + addGated(GUI:CreateExpiringSubheader(parent, L["Expiring Effects"]), 18) + end + if fx then + if fx.fillPulsate and K.fillPulsate then addGated(GUI:CreateCheckbox(parent, L["Fill Pulsate"], dbTable, K.fillPulsate, fullUpdate), 30) end + if fx.wholeAlpha and K.wholeAlpha then addGated(GUI:CreateCheckbox(parent, L["Whole Alpha Pulse"], dbTable, K.wholeAlpha, fullUpdate), 30) end + if fx.bounce and K.bounce then addGated(GUI:CreateCheckbox(parent, L["Bounce"], dbTable, K.bounce, fullUpdate), 30) end + end + if hasTint then + -- Toggling tint must reflow the section so the Tint Color picker's hideOn + -- re-evaluates (else the picker only appears after a full page rebuild). + addGated(GUI:CreateCheckbox(parent, L["Show Expiring Tint"], dbTable, K.tintEnable, function() + refreshStates(); fullUpdate() + end), 30) + if K.tintColor then + local lightTint = opts.lightTint + local tc = GUI:CreateColorPicker(parent, L["Tint Color"], dbTable, K.tintColor, true, fullUpdate, lightTint, lightTint ~= nil) + tc.hideOn = function() return masterOff() or dbTable[K.tintEnable] == false end + group:AddWidget(tc, 35) + end + end + + return w +end + -- ============================================================ -- GROWTH DIRECTION CONTROL -- Three linked dropdowns (Orientation, Wrap, Direction) that @@ -3779,7 +4729,10 @@ end -- FONT DROPDOWN WITH PREVIEW -- ============================================================ -function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback) +-- inheritKey (optional): when dbTable[dbKey] is nil (no per-element override), +-- the dropdown DISPLAYS dbTable[inheritKey] instead so it shows the inherited +-- (e.g. global) font. Selecting a font still writes dbKey (the override). +function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback, inheritKey) local container = CreateFrame("Frame", nil, parent) container:SetSize(260, 50) @@ -3830,7 +4783,7 @@ function GUI:CreateFontDropdown(parent, label, dbTable, dbKey, callback) local function UpdateText() if dbTable and dbKey then - local val = dbTable[dbKey] + local val = dbTable[dbKey] or (inheritKey and dbTable[inheritKey]) -- Get font display name (handles both names and legacy paths) local displayName = DF:GetFontNameFromPath(val) btn.Text:SetText(displayName or L["Select..."]) @@ -8246,7 +9199,22 @@ function DF:CreateGUI() -- Refresh override indicators RefreshAllOverrideIndicators() end - + + -- Invalidate EVERY page's build cache so the next time each tab is shown it + -- rebuilds from scratch (via RefreshCached -> DoBuild). The page cache is + -- keyed on mode (party/raid) only, NOT on the active auto-layout/profile, so + -- switching between raid auto-layouts leaves cacheValid=true and tabs re-show + -- stale geometry — most visibly the Aura Designer / Text Designer frame + -- previews, which size their mock frame to the layout's frameWidth/Height at + -- build time and so stay stuck at the first-edited layout's size. Call this + -- whenever the active layout changes (enter/exit auto-profile editing). + GUI.InvalidateAllPages = function() + if not GUI.Pages then return end + for _, page in pairs(GUI.Pages) do + if page.Invalidate then page:Invalidate() end + end + end + -- Category system GUI.Categories = {} local categoryY = -8 diff --git a/Libs/LibCustomGlow-1.0/LICENSE b/Libs/LibCustomGlow-1.0/LICENSE new file mode 100644 index 00000000..aab99995 --- /dev/null +++ b/Libs/LibCustomGlow-1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Benjamin Staneck + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.lua b/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.lua new file mode 100644 index 00000000..9ff29079 --- /dev/null +++ b/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.lua @@ -0,0 +1,955 @@ +--[[ +This library contains work of Hendrick "nevcairiel" Leppkes +https://www.wowace.com/projects/libbuttonglow-1-0 +]] + +-- luacheck: globals CreateFromMixins ObjectPoolMixin CreateTexturePool CreateFramePool + +local MAJOR_VERSION = "LibCustomGlow-1.0" +local MINOR_VERSION = 24 +if not LibStub then error(MAJOR_VERSION .. " requires LibStub.") end +local lib, oldversion = LibStub:NewLibrary(MAJOR_VERSION, MINOR_VERSION) +if not lib then return end +local Masque = LibStub("Masque", true) + +local isRetail = WOW_PROJECT_ID == WOW_PROJECT_MAINLINE +local textureList = { + empty = [[Interface\AdventureMap\BrokenIsles\AM_29]], + white = [[Interface\BUTTONS\WHITE8X8]], + shine = [[Interface\ItemSocketingFrame\UI-ItemSockets]] +} + +local shineCoords = {0.3984375, 0.4453125, 0.40234375, 0.44921875} +if isRetail then + textureList.shine = [[Interface\Artifacts\Artifacts]] + shineCoords = {0.8115234375,0.9169921875,0.8798828125,0.9853515625} +end + +function lib.RegisterTextures(texture,id) + textureList[id] = texture +end + +lib.glowList = {} +lib.startList = {} +lib.stopList = {} + +local GlowParent = UIParent +local GlowMaskPool = { + createFunc = function(self) + return self.parent:CreateMaskTexture() + end, + resetFunc = function(self, mask) + mask:Hide() + mask:ClearAllPoints() + end, + AddObject = function(self, object) + local dummy = true + self.activeObjects[object] = dummy + self.activeObjectCount = self.activeObjectCount + 1 + end, + ReclaimObject = function(self, object) + tinsert(self.inactiveObjects, object) + self.activeObjects[object] = nil + self.activeObjectCount = self.activeObjectCount - 1 + end, + Release = function(self, object) + local active = self.activeObjects[object] ~= nil + if active then + self:resetFunc(object) + self:ReclaimObject(object) + end + return active + end, + Acquire = function(self) + local object = tremove(self.inactiveObjects) + local new = object == nil + if new then + object = self:createFunc() + self:resetFunc(object, new) + end + self:AddObject(object) + return object, new + end, + Init = function(self, parent) + self.activeObjects = {} + self.inactiveObjects = {} + self.activeObjectCount = 0 + self.parent = parent + end +} +GlowMaskPool:Init(GlowParent) + +local TexPoolResetter = function(pool,tex) + local maskNum = tex:GetNumMaskTextures() + for i = maskNum , 1, -1 do + tex:RemoveMaskTexture(tex:GetMaskTexture(i)) + end + tex:Hide() + tex:ClearAllPoints() +end +local GlowTexPool = CreateTexturePool(GlowParent ,"ARTWORK",7,nil,TexPoolResetter) +lib.GlowTexPool = GlowTexPool + +local FramePoolResetter = function(framePool,frame) + frame:SetScript("OnUpdate",nil) + local parent = frame:GetParent() + if parent[frame.name] then + parent[frame.name] = nil + end + if frame.textures then + for _, texture in pairs(frame.textures) do + GlowTexPool:Release(texture) + end + end + if frame.bg then + GlowTexPool:Release(frame.bg) + frame.bg = nil + end + if frame.masks then + for _,mask in pairs(frame.masks) do + GlowMaskPool:Release(mask) + end + frame.masks = nil + end + frame.textures = {} + frame.info = {} + frame.name = nil + frame.timer = nil + frame:Hide() + frame:ClearAllPoints() +end +local GlowFramePool = CreateFramePool("Frame",GlowParent,nil,FramePoolResetter) +lib.GlowFramePool = GlowFramePool + +local function addFrameAndTex(r,color,name,key,N,xOffset,yOffset,texture,texCoord,desaturated,frameLevel) + key = key or "" + frameLevel = frameLevel or 8 + if not r[name..key] then + r[name..key] = GlowFramePool:Acquire() + r[name..key]:SetParent(r) + r[name..key].name = name..key + end + local f = r[name..key] + f:SetFrameLevel(r:GetFrameLevel()+frameLevel) + f:SetPoint("TOPLEFT",r,"TOPLEFT",-xOffset+0.05,yOffset+0.05) + f:SetPoint("BOTTOMRIGHT",r,"BOTTOMRIGHT",xOffset,-yOffset+0.05) + f:Show() + + if not f.textures then + f.textures = {} + end + + for i=1,N do + if not f.textures[i] then + f.textures[i] = GlowTexPool:Acquire() + f.textures[i]:SetTexture(texture) + f.textures[i]:SetTexCoord(texCoord[1],texCoord[2],texCoord[3],texCoord[4]) + f.textures[i]:SetDesaturated(desaturated) + f.textures[i]:SetParent(f) + f.textures[i]:SetDrawLayer("ARTWORK",7) + if not isRetail and name == "_AutoCastGlow" then + f.textures[i]:SetBlendMode("ADD") + end + end + -- Handle both array format {r,g,b,a} and Color objects (for WoW 12.0 secret values) + if type(color) == "table" and color.GetRGBA then + f.textures[i]:SetVertexColor(color:GetRGBA()) + else + f.textures[i]:SetVertexColor(color[1],color[2],color[3],color[4]) + end + f.textures[i]:Show() + end + while #f.textures>N do + GlowTexPool:Release(f.textures[#f.textures]) + table.remove(f.textures) + end +end + + +--Pixel Glow Functions-- +local pCalc1 = function(progress,s,th,p) + local c + if progress>p[3] or progressp[2] then + c =s-th-(progress-p[2])/(p[3]-p[2])*(s-th) + elseif progress>p[1] then + c =s-th + else + c = (progress-p[0])/(p[1]-p[0])*(s-th) + end + return math.floor(c+0.5) +end + +local pCalc2 = function(progress,s,th,p) + local c + if progress>p[3] then + c = s-th-(progress-p[3])/(p[0]+1-p[3])*(s-th) + elseif progress>p[2] then + c = s-th + elseif progress>p[1] then + c = (progress-p[1])/(p[2]-p[1])*(s-th) + elseif progress>p[0] then + c = 0 + else + c = s-th-(progress+1-p[3])/(p[0]+1-p[3])*(s-th) + end + return math.floor(c+0.5) +end + +local pUpdate = function(self,elapsed) + self.timer = self.timer+elapsed/self.info.period + if self.timer>1 or self.timer <-1 then + self.timer = self.timer%1 + end + local progress = self.timer + local width,height = self:GetSize() + if width ~= self.info.width or height ~= self.info.height then + local perimeter = 2*(width+height) + if not (perimeter>0) then + return + end + self.info.width = width + self.info.height = height + self.info.pTLx = { + [0] = (height+self.info.length/2)/perimeter, + [1] = (height+width+self.info.length/2)/perimeter, + [2] = (2*height+width-self.info.length/2)/perimeter, + [3] = 1-self.info.length/2/perimeter + } + self.info.pTLy ={ + [0] = (height-self.info.length/2)/perimeter, + [1] = (height+width+self.info.length/2)/perimeter, + [2] = (height*2+width+self.info.length/2)/perimeter, + [3] = 1-self.info.length/2/perimeter + } + self.info.pBRx ={ + [0] = self.info.length/2/perimeter, + [1] = (height-self.info.length/2)/perimeter, + [2] = (height+width-self.info.length/2)/perimeter, + [3] = (height*2+width+self.info.length/2)/perimeter + } + self.info.pBRy ={ + [0] = self.info.length/2/perimeter, + [1] = (height+self.info.length/2)/perimeter, + [2] = (height+width-self.info.length/2)/perimeter, + [3] = (height*2+width-self.info.length/2)/perimeter + } + end + if self:IsShown() then + if not (self.masks[1]:IsShown()) then + self.masks[1]:Show() + self.masks[1]:SetPoint("TOPLEFT",self,"TOPLEFT",self.info.th,-self.info.th) + self.masks[1]:SetPoint("BOTTOMRIGHT",self,"BOTTOMRIGHT",-self.info.th,self.info.th) + end + if self.masks[2] and not(self.masks[2]:IsShown()) then + self.masks[2]:Show() + self.masks[2]:SetPoint("TOPLEFT",self,"TOPLEFT",self.info.th+1,-self.info.th-1) + self.masks[2]:SetPoint("BOTTOMRIGHT",self,"BOTTOMRIGHT",-self.info.th-1,self.info.th+1) + end + if self.bg and not(self.bg:IsShown()) then + self.bg:Show() + end + for k,line in pairs(self.textures) do + line:SetPoint("TOPLEFT",self,"TOPLEFT",pCalc1((progress+self.info.step*(k-1))%1,width,self.info.th,self.info.pTLx),-pCalc2((progress+self.info.step*(k-1))%1,height,self.info.th,self.info.pTLy)) + line:SetPoint("BOTTOMRIGHT",self,"TOPLEFT",self.info.th+pCalc2((progress+self.info.step*(k-1))%1,width,self.info.th,self.info.pBRx),-height+pCalc1((progress+self.info.step*(k-1))%1,height,self.info.th,self.info.pBRy)) + end + end +end + +function lib.PixelGlow_Start(r,color,N,frequency,length,th,xOffset,yOffset,border,key,frameLevel) + if not r then + return + end + if not color then + color = {0.95,0.95,0.32,1} + end + + if not(N and N>0) then + N = 8 + end + + local period + if frequency then + if not(frequency>0 or frequency<0) then + period = 4 + else + period = 1/frequency + end + else + period = 4 + end + local width,height = r:GetSize() + length = length or math.floor((width+height)*(2/N-0.1)) + length = min(length,min(width,height)) + th = th or 1 + xOffset = xOffset or 0 + yOffset = yOffset or 0 + key = key or "" + + addFrameAndTex(r,color,"_PixelGlow",key,N,xOffset,yOffset,textureList.white,{0,1,0,1},nil,frameLevel) + local f = r["_PixelGlow"..key] + if not f.masks then + f.masks = {} + end + if not f.masks[1] then + f.masks[1] = GlowMaskPool:Acquire() + f.masks[1]:SetTexture(textureList.empty, "CLAMPTOWHITE","CLAMPTOWHITE") + f.masks[1]:Show() + end + f.masks[1]:SetPoint("TOPLEFT",f,"TOPLEFT",th,-th) + f.masks[1]:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",-th,th) + + if not(border==false) then + if not f.masks[2] then + f.masks[2] = GlowMaskPool:Acquire() + f.masks[2]:SetTexture(textureList.empty, "CLAMPTOWHITE","CLAMPTOWHITE") + end + f.masks[2]:SetPoint("TOPLEFT",f,"TOPLEFT",th+1,-th-1) + f.masks[2]:SetPoint("BOTTOMRIGHT",f,"BOTTOMRIGHT",-th-1,th+1) + + if not f.bg then + f.bg = GlowTexPool:Acquire() + f.bg:SetColorTexture(0.1,0.1,0.1,0.8) + f.bg:SetParent(f) + f.bg:SetAllPoints(f) + f.bg:SetDrawLayer("ARTWORK",6) + f.bg:AddMaskTexture(f.masks[2]) + end + else + if f.bg then + GlowTexPool:Release(f.bg) + f.bg = nil + end + if f.masks[2] then + GlowMaskPool:Release(f.masks[2]) + f.masks[2] = nil + end + end + for _,tex in pairs(f.textures) do + if tex:GetNumMaskTextures() < 1 then + tex:AddMaskTexture(f.masks[1]) + end + end + f.timer = f.timer or 0 + f.info = f.info or {} + f.info.step = 1/N + f.info.period = period + f.info.th = th + if f.info.length ~= length then + f.info.width = nil + f.info.length = length + end + pUpdate(f, 0) + f:SetScript("OnUpdate",pUpdate) +end + +function lib.PixelGlow_Stop(r,key) + if not r then + return + end + key = key or "" + if not r["_PixelGlow"..key] then + return false + else + GlowFramePool:Release(r["_PixelGlow"..key]) + end +end + +table.insert(lib.glowList, "Pixel Glow") +lib.startList["Pixel Glow"] = lib.PixelGlow_Start +lib.stopList["Pixel Glow"] = lib.PixelGlow_Stop + + +--Autocast Glow Functions-- +local function acUpdate(self,elapsed) + local width,height = self:GetSize() + if width ~= self.info.width or height ~= self.info.height then + if width*height == 0 then return end -- Avoid division by zero + self.info.width = width + self.info.height = height + self.info.perimeter = 2*(width+height) + self.info.bottomlim = height*2+width + self.info.rightlim = height+width + self.info.space = self.info.perimeter/self.info.N + end + + local texIndex = 0; + for k=1,4 do + self.timer[k] = self.timer[k]+elapsed/(self.info.period*k) + if self.timer[k] > 1 or self.timer[k] <-1 then + self.timer[k] = self.timer[k]%1 + end + for i = 1,self.info.N do + texIndex = texIndex+1 + local position = (self.info.space*i+self.info.perimeter*self.timer[k])%self.info.perimeter + if position>self.info.bottomlim then + self.textures[texIndex]: SetPoint("CENTER",self,"BOTTOMRIGHT",-position+self.info.bottomlim,0) + elseif position>self.info.rightlim then + self.textures[texIndex]: SetPoint("CENTER",self,"TOPRIGHT",0,-position+self.info.rightlim) + elseif position>self.info.height then + self.textures[texIndex]: SetPoint("CENTER",self,"TOPLEFT",position-self.info.height,0) + else + self.textures[texIndex]: SetPoint("CENTER",self,"BOTTOMLEFT",0,position) + end + end + end +end + +function lib.AutoCastGlow_Start(r,color,N,frequency,scale,xOffset,yOffset,key,frameLevel) + if not r then + return + end + + if not color then + color = {0.95,0.95,0.32,1} + end + + if not(N and N>0) then + N = 4 + end + + local period + if frequency then + if not(frequency>0 or frequency<0) then + period = 8 + else + period = 1/frequency + end + else + period = 8 + end + scale = scale or 1 + xOffset = xOffset or 0 + yOffset = yOffset or 0 + key = key or "" + + addFrameAndTex(r,color,"_AutoCastGlow",key,N*4,xOffset,yOffset,textureList.shine,shineCoords, true, frameLevel) + local f = r["_AutoCastGlow"..key] + local sizes = {7,6,5,4} + for k,size in pairs(sizes) do + for i = 1,N do + f.textures[i+N*(k-1)]:SetSize(size*scale,size*scale) + end + end + f.timer = f.timer or {0,0,0,0} + f.info = f.info or {} + f.info.N = N + f.info.period = period + f:SetScript("OnUpdate",acUpdate) + acUpdate(f, 0) +end + +function lib.AutoCastGlow_Stop(r,key) + if not r then + return + end + + key = key or "" + if not r["_AutoCastGlow"..key] then + return false + else + GlowFramePool:Release(r["_AutoCastGlow"..key]) + end +end + +table.insert(lib.glowList, "Autocast Shine") +lib.startList["Autocast Shine"] = lib.AutoCastGlow_Start +lib.stopList["Autocast Shine"] = lib.AutoCastGlow_Stop + +--Action Button Glow-- +local function ButtonGlowResetter(framePool,frame) + frame:SetScript("OnUpdate",nil) + local parent = frame:GetParent() + if parent._ButtonGlow then + parent._ButtonGlow = nil + end + frame:Hide() + frame:ClearAllPoints() +end +local ButtonGlowPool = CreateFramePool("Frame",GlowParent,nil,ButtonGlowResetter) +lib.ButtonGlowPool = ButtonGlowPool + +local function CreateScaleAnim(group, target, order, duration, x, y, delay) + local scale = group:CreateAnimation("Scale") + scale:SetChildKey(target) + scale:SetOrder(order) + scale:SetDuration(duration) + scale:SetScale(x, y) + + if delay then + scale:SetStartDelay(delay) + end +end + +local function CreateAlphaAnim(group, target, order, duration, fromAlpha, toAlpha, delay, appear) + local alpha = group:CreateAnimation("Alpha") + alpha:SetChildKey(target) + alpha:SetOrder(order) + alpha:SetDuration(duration) + alpha:SetFromAlpha(fromAlpha) + alpha:SetToAlpha(toAlpha) + if delay then + alpha:SetStartDelay(delay) + end + if appear then + table.insert(group.appear, alpha) + else + table.insert(group.fade, alpha) + end +end + +local function AnimIn_OnPlay(group) + local frame = group:GetParent() + local frameWidth, frameHeight = frame:GetSize() + frame.spark:SetSize(frameWidth, frameHeight) + frame.spark:SetAlpha(not(frame.color) and 1.0 or 0.3*frame.color[4]) + frame.innerGlow:SetSize(frameWidth / 2, frameHeight / 2) + frame.innerGlow:SetAlpha(not(frame.color) and 1.0 or frame.color[4]) + frame.innerGlowOver:SetAlpha(not(frame.color) and 1.0 or frame.color[4]) + frame.outerGlow:SetSize(frameWidth * 2, frameHeight * 2) + frame.outerGlow:SetAlpha(not(frame.color) and 1.0 or frame.color[4]) + frame.outerGlowOver:SetAlpha(not(frame.color) and 1.0 or frame.color[4]) + frame.ants:SetSize(frameWidth * 0.85, frameHeight * 0.85) + frame.ants:SetAlpha(0) + frame:Show() +end + +local function AnimIn_OnFinished(group) + local frame = group:GetParent() + local frameWidth, frameHeight = frame:GetSize() + frame.spark:SetAlpha(0) + frame.innerGlow:SetAlpha(0) + frame.innerGlow:SetSize(frameWidth, frameHeight) + frame.innerGlowOver:SetAlpha(0.0) + frame.outerGlow:SetSize(frameWidth, frameHeight) + frame.outerGlowOver:SetAlpha(0.0) + frame.outerGlowOver:SetSize(frameWidth, frameHeight) + frame.ants:SetAlpha(not(frame.color) and 1.0 or frame.color[4]) +end + +local function AnimIn_OnStop(group) + local frame = group:GetParent() + local frameWidth, frameHeight = frame:GetSize() + frame.spark:SetAlpha(0) + frame.innerGlow:SetAlpha(0) + frame.innerGlowOver:SetAlpha(0.0) + frame.outerGlowOver:SetAlpha(0.0) +end + +local function bgHide(self) + if self.animOut:IsPlaying() then + self.animOut:Stop() + ButtonGlowPool:Release(self) + end +end + +local function bgUpdate(self, elapsed) + AnimateTexCoords(self.ants, 256, 256, 48, 48, 22, elapsed, self.throttle); + local cooldown = self:GetParent().cooldown; + local duration = cooldown and cooldown:IsShown() and cooldown:GetCooldownDuration() + if((not issecretvalue or not issecretvalue(duration)) and duration and duration > 3000) then + self:SetAlpha(0.5); + else + self:SetAlpha(1.0); + end +end + +local function configureButtonGlow(f,alpha) + f.spark = f:CreateTexture(nil, "BACKGROUND") + f.spark:SetPoint("CENTER") + f.spark:SetAlpha(0) + f.spark:SetTexture([[Interface\SpellActivationOverlay\IconAlert]]) + f.spark:SetTexCoord(0.00781250, 0.61718750, 0.00390625, 0.26953125) + + -- inner glow + f.innerGlow = f:CreateTexture(nil, "ARTWORK") + f.innerGlow:SetPoint("CENTER") + f.innerGlow:SetAlpha(0) + f.innerGlow:SetTexture([[Interface\SpellActivationOverlay\IconAlert]]) + f.innerGlow:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375) + + -- inner glow over + f.innerGlowOver = f:CreateTexture(nil, "ARTWORK") + f.innerGlowOver:SetPoint("TOPLEFT", f.innerGlow, "TOPLEFT") + f.innerGlowOver:SetPoint("BOTTOMRIGHT", f.innerGlow, "BOTTOMRIGHT") + f.innerGlowOver:SetAlpha(0) + f.innerGlowOver:SetTexture([[Interface\SpellActivationOverlay\IconAlert]]) + f.innerGlowOver:SetTexCoord(0.00781250, 0.50781250, 0.53515625, 0.78515625) + + -- outer glow + f.outerGlow = f:CreateTexture(nil, "ARTWORK") + f.outerGlow:SetPoint("CENTER") + f.outerGlow:SetAlpha(0) + f.outerGlow:SetTexture([[Interface\SpellActivationOverlay\IconAlert]]) + f.outerGlow:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375) + + -- outer glow over + f.outerGlowOver = f:CreateTexture(nil, "ARTWORK") + f.outerGlowOver:SetPoint("TOPLEFT", f.outerGlow, "TOPLEFT") + f.outerGlowOver:SetPoint("BOTTOMRIGHT", f.outerGlow, "BOTTOMRIGHT") + f.outerGlowOver:SetAlpha(0) + f.outerGlowOver:SetTexture([[Interface\SpellActivationOverlay\IconAlert]]) + f.outerGlowOver:SetTexCoord(0.00781250, 0.50781250, 0.53515625, 0.78515625) + + -- ants + f.ants = f:CreateTexture(nil, "OVERLAY") + f.ants:SetPoint("CENTER") + f.ants:SetAlpha(0) + f.ants:SetTexture([[Interface\SpellActivationOverlay\IconAlertAnts]]) + + f.animIn = f:CreateAnimationGroup() + f.animIn.appear = {} + f.animIn.fade = {} + CreateScaleAnim(f.animIn, "spark", 1, 0.2, 1.5, 1.5) + CreateAlphaAnim(f.animIn, "spark", 1, 0.2, 0, alpha, nil, true) + CreateScaleAnim(f.animIn, "innerGlow", 1, 0.3, 2, 2) + CreateScaleAnim(f.animIn, "innerGlowOver", 1, 0.3, 2, 2) + CreateAlphaAnim(f.animIn, "innerGlowOver", 1, 0.3, alpha, 0, nil, false) + CreateScaleAnim(f.animIn, "outerGlow", 1, 0.3, 0.5, 0.5) + CreateScaleAnim(f.animIn, "outerGlowOver", 1, 0.3, 0.5, 0.5) + CreateAlphaAnim(f.animIn, "outerGlowOver", 1, 0.3, alpha, 0, nil, false) + CreateScaleAnim(f.animIn, "spark", 1, 0.2, 2/3, 2/3, 0.2) + CreateAlphaAnim(f.animIn, "spark", 1, 0.2, alpha, 0, 0.2, false) + CreateAlphaAnim(f.animIn, "innerGlow", 1, 0.2, alpha, 0, 0.3, false) + CreateAlphaAnim(f.animIn, "ants", 1, 0.2, 0, alpha, 0.3, true) + f.animIn:SetScript("OnPlay", AnimIn_OnPlay) + f.animIn:SetScript("OnStop", AnimIn_OnStop) + f.animIn:SetScript("OnFinished", AnimIn_OnFinished) + + f.animOut = f:CreateAnimationGroup() + f.animOut.appear = {} + f.animOut.fade = {} + CreateAlphaAnim(f.animOut, "outerGlowOver", 1, 0.2, 0, alpha, nil, true) + CreateAlphaAnim(f.animOut, "ants", 1, 0.2, alpha, 0, nil, false) + CreateAlphaAnim(f.animOut, "outerGlowOver", 2, 0.2, alpha, 0, nil, false) + CreateAlphaAnim(f.animOut, "outerGlow", 2, 0.2, alpha, 0, nil, false) + f.animOut:SetScript("OnFinished", function(self) ButtonGlowPool:Release(self:GetParent()) end) + + f:SetScript("OnHide", bgHide) +end + +local function updateAlphaAnim(f,alpha) + for _,anim in pairs(f.animIn.appear) do + anim:SetToAlpha(alpha) + end + for _,anim in pairs(f.animIn.fade) do + anim:SetFromAlpha(alpha) + end + for _,anim in pairs(f.animOut.appear) do + anim:SetToAlpha(alpha) + end + for _,anim in pairs(f.animOut.fade) do + anim:SetFromAlpha(alpha) + end +end + +local ButtonGlowTextures = {["spark"] = true,["innerGlow"] = true,["innerGlowOver"] = true,["outerGlow"] = true,["outerGlowOver"] = true,["ants"] = true} + +local function noZero(num) + if num == 0 then + return 0.001 + else + return num + end +end + +function lib.ButtonGlow_Start(r,color,frequency,frameLevel) + if not r then + return + end + frameLevel = frameLevel or 8; + local throttle + if frequency and frequency > 0 then + throttle = 0.25/frequency*0.01 + else + throttle = 0.01 + end + if r._ButtonGlow then + local f = r._ButtonGlow + local width,height = r:GetSize() + f:SetFrameLevel(r:GetFrameLevel()+frameLevel) + f:SetSize(width*1.4 , height*1.4) + f:SetPoint("TOPLEFT", r, "TOPLEFT", -width * 0.2, height * 0.2) + f:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", width * 0.2, -height * 0.2) + f.ants:SetSize(width*1.4*0.85, height*1.4*0.85) + AnimIn_OnFinished(f.animIn) + if f.animOut:IsPlaying() then + f.animOut:Stop() + f.animIn:Play() + end + + if not(color) then + for texture in pairs(ButtonGlowTextures) do + f[texture]:SetDesaturated(nil) + f[texture]:SetVertexColor(1,1,1) + local alpha = math.min(f[texture]:GetAlpha()/noZero(f.color and f.color[4] or 1), 1) + f[texture]:SetAlpha(alpha) + updateAlphaAnim(f, 1) + end + f.color = false + else + for texture in pairs(ButtonGlowTextures) do + f[texture]:SetDesaturated(1) + if type(color) == "table" and color.GetRGBA then + local r, g, b = color:GetRGBA() + f[texture]:SetVertexColor(r, g, b) + else + f[texture]:SetVertexColor(color[1],color[2],color[3]) + end + local alpha = math.min(f[texture]:GetAlpha()/noZero(f.color and f.color[4] or 1)*color[4], 1) + f[texture]:SetAlpha(alpha) + updateAlphaAnim(f,color and color[4] or 1) + end + f.color = color + end + f.throttle = throttle + else + local f, new = ButtonGlowPool:Acquire() + if new then + configureButtonGlow(f,color and color[4] or 1) + else + updateAlphaAnim(f,color and color[4] or 1) + end + r._ButtonGlow = f + local width,height = r:GetSize() + f:SetParent(r) + f:SetFrameLevel(r:GetFrameLevel()+frameLevel) + f:SetSize(width * 1.4, height * 1.4) + f:SetPoint("TOPLEFT", r, "TOPLEFT", -width * 0.2, height * 0.2) + f:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", width * 0.2, -height * 0.2) + if not(color) then + f.color = false + for texture in pairs(ButtonGlowTextures) do + f[texture]:SetDesaturated(nil) + f[texture]:SetVertexColor(1,1,1) + end + else + f.color = color + for texture in pairs(ButtonGlowTextures) do + f[texture]:SetDesaturated(1) + if type(color) == "table" and color.GetRGBA then + local r, g, b = color:GetRGBA() + f[texture]:SetVertexColor(r, g, b) + else + f[texture]:SetVertexColor(color[1],color[2],color[3]) + end + end + end + f.throttle = throttle + f:SetScript("OnUpdate", bgUpdate) + + f.animIn:Play() + + if Masque and Masque.UpdateSpellAlert then + Masque:UpdateSpellAlert(r, f) + end + end +end + +function lib.ButtonGlow_Stop(r) + if r._ButtonGlow then + if r._ButtonGlow.animOut:IsPlaying() then + -- Do nothing the animOut finishing will release + elseif r._ButtonGlow.animIn:IsPlaying() then + r._ButtonGlow.animIn:Stop() + ButtonGlowPool:Release(r._ButtonGlow) + elseif r:IsVisible() then + r._ButtonGlow.animOut:Play() + else + ButtonGlowPool:Release(r._ButtonGlow) + end + end +end + +table.insert(lib.glowList, "Action Button Glow") +lib.startList["Action Button Glow"] = lib.ButtonGlow_Start +lib.stopList["Action Button Glow"] = lib.ButtonGlow_Stop + + +-- ProcGlow + +local function ProcGlowResetter(framePool, frame) + frame:Hide() + frame:ClearAllPoints() + frame:SetScript("OnShow", nil) + frame:SetScript("OnHide", nil) + local parent = frame:GetParent() + if frame.key and parent[frame.key] then + parent[frame.key] = nil + end +end + +local ProcGlowPool = CreateFramePool("Frame", GlowParent, nil, ProcGlowResetter) +lib.ProcGlowPool = ProcGlowPool + +local function InitProcGlow(f) + f.ProcStart = f:CreateTexture(nil, "ARTWORK") + f.ProcStart:SetBlendMode("ADD") + f.ProcStart:SetAtlas("UI-HUD-ActionBar-Proc-Start-Flipbook") + f.ProcStart:SetAlpha(1) + f.ProcStart:SetSize(150, 150) + f.ProcStart:SetPoint("CENTER") + + f.ProcLoop = f:CreateTexture(nil, "ARTWORK") + f.ProcLoop:SetAtlas("UI-HUD-ActionBar-Proc-Loop-Flipbook") + f.ProcLoop:SetAlpha(0) + f.ProcLoop:SetAllPoints() + + f.ProcLoopAnim = f:CreateAnimationGroup() + f.ProcLoopAnim:SetLooping("REPEAT") + f.ProcLoopAnim:SetToFinalAlpha(true) + + local alphaRepeat = f.ProcLoopAnim:CreateAnimation("Alpha") + alphaRepeat:SetChildKey("ProcLoop") + alphaRepeat:SetFromAlpha(1) + alphaRepeat:SetToAlpha(1) + alphaRepeat:SetDuration(.001) + alphaRepeat:SetOrder(0) + f.ProcLoopAnim.alphaRepeat = alphaRepeat + + local flipbookRepeat = f.ProcLoopAnim:CreateAnimation("FlipBook") + flipbookRepeat:SetChildKey("ProcLoop") + flipbookRepeat:SetDuration(1) + flipbookRepeat:SetOrder(0) + flipbookRepeat:SetFlipBookRows(6) + flipbookRepeat:SetFlipBookColumns(5) + flipbookRepeat:SetFlipBookFrames(30) + flipbookRepeat:SetFlipBookFrameWidth(0) + flipbookRepeat:SetFlipBookFrameHeight(0) + f.ProcLoopAnim.flipbookRepeat = flipbookRepeat + + f.ProcStartAnim = f:CreateAnimationGroup() + f.ProcStartAnim:SetToFinalAlpha(true) + + local flipbookStartAlphaIn = f.ProcStartAnim:CreateAnimation("Alpha") + flipbookStartAlphaIn:SetChildKey("ProcStart") + flipbookStartAlphaIn:SetDuration(.001) + flipbookStartAlphaIn:SetOrder(0) + flipbookStartAlphaIn:SetFromAlpha(1) + flipbookStartAlphaIn:SetToAlpha(1) + + local flipbookStart = f.ProcStartAnim:CreateAnimation("FlipBook") + flipbookStart:SetChildKey("ProcStart") + flipbookStart:SetDuration(0.7) + flipbookStart:SetOrder(1) + flipbookStart:SetFlipBookRows(6) + flipbookStart:SetFlipBookColumns(5) + flipbookStart:SetFlipBookFrames(30) + flipbookStart:SetFlipBookFrameWidth(0) + flipbookStart:SetFlipBookFrameHeight(0) + + local flipbookStartAlphaOut = f.ProcStartAnim:CreateAnimation("Alpha") + flipbookStartAlphaOut:SetChildKey("ProcStart") + flipbookStartAlphaOut:SetDuration(.001) + flipbookStartAlphaOut:SetOrder(2) + flipbookStartAlphaOut:SetFromAlpha(1) + flipbookStartAlphaOut:SetToAlpha(0) + + f.ProcStartAnim.flipbookStart = flipbookStart + f.ProcStartAnim:SetScript("OnFinished", function(self) + self:GetParent().ProcLoopAnim:Play() + self:GetParent().ProcLoop:Show() + end) + +end + +local function SetupProcGlow(f, options) + f.key = "_ProcGlow" .. options.key -- for resetter + f:SetScript("OnHide", function(self) + if self.ProcStartAnim:IsPlaying() then + self.ProcStartAnim:Stop() + end + if self.ProcLoopAnim:IsPlaying() then + self.ProcLoopAnim:Stop() + end + end) + f:SetScript("OnShow", function(self) + if self.startAnim then + if not self.ProcStartAnim:IsPlaying() and not self.ProcLoopAnim:IsPlaying() then + --[[ +to future me: +i wish you'r ok, if you wonder where are this constants coming from, check: +https://github.com/Gethe/wow-ui-source/blob/eb4459c679a1bd8919cad92934ea83c4f5e77e8b/Interface/FrameXML/ActionButton.lua#L816 +https://github.com/Gethe/wow-ui-source/blob/d8e8ebf572c3b28237cf83e8fc5c0583b5453a2b/Interface/FrameXML/ActionButtonTemplate.xml#L5-L14 + ]] + local width, height = self:GetSize() + self.ProcStart:SetSize((width / 42 * 150) / 1.4, (height / 42 * 150) / 1.4) + self.ProcStart:Show() + self.ProcLoop:Hide() + self.ProcStartAnim:Play() + end + else + if not self.ProcLoopAnim:IsPlaying() then + self.ProcStart:Hide() + self.ProcLoop:Show() + self.ProcLoopAnim:Play() + end + end + end) + if not options.color then + f.ProcStart:SetDesaturated(nil) + f.ProcStart:SetVertexColor(1, 1, 1, 1) + f.ProcLoop:SetDesaturated(nil) + f.ProcLoop:SetVertexColor(1, 1, 1, 1) + else + f.ProcStart:SetDesaturated(1) + f.ProcStart:SetVertexColor(options.color[1], options.color[2], options.color[3], options.color[4]) + f.ProcLoop:SetDesaturated(1) + f.ProcLoop:SetVertexColor(options.color[1], options.color[2], options.color[3], options.color[4]) + end + f.ProcLoopAnim.flipbookRepeat:SetDuration(options.duration) + f.startAnim = options.startAnim +end + +local ProcGlowDefaults = { + frameLevel = 8, + color = nil, + startAnim = true, + xOffset = 0, + yOffset = 0, + duration = 1, + key = "" +} + +function lib.ProcGlow_Start(r, options) + if not r then + return + end + options = options or {} + setmetatable(options, { __index = ProcGlowDefaults }) + local key = "_ProcGlow" .. options.key + local f, new + if r[key] then + f = r[key] + else + f, new = ProcGlowPool:Acquire() + if new then + InitProcGlow(f) + end + r[key] = f + end + f:SetParent(r) + f:SetFrameLevel(r:GetFrameLevel() + options.frameLevel) + + local width, height = r:GetSize() + local xOffset = options.xOffset + width * 0.2 + local yOffset = options.yOffset + height * 0.2 + f:SetPoint("TOPLEFT", r, "TOPLEFT", -xOffset, yOffset) + f:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", xOffset, -yOffset) + + SetupProcGlow(f, options) + f:Show() +end + +function lib.ProcGlow_Stop(r, key) + key = key or "" + local f = r["_ProcGlow" .. key] + if f then + ProcGlowPool:Release(f) + end +end + +table.insert(lib.glowList, "Proc Glow") +lib.startList["Proc Glow"] = lib.ProcGlow_Start +lib.stopList["Proc Glow"] = lib.ProcGlow_Stop diff --git a/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.toc b/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.toc new file mode 100644 index 00000000..e4abaac1 --- /dev/null +++ b/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.toc @@ -0,0 +1,12 @@ +## Interface: 120001, 120000 +## Title: Lib: CustomGlow +## Notes: Creates custom glow functions +## Author: deezo +## X-Category: Library +## X-License: BSD +## Version: 51de51c +## OptionalDeps: Masque + +LibStub\LibStub.lua + +LibCustomGlow-1.0.xml diff --git a/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.xml b/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.xml new file mode 100644 index 00000000..c93a784e --- /dev/null +++ b/Libs/LibCustomGlow-1.0/LibCustomGlow-1.0.xml @@ -0,0 +1,4 @@ + +