-- 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 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 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 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.GetTrackerLabel = GetTrackerLabel 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:GetTrackerFrameKey(tracker) if type(tracker) == "table" then return GetTrackerFrameKey(tracker.id) end return GetTrackerFrameKey(tracker) 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 {} local tickThis = false entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) 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 if tickThis then shouldTick = true 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 local tickThis = false entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) 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 if tickThis then shouldTick = true 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 local tickThis = false entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) 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 if tickThis then shouldTick = true 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