Files
SuperCleveRoidMacros/Extensions/MacroErrorUI.lua
T
Brues 1de455f96e Support Blizzard-style bracket groups: [a][b] Spell and [] fall-through
A clause may now carry a leading run of [group] blocks sharing one action;
groups are OR'd, first pass wins, and [] always passes. The existing
;-separated single-block form is unchanged and mixes freely.

The OR loop lives in the two single-clause evaluators, DoWithConditionals
and TestAction, which expand a multi-group clause into one-group variants
and recurse. That covers every ;-split site, /castsequence (where ; is not
a separator and the sequence is keyed by the full args), /cancelaura,
/stopmacro and the editor highlight without rewriting strings, so
action.args, the ParsedMsg/Sequences caches and highlight offsets keep
their identity.

ParseMsg scans the leading group run (quote-aware) instead of the greedy
%[(.+)%], which previously turned [a][b] into the bogus key a][b and made
such clauses fail silently on both the cast and icon paths. The flag
pattern still runs on the whole clause when there are no groups, so !Spell
is byte-identical.

TestAction reports which variant passed and TestForActiveAction stores its
conditionals, so range/usable colouring follows the passing group's @unit
rather than group 1's. /target gains ; and group support; /pfcast injects
the resolved unit into every group, not just the first. The macro checker
validates every leading block, drops the EMPTY_CONDITIONAL error, and
catches a missing action after the last group.
2026-09-10 17:17:08 -05:00

1423 lines
50 KiB
Lua

--[[
Macro Error UI - Real-time visual error feedback in the macro editor
Author: Mewtiny
License: MIT License
Provides:
- Error summary panel anchored below MacroFrame
- Red semi-transparent backdrop on error lines in the EditBox
]]
local _G = _G or getfenv(0)
local CleveRoids = _G.CleveRoids or {}
local Extension = CleveRoids.RegisterExtension("MacroErrorUI")
-- Constants
local DEBOUNCE_DELAY = 0.3
local MAX_PANEL_HEIGHT = 120
local MAX_DISPLAY_ERRORS = 5
local HIGHLIGHT_POOL_SIZE = 20
local ERROR_FONT_POOL_SIZE = 6 -- header + max errors
local COND_HIGHLIGHT_POOL_SIZE = 20
local COND_EVAL_INTERVAL = 0.5 -- seconds between conditional evaluations
-- State
local errorPanel = nil
local headerText = nil
local errorFontStrings = {}
local lineHighlights = {}
local lastKeystroke = 0
local pendingValidation = false
local lastSelectedMacro = nil
local updateFrame = nil
local hooked = false
local measureFs = nil -- hidden FontString for measuring text width (GetStringWidth)
local wrapFs = nil -- hidden FontString with SetWidth for height-based wrap detection
-- Cache for current errors (avoids re-validation on every frame)
local currentErrors = nil
local nameHighlight = nil -- Yellow backdrop behind macro name when name has errors
local condHighlights = {} -- green texture pool for conditional highlights
local lastCondEvalTime = 0 -- GetTime() of last conditional evaluation
local lastCondFingerprint = nil -- cache key to avoid repositioning when unchanged
local cachedMacroText = nil -- text key for parsed line structure cache
local cachedLineData = nil -- cached parsed lines: { {cmd, args, offsets, lineText}, ... }
-- Whitelist GUI state
local whitelistButton = nil
local whitelistPopup = nil
local WHITELIST_ROW_POOL_SIZE = 8
local whitelistRows = {} -- pre-allocated label+remove rows
-- Forward declarations (defined later, referenced by whitelist GUI)
local RequestValidation
-- ============================================================================
-- Error Panel (Option A)
-- ============================================================================
local function CreateErrorPanel()
if errorPanel then return end
local panel = CreateFrame("Frame", "CleveRoidsErrorPanel", MacroFrame)
-- Anchor to the scroll frame (text edit area) instead of the full MacroFrame,
-- which can extend far below the visible UI on extended macro clients
local scrollRef = MacroFrameScrollFrame or MacroFrame
panel:SetPoint("TOPLEFT", scrollRef, "BOTTOMLEFT", -20, -47)
panel:SetPoint("TOPRIGHT", scrollRef, "BOTTOMRIGHT", 0, -47)
panel:SetHeight(1)
panel:SetFrameStrata("DIALOG")
panel: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 },
})
panel:SetBackdropColor(0.1, 0.05, 0.05, 0.92)
panel:SetBackdropBorderColor(0.6, 0.1, 0.1, 0.8)
panel:Hide()
-- Header: "N error(s) found"
headerText = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
headerText:SetPoint("TOPLEFT", panel, "TOPLEFT", 8, -8)
headerText:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -8, -8)
headerText:SetTextColor(1, 0.3, 0.3, 1)
-- Pre-allocate error message FontStrings
for i = 1, MAX_DISPLAY_ERRORS do
local fs = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
fs:SetPoint("TOPLEFT", headerText, "BOTTOMLEFT", 0, -2 - (i - 1) * 12)
fs:SetPoint("TOPRIGHT", headerText, "BOTTOMRIGHT", 0, -2 - (i - 1) * 12)
fs:SetJustifyH("LEFT")
fs:Hide()
errorFontStrings[i] = fs
end
errorPanel = panel
end
local function UpdateErrorPanel(errors)
if not errorPanel then return end
local count = errors and table.getn(errors) or 0
if count == 0 then
errorPanel:Hide()
return
end
-- Header
if count == 1 then
headerText:SetText("1 error found")
else
headerText:SetText(count .. " errors found")
end
-- Populate error lines
local displayed = 0
for i = 1, MAX_DISPLAY_ERRORS do
local fs = errorFontStrings[i]
if i <= count then
local err = errors[i]
local linePrefix = err.line and ("Line " .. err.line .. ": ") or ""
local msg = linePrefix .. (err.message or "Unknown error")
-- Truncate long messages
if string.len(msg) > 80 then
msg = string.sub(msg, 1, 77) .. "..."
end
-- Name errors in yellow, syntax errors in red
local color = err.type == "NAME_ERROR" and "|cffffcc60" or "|cffffa0a0"
fs:SetText(color .. msg .. "|r")
fs:Show()
displayed = displayed + 1
else
fs:SetText("")
fs:Hide()
end
end
-- Show overflow indicator
if count > MAX_DISPLAY_ERRORS then
local lastFs = errorFontStrings[MAX_DISPLAY_ERRORS]
lastFs:SetText("|cff888888... and " .. (count - MAX_DISPLAY_ERRORS + 1) .. " more|r")
lastFs:Show()
end
-- Dynamic height: header(16) + padding(8+6) + lines(12 each)
local linesShown = displayed
if linesShown > MAX_DISPLAY_ERRORS then linesShown = MAX_DISPLAY_ERRORS end
local height = 8 + 16 + 2 + (linesShown * 12) + 6
if height > MAX_PANEL_HEIGHT then height = MAX_PANEL_HEIGHT end
errorPanel:SetHeight(height)
errorPanel:Show()
end
-- ============================================================================
-- Visual Line Break Detection
-- ============================================================================
-- Find character positions where each visual line starts in a text string.
-- Returns array of 1-based char indices: {1, 30, 58, ...}
-- Uses a width-constrained FontString (wrapFs) with GetHeight() to detect
-- wraps. SetNonSpaceWrap(true) is used to reliably detect line count (works
-- even for text with no spaces), then binary search with word-wrapping
-- (SetNonSpaceWrap false) finds actual break positions matching the EditBox's
-- word-wrap behavior.
local function FindVisualLineBreaks(text, editWidth, fs)
local breaks = {1}
local textLen = string.len(text)
if textLen == 0 or editWidth <= 0 then return breaks end
-- Initialize wrapFs lazily with same font, constrained to editWidth
if not wrapFs then
wrapFs = (MacroFrame or UIParent):CreateFontString(nil, "OVERLAY")
wrapFs:Hide()
end
local fontPath, fontSize, fontFlags = fs:GetFont()
if fontPath then
wrapFs:SetFont(fontPath, fontSize, fontFlags)
end
wrapFs:SetWidth(editWidth)
-- Get single line height for line counting
wrapFs:SetText("M")
local singleH = wrapFs:GetHeight()
if not singleH or singleH <= 0 then return breaks end
-- Use SetNonSpaceWrap to reliably detect total line count
-- (works even for text with no spaces)
wrapFs:SetNonSpaceWrap(true)
wrapFs:SetText(text)
local totalH = wrapFs:GetHeight()
local numLines = math.floor(totalH / singleH + 0.5)
if numLines <= 1 then
wrapFs:SetNonSpaceWrap(false)
return breaks
end
-- Switch to word wrapping for break position detection
-- EditBox uses word wrapping (breaks at spaces), not character-level
wrapFs:SetNonSpaceWrap(false)
-- Binary search for each visual line break position
local searchStart = 1
for targetLine = 2, numLines do
local lo, hi = searchStart, textLen
local breakAt = textLen + 1
while lo <= hi do
local mid = math.floor((lo + hi) / 2)
wrapFs:SetText(string.sub(text, 1, mid))
local h = wrapFs:GetHeight()
local lines = math.floor(h / singleH + 0.5)
if lines < targetLine then
lo = mid + 1
else
breakAt = mid
hi = mid - 1
end
end
if breakAt <= textLen then
-- Word wrapping: the overflow is at breakAt, but the actual line
-- break is at the start of the word that caused the overflow.
-- Search backward for the last space to find the word boundary.
local wordStart = breakAt
for p = breakAt - 1, searchStart, -1 do
if string.byte(text, p) == 32 then -- space
wordStart = p + 1
break
end
end
table.insert(breaks, wordStart)
searchStart = wordStart
end
end
return breaks
end
-- ============================================================================
-- Line Highlights (Option B)
-- ============================================================================
local function GetLineHeight()
if not MacroFrameText then return 13 end
local _, fontSize = MacroFrameText:GetFont()
-- Use raw font size - this matches the EditBox's actual line spacing
return fontSize or 13
end
local function GetTopTextInset()
if not MacroFrameText then return 0 end
-- GetTextInsets returns left, right, top, bottom padding inside the EditBox
local success, l, r, t, b = pcall(MacroFrameText.GetTextInsets, MacroFrameText)
if success and t then
return t
end
return 0
end
local function EnsureHighlightPool()
if table.getn(lineHighlights) >= HIGHLIGHT_POOL_SIZE then return end
for i = table.getn(lineHighlights) + 1, HIGHLIGHT_POOL_SIZE do
local tex = MacroFrameText:CreateTexture(nil, "BACKGROUND")
tex:SetTexture(0.6, 0.08, 0.08, 0.25)
tex:Hide()
lineHighlights[i] = tex
end
end
local function UpdateLineHighlights(errors)
if not MacroFrameText then return end
EnsureHighlightPool()
-- Collect which lines have errors (deduplicate)
local errorLines = {}
if errors then
for _, err in ipairs(errors) do
if err.line then
errorLines[err.line] = true
end
end
end
local lineHeight = GetLineHeight()
local topInset = GetTopTextInset()
local editWidth = MacroFrameText:GetWidth()
if editWidth < 10 then editWidth = 260 end -- fallback
-- Create measurement FontString lazily (visible but offscreen for layout)
if not measureFs then
measureFs = (MacroFrame or UIParent):CreateFontString(nil, "OVERLAY")
measureFs:Hide()
end
local fontPath, fontSize, fontFlags = MacroFrameText:GetFont()
if fontPath then
measureFs:SetFont(fontPath, fontSize, fontFlags)
end
-- Split macro text into logical lines for wrap calculation
local text = MacroFrameText:GetText() or ""
local logicalLines = {}
local lineStart = 1
while true do
local nlPos = string.find(text, "\n", lineStart, true)
if nlPos then
table.insert(logicalLines, string.sub(text, lineStart, nlPos - 1))
lineStart = nlPos + 1
else
table.insert(logicalLines, string.sub(text, lineStart))
break
end
end
-- Calculate visual line count for each logical line
local visualCounts = {}
for i, line in ipairs(logicalLines) do
if line == "" or editWidth <= 0 then
visualCounts[i] = 1
else
visualCounts[i] = table.getn(FindVisualLineBreaks(line, editWidth, measureFs))
end
end
local highlightIdx = 1
for lineNum, _ in pairs(errorLines) do
if highlightIdx > HIGHLIGHT_POOL_SIZE then break end
local tex = lineHighlights[highlightIdx]
-- Y offset: sum visual lines of all preceding logical lines
local yLines = 0
for i = 1, lineNum - 1 do
yLines = yLines + (visualCounts[i] or 1)
end
local yOffset = -topInset - (yLines * lineHeight)
local highlightHeight = (visualCounts[lineNum] or 1) * lineHeight
tex:ClearAllPoints()
tex:SetPoint("TOPLEFT", MacroFrameText, "TOPLEFT", -2, yOffset)
tex:SetWidth(editWidth + 4)
tex:SetHeight(highlightHeight)
tex:Show()
highlightIdx = highlightIdx + 1
end
-- Hide unused highlights
for i = highlightIdx, HIGHLIGHT_POOL_SIZE do
if lineHighlights[i] then
lineHighlights[i]:Hide()
end
end
end
-- ============================================================================
-- Name Highlight (yellow backdrop on macro name)
-- ============================================================================
local function CreateNameHighlight()
if nameHighlight then return end
-- Find the macro name display element
-- Standard Blizzard_MacroUI uses MacroFrameSelectedMacroName (FontString)
-- and MacroFrameSelectedMacroButton (icon)
local nameFrame = getglobal("MacroFrameSelectedMacroName")
if not nameFrame then return end
-- FontStrings can't own textures, so create on their parent
local parent = nameFrame:GetParent() or MacroFrame
local tex = parent:CreateTexture(nil, "BACKGROUND")
tex:SetTexture(0.7, 0.6, 0.1, 0.3)
tex:SetPoint("TOPLEFT", nameFrame, "TOPLEFT", -3, 3)
tex:SetPoint("BOTTOMRIGHT", nameFrame, "BOTTOMRIGHT", 3, -3)
tex:Hide()
nameHighlight = tex
end
local function UpdateNameHighlight(hasNameErrors)
if not nameHighlight then
CreateNameHighlight()
end
if nameHighlight then
if hasNameErrors then
nameHighlight:Show()
else
nameHighlight:Hide()
end
end
end
-- ============================================================================
-- Command Whitelist GUI
-- ============================================================================
local function RefreshWhitelistDisplay()
if not whitelistPopup then return end
local commands = CleveRoids.GetWhitelistedCommandsList()
local count = table.getn(commands)
-- Update rows
for i = 1, WHITELIST_ROW_POOL_SIZE do
local row = whitelistRows[i]
if row then
if i <= count then
row.label:SetText(commands[i])
row.frame:Show()
else
row.label:SetText("")
row.frame:Hide()
end
end
end
-- Show overflow indicator
local overflowText = whitelistPopup.overflowText
if count > WHITELIST_ROW_POOL_SIZE then
overflowText:SetText("|cff888888... and " .. (count - WHITELIST_ROW_POOL_SIZE) .. " more|r")
overflowText:Show()
else
overflowText:SetText("")
overflowText:Hide()
end
-- Dynamic height: title(20) + gap(8) + editbox(18) + rows(18 each) + padding
local rowsShown = count
if rowsShown > WHITELIST_ROW_POOL_SIZE then rowsShown = WHITELIST_ROW_POOL_SIZE end
local height = 8 + 20 + 8 + 18 + 2 + (rowsShown * 18)
if count > WHITELIST_ROW_POOL_SIZE then
height = height + 14
end
height = height + 8 -- bottom padding
whitelistPopup:SetHeight(height)
-- Re-trigger error checking so results update in real time
RequestValidation()
end
local function CreateWhitelistPopup()
if whitelistPopup then return end
local popup = CreateFrame("Frame", "CleveRoidsWhitelistPopup", UIParent)
popup:SetWidth(220)
popup:SetHeight(120)
popup:SetPoint("CENTER", UIParent, "CENTER", 0, 100)
popup:SetFrameStrata("FULLSCREEN_DIALOG")
popup:SetMovable(true)
popup:EnableMouse(true)
-- pfUI pixel-perfect flat style
popup:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8x8",
edgeFile = "Interface\\Buttons\\WHITE8x8",
tile = false, tileSize = 0, edgeSize = 1,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
popup:SetBackdropColor(0.08, 0.08, 0.1, 0.95)
popup:SetBackdropBorderColor(0.2, 0.2, 0.2, 1)
popup:Hide()
-- Drag handling
popup:RegisterForDrag("LeftButton")
popup:SetScript("OnDragStart", function()
this:StartMoving()
end)
popup:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
end)
-- Title
local title = popup:CreateFontString(nil, "OVERLAY")
title:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE")
title:SetPoint("TOPLEFT", popup, "TOPLEFT", 8, -8)
title:SetText("Whitelisted Commands")
title:SetTextColor(0.9, 0.8, 0.5, 1)
-- Close button (flat X)
local closeBtn = CreateFrame("Button", nil, popup)
closeBtn:SetWidth(16)
closeBtn:SetHeight(16)
closeBtn:SetPoint("TOPRIGHT", popup, "TOPRIGHT", -4, -4)
local closeLbl = closeBtn:CreateFontString(nil, "OVERLAY")
closeLbl:SetFont("Fonts\\FRIZQT__.TTF", 12, "OUTLINE")
closeLbl:SetPoint("CENTER", 0, 0)
closeLbl:SetText("x")
closeLbl:SetTextColor(0.6, 0.6, 0.6, 1)
closeBtn:SetScript("OnEnter", function()
closeLbl:SetTextColor(1, 0.3, 0.3, 1)
end)
closeBtn:SetScript("OnLeave", function()
closeLbl:SetTextColor(0.6, 0.6, 0.6, 1)
end)
closeBtn:SetScript("OnClick", function()
popup:Hide()
end)
-- EditBox (flat style)
local editBox = CreateFrame("EditBox", "CleveRoidsWhitelistEditBox", popup)
editBox:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8)
editBox:SetWidth(148)
editBox:SetHeight(18)
editBox:SetAutoFocus(false)
editBox:SetMaxLetters(60)
editBox:SetFont("Fonts\\FRIZQT__.TTF", 10, "")
editBox:SetTextColor(1, 1, 1, 1)
editBox:SetTextInsets(4, 4, 0, 0)
editBox:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8x8",
edgeFile = "Interface\\Buttons\\WHITE8x8",
tile = false, tileSize = 0, edgeSize = 1,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
editBox:SetBackdropColor(0.05, 0.05, 0.07, 0.9)
editBox:SetBackdropBorderColor(0.3, 0.3, 0.35, 1)
-- Add button (flat style)
local addBtn = CreateFrame("Button", nil, popup)
addBtn:SetPoint("LEFT", editBox, "RIGHT", 4, 0)
addBtn:SetWidth(40)
addBtn:SetHeight(18)
addBtn:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8x8",
edgeFile = "Interface\\Buttons\\WHITE8x8",
tile = false, tileSize = 0, edgeSize = 1,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
addBtn:SetBackdropColor(0.15, 0.15, 0.15, 0.9)
addBtn:SetBackdropBorderColor(0.3, 0.3, 0.35, 1)
local addLbl = addBtn:CreateFontString(nil, "OVERLAY")
addLbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
addLbl:SetPoint("CENTER", 0, 0)
addLbl:SetText("Add")
addLbl:SetTextColor(0.8, 0.8, 0.8, 1)
addBtn:SetScript("OnEnter", function()
this:SetBackdropColor(0.25, 0.25, 0.25, 0.95)
this:SetBackdropBorderColor(0.45, 0.45, 0.5, 1)
addLbl:SetTextColor(1, 1, 1, 1)
end)
addBtn:SetScript("OnLeave", function()
this:SetBackdropColor(0.15, 0.15, 0.15, 0.9)
this:SetBackdropBorderColor(0.3, 0.3, 0.35, 1)
addLbl:SetTextColor(0.8, 0.8, 0.8, 1)
end)
local function AddCommand()
local text = editBox:GetText()
if not text or CleveRoids.Trim(text) == "" then return end
text = CleveRoids.Trim(text)
CleveRoids.AddWhitelistedCommand(text)
editBox:SetText("")
editBox:ClearFocus()
RefreshWhitelistDisplay()
end
addBtn:SetScript("OnClick", AddCommand)
editBox:SetScript("OnEnterPressed", function()
AddCommand()
end)
editBox:SetScript("OnEscapePressed", function()
this:ClearFocus()
end)
-- Anchor for rows: below the editbox row
local rowAnchor = editBox
-- Pre-allocate row pool
for i = 1, WHITELIST_ROW_POOL_SIZE do
local rowFrame = CreateFrame("Frame", nil, popup)
rowFrame:SetHeight(16)
rowFrame:SetPoint("TOPLEFT", rowAnchor, "BOTTOMLEFT", 0, -2)
rowFrame:SetPoint("RIGHT", popup, "RIGHT", -8, 0)
rowFrame:Hide()
local label = rowFrame:CreateFontString(nil, "OVERLAY")
label:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
label:SetPoint("LEFT", rowFrame, "LEFT", 2, 0)
label:SetJustifyH("LEFT")
label:SetTextColor(0.7, 0.7, 0.7, 1)
-- Flat X remove button
local removeBtn = CreateFrame("Button", nil, rowFrame)
removeBtn:SetWidth(14)
removeBtn:SetHeight(14)
removeBtn:SetPoint("RIGHT", rowFrame, "RIGHT", -1, 0)
local removeLbl = removeBtn:CreateFontString(nil, "OVERLAY")
removeLbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
removeLbl:SetPoint("CENTER", 0, 0)
removeLbl:SetText("x")
removeLbl:SetTextColor(0.5, 0.5, 0.5, 1)
removeBtn.removeLbl = removeLbl
removeBtn.rowIndex = i
removeBtn:SetScript("OnEnter", function()
this.removeLbl:SetTextColor(1, 0.3, 0.3, 1)
end)
removeBtn:SetScript("OnLeave", function()
this.removeLbl:SetTextColor(0.5, 0.5, 0.5, 1)
end)
removeBtn:SetScript("OnClick", function()
local idx = this.rowIndex
local row = whitelistRows[idx]
if row and row.label then
local cmd = row.label:GetText()
if cmd and cmd ~= "" then
CleveRoids.RemoveWhitelistedCommand(cmd)
RefreshWhitelistDisplay()
end
end
end)
whitelistRows[i] = { frame = rowFrame, label = label, removeBtn = removeBtn }
rowAnchor = rowFrame
end
-- Overflow text
local overflowText = popup:CreateFontString(nil, "OVERLAY")
overflowText:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE")
overflowText:SetPoint("TOPLEFT", whitelistRows[WHITELIST_ROW_POOL_SIZE].frame, "BOTTOMLEFT", 2, -2)
overflowText:SetJustifyH("LEFT")
overflowText:SetTextColor(0.5, 0.5, 0.5, 1)
overflowText:Hide()
popup.overflowText = overflowText
whitelistPopup = popup
end
local function CreateWhitelistButton()
if whitelistButton then return end
if not MacroFrame then return end
local btn = CreateFrame("Button", "CleveRoidsWhitelistButton", MacroFrame)
btn:SetWidth(80)
btn:SetHeight(18)
btn:SetPoint("BOTTOMRIGHT", MacroFrame, "BOTTOMRIGHT", 8, 32)
btn:SetFrameStrata("DIALOG")
-- pfUI pixel-perfect flat style
btn:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8x8",
edgeFile = "Interface\\Buttons\\WHITE8x8",
tile = false, tileSize = 0, edgeSize = 1,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
})
btn:SetBackdropColor(0.1, 0.1, 0.1, 0.85)
btn:SetBackdropBorderColor(0.3, 0.3, 0.3, 1)
local label = btn:CreateFontString(nil, "OVERLAY")
label:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
label:SetPoint("CENTER", btn, "CENTER", 0, 0)
label:SetText("Whitelist")
label:SetTextColor(0.8, 0.8, 0.8, 1)
btn.label = label
-- Hover highlight
btn:SetScript("OnEnter", function()
this:SetBackdropColor(0.2, 0.2, 0.2, 0.95)
this:SetBackdropBorderColor(0.45, 0.45, 0.5, 1)
this.label:SetTextColor(1, 1, 1, 1)
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:SetText("Command Whitelist")
GameTooltip:AddLine("Add third-party addon commands so the", 1, 1, 1, true)
GameTooltip:AddLine("error checker stops flagging them.", 1, 1, 1, true)
GameTooltip:Show()
end)
btn:SetScript("OnLeave", function()
this:SetBackdropColor(0.1, 0.1, 0.1, 0.85)
this:SetBackdropBorderColor(0.3, 0.3, 0.3, 1)
this.label:SetTextColor(0.8, 0.8, 0.8, 1)
GameTooltip:Hide()
end)
btn:SetScript("OnClick", function()
CreateWhitelistPopup()
if whitelistPopup:IsVisible() then
whitelistPopup:Hide()
else
RefreshWhitelistDisplay()
whitelistPopup:Show()
end
end)
whitelistButton = btn
end
-- ============================================================================
-- Live Conditional Highlighting
-- ============================================================================
local function EnsureCondHighlightPool()
if table.getn(condHighlights) >= COND_HIGHLIGHT_POOL_SIZE then return end
for i = table.getn(condHighlights) + 1, COND_HIGHLIGHT_POOL_SIZE do
local tex = MacroFrameText:CreateTexture(nil, "BACKGROUND")
tex:SetTexture(0.08, 0.6, 0.08, 0.25)
tex:Hide()
condHighlights[i] = tex
end
end
-- Evaluate one single-group clause of a non-dynamic command against Keywords.
-- Returns: true (passes), false (fails), nil (could not parse)
local function EvaluateNonDynamic(alternative)
local ok, action, conditionals = pcall(CleveRoids.GetParsedMsg, alternative)
if not ok or not conditionals then
return nil
end
CleveRoids._isTestingAction = true
local passes = true
local k = next(conditionals)
while k do
if not CleveRoids.ignoreKeywords[k] then
local handler = CleveRoids.Keywords[k]
if not handler or not handler(conditionals) then
passes = false
break
end
end
k = next(conditionals, k)
end
CleveRoids._isTestingAction = false
return passes
end
-- Test whether conditionals pass for a given command + alternative text.
-- Returns: true (passes), false (fails), nil (unconditional / no conditionals)
local function TestConditionalPasses(cmd, alternative)
-- Strip ? tooltip hints (irrelevant for conditional evaluation)
if string.find(alternative, "?", 1, true) then
alternative = string.gsub(alternative, "%?", "")
end
local hasConditional = string.find(alternative, "%[") ~= nil
-- Dynamic commands: delegate to TestAction
if CleveRoids.dynamicCmds[cmd] then
local result = CleveRoids.TestAction(cmd, alternative)
if not hasConditional then
return nil -- unconditional
end
return result ~= nil and result ~= false
end
-- Non-dynamic commands: evaluate Keywords manually. `[a][b] X` passes when
-- any of its groups does.
if not hasConditional then
return nil -- unconditional
end
local variants = CleveRoids.ExpandBracketGroups(alternative)
if not variants then
return EvaluateNonDynamic(alternative)
end
for i = 1, variants.n do
if EvaluateNonDynamic(variants[i]) then
return true
end
end
return false
end
-- Find character offset ranges for each semicolon-separated alternative.
-- Returns array of { start, finish } pairs (1-based indices within argsText).
local function FindAlternativeOffsets(argsText)
local result = {}
local inQuote = false
local bracketDepth = 0
local altStart = 1
for i = 1, string.len(argsText) do
local ch = string.sub(argsText, i, i)
if ch == "\"" then
inQuote = not inQuote
elseif not inQuote then
if ch == "[" then
bracketDepth = bracketDepth + 1
elseif ch == "]" then
bracketDepth = bracketDepth - 1
if bracketDepth < 0 then bracketDepth = 0 end
elseif ch == ";" and bracketDepth == 0 then
table.insert(result, { start = altStart, finish = i - 1 })
altStart = i + 1
end
end
end
-- Final alternative (after last semicolon, or entire string if no semicolons)
if altStart <= string.len(argsText) then
table.insert(result, { start = altStart, finish = string.len(argsText) })
end
return result
end
-- Parse macro text into line structure (cached — only re-parses when text changes).
-- Returns array of { lineNum, cmd, args, argsStartInLine, offsets, lineText, alts }
local function GetParsedLineData(text)
if text == cachedMacroText and cachedLineData then
return cachedLineData
end
local data = {}
local lineStart = 1
local lineNum = 0
while true do
lineNum = lineNum + 1
local nlPos = string.find(text, "\n", lineStart, true)
local line
if nlPos then
line = string.sub(text, lineStart, nlPos - 1)
else
line = string.sub(text, lineStart)
end
-- Filter: only lines with conditionals
if line and line ~= "" and not string.find(line, "^#showtooltip") and string.find(line, "%[") then
local cmd, args = CleveRoids.SplitCommandAndArgs(line)
if cmd and args and args ~= "" then
local cmdLen = string.len(cmd)
local argsStartInLine = string.find(line, args, cmdLen + 1, true)
if argsStartInLine then
local offsets = FindAlternativeOffsets(args)
if table.getn(offsets) > 0 then
-- Pre-extract alternative substrings and conditional flags
local alts = {}
for i = 1, table.getn(offsets) do
local altText = string.sub(args, offsets[i].start, offsets[i].finish)
alts[i] = {
text = altText,
hasConditional = string.find(altText, "%[") ~= nil,
startInLine = argsStartInLine + offsets[i].start - 1,
endInLine = argsStartInLine + offsets[i].finish - 1,
}
end
table.insert(data, {
lineNum = lineNum,
cmd = cmd,
alts = alts,
lineText = line,
})
end
end
end
end
if not nlPos then break end
lineStart = nlPos + 1
end
cachedMacroText = text
cachedLineData = data
return data
end
-- Evaluate which conditional alternatives pass for each line of macro text.
-- Returns: { [lineNum] = { startChar, endChar, lineText } }
local function EvaluateConditionals(text)
local lineData = GetParsedLineData(text)
local results = {}
for _, entry in ipairs(lineData) do
local anyConditionalSeen = false
local allConditionalsFailed = true
for i = 1, table.getn(entry.alts) do
local alt = entry.alts[i]
if alt.hasConditional then
anyConditionalSeen = true
end
local ok, passed = pcall(TestConditionalPasses, entry.cmd, alt.text)
if not ok then
passed = false
end
if passed == true then
allConditionalsFailed = false
results[entry.lineNum] = { startChar = alt.startInLine, endChar = alt.endInLine, lineText = entry.lineText }
break
elseif passed == nil then
if anyConditionalSeen and allConditionalsFailed then
results[entry.lineNum] = { startChar = alt.startInLine, endChar = alt.endInLine, lineText = entry.lineText }
end
break
end
end
end
return results
end
-- Position green highlights for passing conditional sections.
local function UpdateCondHighlights(condResults, errorLines)
if not MacroFrameText then return end
EnsureCondHighlightPool()
local lineHeight = GetLineHeight()
local topInset = GetTopTextInset()
local editWidth = MacroFrameText:GetWidth()
if editWidth < 10 then editWidth = 260 end
-- Ensure measureFs exists (shared with UpdateLineHighlights)
if not measureFs then
measureFs = (MacroFrame or UIParent):CreateFontString(nil, "OVERLAY")
measureFs:Hide()
end
local fontPath, fontSize, fontFlags = MacroFrameText:GetFont()
if fontPath then
measureFs:SetFont(fontPath, fontSize, fontFlags)
end
-- Split text into logical lines for wrap calculation
local text = MacroFrameText:GetText() or ""
local logicalLines = {}
local ls = 1
while true do
local nlPos = string.find(text, "\n", ls, true)
if nlPos then
table.insert(logicalLines, string.sub(text, ls, nlPos - 1))
ls = nlPos + 1
else
table.insert(logicalLines, string.sub(text, ls))
break
end
end
local visualCounts = {}
for i, line in ipairs(logicalLines) do
if line == "" or editWidth <= 0 then
visualCounts[i] = 1
else
visualCounts[i] = table.getn(FindVisualLineBreaks(line, editWidth, measureFs))
end
end
local highlightIdx = 1
for lineNum, info in pairs(condResults) do
if highlightIdx > COND_HIGHLIGHT_POOL_SIZE then break end
-- Red overrides green
if not errorLines[lineNum] then
local tex = condHighlights[highlightIdx]
-- Y offset: sum visual lines of preceding logical lines
local yLines = 0
for i = 1, lineNum - 1 do
yLines = yLines + (visualCounts[i] or 1)
end
local yOffset = -topInset - (yLines * lineHeight)
-- X offset via text measurement
local prefix = string.sub(info.lineText, 1, info.startChar - 1)
measureFs:SetText(prefix)
local xOffset = measureFs:GetStringWidth()
local section = string.sub(info.lineText, info.startChar, info.endChar)
measureFs:SetText(section)
local sectionWidth = measureFs:GetStringWidth()
-- Handle wrapped lines: find actual visual line break positions
-- instead of modular arithmetic (inaccurate with variable-width chars)
local prefixWrappedLines = 0
local effectiveX = xOffset
local visualBreaks -- computed on demand for wrapped lines
if editWidth > 0 and (visualCounts[lineNum] or 1) > 1 then
visualBreaks = FindVisualLineBreaks(info.lineText, editWidth, measureFs)
-- Find which visual line startChar is on, measure X from that line's start
for vl = table.getn(visualBreaks), 1, -1 do
if info.startChar >= visualBreaks[vl] then
prefixWrappedLines = vl - 1
local vlStart = visualBreaks[vl]
if vlStart < info.startChar then
measureFs:SetText(string.sub(info.lineText, vlStart, info.startChar - 1))
effectiveX = measureFs:GetStringWidth()
else
effectiveX = 0
end
break
end
end
end
-- Adjust Y for the visual lines consumed by the prefix wrapping
local adjustedYOffset = yOffset - (prefixWrappedLines * lineHeight)
local sectionWraps = editWidth > 0 and effectiveX + sectionWidth > editWidth
if sectionWraps then
-- Section wraps: first texture covers from effectiveX to end of line
tex:ClearAllPoints()
tex:SetPoint("TOPLEFT", MacroFrameText, "TOPLEFT", effectiveX - 1, adjustedYOffset)
tex:SetWidth(editWidth - effectiveX + 2)
tex:SetHeight(lineHeight)
tex:Show()
-- Find how many continuation visual lines the section spans
if not visualBreaks then
visualBreaks = FindVisualLineBreaks(info.lineText, editWidth, measureFs)
end
local endWrappedLines = 0
for vl = table.getn(visualBreaks), 1, -1 do
if info.endChar >= visualBreaks[vl] then
endWrappedLines = vl - 1
break
end
end
local continuationLines = math.max(1, endWrappedLines - prefixWrappedLines)
if continuationLines > 0 then
highlightIdx = highlightIdx + 1
if highlightIdx <= COND_HIGHLIGHT_POOL_SIZE then
local tex2 = condHighlights[highlightIdx]
tex2:ClearAllPoints()
tex2:SetPoint("TOPLEFT", MacroFrameText, "TOPLEFT", -1, adjustedYOffset - lineHeight)
tex2:SetWidth(editWidth + 2)
tex2:SetHeight(continuationLines * lineHeight)
tex2:Show()
end
end
else
tex:ClearAllPoints()
tex:SetPoint("TOPLEFT", MacroFrameText, "TOPLEFT", effectiveX - 1, adjustedYOffset)
tex:SetWidth(sectionWidth + 2)
tex:SetHeight(lineHeight)
tex:Show()
end
highlightIdx = highlightIdx + 1
end
end
-- Hide unused pool textures
for i = highlightIdx, COND_HIGHLIGHT_POOL_SIZE do
if condHighlights[i] then
condHighlights[i]:Hide()
end
end
end
-- Orchestrator: evaluate conditionals and update highlights
local function RunConditionalEvaluation()
if not MacroFrameText then return end
local text = MacroFrameText:GetText()
if not text or text == "" then
-- Hide all green highlights
for i = 1, table.getn(condHighlights) do
if condHighlights[i] then condHighlights[i]:Hide() end
end
lastCondFingerprint = nil
return
end
local ok, condResults = pcall(EvaluateConditionals, text)
if not ok or not condResults then
return
end
-- Build fingerprint from results
local parts = {}
for lineNum, info in pairs(condResults) do
table.insert(parts, lineNum .. ":" .. info.startChar .. ":" .. info.endChar)
end
table.sort(parts)
local fingerprint = table.concat(parts, "|")
if fingerprint == lastCondFingerprint then return end
lastCondFingerprint = fingerprint
-- Build errorLines set from currentErrors
local errorLines = {}
if currentErrors then
for _, err in ipairs(currentErrors) do
if err.line then
errorLines[err.line] = true
end
end
end
UpdateCondHighlights(condResults, errorLines)
end
-- ============================================================================
-- Validation & Debounce
-- ============================================================================
-- Macro names are no longer restricted: action-bar macros are identified by
-- slot/index (ClassicAPI GetActionInfo), so blank, duplicate, and spell/item-name
-- macros are all valid. (Name-based references like {MacroName}/runmacro still
-- need a unique name, but that's not enforced here.)
local function ValidateMacroName()
return {}
end
local function RunValidation()
if not MacroFrameText then return end
local bodyText = MacroFrameText:GetText()
if not bodyText or bodyText == "" then
currentErrors = nil
UpdateErrorPanel(nil)
UpdateLineHighlights(nil)
UpdateNameHighlight(false)
return
end
-- Combine name errors + body errors
local errors = {}
local nameErrors = ValidateMacroName()
local hasNameErrors = table.getn(nameErrors) > 0
for _, err in ipairs(nameErrors) do
table.insert(errors, err)
end
local bodyErrors = CleveRoids.ValidateMacroBody(bodyText)
if bodyErrors then
for _, err in ipairs(bodyErrors) do
table.insert(errors, err)
end
end
if table.getn(errors) == 0 then errors = nil end
currentErrors = errors
UpdateErrorPanel(errors)
UpdateLineHighlights(errors)
UpdateNameHighlight(hasNameErrors)
end
RequestValidation = function()
lastKeystroke = GetTime()
pendingValidation = true
end
local function OnUpdateTick()
if not MacroFrame or not MacroFrame:IsVisible() then return end
-- Debounced keystroke validation
if pendingValidation and (GetTime() - lastKeystroke) >= DEBOUNCE_DELAY then
pendingValidation = false
RunValidation()
end
-- Detect macro selection change (poll-based, safer than hooking unknown functions)
if MacroFrame.selectedMacro ~= lastSelectedMacro then
lastSelectedMacro = MacroFrame.selectedMacro
-- Immediate validation on selection change
pendingValidation = false
lastCondFingerprint = nil -- force re-evaluation on selection change
cachedMacroText = nil -- invalidate line structure cache
cachedLineData = nil
-- Immediately hide stale green highlights from previous macro
for i = 1, table.getn(condHighlights) do
if condHighlights[i] then condHighlights[i]:Hide() end
end
RunValidation()
end
-- Periodic conditional evaluation (live green highlights)
local now = GetTime()
if (now - lastCondEvalTime) >= COND_EVAL_INTERVAL then
lastCondEvalTime = now
pcall(RunConditionalEvaluation)
end
end
-- ============================================================================
-- Cleanup
-- ============================================================================
-- Report all macro errors to chat on frame close
local function ReportAllMacroErrors()
local accountEntries = {} -- { {name=, slot=, errors=}, ... }
local characterEntries = {}
-- Collect body (syntax) errors per macro. Macro names are no longer
-- restricted (slot/index-based identification), so no name checks here.
for i = 1, 36 do
local nameOk, name = pcall(GetMacroInfo, i)
if nameOk and name and name ~= "" then
local errors = {}
-- Validate body
local _, _, body = GetMacroInfo(i)
if body and body ~= "" then
local bodyErrors = CleveRoids.ValidateMacroBody(body)
if bodyErrors then
for _, err in ipairs(bodyErrors) do
table.insert(errors, err)
end
end
end
local entry = { name = name, slot = i, errors = errors }
if i <= 18 then
table.insert(accountEntries, entry)
else
table.insert(characterEntries, entry)
end
end
end
-- Output: filter to only entries with errors
local accountErrors = {}
local characterErrors = {}
for _, entry in ipairs(accountEntries) do
if table.getn(entry.errors) > 0 then
table.insert(accountErrors, entry)
end
end
for _, entry in ipairs(characterEntries) do
if table.getn(entry.errors) > 0 then
table.insert(characterErrors, entry)
end
end
local totalMacros = table.getn(accountErrors) + table.getn(characterErrors)
if totalMacros == 0 then return end
DEFAULT_CHAT_FRAME:AddMessage("|cffff6060[MacroErrorChecker]|r Found errors in " .. totalMacros .. " macro(s):", 1, 0.8, 0.4)
local function PrintSection(label, entries)
if table.getn(entries) == 0 then return end
DEFAULT_CHAT_FRAME:AddMessage(" |cff88aaff--- " .. label .. " ---|r")
for _, entry in ipairs(entries) do
local count = table.getn(entry.errors)
DEFAULT_CHAT_FRAME:AddMessage(" |cffffffff" .. entry.name .. "|r - " .. count .. " error(s)")
for _, err in ipairs(entry.errors) do
local linePrefix = err.line and ("L" .. err.line .. ": ") or ""
local msg = linePrefix .. (err.message or "Unknown error")
if string.len(msg) > 90 then
msg = string.sub(msg, 1, 87) .. "..."
end
-- Name errors in yellow, syntax errors in red
local color = err.type == "NAME_ERROR" and "|cffffcc60" or "|cffffa0a0"
DEFAULT_CHAT_FRAME:AddMessage(" " .. color .. msg .. "|r")
end
end
end
PrintSection("General Macros", accountErrors)
PrintSection("Character Macros", characterErrors)
end
local function ClearAll()
currentErrors = nil
pendingValidation = false
lastSelectedMacro = nil
if errorPanel then
errorPanel:Hide()
end
for i = 1, MAX_DISPLAY_ERRORS do
if errorFontStrings[i] then
errorFontStrings[i]:SetText("")
errorFontStrings[i]:Hide()
end
end
for i = 1, table.getn(lineHighlights) do
if lineHighlights[i] then
lineHighlights[i]:Hide()
end
end
if nameHighlight then
nameHighlight:Hide()
end
for i = 1, table.getn(condHighlights) do
if condHighlights[i] then condHighlights[i]:Hide() end
end
lastCondFingerprint = nil
lastCondEvalTime = 0
cachedMacroText = nil
cachedLineData = nil
if whitelistPopup then
whitelistPopup:Hide()
end
end
-- ============================================================================
-- Hook Installation
-- ============================================================================
-- ============================================================================
-- Dynamic macro-list icons
-- Blizzard shows the default question-mark icon for macros with no chosen icon
-- (e.g. "#showtooltip Shoot"). Replace it with the icon the action bar would
-- show -- resolved from the macro's #showtooltip/first action -- but ONLY when
-- the saved icon is the question mark, so user-chosen icons are never touched.
-- Purely cosmetic: Blizzard repaints from GetMacroInfo on the next update, so
-- if resolution fails or the macro changes, the default simply returns.
-- ============================================================================
local QUESTION_MARK = string.lower(CleveRoids.unknownTexture or "Interface\\Icons\\INV_Misc_QuestionMark")
local function IsQuestionMark(iconTexture)
local tex = iconTexture and iconTexture:GetTexture()
return type(tex) == "string" and string.lower(tex) == QUESTION_MARK
end
-- Resolved tooltip texture for a Blizzard macro index, or nil when it resolves
-- to nothing better than the question mark (no #showtooltip, unresolved spell).
local function ResolveMacroIcon(macroIndex)
if not macroIndex or macroIndex < 1 then return nil end
local ok, macro = pcall(CleveRoids.GetMacroByIndex, macroIndex)
if not ok or not macro or not macro.actions or not macro.actions.tooltip then return nil end
local tex = macro.actions.tooltip.texture
if type(tex) == "string" and string.lower(tex) ~= QUESTION_MARK then
return tex
end
return nil
end
local function FixMacroListIcons()
if not MacroFrame or not MacroFrame:IsVisible() then return end
local base = MacroFrame.macroBase or 0
for i = 1, (MAX_MACROS or 18) do
local icon = getglobal("MacroButton" .. i .. "Icon")
if icon and IsQuestionMark(icon) then
local tex = ResolveMacroIcon(base + i)
if tex then icon:SetTexture(tex) end
end
end
-- Large icon for the currently-selected macro (details pane).
if MacroFrame.selectedMacro and MacroFrameSelectedMacroButtonIcon
and IsQuestionMark(MacroFrameSelectedMacroButtonIcon) then
local tex = ResolveMacroIcon(MacroFrame.selectedMacro)
if tex then MacroFrameSelectedMacroButtonIcon:SetTexture(tex) end
end
end
local function InstallHooks()
if hooked then return end
if not MacroFrameText or not MacroFrame then return end
-- Skip if SuperMacro is active (it replaces the macro editor entirely)
if SuperMacroFrame ~= nil then return end
-- OnTextChanged: trigger debounced validation on every keystroke
local origOnTextChanged = MacroFrameText:GetScript("OnTextChanged")
MacroFrameText:SetScript("OnTextChanged", function()
if origOnTextChanged then
origOnTextChanged()
end
RequestValidation()
end)
-- OnShow: validate immediately when macro frame opens
local origOnShow = MacroFrame:GetScript("OnShow")
MacroFrame:SetScript("OnShow", function()
if origOnShow then
origOnShow()
end
-- Create UI lazily on first show
CreateErrorPanel()
CreateWhitelistButton()
-- Reset state and validate
lastSelectedMacro = MacroFrame.selectedMacro
RunValidation()
end)
-- OnHide: clean up everything
local origOnHide = MacroFrame:GetScript("OnHide")
MacroFrame:SetScript("OnHide", function()
if origOnHide then
origOnHide()
end
ClearAll()
-- Report all macro errors to chat when closing the editor
pcall(ReportAllMacroErrors)
end)
-- OnUpdate for debounce timer and selection change polling
updateFrame = CreateFrame("Frame")
updateFrame:SetScript("OnUpdate", function()
local success, err = pcall(OnUpdateTick)
if not success then
-- Silently fail - don't spam errors every frame
pendingValidation = false
end
end)
-- After every Blizzard macro-list refresh, swap question-mark icons for the
-- dynamically-resolved ones. Post-hook so Blizzard has already set the
-- default texture we test against.
if hooksecurefunc and type(MacroFrame_Update) == "function" then
hooksecurefunc("MacroFrame_Update", function()
pcall(FixMacroListIcons)
end)
end
hooked = true
end
-- ============================================================================
-- Extension Entry Points
-- ============================================================================
function Extension.OnLoad()
-- Skip if macro checker is disabled
if CleveRoidMacros and CleveRoidMacros.macrocheck == 0 then return end
-- Skip if SuperMacro is loaded (detected at load time)
if SuperMacroFrame ~= nil then return end
-- Install once Blizzard's macro UI is available (fires immediately if already
-- loaded), replacing the ADDON_LOADED listener + manual "already loaded" check.
EventUtil.ContinueOnAddOnLoaded("Blizzard_MacroUI", InstallHooks)
end
_G["CleveRoids"] = CleveRoids