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..03240037 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) @@ -5887,18 +6497,44 @@ function DF.BuildAuraDesignerPage(guiRef, pageRef, dbRef) mainFrame = CreateFrame("Frame", nil, parent) mainFrame:SetAllPoints() - -- Override RefreshStates: Aura Designer uses its own layout system + -- 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..f515b9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # 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) + +### 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) + +### 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) + ## [4.3.12] ### New Features diff --git a/Config.lua b/Config.lua index 25aff771..f42002dd 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,30 @@ 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, -- Targeted Spells (on-frame) targetedSpellAlpha = 1, @@ -1864,7 +2110,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 +2183,7 @@ DF.PartyDefaults = { targetedListDurationAnchor = "RIGHT", targetedListDurationAlign = "RIGHT", targetedListDurationFontSize = 17, - targetedListDurationX = -6, + targetedListDurationX = 30, targetedListDurationY = 0, targetedListInterruptTextAnchor = "CENTER", targetedListInterruptTextAlign = "CENTER", @@ -2186,7 +2445,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 +2453,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 +2510,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 +2590,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 +2637,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 +2700,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 +2768,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 +2814,7 @@ DF.RaidDefaults = { debuffDurationHideAboveThreshold = 10, debuffDurationFont = "DF Roboto SemiBold", debuffDurationAnchor = "CENTER", - debuffDurationOutline = "SHADOW", + debuffDurationOutline = "SHADOW;OUTLINE", debuffDurationScale = 1, debuffDurationX = 0, debuffDurationY = 0, @@ -2453,18 +2834,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 +2872,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 +3048,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 +3090,7 @@ DF.RaidDefaults = { healthColorMediumWeight = 2, healthColorMode = "CLASS", healthFont = "DF Roboto SemiBold", - healthFontSize = 10, + healthFontSize = 11, healthOrientation = "HORIZONTAL", healthTextAbbreviate = true, healthTextAnchor = "CENTER", @@ -2698,7 +3107,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 +3157,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 +3212,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 +3243,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 +3280,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 +3309,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 +3449,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 +3495,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 +3547,7 @@ DF.RaidDefaults = { roleIconExternalDPS = "", roleIconExternalHealer = "", roleIconExternalTank = "", - roleIconOnlyInCombat = false, + roleIconHideInCombat = false, roleIconScale = 1, roleIconShowDPS = true, roleIconShowHealer = true, @@ -3118,18 +3607,30 @@ 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, -- Targeted Spells (on-frame) targetedSpellAlpha = 1, diff --git a/Core.lua b/Core.lua index 40c658e4..586bf088 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,36 @@ 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 + -- 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 +3871,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 +4070,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. @@ -5256,7 +5522,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..abe4bec1 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,18 @@ DF.ExportCategories = { "raidRoleIconShowText", "raidRoleIconTextTank", "raidRoleIconTextAssist", - + + -- BG Objective Carrier Icon + "bgCarrierIconEnabled", + "bgCarrierIconAnchor", + "bgCarrierIconX", + "bgCarrierIconY", + "bgCarrierIconScale", + "bgCarrierIconAlpha", + "bgCarrierIconFrameLevel", + "bgCarrierIconShowText", + "bgCarrierIconText", + -- Defensive Icon "defensiveIconEnabled", "defensiveIconAnchor", @@ -683,8 +729,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 +844,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 +951,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 +1007,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 +1164,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/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/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..8a472117 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,15 @@ 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 + -- ======================================== -- CENTER STATUS ICON (DEPRECATED - backward compat) -- ======================================== @@ -173,6 +182,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 +281,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 +312,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 +388,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 +459,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 +468,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 +476,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 +709,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 +739,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 +816,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 +842,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 +981,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 +995,76 @@ 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 + -- ============================================================ -- 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) end -- ============================================================ @@ -967,29 +1072,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 +1146,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 +1155,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 +1246,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 +1314,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..e48b690c 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..."]) 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 @@ + +