-- Modules/Tracker/TrackerManager.lua -- Generic tracker manager for category-driven tracker frames. local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end local Manager = {} HMGT.TrackerManager = Manager Manager.frames = Manager.frames or {} Manager.perPlayerFrames = Manager.perPlayerFrames or {} Manager.activeOrders = Manager.activeOrders or {} Manager.unitByPlayer = Manager.unitByPlayer or {} Manager.anchorLayoutSignatures = Manager.anchorLayoutSignatures or {} Manager.nextAnchorRetryAt = Manager.nextAnchorRetryAt or {} Manager.enabled = false Manager.visualTicker = nil Manager.lastEntryCount = 0 Manager._shared = Manager._shared or {} Manager._trackerCache = Manager._trackerCache or nil Manager._trackerCacheSignature = Manager._trackerCacheSignature or nil Manager._displaySignatures = Manager._displaySignatures or {} Manager._layoutDirty = Manager._layoutDirty == true local function GetTrackerFrameKey(trackerId) return "tracker:" .. tostring(tonumber(trackerId) or 0) end local function GetTrackerFrameName(trackerId) return "GenericTracker_" .. tostring(tonumber(trackerId) or 0) end local function GetTrackerPlayerFrameName(trackerId, playerName) local token = tostring(playerName or "Unknown"):gsub("[^%w_]", "_") return string.format("%s_%s", GetTrackerFrameName(trackerId), token) end local function ShortName(name) if not name then return "" end local short = name:match("^[^-]+") return short or name end local function IsUsableAnchorFrame(frame) return frame and frame.IsObjectType and (frame:IsObjectType("Frame") or frame:IsObjectType("Button")) end local function GetFrameUnit(frame) if not frame then return nil end local unit = frame.unit if not unit and frame.GetAttribute then unit = frame:GetAttribute("unit") end return unit end local function FrameMatchesUnit(frame, unitId) if not IsUsableAnchorFrame(frame) then return false end if not unitId then return true end return GetFrameUnit(frame) == unitId end local PLAYER_FRAME_CANDIDATES = { "PlayerFrame", "ElvUF_Player", "NephUI_PlayerFrame", "NephUIPlayerFrame", "oUF_NephUI_Player", "SUFUnitplayer", } local PARTY_FRAME_PATTERNS = { "PartyMemberFrame%d", "CompactPartyFrameMember%d", "ElvUF_PartyGroup1UnitButton%d", "ElvUF_PartyUnitButton%d", "NephUI_PartyUnitButton%d", "NephUI_PartyFrame%d", "NephUIPartyFrame%d", "oUF_NephUI_PartyUnitButton%d", "SUFUnitparty%d", } local unitFrameCache = {} local function EntryNeedsVisualTicker(entry) if type(entry) ~= "table" then return false end local remaining = tonumber(entry.remaining) or 0 if remaining > 0 then return true end local maxCharges = tonumber(entry.maxCharges) or 0 local currentCharges = tonumber(entry.currentCharges) if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then return true end return false end local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer) local parts = { settings.attachToPartyFrame == true and "attach" or "stack", tostring(settings.partyAttachSide or "RIGHT"), tostring(tonumber(settings.partyAttachOffsetX) or 8), tostring(tonumber(settings.partyAttachOffsetY) or 0), tostring(settings.showBar and "bar" or "icon"), tostring(settings.growDirection or "DOWN"), tostring(settings.width or 250), tostring(settings.barHeight or 20), tostring(settings.iconSize or 32), tostring(settings.iconCols or 6), tostring(settings.barSpacing or 2), tostring(settings.locked), tostring(settings.anchorTo or "UIParent"), tostring(settings.anchorPoint or "TOPLEFT"), tostring(settings.anchorRelPoint or "TOPLEFT"), tostring(settings.anchorX or settings.posX or 0), tostring(settings.anchorY or settings.posY or 0), } for _, playerName in ipairs(ordered or {}) do parts[#parts + 1] = tostring(playerName) parts[#parts + 1] = tostring(unitByPlayer and unitByPlayer[playerName] or "") end return table.concat(parts, "|") end local function IsGroupTracker(tracker) return type(tracker) == "table" and tracker.trackerType == "group" end local function ResolveNamedUnitFrame(unitId) if unitId == "player" then for _, frameName in ipairs(PLAYER_FRAME_CANDIDATES) do local frame = _G[frameName] if FrameMatchesUnit(frame, unitId) or (frameName == "PlayerFrame" and IsUsableAnchorFrame(frame)) then return frame end end return nil end local idx = type(unitId) == "string" and unitId:match("^party(%d+)$") if not idx then return nil end idx = tonumber(idx) for _, pattern in ipairs(PARTY_FRAME_PATTERNS) do local frame = _G[pattern:format(idx)] if FrameMatchesUnit(frame, unitId) then return frame end end return nil end local function ScanUnitFrame(unitId) local frame = EnumerateFrames() local scanned = 0 while frame and scanned < 8000 do if IsUsableAnchorFrame(frame) and GetFrameUnit(frame) == unitId then HMGT:Debug("verbose", "TrackerAttach scan unit=%s scanned=%d found=true", tostring(unitId), scanned) return frame end scanned = scanned + 1 frame = EnumerateFrames(frame) end HMGT:Debug("verbose", "TrackerAttach scan unit=%s scanned=%d found=false", tostring(unitId), scanned) return nil end local function ResolveUnitAnchorFrame(unitId) if not unitId then return nil end local now = GetTime() local cached = unitFrameCache[unitId] if cached and now < (cached.expires or 0) then if cached.frame and cached.frame:IsShown() then return cached.frame end return nil end local frame = ResolveNamedUnitFrame(unitId) if not frame then frame = ScanUnitFrame(unitId) end local expiresIn = 1.0 if frame and frame:IsShown() then expiresIn = 10.0 end unitFrameCache[unitId] = { frame = frame, expires = now + expiresIn, } if frame and frame:IsShown() then return frame end return nil end local function GetGroupPlayers(tracker) local players = {} local ownName = HMGT:NormalizePlayerName(UnitName("player")) local ownClass = select(2, UnitClass("player")) local includeOwnPlayer = true if IsGroupTracker(tracker) then includeOwnPlayer = tracker.includeSelfFrame == true end if includeOwnPlayer then players[#players + 1] = { name = ownName, class = ownClass, isOwn = true, unitId = "player", } end if IsInRaid() then for i = 1, GetNumGroupMembers() do local unitId = "raid" .. i local name = HMGT:NormalizePlayerName(UnitName(unitId)) local class = select(2, UnitClass(unitId)) if name and name ~= ownName then players[#players + 1] = { name = name, class = class, unitId = unitId, } end end elseif IsInGroup() then for i = 1, GetNumGroupMembers() - 1 do local unitId = "party" .. i local name = HMGT:NormalizePlayerName(UnitName(unitId)) local class = select(2, UnitClass(unitId)) if name and name ~= ownName then players[#players + 1] = { name = name, class = class, unitId = unitId, } end end end return players end local function GetTrackerLabel(tracker) if type(tracker) ~= "table" then return "Tracker" end local name = tostring(tracker.name or ""):gsub("^%s+", ""):gsub("%s+$", "") local trackerId = tonumber(tracker.id) or 0 if name ~= "" then return name end if trackerId > 0 then return string.format("Tracker %d", trackerId) end return "Tracker" end local function GetTrackerSpellPool(categories) if HMGT_SpellData and type(HMGT_SpellData.GetSpellPoolForCategories) == "function" then return HMGT_SpellData.GetSpellPoolForCategories(categories) end return {} end local function GetTrackerSpellsForPlayer(classToken, specIndex, categories) if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForCategories) == "function" then return HMGT_SpellData.GetSpellsForCategories(classToken, specIndex, categories) end return {} end local function CollectEntriesForPlayer(tracker, playerInfo) local entries = {} if type(tracker) ~= "table" or type(playerInfo) ~= "table" then return entries end local playerName = playerInfo.name if not playerName then return entries end local pData = HMGT.playerData[playerName] local classToken = pData and pData.class or playerInfo.class if not classToken then return entries end local specIndex if playerInfo.isOwn then specIndex = GetSpecialization() if not specIndex or specIndex == 0 then return entries end else specIndex = pData and pData.specIndex or nil if not specIndex or tonumber(specIndex) <= 0 then return entries end end local talents = pData and pData.talents or {} local spells = GetTrackerSpellsForPlayer(classToken, specIndex, tracker.categories) for _, spellEntry in ipairs(spells) do if tracker.enabledSpells[spellEntry.spellId] ~= false then local remaining, total, currentCharges, maxCharges = HMGT:GetCooldownInfo(playerName, spellEntry.spellId) local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local isAvailabilitySpell = HMGT:IsAvailabilitySpell(spellEntry) local include = HMGT:ShouldDisplayEntry(tracker, remaining, currentCharges, maxCharges, spellEntry) local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges if not spellKnown and not hasActiveCd then include = false end if isAvailabilitySpell and not spellKnown then include = false end if not playerInfo.isOwn and isAvailabilitySpell and not HMGT:HasAvailabilityState(playerName, spellEntry.spellId) then include = false end if include then entries[#entries + 1] = { playerName = playerName, class = classToken, spellEntry = spellEntry, remaining = remaining, total = total > 0 and total or effectiveCd, currentCharges = currentCharges, maxCharges = maxCharges, } end end end return entries end local function CopyEntriesForPreview(entries, playerName) local copies = {} for _, entry in ipairs(entries or {}) do local nextEntry = {} for key, value in pairs(entry) do nextEntry[key] = value end nextEntry.playerName = playerName copies[#copies + 1] = nextEntry end return copies end local function GetAvailablePartyPreviewUnits() local units = {} for index = 1, 4 do local unitId = "party" .. index if ResolveUnitAnchorFrame(unitId) then units[#units + 1] = { playerName = string.format("Party %d", index), unitId = unitId, } end end return units end local function BuildPartyPreviewEntries(entries) local byPlayer = {} local order = {} local unitByPlayer = {} local previewUnits = GetAvailablePartyPreviewUnits() for _, previewUnit in ipairs(previewUnits) do local playerName = previewUnit.playerName local playerEntries = CopyEntriesForPreview(entries, playerName) if #playerEntries > 0 then byPlayer[playerName] = playerEntries order[#order + 1] = playerName unitByPlayer[playerName] = previewUnit.unitId end end return byPlayer, order, unitByPlayer, #order > 0 end local function SortTrackers(trackers) table.sort(trackers, function(a, b) local aId = tonumber(a and a.id) or 0 local bId = tonumber(b and b.id) or 0 if aId ~= bId then return aId < bId end return tostring(a and a.name or "") < tostring(b and b.name or "") end) return trackers end local function BuildTrackerCacheSignature(trackers) local parts = { tostring(#(trackers or {})) } for index, tracker in ipairs(trackers or {}) do parts[#parts + 1] = tostring(index) parts[#parts + 1] = tostring(tonumber(tracker and tracker.id) or 0) parts[#parts + 1] = tostring(tracker and tracker.trackerType or "normal") parts[#parts + 1] = tostring(tracker and tracker.enabled) parts[#parts + 1] = tostring(tracker and tracker.name or "") end return table.concat(parts, "|") end local function BuildNormalDisplaySignature(frameShown, entries) local parts = { frameShown and "1" or "0", tostring(#(entries or {})) } for index, entry in ipairs(entries or {}) do parts[#parts + 1] = tostring(index) parts[#parts + 1] = tostring(entry and entry.playerName or "") parts[#parts + 1] = tostring(entry and entry.spellEntry and entry.spellEntry.spellId or 0) end return table.concat(parts, "|") end local function BuildGroupDisplaySignature(order, byPlayer) local parts = { tostring(#(order or {})) } for _, playerName in ipairs(order or {}) do parts[#parts + 1] = tostring(playerName) parts[#parts + 1] = tostring(#((byPlayer and byPlayer[playerName]) or {})) end return table.concat(parts, "|") end Manager._shared.GetTrackerFrameKey = GetTrackerFrameKey Manager._shared.GetTrackerFrameName = GetTrackerFrameName Manager._shared.GetTrackerPlayerFrameName = GetTrackerPlayerFrameName Manager._shared.ShortName = ShortName Manager._shared.BuildAnchorLayoutSignature = BuildAnchorLayoutSignature Manager._shared.IsGroupTracker = IsGroupTracker Manager._shared.ResolveUnitAnchorFrame = ResolveUnitAnchorFrame Manager._shared.GetGroupPlayers = GetGroupPlayers Manager._shared.GetTrackerLabel = GetTrackerLabel Manager._shared.GetTrackerSpellPool = GetTrackerSpellPool Manager._shared.GetTrackerSpellsForPlayer = GetTrackerSpellsForPlayer Manager._shared.CollectEntriesForPlayer = CollectEntriesForPlayer Manager._shared.BuildPartyPreviewEntries = BuildPartyPreviewEntries Manager._shared.EntryNeedsVisualTicker = EntryNeedsVisualTicker Manager._shared.BuildGroupDisplaySignature = BuildGroupDisplaySignature function Manager:GetTrackers() local profile = HMGT and HMGT.db and HMGT.db.profile local trackers = profile and profile.trackers or {} local signature = BuildTrackerCacheSignature(trackers) if self._trackerCache and self._trackerCacheSignature == signature then return self._trackerCache end local result = {} for _, tracker in ipairs(trackers) do result[#result + 1] = tracker end self._trackerCache = SortTrackers(result) self._trackerCacheSignature = signature return self._trackerCache end function Manager:MarkTrackersDirty() self._trackerCache = nil self._trackerCacheSignature = nil self._layoutDirty = true end function Manager:MarkLayoutDirty() self._layoutDirty = true end function Manager:EnsureFrame(tracker) local frameKey = GetTrackerFrameKey(tracker.id) local frame = self.frames[frameKey] if not frame then frame = HMGT.TrackerFrame:CreateTrackerFrame(GetTrackerFrameName(tracker.id), tracker) frame._hmgtTrackerId = tonumber(tracker.id) or 0 self.frames[frameKey] = frame end frame._settings = tracker HMGT.TrackerFrame:SetTitle(frame, GetTrackerLabel(tracker)) HMGT.TrackerFrame:SetLocked(frame, tracker.locked) return frame end function Manager:GetAnchorFrame(tracker) if type(tracker) ~= "table" then return nil end if IsGroupTracker(tracker) then local frameKey = GetTrackerFrameKey(tracker.id) local order = self.activeOrders[frameKey] or {} local frames = self.perPlayerFrames[frameKey] or {} if order[1] and frames[order[1]] and frames[order[1]]:IsShown() then return frames[order[1]] end end return self:EnsureFrame(tracker) end function Manager:StopVisualTicker() if self.visualTicker then self.visualTicker:Cancel() self.visualTicker = nil end end function Manager:SetVisualTickerEnabled(enabled) if enabled then if not self.visualTicker then self.visualTicker = C_Timer.NewTicker(0.1, function() self:RefreshVisibleVisuals() end) end else self:StopVisualTicker() end end function Manager:RefreshAnchors(force) for _, tracker in ipairs(self:GetTrackers()) do local frameKey = GetTrackerFrameKey(tracker.id) if IsGroupTracker(tracker) then local anchorFrame = self.frames[frameKey] if anchorFrame and not anchorFrame._hmgtDragging then HMGT.TrackerFrame:ApplyAnchor(anchorFrame) end self:RefreshPerGroupAnchors(tracker, force) else local frame = self.frames[frameKey] if frame and (force or frame:IsShown()) then if not frame._hmgtDragging then HMGT.TrackerFrame:ApplyAnchor(frame) end frame:EnableMouse(not tracker.locked) end end end end function Manager:InvalidateAnchorLayout() self.anchorLayoutSignatures = {} self.nextAnchorRetryAt = {} self:MarkLayoutDirty() self:RefreshAnchors(true) end function Manager:SetAllLocked(locked) for _, frame in pairs(self.frames) do HMGT.TrackerFrame:SetLocked(frame, locked) end for _, frameSet in pairs(self.perPlayerFrames) do for _, frame in pairs(frameSet) do HMGT.TrackerFrame:SetLocked(frame, locked) end end end function Manager:GetAnchorableFrames() local frames = {} for _, tracker in ipairs(self:GetTrackers()) do local anchorKey = HMGT.GetTrackerAnchorKey and HMGT:GetTrackerAnchorKey(tracker.id) or nil local frame = self:GetAnchorFrame(tracker) if anchorKey and frame then frames[anchorKey] = frame end end return frames end function Manager:Enable() self.enabled = true self:MarkTrackersDirty() self:UpdateDisplay() end function Manager:Disable() self.enabled = false self:StopVisualTicker() self._layoutDirty = true for _, frame in pairs(self.frames) do frame:Hide() end for frameKey in pairs(self.perPlayerFrames) do self:HidePlayerFrames(frameKey) end end function Manager:RefreshVisibleVisuals() if not self.enabled then self:StopVisualTicker() return end local shouldTick = false local needsFullRefresh = false local totalEntries = 0 for _, tracker in ipairs(self:GetTrackers()) do local frameKey = GetTrackerFrameKey(tracker.id) if IsGroupTracker(tracker) then local currentOrder = self.activeOrders[frameKey] or {} if #currentOrder > 0 then local byPlayer, order, unitByPlayer, shouldShow = self:BuildEntriesByPlayerForTracker(tracker) if not shouldShow or #order ~= #currentOrder then needsFullRefresh = true else local byPlayerFiltered = {} for index, playerName in ipairs(order) do if playerName ~= currentOrder[index] then needsFullRefresh = true break end local entries = byPlayer[playerName] or {} if HMGT.FilterDisplayEntries then entries = HMGT:FilterDisplayEntries(tracker, entries) or entries end if HMGT.SortDisplayEntries then HMGT:SortDisplayEntries(entries) end if #entries == 0 then needsFullRefresh = true break end local frame = self.perPlayerFrames[frameKey] and self.perPlayerFrames[frameKey][playerName] if not frame or not frame:IsShown() then needsFullRefresh = true break end HMGT.TrackerFrame:UpdateFrame(frame, entries, true) totalEntries = totalEntries + #entries byPlayerFiltered[playerName] = entries for _, entry in ipairs(entries) do if EntryNeedsVisualTicker(entry) then shouldTick = true break end end end local newSignature = BuildGroupDisplaySignature(currentOrder, byPlayerFiltered) if self._displaySignatures[frameKey] ~= newSignature then needsFullRefresh = true end end end else local frame = self.frames[frameKey] if frame and frame:IsShown() then local entries, shouldShow = self:BuildEntriesForTracker(tracker) if not shouldShow then needsFullRefresh = true else if HMGT.FilterDisplayEntries then entries = HMGT:FilterDisplayEntries(tracker, entries) or entries end if HMGT.SortDisplayEntries then HMGT:SortDisplayEntries(entries) end if #entries == 0 then needsFullRefresh = true else HMGT.TrackerFrame:UpdateFrame(frame, entries, true) totalEntries = totalEntries + #entries local newSignature = BuildNormalDisplaySignature(true, entries) if self._displaySignatures[frameKey] ~= newSignature then needsFullRefresh = true end for _, entry in ipairs(entries) do if EntryNeedsVisualTicker(entry) then shouldTick = true break end end end end end end end self.lastEntryCount = totalEntries self:SetVisualTickerEnabled(shouldTick) if needsFullRefresh then HMGT:TriggerTrackerUpdate("layout") end end function Manager:UpdateDisplay() if not self.enabled then self:StopVisualTicker() return end local trackers = self:GetTrackers() local activeFrames = {} local shouldTick = false local totalEntries = 0 local layoutDirty = self._layoutDirty == true for _, tracker in ipairs(trackers) do local frameKey = GetTrackerFrameKey(tracker.id) local frame = self:EnsureFrame(tracker) if IsGroupTracker(tracker) then frame:Hide() local shown, entryCount, trackerShouldTick = self:UpdatePerGroupMemberTracker(tracker) totalEntries = totalEntries + (entryCount or 0) if trackerShouldTick then shouldTick = true end if not shown then self:HidePlayerFrames(frameKey) end else self:HidePlayerFrames(frameKey) local entries, shouldShow = self:BuildEntriesForTracker(tracker) if shouldShow then if HMGT.FilterDisplayEntries then entries = HMGT:FilterDisplayEntries(tracker, entries) or entries end if HMGT.SortDisplayEntries then HMGT:SortDisplayEntries(entries) end HMGT.TrackerFrame:UpdateFrame(frame, entries, true) frame:Show() frame:EnableMouse(not tracker.locked) activeFrames[frameKey] = true totalEntries = totalEntries + #entries local newSignature = BuildNormalDisplaySignature(true, entries) if self._displaySignatures[frameKey] ~= newSignature then self._displaySignatures[frameKey] = newSignature layoutDirty = true end for _, entry in ipairs(entries) do if EntryNeedsVisualTicker(entry) then shouldTick = true break end end else frame:Hide() if self._displaySignatures[frameKey] ~= "0" then self._displaySignatures[frameKey] = "0" layoutDirty = true end end end end for frameKey, frame in pairs(self.frames) do if not activeFrames[frameKey] then if frame:IsShown() then layoutDirty = true end frame:Hide() end end self.lastEntryCount = totalEntries self:SetVisualTickerEnabled(shouldTick) if layoutDirty then self._layoutDirty = false self:RefreshAnchors() end end