diff --git a/Extensions/MacroErrorUI.lua b/Extensions/MacroErrorUI.lua index 29b28ea..78bcfd9 100644 --- a/Extensions/MacroErrorUI.lua +++ b/Extensions/MacroErrorUI.lua @@ -18,6 +18,8 @@ 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 @@ -34,6 +36,11 @@ local measureFs = nil -- hidden FontString for measuring text width (wrap detec -- 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 @@ -593,6 +600,371 @@ local function CreateWhitelistButton() 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 + +-- 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: parse and evaluate Keywords manually + if not hasConditional then + return nil -- unconditional + end + + 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 + +-- 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 + measureFs:SetText(line) + local textWidth = measureFs:GetStringWidth() + visualCounts[i] = math.max(1, math.ceil(textWidth / editWidth)) + 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: convert total text width to position + -- within the current visual line + local visualLinesForLine = visualCounts[lineNum] or 1 + local prefixWrappedLines = 0 + local effectiveX = xOffset + + if editWidth > 0 and xOffset >= editWidth then + -- Section starts after a line wrap + prefixWrappedLines = math.floor(xOffset / editWidth) + effectiveX = xOffset - (prefixWrappedLines * editWidth) + 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() + + -- Second texture covers continuation lines at X=0 + local continuationLines = math.max(1, visualLinesForLine - prefixWrappedLines - 1) + 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 -- ============================================================================ @@ -748,8 +1120,22 @@ local function OnUpdateTick() 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 -- ============================================================================ @@ -911,6 +1297,14 @@ local function ClearAll() 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 diff --git a/NampowerAPI.lua b/NampowerAPI.lua index 95b5a11..2d19386 100644 --- a/NampowerAPI.lua +++ b/NampowerAPI.lua @@ -203,15 +203,17 @@ - SPELL_DISPEL_BY_SELF / SPELL_DISPEL_BY_OTHER - Spell dispel events. Params: casterGuid, targetGuid, spellId. No CVar needed. - GetSpellRangeData, PvP Unit Data Blocking (v4.0+): + GetSpellRangeData, GUID Event Init Fix (v4.0+): - GetSpellRangeData(rangeIndex) - Returns minRange, maxRange, flags, name for a SpellRange DBC entry. flags=0 is ranged, flags=1 is melee (Combat Range). Use with GetSpellRecField(spellId, "rangeIndex") to look up a spell's range. - - BREAKING: GetUnitData/GetUnitField now return nil for PvP-flagged units you can - attack. BUFF_ADDED_OTHER/BUFF_REMOVED_OTHER no longer fire for those units. - Existing API wrappers (GetUnitHealth, etc.) fall back to standard WoW API. - Hooks initialized later so _GUID events work even with old perf_boost. + PvP Unit Data Revert (v4.1+): + - v4.0 restricted GetUnitData/GetUnitField and suppressed BUFF/DEBUFF_ADDED/REMOVED_OTHER + for PvP-flagged attackable units. v4.1 reverts this — the information is already + accessible via tooltips in the base game. + Current version: v4.0.0 ]] @@ -435,10 +437,12 @@ API.VERSION_REQUIREMENTS = { ["DamageShieldEvents"] = { 3, 4, 0 }, -- DAMAGE_SHIELD_SELF/OTHER events ["SpellDispelEvents"] = { 3, 4, 0 }, -- SPELL_DISPEL_BY_SELF/OTHER events - -- v4.0+ - GetSpellRangeData, PvP unit data blocking, GUID event init fix + -- v4.0+ - GetSpellRangeData, GUID event init fix ["GetSpellRangeData"] = { 4, 0, 0, "GetSpellRangeData" }, - ["PvpUnitDataBlocking"] = { 4, 0, 0 }, -- GetUnitData/GetUnitField return nil for PvP-flagged attackable units; BUFF/DEBUFF_ADDED/REMOVED_OTHER suppressed ["GuidEventInitFix"] = { 4, 0, 0 }, -- _GUID events now work with old perf_boost (hooks initialized later) + + -- v4.1+ - Revert PvP unit data restrictions from v4.0 + ["PvpUnitDataRevert"] = { 4, 1, 0 }, -- GetUnitData/GetUnitField no longer restricted for PvP-flagged units; BUFF/DEBUFF events restored } -- Check if a specific feature is available @@ -644,11 +648,13 @@ local function InitializeFeatures() f.hasDamageShieldEvents = API.HasFeature("DamageShieldEvents") f.hasSpellDispelEvents = API.HasFeature("SpellDispelEvents") - -- v4.0+ GetSpellRangeData, PvP unit data blocking, GUID event init fix + -- v4.0+ GetSpellRangeData, GUID event init fix f.hasGetSpellRangeData = API.HasFeature("GetSpellRangeData") - f.hasPvpUnitDataBlocking = API.HasFeature("PvpUnitDataBlocking") f.hasGuidEventInitFix = API.HasFeature("GuidEventInitFix") + -- v4.1+ Revert PvP unit data restrictions + f.hasPvpUnitDataRevert = API.HasFeature("PvpUnitDataRevert") + -- Runtime detection for enhanced spell functions (verify by testing) if f.hasEnhancedSpellFunctions and GetSpellTexture then local success, result = pcall(function() diff --git a/SuperCleveRoidMacros.toc b/SuperCleveRoidMacros.toc index 14a89cb..ddb59a9 100644 --- a/SuperCleveRoidMacros.toc +++ b/SuperCleveRoidMacros.toc @@ -2,7 +2,7 @@ ## Author: DWG, _brain, Torio, yani9o, Avitasia ## Title: [Super]CleveRoidMacros ## Notes: /cleveroid for settings -## Version: 2.3 +## Version: 2.4 ## OptionalDeps: ClassicFocus, FocusFrame, pfUI, SuperMacro, Bongos_ActionBar, Cursive, XPerl, LunaUnitFrames, DragonflightReloaded, -Dragonflight3 ## SavedVariables: CleveRoidMacros, CleveRoids_LearnedDurations, CleveRoids_AuraTextures, CleveRoids_ImmunityData, CleveRoids_ComboDurations, CleveRoids_SpellSchools Localization.lua