local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} local PA = HMGT:NewModule("PersonalAuras") HMGT.PersonalAuras = PA PA.runtimeEnabled = false PA.eventFrame = nil PA.ticker = nil PA.frame = nil PA.rows = nil local function ClampNumber(value, minValue, maxValue, fallback) local numericValue = tonumber(value) if numericValue == nil then return fallback end if numericValue < minValue then return minValue end if numericValue > maxValue then return maxValue end return numericValue end local function GetSpellName(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end if C_Spell and type(C_Spell.GetSpellName) == "function" then local name = C_Spell.GetSpellName(sid) if type(name) == "string" and name ~= "" then return name end end if type(GetSpellInfo) == "function" then local name = GetSpellInfo(sid) if type(name) == "string" and name ~= "" then return name end end return nil end local function GetSpellIcon(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then local icon = HMGT_SpellData.GetSpellIcon(sid) if icon and icon ~= "" then return icon end end if C_Spell and type(C_Spell.GetSpellTexture) == "function" then local icon = C_Spell.GetSpellTexture(sid) if icon and icon ~= "" then return icon end end if type(GetSpellInfo) == "function" then local _, _, icon = GetSpellInfo(sid) if icon and icon ~= "" then return icon end end return 136243 end local function NormalizeAuraApplications(auraData) if type(auraData) ~= "table" then return 0 end local count = tonumber(auraData.applications or auraData.stackCount or auraData.stacks or auraData.charges or auraData.count) if count ~= nil then return math.max(0, count) end return 0 end local function FormatRemainingTime(secondsRemaining) local seconds = tonumber(secondsRemaining) if not seconds or seconds <= 0 then return "" end if seconds >= 60 then local total = math.floor(seconds + 0.5) local minutes = math.floor(total / 60) local rest = total % 60 return string.format("%d:%02d", minutes, rest) end if seconds >= 10 then return string.format("%ds", math.floor(seconds + 0.5)) end return string.format("%.1fs", seconds) end local function NormalizeSettings(settings) if type(settings) ~= "table" then settings = {} end if settings.enabled == nil then settings.enabled = false end settings.unlocked = settings.unlocked == true settings.posX = ClampNumber(settings.posX, -2000, 2000, 0) settings.posY = ClampNumber(settings.posY, -2000, 2000, 120) settings.width = math.floor(ClampNumber(settings.width, 160, 420, 260) + 0.5) settings.rowHeight = math.floor(ClampNumber(settings.rowHeight, 18, 42, 24) + 0.5) settings.iconSize = math.floor(ClampNumber(settings.iconSize, 14, 32, 20) + 0.5) settings.fontSize = math.floor(ClampNumber(settings.fontSize, 8, 24, 12) + 0.5) if type(settings.trackedDebuffs) ~= "table" then settings.trackedDebuffs = {} return settings end local normalized = {} for sid, value in pairs(settings.trackedDebuffs) do local id = tonumber(sid) if id and id > 0 and value ~= false and value ~= nil then normalized[id] = true end end settings.trackedDebuffs = normalized return settings end local function SetFontObject(fontString, size) if not fontString or type(fontString.SetFont) ~= "function" then return end local fontPath = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" fontString:SetFont(fontPath, tonumber(size) or 12, "OUTLINE") end local function IsAuraDataHarmful(auraData) if type(auraData) ~= "table" then return false end if auraData.isHarmful ~= nil then return auraData.isHarmful == true end if auraData.isHelpful ~= nil then return auraData.isHelpful ~= true end return true end local function GetPlayerTrackedDebuff(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end if C_UnitAuras and type(C_UnitAuras.GetPlayerAuraBySpellID) == "function" then local auraData = C_UnitAuras.GetPlayerAuraBySpellID(sid) if auraData and IsAuraDataHarmful(auraData) then return { spellId = sid, name = tostring(auraData.name or GetSpellName(sid) or ("Spell " .. tostring(sid))), icon = auraData.icon or auraData.iconFileID or GetSpellIcon(sid), applications = NormalizeAuraApplications(auraData), duration = tonumber(auraData.duration) or 0, expirationTime = tonumber(auraData.expirationTime) or 0, } end end if AuraUtil and type(AuraUtil.FindAuraBySpellID) == "function" then local name, icon, applications, _, duration, expirationTime = AuraUtil.FindAuraBySpellID(sid, "player", "HARMFUL") if name then return { spellId = sid, name = tostring(name), icon = icon or GetSpellIcon(sid), applications = math.max(0, tonumber(applications) or 0), duration = tonumber(duration) or 0, expirationTime = tonumber(expirationTime) or 0, } end end return nil end function PA:GetSettings() local profile = HMGT.db and HMGT.db.profile if not profile then return nil end profile.personalAuras = NormalizeSettings(profile.personalAuras) return profile.personalAuras end function PA:GetTrackedSpellIds() local ids = {} local settings = self:GetSettings() local tracked = settings and settings.trackedDebuffs or {} for sid, enabled in pairs(tracked) do local id = tonumber(sid) if id and id > 0 and enabled then ids[#ids + 1] = id end end table.sort(ids, function(a, b) local nameA = tostring(GetSpellName(a) or a):lower() local nameB = tostring(GetSpellName(b) or b):lower() if nameA == nameB then return a < b end return nameA < nameB end) return ids end function PA:GetTrackedEntries() local entries = {} for _, sid in ipairs(self:GetTrackedSpellIds()) do entries[#entries + 1] = { spellId = sid, name = GetSpellName(sid) or ("Spell " .. tostring(sid)), icon = GetSpellIcon(sid), } end return entries end function PA:AddTrackedDebuff(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return false, "invalid" end local name = GetSpellName(sid) if not name then return false, "invalid" end local settings = self:GetSettings() settings.trackedDebuffs[sid] = true self:Refresh() return true, name end function PA:RemoveTrackedDebuff(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return false, "invalid" end local settings = self:GetSettings() if not settings.trackedDebuffs[sid] then return false, "missing" end settings.trackedDebuffs[sid] = nil self:Refresh() return true, GetSpellName(sid) or ("Spell " .. tostring(sid)) end function PA:HasTrackedDebuffsConfigured() return #self:GetTrackedSpellIds() > 0 end function PA:GetActiveDebuffEntries() local now = GetTime() local entries = {} for _, sid in ipairs(self:GetTrackedSpellIds()) do local auraData = GetPlayerTrackedDebuff(sid) if auraData then local expirationTime = tonumber(auraData.expirationTime) or 0 local remaining = 0 if expirationTime > 0 then remaining = math.max(0, expirationTime - now) end auraData.remaining = remaining entries[#entries + 1] = auraData end end table.sort(entries, function(a, b) local expA = tonumber(a.expirationTime) or 0 local expB = tonumber(b.expirationTime) or 0 if expA > 0 and expB > 0 and expA ~= expB then return expA < expB end if expA > 0 and expB <= 0 then return true end if expB > 0 and expA <= 0 then return false end return tostring(a.name or "") < tostring(b.name or "") end) return entries end function PA:SaveFramePosition() local frame = self.frame local settings = self:GetSettings() if not frame or not settings then return end local centerX, centerY = frame:GetCenter() local parentX, parentY = UIParent:GetCenter() if not centerX or not centerY or not parentX or not parentY then return end settings.posX = math.floor((centerX - parentX) + 0.5) settings.posY = math.floor((centerY - parentY) + 0.5) end function PA:ApplyFramePosition() local frame = self.frame local settings = self:GetSettings() if not frame or not settings then return end frame:ClearAllPoints() frame:SetPoint("CENTER", UIParent, "CENTER", tonumber(settings.posX) or 0, tonumber(settings.posY) or 120) end function PA:AcquireRow(index) self.rows = self.rows or {} local row = self.rows[index] if row then return row end row = CreateFrame("Frame", nil, self.frame) row:EnableMouse(true) row.icon = row:CreateTexture(nil, "ARTWORK") row.name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") row.timer = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") row.icon:SetPoint("LEFT", row, "LEFT", 8, 0) row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0) row.name:SetPoint("RIGHT", row.timer, "LEFT", -8, 0) row.name:SetJustifyH("LEFT") row.timer:SetPoint("RIGHT", row, "RIGHT", -8, 0) row.timer:SetJustifyH("RIGHT") row:SetScript("OnEnter", function(selfRow) if not selfRow.spellId or not GameTooltip then return end GameTooltip:SetOwner(selfRow, "ANCHOR_RIGHT") if type(GameTooltip.SetSpellByID) == "function" then GameTooltip:SetSpellByID(selfRow.spellId) else GameTooltip:SetText(GetSpellName(selfRow.spellId) or ("Spell " .. tostring(selfRow.spellId))) end if HMGT.SafeShowTooltip then HMGT:SafeShowTooltip(GameTooltip) else GameTooltip:Show() end end) row:SetScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end) self.rows[index] = row return row end function PA:EnsureFrame() if self.frame then return self.frame end local frame = CreateFrame("Frame", "HMGTPersonalAurasFrame", UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil) frame:SetClampedToScreen(true) frame:SetMovable(true) frame:EnableMouse(true) frame:RegisterForDrag("LeftButton") frame:SetScript("OnDragStart", function(selfFrame) local settings = self:GetSettings() if settings and settings.unlocked then selfFrame:StartMoving() end end) frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() self:SaveFramePosition() end) frame:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 12, insets = { left = 3, right = 3, top = 3, bottom = 3 }, }) frame:SetBackdropColor(0.02, 0.02, 0.04, 0.88) frame:SetBackdropBorderColor(0.35, 0.35, 0.4, 1) frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 10, -9) frame.title:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -10, -9) frame.title:SetJustifyH("LEFT") frame.placeholder = frame:CreateFontString(nil, "OVERLAY", "GameFontDisable") frame.placeholder:SetPoint("CENTER", frame, "CENTER", 0, -4) frame.placeholder:SetJustifyH("CENTER") frame.placeholder:SetJustifyV("MIDDLE") self.frame = frame self.rows = {} self:ApplyFramePosition() return frame end function PA:UpdateFrameInteractivity() local frame = self.frame local settings = self:GetSettings() if not frame or not settings then return end frame:EnableMouse(settings.unlocked == true) if settings.unlocked then frame:SetBackdropBorderColor(1, 0.82, 0.15, 0.9) else frame:SetBackdropBorderColor(0.35, 0.35, 0.4, 1) end end function PA:RenderFrame(entries) local settings = self:GetSettings() local frame = self:EnsureFrame() entries = entries or {} self:ApplyFramePosition() self:UpdateFrameInteractivity() if not settings or settings.enabled ~= true then frame:Hide() return end local showFrame = (#entries > 0) or settings.unlocked if not showFrame then frame:Hide() return end local width = tonumber(settings.width) or 260 local rowHeight = tonumber(settings.rowHeight) or 24 local iconSize = tonumber(settings.iconSize) or 20 local fontSize = tonumber(settings.fontSize) or 12 local headerHeight = 26 local bottomPadding = 8 local visibleRows = math.max(#entries, settings.unlocked and 1 or 0) local height = headerHeight + bottomPadding + (visibleRows * rowHeight) if height < 72 then height = 72 end frame:SetSize(width, height) SetFontObject(frame.title, fontSize + 1) SetFontObject(frame.placeholder, fontSize) frame.title:SetText((L["PA_NAME"] or "Personal Auras") .. ((#entries > 0) and (" (" .. tostring(#entries) .. ")") or "")) frame.placeholder:SetText(L["OPT_PA_UNLOCK_HINT"] or "Personal Auras\nDrag to move") frame.placeholder:SetShown(#entries == 0 and settings.unlocked) for index, entry in ipairs(entries) do local row = self:AcquireRow(index) row:ClearAllPoints() row:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -(headerHeight + ((index - 1) * rowHeight))) row:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -(headerHeight + ((index - 1) * rowHeight))) row:SetHeight(rowHeight) row.spellId = tonumber(entry.spellId) or 0 row.icon:SetSize(iconSize, iconSize) row.icon:SetTexture(entry.icon or GetSpellIcon(entry.spellId)) if (tonumber(entry.applications) or 0) > 1 then row.name:SetText(string.format("%s x%d", tostring(entry.name or ("Spell " .. tostring(entry.spellId))), tonumber(entry.applications) or 0)) else row.name:SetText(tostring(entry.name or ("Spell " .. tostring(entry.spellId)))) end row.timer:SetText(FormatRemainingTime(entry.remaining)) SetFontObject(row.name, fontSize) SetFontObject(row.timer, fontSize) row:Show() end if self.rows then for index = #entries + 1, #self.rows do self.rows[index]:Hide() end end frame:Show() end function PA:StopTicker() if self.ticker then self.ticker:Cancel() self.ticker = nil end end function PA:EnsureTicker() if self.ticker then return end self.ticker = C_Timer.NewTicker(0.1, function() self:OnTicker() end) end function PA:UpdateRuntimeEventRegistrations() if not self.eventFrame then return end self.eventFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") self.eventFrame:UnregisterEvent("UNIT_AURA") if not self.runtimeEnabled then return end self.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") local settings = self:GetSettings() if settings and settings.enabled and self:HasTrackedDebuffsConfigured() then self.eventFrame:RegisterUnitEvent("UNIT_AURA", "player") end end function PA:Refresh() if not self.runtimeEnabled then return end self:UpdateRuntimeEventRegistrations() local settings = self:GetSettings() if not settings or settings.enabled ~= true then self:StopTicker() if self.frame then self.frame:Hide() end return end local entries = self:GetActiveDebuffEntries() self:RenderFrame(entries) if #entries > 0 then self:EnsureTicker() else self:StopTicker() end end function PA:OnTicker() if not self.runtimeEnabled then self:StopTicker() return end local entries = self:GetActiveDebuffEntries() self:RenderFrame(entries) if #entries == 0 then self:StopTicker() end end function PA:OnEvent(event, ...) if event == "UNIT_AURA" then local unit = ... if unit ~= "player" then return end end self:Refresh() end function PA:StartRuntime() if self.runtimeEnabled then self:Refresh() return end self.runtimeEnabled = true if not self.eventFrame then self.eventFrame = CreateFrame("Frame") self.eventFrame:SetScript("OnEvent", function(_, event, ...) self:OnEvent(event, ...) end) end self:Refresh() end function PA:StopRuntime() self.runtimeEnabled = false if self.eventFrame then self.eventFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") self.eventFrame:UnregisterEvent("UNIT_AURA") end self:StopTicker() if self.frame then self.frame:Hide() end end function PA:OnInitialize() HMGT.PersonalAuras = self end function PA:OnEnable() self:StartRuntime() end function PA:OnDisable() self:StopRuntime() end