From 9aec8d31c6e91953e4e12e4050c2d8f1faebf39e Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:03:54 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20class=20count=20configuration=20?= =?UTF-8?q?=E2=80=94=20duplicate=20cooldown=20rows=20per=20class=20player?= =?UTF-8?q?=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core.lua | 5 ++- Settings.lua | 109 +++++++++++++++++++++++++++++++++++++++++++++++---- UI.lua | 60 +++++++++++++++++++++++++--- 3 files changed, 160 insertions(+), 14 deletions(-) diff --git a/Core.lua b/Core.lua index c2bc897..812d34d 100644 --- a/Core.lua +++ b/Core.lua @@ -24,6 +24,9 @@ eventFrame:SetScript("OnEvent", function(_, event, name) if event == "ADDON_LOADED" and name == AddonName then -- Initialise saved variables CooldownTrackerDB = CooldownTrackerDB or {} + CooldownTrackerDB.classCounts = CooldownTrackerDB.classCounts or {} + -- Expand cooldowns before building UI + CT:BuildExpandedCooldowns() -- Build the UI (defined in UI.lua) then restore the saved position CT:BuildUI() CT:RestorePosition() @@ -40,7 +43,7 @@ SLASH_COOLDOWNTRACKER2 = "/cooldowntracker" SlashCmdList["COOLDOWNTRACKER"] = function(msg) local cmd = msg and msg:lower():match("^%s*(.-)%s*$") or "" if cmd == "reset" then - for _, cd in ipairs(CT.COOLDOWNS) do + for _, cd in ipairs(CT.expandedCooldowns) do CT.activeTimers[cd.id] = nil end print("|cffaaddff[CooldownTracker]|r All timers reset.") diff --git a/Settings.lua b/Settings.lua index 748d181..0ea7349 100644 --- a/Settings.lua +++ b/Settings.lua @@ -115,9 +115,98 @@ local function CreateSettingsPanel() layoutDivider:SetWidth(CONTENT_WIDTH) layoutDivider:SetColorTexture(0.3, 0.3, 0.4, 0.4) + -- ----- Class Roster section --------------------------------------------- + local rosterSection = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall") + rosterSection:SetPoint("TOPLEFT", layoutDivider, "BOTTOMLEFT", 0, -10) + rosterSection:SetText("|cffaaddffClass Roster|r") + + local rosterDesc = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + rosterDesc:SetPoint("TOPLEFT", rosterSection, "BOTTOMLEFT", 0, -4) + rosterDesc:SetText("How many of each class are in your raid. Abilities duplicate per player.") + rosterDesc:SetTextColor(0.6, 0.6, 0.6) + rosterDesc:SetWidth(CONTENT_WIDTH) + rosterDesc:SetJustifyH("LEFT") + + -- Derive unique classes in encounter order (preserving CT.COOLDOWNS order) + local classCountBoxes = {} + local seenClasses = {} + local classList = {} + for _, cd in ipairs(CT.COOLDOWNS) do + if not seenClasses[cd.class] then + seenClasses[cd.class] = true + table.insert(classList, { class = cd.class, r = cd.r, g = cd.g, b = cd.b }) + end + end + + local rosterAnchor = rosterDesc + for _, cls in ipairs(classList) do + local clsName = cls.class + + local clsLabel = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + clsLabel:SetPoint("TOPLEFT", rosterAnchor, "BOTTOMLEFT", 0, -8) + clsLabel:SetText(clsName) + clsLabel:SetTextColor(cls.r, cls.g, cls.b) + clsLabel:SetWidth(160) + + local countBox = CreateFrame("EditBox", "CTSettingsCount_" .. clsName:gsub(" ", ""), panel, "BackdropTemplate") + countBox:SetSize(40, 22) + countBox:SetPoint("LEFT", clsLabel, "RIGHT", 8, 0) + countBox:SetAutoFocus(false) + countBox:SetFontObject("ChatFontNormal") + countBox:SetJustifyH("CENTER") + if countBox.SetBackdrop then + countBox: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 }, + }) + countBox:SetBackdropColor(0.1, 0.1, 0.1, 0.8) + countBox:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8) + end + countBox:SetTextInsets(4, 4, 2, 2) + + local countHint = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + countHint:SetPoint("LEFT", countBox, "RIGHT", 8, 0) + countHint:SetText("players (1-5)") + countHint:SetTextColor(0.5, 0.5, 0.5) + + countBox:SetScript("OnTextChanged", function(self, userInput) + if not userInput then return end + local val = tonumber(self:GetText()) + if val and val >= 1 and val <= 5 then + CooldownTrackerDB.classCounts = CooldownTrackerDB.classCounts or {} + CooldownTrackerDB.classCounts[clsName] = val + if CT.RebuildUI then CT:RebuildUI() end + end + end) + countBox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end) + countBox:SetScript("OnEscapePressed", function(self) + self:SetText(tostring((CooldownTrackerDB.classCounts or {})[clsName] or 1)) + self:ClearFocus() + end) + countBox:SetScript("OnEnter", function() + GameTooltip:SetOwner(countBox, "ANCHOR_RIGHT") + GameTooltip:SetText(clsName .. " Count") + GameTooltip:AddLine("Number of " .. clsName .. "s in the raid.", 1, 1, 1) + GameTooltip:AddLine("Each ability for this class will appear N times.", 0.8, 0.8, 0.8) + GameTooltip:Show() + end) + countBox:SetScript("OnLeave", function() GameTooltip:Hide() end) + + classCountBoxes[clsName] = countBox + rosterAnchor = clsLabel + end + + local rosterDivider = panel:CreateTexture(nil, "ARTWORK") + rosterDivider:SetHeight(1) + rosterDivider:SetPoint("TOPLEFT", rosterAnchor, "BOTTOMLEFT", 0, -10) + rosterDivider:SetWidth(CONTENT_WIDTH) + rosterDivider:SetColorTexture(0.3, 0.3, 0.4, 0.4) + -- ----- Cooldown duration headers ---------------------------------------- local hdrAbility = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall") - hdrAbility:SetPoint("TOPLEFT", layoutDivider, "BOTTOMLEFT", ICON_SIZE + 12, -8) + hdrAbility:SetPoint("TOPLEFT", rosterDivider, "BOTTOMLEFT", ICON_SIZE + 12, -8) hdrAbility:SetText("Ability") hdrAbility:SetTextColor(0.7, 0.7, 0.7) @@ -133,10 +222,18 @@ local function CreateSettingsPanel() divider:SetColorTexture(0.3, 0.3, 0.4, 0.6) -- refresh colBox on panel show - local origOnShow = panel:GetScript("OnShow") + -- local origOnShow = panel:GetScript("OnShow") -- This was removed as it's no longer needed. panel:SetScript("OnShow", function(self) - colBox:SetText(tostring(CooldownTrackerDB.columns or 1)) - if origOnShow then origOnShow(self) end + -- Refresh all boxes on panel show (deferred one frame so Settings canvas doesn't wipe them) + C_Timer.After(0, function() + colBox:SetText(tostring(CooldownTrackerDB.columns or 1)) + local counts = CooldownTrackerDB.classCounts or {} + for clsName, box in pairs(classCountBoxes) do + box:SetText(tostring(counts[clsName] or 1)) + end + -- Call the original refresh for duration edit boxes + RefreshAllEditBoxes() + end) end) -- ----- Scroll frame ----------------------------------------------------- @@ -291,9 +388,6 @@ local function CreateSettingsPanel() print("|cffaaddff[CooldownTracker]|r All durations reset to defaults.") end) - -- When the Settings canvas shows our panel, wait one frame then fill boxes - panel:SetScript("OnShow", RefreshAllEditBoxes) - return panel end @@ -304,6 +398,7 @@ end function CT:InitSettings() CooldownTrackerDB.customDurations = CooldownTrackerDB.customDurations or {} CooldownTrackerDB.columns = CooldownTrackerDB.columns or 1 + CooldownTrackerDB.classCounts = CooldownTrackerDB.classCounts or {} ApplyCustomDurations() local panel = CreateSettingsPanel() diff --git a/UI.lua b/UI.lua index e168d89..2cc861e 100644 --- a/UI.lua +++ b/UI.lua @@ -268,14 +268,62 @@ local function CreateRow(parent, cd) return row end +-- --------------------------------------------------------------------------- +-- Public: CT:BuildExpandedCooldowns() +-- Populates CT.expandedCooldowns from CT.COOLDOWNS + class counts. +-- count=1: original entry unchanged (no "#N" suffix) +-- count>1: N copies with unique IDs and "#N" appended to name +-- --------------------------------------------------------------------------- +function CT:BuildExpandedCooldowns() + local counts = CooldownTrackerDB.classCounts or {} + CT.expandedCooldowns = {} + for _, cd in ipairs(CT.COOLDOWNS) do + local count = math.max(1, math.min(5, counts[cd.class] or 1)) + if count == 1 then + table.insert(CT.expandedCooldowns, cd) + else + for n = 1, count do + local copy = {} + for k, v in pairs(cd) do copy[k] = v end + copy.id = cd.id .. "_" .. n + copy.name = cd.name .. " #" .. n + table.insert(CT.expandedCooldowns, copy) + end + end + end +end + +-- --------------------------------------------------------------------------- +-- Public: CT:RebuildUI() +-- Called when class counts change: hides old rows, rebuilds the expanded +-- cooldown list, creates fresh rows, and re-layouts. +-- --------------------------------------------------------------------------- +function CT:RebuildUI() + -- Hide all existing row frames (WoW can't truly destroy frames) + for _, row in pairs(CT.rows) do + row:Hide() + row:SetParent(nil) + end + CT.rows = {} + CT.activeTimers = {} + + CT:BuildExpandedCooldowns() + + for _, cd in ipairs(CT.expandedCooldowns) do + CreateRow(CT.mainFrame, cd) + end + + CT:LayoutRows() +end + -- --------------------------------------------------------------------------- -- Public: CT:LayoutRows() -- Repositions all row frames based on CooldownTrackerDB.columns. -- Safe to call any time (e.g. from settings panel after columns change). -- --------------------------------------------------------------------------- function CT:LayoutRows() - local cols = math.max(1, math.min(#CT.COOLDOWNS, CooldownTrackerDB.columns or 1)) - local n = #CT.COOLDOWNS + local cols = math.max(1, math.min(#CT.expandedCooldowns, CooldownTrackerDB.columns or 1)) + local n = #CT.expandedCooldowns local f = CT.mainFrame if cols == 1 then @@ -285,7 +333,7 @@ function CT:LayoutRows() + BOTTOM_PAD f:SetSize(WIDE_W, frameH) - for i, cd in ipairs(CT.COOLDOWNS) do + for i, cd in ipairs(CT.expandedCooldowns) do local row = CT.rows[cd.id] row:ClearAllPoints() row:SetPoint("TOPLEFT", f, "TOPLEFT", @@ -302,7 +350,7 @@ function CT:LayoutRows() + BOTTOM_PAD f:SetSize(frameW, frameH) - for i, cd in ipairs(CT.COOLDOWNS) do + for i, cd in ipairs(CT.expandedCooldowns) do local col = (i - 1) % cols local row = math.floor((i - 1) / cols) local r = CT.rows[cd.id] @@ -320,7 +368,7 @@ end -- --------------------------------------------------------------------------- function CT:UpdateAllRows() local now = GetTime() - for _, cd in ipairs(CT.COOLDOWNS) do + for _, cd in ipairs(CT.expandedCooldowns) do local row = CT.rows[cd.id] if row then UpdateRow(row, now) end end @@ -394,7 +442,7 @@ function CT:BuildUI() divider:SetColorTexture(0.3, 0.3, 0.5, 0.6) -- Create all rows (layout applied below) - for _, cd in ipairs(CT.COOLDOWNS) do + for _, cd in ipairs(CT.expandedCooldowns) do CreateRow(f, cd) end From a341150fc14751cc553baa985585e4a79e352b6b Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:07:52 -0400 Subject: [PATCH 2/8] fix: hoist editBoxes and RefreshAllEditBoxes before OnShow so closure can reference them --- Settings.lua | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/Settings.lua b/Settings.lua index 0ea7349..3296b08 100644 --- a/Settings.lua +++ b/Settings.lua @@ -221,17 +221,29 @@ local function CreateSettingsPanel() divider:SetWidth(CONTENT_WIDTH) divider:SetColorTexture(0.3, 0.3, 0.4, 0.6) - -- refresh colBox on panel show - -- local origOnShow = panel:GetScript("OnShow") -- This was removed as it's no longer needed. - panel:SetScript("OnShow", function(self) - -- Refresh all boxes on panel show (deferred one frame so Settings canvas doesn't wipe them) + -- editBoxes and RefreshAllEditBoxes must be declared here so the OnShow + -- closure below can reference them (Lua requires locals before use). + local editBoxes = {} + local function RefreshAllEditBoxes() + C_Timer.After(0, function() + for _, cd in ipairs(CT.COOLDOWNS) do + local eb = editBoxes[cd.id] + if eb then + eb:SetText(tostring(GetEffectiveDuration(cd))) + eb:SetCursorPosition(0) + end + end + end) + end + + -- refresh all boxes on panel show (deferred so Settings canvas doesn't wipe them) + panel:SetScript("OnShow", function() C_Timer.After(0, function() colBox:SetText(tostring(CooldownTrackerDB.columns or 1)) local counts = CooldownTrackerDB.classCounts or {} for clsName, box in pairs(classCountBoxes) do box:SetText(tostring(counts[clsName] or 1)) end - -- Call the original refresh for duration edit boxes RefreshAllEditBoxes() end) end) @@ -246,24 +258,6 @@ local function CreateSettingsPanel() content:SetSize(CONTENT_WIDTH, contentHeight) scrollFrame:SetScrollChild(content) - -- Track edit boxes so Reset All / refresh can update them - local editBoxes = {} - - -- Populates every edit box. Uses C_Timer.After(0) to defer to next frame, - -- because WoW's Settings canvas clears EditBox text during its own show - -- sequence, so we must run AFTER that completes. - local function RefreshAllEditBoxes() - C_Timer.After(0, function() - for _, cd in ipairs(CT.COOLDOWNS) do - local eb = editBoxes[cd.id] - if eb then - eb:SetText(tostring(GetEffectiveDuration(cd))) - eb:SetCursorPosition(0) - end - end - end) - end - for i, cd in ipairs(CT.COOLDOWNS) do local yOff = -((i - 1) * (PANEL_ROW_HEIGHT + PANEL_ROW_PAD)) From 3c03221cadc2a4133bb6174dd9128714308f21f9 Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:11:22 -0400 Subject: [PATCH 3/8] fix: replace taint-causing SetParent/CreateFrame in RebuildUI with safe frame pool --- UI.lua | 78 +++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/UI.lua b/UI.lua index 2cc861e..43b87d9 100644 --- a/UI.lua +++ b/UI.lua @@ -295,27 +295,59 @@ end -- --------------------------------------------------------------------------- -- Public: CT:RebuildUI() --- Called when class counts change: hides old rows, rebuilds the expanded --- cooldown list, creates fresh rows, and re-layouts. +-- Safe to call at any time (including from OnTextChanged). +-- Reconfigures the existing pre-allocated frame pool — no SetParent or +-- CreateFrame calls at runtime, which avoids WoW taint errors. -- --------------------------------------------------------------------------- function CT:RebuildUI() - -- Hide all existing row frames (WoW can't truly destroy frames) - for _, row in pairs(CT.rows) do - row:Hide() - row:SetParent(nil) - end - CT.rows = {} CT.activeTimers = {} - CT:BuildExpandedCooldowns() - for _, cd in ipairs(CT.expandedCooldowns) do - CreateRow(CT.mainFrame, cd) + -- Reconfigure visible pool frames for the new expanded list + for i, cd in ipairs(CT.expandedCooldowns) do + local row = CT.pool[i] + if row then + row:Show() + -- Rebind the cooldown data and display + row.cd = cd + row.strip:SetColorTexture(cd.r, cd.g, cd.b, 0.9) + row.iconTex:SetTexture(cd.icon) + row.nameLabel:SetText(cd.name) + row.nameLabel:SetTextColor(1, 1, 1) + row.classLabel:SetText(cd.class) + row.classLabel:SetTextColor(cd.r, cd.g, cd.b) + row.timerLabel:SetText("|cff00ff00Ready|r") + row.barFill:SetVertexColor(cd.r, cd.g, cd.b) + row.button:SetText("Used") + SetBtnColor(row.button, 0.3, 0.85, 0.3) + -- Rebind button click to new cd + row.button:SetScript("OnClick", function() + if CT.activeTimers[cd.id] then + CT.activeTimers[cd.id] = nil + else + CT.activeTimers[cd.id] = GetTime() + cd.duration + end + UpdateRow(row, GetTime()) + end) + CT.rows[cd.id] = row + end + end + + -- Hide unused pool frames beyond the new count + for i = #CT.expandedCooldowns + 1, #CT.pool do + CT.pool[i]:Hide() + end + + -- Rebuild rows lookup (only active entries) + CT.rows = {} + for i, cd in ipairs(CT.expandedCooldowns) do + CT.rows[cd.id] = CT.pool[i] end CT:LayoutRows() end + -- --------------------------------------------------------------------------- -- Public: CT:LayoutRows() -- Repositions all row frames based on CooldownTrackerDB.columns. @@ -441,9 +473,26 @@ function CT:BuildUI() divider:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -TITLE_HEIGHT) divider:SetColorTexture(0.3, 0.3, 0.5, 0.6) - -- Create all rows (layout applied below) - for _, cd in ipairs(CT.expandedCooldowns) do - CreateRow(f, cd) + -- Pre-allocate the maximum possible row pool at load time. + -- Max = base cooldown count × 5 (max class count per class). + -- This avoids CreateFrame/SetParent calls at runtime (which cause taint). + CT.pool = {} + local maxRows = #CT.COOLDOWNS * 5 + -- Use expandedCooldowns for the first N frames, dummy cd for the rest. + local dummyCd = CT.COOLDOWNS[1] + for i = 1, maxRows do + local cd = CT.expandedCooldowns[i] or dummyCd + local row = CreateRow(f, cd) + CT.pool[i] = row + if not CT.expandedCooldowns[i] then + row:Hide() + end + end + + -- Build CT.rows from the initial expanded list + CT.rows = {} + for i, cd in ipairs(CT.expandedCooldowns) do + CT.rows[cd.id] = CT.pool[i] end f:SetScript("OnUpdate", function() CT:UpdateAllRows() end) @@ -453,3 +502,4 @@ function CT:BuildUI() -- Apply layout (reads CooldownTrackerDB.columns, defaults to 1) CT:LayoutRows() end + From 221fa46972738df2b86a7c4431a3a4d55862fd8e Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:14:25 -0400 Subject: [PATCH 4/8] fix: defer Settings.OpenToCategory via C_Timer to avoid protected call taint --- Settings.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Settings.lua b/Settings.lua index 3296b08..09204da 100644 --- a/Settings.lua +++ b/Settings.lua @@ -403,6 +403,11 @@ end function CT:OpenSettings() if CT.settingsCategory then - Settings.OpenToCategory(CT.settingsCategory:GetID()) + -- Deferred to next frame to escape the protected chat frame context; + -- calling Settings.OpenToCategory directly from a slash command + -- triggers a taint error because it invokes OpenSettingsPanel(). + C_Timer.After(0, function() + Settings.OpenToCategory(CT.settingsCategory:GetID()) + end) end end From f1415449f3fe63a9b3d4439ffc7afb4be9c0f728 Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:15:38 -0400 Subject: [PATCH 5/8] fix: remove programmatic Settings.OpenToCategory (fully protected in Midnight) --- Settings.lua | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Settings.lua b/Settings.lua index 09204da..7e14d07 100644 --- a/Settings.lua +++ b/Settings.lua @@ -402,12 +402,5 @@ function CT:InitSettings() end function CT:OpenSettings() - if CT.settingsCategory then - -- Deferred to next frame to escape the protected chat frame context; - -- calling Settings.OpenToCategory directly from a slash command - -- triggers a taint error because it invokes OpenSettingsPanel(). - C_Timer.After(0, function() - Settings.OpenToCategory(CT.settingsCategory:GetID()) - end) - end + print("|cffaaddff[CooldownTracker]|r Open settings via: |cffffffffEscape → Options → AddOns → Healer Cooldown Tracker|r") end From 1e711c0f02fff0d4fd827a8d6ec9b90bd0a0679b Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:18:30 -0400 Subject: [PATCH 6/8] fix: eliminate taint by removing SetScript in RebuildUI and dummy pool frames; use row.cd indirection --- Settings.lua | 4 ++- UI.lua | 83 ++++++++++++++++++++++------------------------------ 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/Settings.lua b/Settings.lua index 7e14d07..3296b08 100644 --- a/Settings.lua +++ b/Settings.lua @@ -402,5 +402,7 @@ function CT:InitSettings() end function CT:OpenSettings() - print("|cffaaddff[CooldownTracker]|r Open settings via: |cffffffffEscape → Options → AddOns → Healer Cooldown Tracker|r") + if CT.settingsCategory then + Settings.OpenToCategory(CT.settingsCategory:GetID()) + end end diff --git a/UI.lua b/UI.lua index 43b87d9..432962c 100644 --- a/UI.lua +++ b/UI.lua @@ -228,22 +228,24 @@ local function CreateRow(parent, cd) btn:SetText("Used") SetBtnColor(btn, 0.3, 0.85, 0.3) btn:SetScript("OnClick", function() - if CT.activeTimers[cd.id] then - CT.activeTimers[cd.id] = nil + local mycd = row.cd + if CT.activeTimers[mycd.id] then + CT.activeTimers[mycd.id] = nil else - CT.activeTimers[cd.id] = GetTime() + cd.duration + CT.activeTimers[mycd.id] = GetTime() + mycd.duration end UpdateRow(row, GetTime()) end) row.button = btn row:SetScript("OnEnter", function() - bg:SetColorTexture(cd.r * 0.15, cd.g * 0.15, cd.b * 0.15, 0.5) + local mycd = row.cd + bg:SetColorTexture(mycd.r * 0.15, mycd.g * 0.15, mycd.b * 0.15, 0.5) GameTooltip:SetOwner(row, "ANCHOR_RIGHT") - GameTooltip:SetText(cd.name) - GameTooltip:AddLine("Class: " .. cd.class, 1, 1, 1) - local m = math.floor(cd.duration / 60) - local s = cd.duration % 60 + GameTooltip:SetText(mycd.name) + GameTooltip:AddLine("Class: " .. mycd.class, 1, 1, 1) + local m = math.floor(mycd.duration / 60) + local s = mycd.duration % 60 if m > 0 and s > 0 then GameTooltip:AddLine(string.format("Cooldown: %dm %ds", m, s), 0.8, 0.8, 0.8) elseif m > 0 then @@ -252,7 +254,7 @@ local function CreateRow(parent, cd) GameTooltip:AddLine(string.format("Cooldown: %ds", s), 0.8, 0.8, 0.8) end GameTooltip:AddLine(" ") - if CT.activeTimers[cd.id] then + if CT.activeTimers[mycd.id] then GameTooltip:AddLine("Click to |cffff4040reset|r the timer.", 1, 0.8, 0) else GameTooltip:AddLine("Click to start the cooldown timer.", 1, 0.8, 0) @@ -303,42 +305,36 @@ function CT:RebuildUI() CT.activeTimers = {} CT:BuildExpandedCooldowns() - -- Reconfigure visible pool frames for the new expanded list + -- Grow the pool if needed (only at first expansion — safe during ADDON_LOADED-like context) + while #CT.pool < #CT.expandedCooldowns do + local row = CreateRow(CT.mainFrame, CT.expandedCooldowns[#CT.pool + 1]) + row:Hide() + CT.pool[#CT.pool + 1] = row + end + + -- Reconfigure pool frames: update cd data and visuals (no SetScript!) for i, cd in ipairs(CT.expandedCooldowns) do local row = CT.pool[i] - if row then - row:Show() - -- Rebind the cooldown data and display - row.cd = cd - row.strip:SetColorTexture(cd.r, cd.g, cd.b, 0.9) - row.iconTex:SetTexture(cd.icon) - row.nameLabel:SetText(cd.name) - row.nameLabel:SetTextColor(1, 1, 1) - row.classLabel:SetText(cd.class) - row.classLabel:SetTextColor(cd.r, cd.g, cd.b) - row.timerLabel:SetText("|cff00ff00Ready|r") - row.barFill:SetVertexColor(cd.r, cd.g, cd.b) - row.button:SetText("Used") - SetBtnColor(row.button, 0.3, 0.85, 0.3) - -- Rebind button click to new cd - row.button:SetScript("OnClick", function() - if CT.activeTimers[cd.id] then - CT.activeTimers[cd.id] = nil - else - CT.activeTimers[cd.id] = GetTime() + cd.duration - end - UpdateRow(row, GetTime()) - end) - CT.rows[cd.id] = row - end + row:Show() + row.cd = cd + row.strip:SetColorTexture(cd.r, cd.g, cd.b, 0.9) + row.iconTex:SetTexture(cd.icon) + row.nameLabel:SetText(cd.name) + row.nameLabel:SetTextColor(1, 1, 1) + row.classLabel:SetText(cd.class) + row.classLabel:SetTextColor(cd.r, cd.g, cd.b) + row.timerLabel:SetText("|cff00ff00Ready|r") + row.barFill:SetVertexColor(cd.r, cd.g, cd.b) + row.button:SetText("Used") + SetBtnColor(row.button, 0.3, 0.85, 0.3) end - -- Hide unused pool frames beyond the new count + -- Hide unused pool frames for i = #CT.expandedCooldowns + 1, #CT.pool do CT.pool[i]:Hide() end - -- Rebuild rows lookup (only active entries) + -- Rebuild rows lookup CT.rows = {} for i, cd in ipairs(CT.expandedCooldowns) do CT.rows[cd.id] = CT.pool[i] @@ -473,20 +469,11 @@ function CT:BuildUI() divider:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -TITLE_HEIGHT) divider:SetColorTexture(0.3, 0.3, 0.5, 0.6) - -- Pre-allocate the maximum possible row pool at load time. - -- Max = base cooldown count × 5 (max class count per class). - -- This avoids CreateFrame/SetParent calls at runtime (which cause taint). + -- Create initial rows and pool CT.pool = {} - local maxRows = #CT.COOLDOWNS * 5 - -- Use expandedCooldowns for the first N frames, dummy cd for the rest. - local dummyCd = CT.COOLDOWNS[1] - for i = 1, maxRows do - local cd = CT.expandedCooldowns[i] or dummyCd + for i, cd in ipairs(CT.expandedCooldowns) do local row = CreateRow(f, cd) CT.pool[i] = row - if not CT.expandedCooldowns[i] then - row:Hide() - end end -- Build CT.rows from the initial expanded list From 23fabed15b25dad762392e73151484404a3eb035 Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:22:29 -0400 Subject: [PATCH 7/8] fix: load settings before expanding cooldowns to apply custom durations on load --- Core.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core.lua b/Core.lua index 812d34d..9ae5544 100644 --- a/Core.lua +++ b/Core.lua @@ -25,13 +25,13 @@ eventFrame:SetScript("OnEvent", function(_, event, name) -- Initialise saved variables CooldownTrackerDB = CooldownTrackerDB or {} CooldownTrackerDB.classCounts = CooldownTrackerDB.classCounts or {} - -- Expand cooldowns before building UI + -- Register the settings panel (applies saved durations to COOLDOWNS) + CT:InitSettings() + -- Expand cooldowns before building UI (so copies inherit saved durations) CT:BuildExpandedCooldowns() -- Build the UI (defined in UI.lua) then restore the saved position CT:BuildUI() CT:RestorePosition() - -- Register the settings panel (defined in Settings.lua) - CT:InitSettings() end end) From 21042ba708463213360df80d4fc5f72a4e49c5c0 Mon Sep 17 00:00:00 2001 From: RubyJ Date: Mon, 16 Mar 2026 19:43:51 -0400 Subject: [PATCH 8/8] docs: add AGENTS.md with AI assistant instructions and taint anti-patterns --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..413acf2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# CooldownTracker - AI Agent Instructions + +Welcome! This document outlines the standards, conventions, and preferences for developing the CooldownTracker World of Warcraft Addon. Please adhere to these guidelines when suggesting or writing code. + +## 🎯 Project Purpose +CooldownTracker is a lightweight, manual tracking tool for Raid Leaders in World of Warcraft (Midnight 12.0+). It displays a customizable grid/column of major healer cooldowns. The raid leader manually clicks abilities to start and reset their timers. It does **not** automate tracking based on combat log events, keeping the addon fast, simple, and strictly within Blizzard's UI terms of service. + +## 🏗️ Architecture & File Structure +The addon is split into modular components, sharing a single private namespace (`CT`). +- `CooldownTracker.toc`: The manifest. Controls load order (critical). +- `Data.lua`: Defines the base abilities (`CT.COOLDOWNS`), default durations, icons, and class colors. +- `UI.lua`: Handles rendering the main tracker window, row layouts (grid vs vertical), and the per-frame `OnUpdate` timer loop. +- `Settings.lua`: Implements the in-game options panel (Escape -> Options -> AddOns) using the modern `Settings` API. Handles SavedVariables overrides. +- `Core.lua`: The bootstrap file. Handles `ADDON_LOADED`, slash commands (`/cdt`), and initializes the UI and Settings. + +## 📜 Coding Standards & Conventions +1. **Private Namespace:** Always use the addon's private namespace passed by the WoW client on load. Do not pollute the global environment. + ```lua + local AddonName, CT = ... + -- CT is the shared table across all files + ``` +2. **SavedVariables:** Use `CooldownTrackerDB` for persistence. Initialize it in `ADDON_LOADED` in `Core.lua`. +3. **Slash Commands:** Register slash commands via the `SlashCmdList` table. Handle arguments cleanly. +4. **No Third-Party Libraries:** The addon intentionally does not use Ace3 or other framework libraries to remain lightweight. Rely on the standard WoW API. + +## ⚠️ Critical WoW API Anti-Patterns (Taint Avoidance) +World of Warcraft has a strict "taint" system for UI frames. Violating these rules will cause the addon to break the user's UI during combat. +1. **Dynamic Frame Creation:** Do NOT use `CreateFrame()` or `SetParent()` dynamically in response to user input (e.g., inside an `OnTextChanged` or `OnClick` handler). + - *Fix:* Pre-allocate a pool of frames at `ADDON_LOADED` time. Show/Hide and reconfigure existing frames. +2. **Rebinding Secure Scripts:** Do NOT call `SetScript("OnClick", ...)` on protected templates (like `UIPanelButtonTemplate`) at runtime after initial creation. + - *Fix:* Set the script once during creation. Have the script read data dynamically from the frame itself (e.g., `local cd = self:GetParent().cd`). +3. **Settings API:** The `Settings.OpenToCategory()` function is fully protected in The War Within/Midnight. + - *Fix:* Do not attempt to programmatically open the Settings menu from slash commands. Instruct the user via chat text to open it manually. + +## 🎨 UI & Layout Preferences +- **Grid vs Vertical:** The UI supports both a wide-row vertical layout (columns = 1) and a compact square-card grid layout (columns 2-9). Use `CT:LayoutRows()` to reflow. +- **Font:** Use `Fonts\\FRIZQT__.TTF` with an `"OUTLINE"` flag for clear, readable text in the tracker. +- **Backdrops:** Use the `BackdropTemplate` mixin for frames requiring backgrounds/borders.