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. diff --git a/Core.lua b/Core.lua index c2bc897..9ae5544 100644 --- a/Core.lua +++ b/Core.lua @@ -24,11 +24,14 @@ 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 {} + -- 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) @@ -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..3296b08 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) @@ -132,29 +221,9 @@ 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") - panel:SetScript("OnShow", function(self) - colBox:SetText(tostring(CooldownTrackerDB.columns or 1)) - if origOnShow then origOnShow(self) end - end) - - -- ----- Scroll frame ----------------------------------------------------- - local scrollFrame = CreateFrame("ScrollFrame", "CTSettingsScrollFrame", panel, "UIPanelScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", divider, "BOTTOMLEFT", 0, -6) - scrollFrame:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -28, 50) - - local contentHeight = #CT.COOLDOWNS * (PANEL_ROW_HEIGHT + PANEL_ROW_PAD) - local content = CreateFrame("Frame", nil, scrollFrame) - content:SetSize(CONTENT_WIDTH, contentHeight) - scrollFrame:SetScrollChild(content) - - -- Track edit boxes so Reset All / refresh can update them + -- editBoxes and RefreshAllEditBoxes must be declared here so the OnShow + -- closure below can reference them (Lua requires locals before use). 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 @@ -167,6 +236,28 @@ local function CreateSettingsPanel() 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 + RefreshAllEditBoxes() + end) + end) + + -- ----- Scroll frame ----------------------------------------------------- + local scrollFrame = CreateFrame("ScrollFrame", "CTSettingsScrollFrame", panel, "UIPanelScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", divider, "BOTTOMLEFT", 0, -6) + scrollFrame:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -28, 50) + + local contentHeight = #CT.COOLDOWNS * (PANEL_ROW_HEIGHT + PANEL_ROW_PAD) + local content = CreateFrame("Frame", nil, scrollFrame) + content:SetSize(CONTENT_WIDTH, contentHeight) + scrollFrame:SetScrollChild(content) + for i, cd in ipairs(CT.COOLDOWNS) do local yOff = -((i - 1) * (PANEL_ROW_HEIGHT + PANEL_ROW_PAD)) @@ -291,9 +382,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 +392,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..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) @@ -268,14 +270,88 @@ 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() +-- 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() + CT.activeTimers = {} + CT:BuildExpandedCooldowns() + + -- 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] + 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 + for i = #CT.expandedCooldowns + 1, #CT.pool do + CT.pool[i]:Hide() + end + + -- Rebuild rows lookup + 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. -- 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 +361,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 +378,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 +396,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 @@ -393,9 +469,17 @@ 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.COOLDOWNS) do - CreateRow(f, cd) + -- Create initial rows and pool + CT.pool = {} + for i, cd in ipairs(CT.expandedCooldowns) do + local row = CreateRow(f, cd) + CT.pool[i] = row + 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) @@ -405,3 +489,4 @@ function CT:BuildUI() -- Apply layout (reads CooldownTrackerDB.columns, defaults to 1) CT:LayoutRows() end +