Skip to content
Merged
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 6 additions & 3 deletions Core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.")
Expand Down
141 changes: 115 additions & 26 deletions Settings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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))

Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down
Loading