diff --git a/Localization/enUS.lua b/Localization/enUS.lua
index ba3aadc..0848db2 100644
--- a/Localization/enUS.lua
+++ b/Localization/enUS.lua
@@ -387,3 +387,39 @@ L["INDEX"] = "Index"
L["DISPLAY_ORDER_DESC"] = "Display order (lower = earlier)"
L["VIEWER"] = "Viewer"
L["ASSIGN_ENTRY_TO_VIEWER_DESC"] = "Assign entry to viewer"
+
+L["ROTATION_ASSIST"] = "Rotation Assist"
+L["ROTATION_ASSIST_DESC"] = "Highlight the next recommended ability using Blizzard's Assisted Combat system"
+L["CDM_HIGHLIGHT"] = "CDM Highlight"
+L["CDM_HIGHLIGHT_DESC"] = "Show a colored border on CDM icons matching the next recommended spell"
+L["ACTION_BAR_HIGHLIGHT"] = "Action Bar Highlight"
+L["ACTION_BAR_HIGHLIGHT_DESC"] = "Show a colored border on action bar buttons matching the next recommended spell"
+L["ROTATION_ASSIST_BUTTON"] = "Rotation Assist Button"
+L["ROTATION_ASSIST_BUTTON_DESC"] = "Show a standalone icon displaying the next recommended spell"
+L["HIGHLIGHT_COLOR"] = "Highlight Color"
+L["HIGHLIGHT_COLOR_DESC"] = "Color of the rotation assist highlight border"
+L["HIGHLIGHT_THICKNESS"] = "Highlight Thickness"
+L["HIGHLIGHT_THICKNESS_DESC"] = "Thickness of the highlight border in pixels"
+L["VISIBILITY_MODE"] = "Visibility"
+L["VISIBILITY_MODE_DESC"] = "When to show the rotation assist button"
+L["VISIBILITY_ALWAYS"] = "Always"
+L["VISIBILITY_COMBAT"] = "In Combat"
+L["VISIBILITY_HOSTILE"] = "Hostile Target"
+L["LOCK_POSITION"] = "Lock Position"
+L["LOCK_POSITION_DESC"] = "Lock the button position (unlock to drag)"
+L["BUTTON_SIZE"] = "Button Size"
+L["BUTTON_SIZE_DESC"] = "Size of the rotation assist button"
+L["FRAME_STRATA"] = "Frame Strata"
+L["FRAME_STRATA_DESC"] = "Frame stacking layer"
+L["SHOW_BORDER"] = "Show Border"
+L["SHOW_BORDER_DESC"] = "Show a border around the button"
+L["SHOW_GCD_SWIPE"] = "Show GCD Swipe"
+L["SHOW_GCD_SWIPE_DESC"] = "Show global cooldown animation on the button"
+L["SHOW_KEYBIND"] = "Show Keybind"
+L["SHOW_KEYBIND_DESC"] = "Show the spell's keybind on the button"
+L["HIGHLIGHT_STYLE"] = "Highlight Style"
+L["HIGHLIGHT_STYLE_DESC"] = "How the highlighted icon is displayed"
+L["STYLE_BORDER"] = "Border"
+L["STYLE_GLOW"] = "Glow"
+L["RESET_POSITION"] = "Reset Position"
+L["RESET_POSITION_DESC"] = "Reset button position to default"
diff --git a/Modules/uCDM/uCDM.lua b/Modules/uCDM/uCDM.lua
index 58a9d81..7d9a06c 100644
--- a/Modules/uCDM/uCDM.lua
+++ b/Modules/uCDM/uCDM.lua
@@ -192,6 +192,49 @@ local defaults = {
},
},
},
+ rotationAssist = {
+ cdmHighlight = {
+ essential = {
+ enabled = false,
+ color = {r = 0, g = 1, b = 0.84, a = 0.8},
+ thickness = 2,
+ style = "border",
+ },
+ utility = {
+ enabled = false,
+ color = {r = 0, g = 1, b = 0.84, a = 0.8},
+ thickness = 2,
+ style = "border",
+ },
+ },
+ actionBarHighlight = {
+ enabled = false,
+ color = {r = 0, g = 1, b = 0.84, a = 0.8},
+ thickness = 2,
+ style = "border",
+ },
+ button = {
+ enabled = false,
+ isLocked = true,
+ iconSize = 56,
+ visibility = "always",
+ frameStrata = "MEDIUM",
+ showBorder = true,
+ borderThickness = 2,
+ borderColor = {r = 0, g = 0, b = 0, a = 1},
+ cooldownSwipeEnabled = true,
+ showKeybind = true,
+ keybindSize = 13,
+ keybindColor = {r = 1, g = 1, b = 1, a = 1},
+ keybindPoint = "BOTTOMRIGHT",
+ keybindOffsetX = -2,
+ keybindOffsetY = 2,
+ anchorFrom = "CENTER",
+ anchorTo = "CENTER",
+ offsetX = 0,
+ offsetY = -180,
+ },
+ },
customEntries = {},
positions = {},
}
@@ -208,6 +251,7 @@ function module:OnInitialize()
if self.LayoutEngine then self.LayoutEngine.Initialize() end
if self.Keybinds then self.Keybinds.Initialize() end
if self.Anchoring then self.Anchoring.Initialize() end
+ if self.RotationAssist then self.RotationAssist.Initialize() end
-- Register events
self:RegisterEvent("PLAYER_EQUIPMENT_CHANGED", "OnEquipmentChanged")
diff --git a/Modules/uCDM/uCDM.xml b/Modules/uCDM/uCDM.xml
index 55b12a6..a64166c 100644
--- a/Modules/uCDM/uCDM.xml
+++ b/Modules/uCDM/uCDM.xml
@@ -15,7 +15,10 @@
-
+
+
+
+
diff --git a/Modules/uCDM/uCDM_Options.lua b/Modules/uCDM/uCDM_Options.lua
index 10606ea..4b2f20f 100644
--- a/Modules/uCDM/uCDM_Options.lua
+++ b/Modules/uCDM/uCDM_Options.lua
@@ -1431,6 +1431,632 @@ local function BuildCustomTabOptions()
}
end
+local FRAME_STRATA_VALUES = {
+ LOW = "LOW",
+ MEDIUM = "MEDIUM",
+ HIGH = "HIGH",
+}
+
+local VISIBILITY_VALUES = {
+ always = L["VISIBILITY_ALWAYS"],
+ combat = L["VISIBILITY_COMBAT"],
+ hostile = L["VISIBILITY_HOSTILE"],
+}
+
+local HIGHLIGHT_STYLE_VALUES = {
+ border = L["STYLE_BORDER"],
+ glow = L["STYLE_GLOW"],
+}
+
+local function BuildRotationAssistOptions()
+ -- Use sub-groups so each section gets its own scrollable area
+ local args = {}
+
+ -- Sub-group 1: CDM Highlight
+ local cdmArgs = {}
+ local order = 1
+
+ cdmArgs.cdmDesc = {
+ type = "description",
+ name = L["CDM_HIGHLIGHT_DESC"],
+ order = order,
+ }
+ order = order + 1
+
+ for _, viewerKey in ipairs({"essential", "utility"}) do
+ local viewerName = viewerKey == "essential" and L["ESSENTIAL"] or L["UTILITY"]
+ local prefix = "cdmHighlight." .. viewerKey
+
+ cdmArgs[viewerKey .. "CdmEnabled"] = {
+ type = "toggle",
+ name = viewerName,
+ desc = string.format(L["CDM_HIGHLIGHT_DESC"] .. " (%s)", viewerName),
+ order = order,
+ get = function()
+ return module:GetSetting("rotationAssist." .. prefix .. ".enabled", false) == true
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist." .. prefix .. ".enabled", value)
+ end,
+ }
+ order = order + 1
+
+ cdmArgs[viewerKey .. "CdmColor"] = {
+ type = "color",
+ name = L["HIGHLIGHT_COLOR"] .. " (" .. viewerName .. ")",
+ desc = L["HIGHLIGHT_COLOR_DESC"],
+ order = order,
+ hasAlpha = true,
+ disabled = function()
+ return not module:GetSetting("rotationAssist." .. prefix .. ".enabled", false)
+ end,
+ get = function()
+ local c = module:GetSetting("rotationAssist." .. prefix .. ".color", {r = 0, g = 1, b = 0.84, a = 0.8})
+ return c.r or 0, c.g or 1, c.b or 0.84, c.a or 0.8
+ end,
+ set = function(_, r, g, b, a)
+ module:SetSetting("rotationAssist." .. prefix .. ".color", {r = r, g = g, b = b, a = a})
+ end,
+ }
+ order = order + 1
+
+ cdmArgs[viewerKey .. "CdmStyle"] = {
+ type = "select",
+ name = L["HIGHLIGHT_STYLE"] .. " (" .. viewerName .. ")",
+ desc = L["HIGHLIGHT_STYLE_DESC"],
+ order = order,
+ values = HIGHLIGHT_STYLE_VALUES,
+ disabled = function()
+ return not module:GetSetting("rotationAssist." .. prefix .. ".enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist." .. prefix .. ".style", "border")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist." .. prefix .. ".style", value)
+ end,
+ }
+ order = order + 1
+
+ cdmArgs[viewerKey .. "CdmThickness"] = {
+ type = "range",
+ name = L["HIGHLIGHT_THICKNESS"] .. " (" .. viewerName .. ")",
+ desc = L["HIGHLIGHT_THICKNESS_DESC"],
+ order = order,
+ min = 1,
+ max = 5,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist." .. prefix .. ".enabled", false)
+ or module:GetSetting("rotationAssist." .. prefix .. ".style", "border") == "glow"
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist." .. prefix .. ".thickness", 2)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist." .. prefix .. ".thickness", value, {
+ type = "number", min = 1, max = 5,
+ })
+ end,
+ }
+ order = order + 1
+ end
+
+ args.cdmHighlight = {
+ type = "group",
+ name = L["CDM_HIGHLIGHT"],
+ order = 1,
+ args = cdmArgs,
+ }
+
+ -- Sub-group 2: Action Bar Highlight
+ local abArgs = {}
+ order = 1
+
+ abArgs.abDesc = {
+ type = "description",
+ name = L["ACTION_BAR_HIGHLIGHT_DESC"],
+ order = order,
+ }
+ order = order + 1
+
+ abArgs.abEnabled = {
+ type = "toggle",
+ name = L["ENABLED"],
+ desc = L["ACTION_BAR_HIGHLIGHT_DESC"],
+ order = order,
+ get = function()
+ return module:GetSetting("rotationAssist.actionBarHighlight.enabled", false) == true
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.actionBarHighlight.enabled", value)
+ end,
+ }
+ order = order + 1
+
+ abArgs.abColor = {
+ type = "color",
+ name = L["HIGHLIGHT_COLOR"],
+ desc = L["HIGHLIGHT_COLOR_DESC"],
+ order = order,
+ hasAlpha = true,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.actionBarHighlight.enabled", false)
+ end,
+ get = function()
+ local c = module:GetSetting("rotationAssist.actionBarHighlight.color", {r = 0, g = 1, b = 0.84, a = 0.8})
+ return c.r or 0, c.g or 1, c.b or 0.84, c.a or 0.8
+ end,
+ set = function(_, r, g, b, a)
+ module:SetSetting("rotationAssist.actionBarHighlight.color", {r = r, g = g, b = b, a = a})
+ end,
+ }
+ order = order + 1
+
+ abArgs.abStyle = {
+ type = "select",
+ name = L["HIGHLIGHT_STYLE"],
+ desc = L["HIGHLIGHT_STYLE_DESC"],
+ order = order,
+ values = HIGHLIGHT_STYLE_VALUES,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.actionBarHighlight.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.actionBarHighlight.style", "border")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.actionBarHighlight.style", value)
+ end,
+ }
+ order = order + 1
+
+ abArgs.abThickness = {
+ type = "range",
+ name = L["HIGHLIGHT_THICKNESS"],
+ desc = L["HIGHLIGHT_THICKNESS_DESC"],
+ order = order,
+ min = 1,
+ max = 5,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.actionBarHighlight.enabled", false)
+ or module:GetSetting("rotationAssist.actionBarHighlight.style", "border") == "glow"
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.actionBarHighlight.thickness", 2)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.actionBarHighlight.thickness", value, {
+ type = "number", min = 1, max = 5,
+ })
+ end,
+ }
+
+ args.actionBarHighlight = {
+ type = "group",
+ name = L["ACTION_BAR_HIGHLIGHT"],
+ order = 2,
+ args = abArgs,
+ }
+
+ -- Sub-group 3: Standalone Button
+ local btnArgs = {}
+ order = 1
+
+ btnArgs.btnDesc = {
+ type = "description",
+ name = L["ROTATION_ASSIST_BUTTON_DESC"],
+ order = order,
+ }
+ order = order + 1
+
+ btnArgs.btnEnabled = {
+ type = "toggle",
+ name = L["ENABLED"],
+ desc = L["ROTATION_ASSIST_BUTTON_DESC"],
+ order = order,
+ get = function()
+ return module:GetSetting("rotationAssist.button.enabled", false) == true
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.enabled", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnLocked = {
+ type = "toggle",
+ name = L["LOCK_POSITION"],
+ desc = L["LOCK_POSITION_DESC"],
+ order = order,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.isLocked", true) ~= false
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.isLocked", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnSize = {
+ type = "range",
+ name = L["BUTTON_SIZE"],
+ desc = L["BUTTON_SIZE_DESC"],
+ order = order,
+ min = 24,
+ max = 128,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.iconSize", 56)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.iconSize", value, {
+ type = "number", min = 24, max = 128,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnVisibility = {
+ type = "select",
+ name = L["VISIBILITY_MODE"],
+ desc = L["VISIBILITY_MODE_DESC"],
+ order = order,
+ values = VISIBILITY_VALUES,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.visibility", "always")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.visibility", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnStrata = {
+ type = "select",
+ name = L["FRAME_STRATA"],
+ desc = L["FRAME_STRATA_DESC"],
+ order = order,
+ values = FRAME_STRATA_VALUES,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.frameStrata", "MEDIUM")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.frameStrata", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnGCDSwipe = {
+ type = "toggle",
+ name = L["SHOW_GCD_SWIPE"],
+ desc = L["SHOW_GCD_SWIPE_DESC"],
+ order = order,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.cooldownSwipeEnabled", true) ~= false
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.cooldownSwipeEnabled", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnBorderHeader = {type = "header", name = L["BORDER"], order = order}
+ order = order + 1
+
+ btnArgs.btnShowBorder = {
+ type = "toggle",
+ name = L["SHOW_BORDER"],
+ desc = L["SHOW_BORDER_DESC"],
+ order = order,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.showBorder", true) ~= false
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.showBorder", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnBorderThickness = {
+ type = "range",
+ name = L["BORDER_THICKNESS"],
+ desc = L["HIGHLIGHT_THICKNESS_DESC"],
+ order = order,
+ min = 1,
+ max = 5,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showBorder", true)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.borderThickness", 2)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.borderThickness", value, {
+ type = "number", min = 1, max = 5,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnBorderColor = {
+ type = "color",
+ name = L["BORDER_COLOR"],
+ desc = L["BORDER_COLOR_ALPHA_DESC"],
+ order = order,
+ hasAlpha = true,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showBorder", true)
+ end,
+ get = function()
+ local c = module:GetSetting("rotationAssist.button.borderColor", {r = 0, g = 0, b = 0, a = 1})
+ return c.r or 0, c.g or 0, c.b or 0, c.a or 1
+ end,
+ set = function(_, r, g, b, a)
+ module:SetSetting("rotationAssist.button.borderColor", {r = r, g = g, b = b, a = a})
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnKeybindHeader = {type = "header", name = L["KEYBIND_DISPLAY"], order = order}
+ order = order + 1
+
+ btnArgs.btnShowKeybind = {
+ type = "toggle",
+ name = L["SHOW_KEYBIND"],
+ desc = L["SHOW_KEYBIND_DESC"],
+ order = order,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.showKeybind", true) ~= false
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.showKeybind", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnKeybindSize = {
+ type = "range",
+ name = L["KEYBIND_TEXT_SIZE"],
+ desc = L["KEYBIND_TEXT_SIZE_DESC"],
+ order = order,
+ min = 6,
+ max = 24,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showKeybind", true)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.keybindSize", 13)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.keybindSize", value, {
+ type = "number", min = 6, max = 24,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnKeybindColor = {
+ type = "color",
+ name = L["KEYBIND_TEXT_COLOR"],
+ desc = L["COLOR_OF_KEYBIND_DESC"],
+ order = order,
+ hasAlpha = true,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showKeybind", true)
+ end,
+ get = function()
+ local c = module:GetSetting("rotationAssist.button.keybindColor", {r = 1, g = 1, b = 1, a = 1})
+ return c.r or 1, c.g or 1, c.b or 1, c.a or 1
+ end,
+ set = function(_, r, g, b, a)
+ module:SetSetting("rotationAssist.button.keybindColor", {r = r, g = g, b = b, a = a})
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnKeybindPoint = {
+ type = "select",
+ name = L["KEYBIND_POSITION"],
+ desc = L["ANCHOR_POINT_KEYBIND_DESC"],
+ order = order,
+ values = ANCHOR_POINTS,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showKeybind", true)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.keybindPoint", "BOTTOMRIGHT")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.keybindPoint", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnKeybindOffsetX = {
+ type = "range",
+ name = L["KEYBIND_OFFSET_X"],
+ desc = L["HORIZONTAL_OFFSET_KEYBIND_DESC"],
+ order = order,
+ min = -50,
+ max = 50,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showKeybind", true)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.keybindOffsetX", -2)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.keybindOffsetX", value, {
+ type = "number", min = -50, max = 50,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnKeybindOffsetY = {
+ type = "range",
+ name = L["KEYBIND_OFFSET_Y"],
+ desc = L["VERTICAL_OFFSET_KEYBIND_DESC"],
+ order = order,
+ min = -50,
+ max = 50,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false) or
+ not module:GetSetting("rotationAssist.button.showKeybind", true)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.keybindOffsetY", 2)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.keybindOffsetY", value, {
+ type = "number", min = -50, max = 50,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnPositionHeader = {type = "header", name = L["POSITION"], order = order}
+ order = order + 1
+
+ btnArgs.btnAnchorFrom = {
+ type = "select",
+ name = L["POINT"],
+ desc = L["ANCHOR_POINT_ON_VIEWER_DESC"],
+ order = order,
+ values = ANCHOR_POINTS,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.anchorFrom", "CENTER")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.anchorFrom", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnAnchorTo = {
+ type = "select",
+ name = L["RELATIVE_POINT"],
+ desc = L["ANCHOR_POINT_ON_TARGET_DESC"],
+ order = order,
+ values = ANCHOR_POINTS,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.anchorTo", "CENTER")
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.anchorTo", value)
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnOffsetX = {
+ type = "range",
+ name = L["OFFSET_X"],
+ desc = L["HORIZONTAL_OFFSET"],
+ order = order,
+ min = -1000,
+ max = 1000,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.offsetX", 0)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.offsetX", value, {
+ type = "number", min = -1000, max = 1000,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnOffsetY = {
+ type = "range",
+ name = L["OFFSET_Y"],
+ desc = L["VERTICAL_OFFSET"],
+ order = order,
+ min = -1000,
+ max = 1000,
+ step = 1,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ get = function()
+ return module:GetSetting("rotationAssist.button.offsetY", -180)
+ end,
+ set = function(_, value)
+ module:SetSetting("rotationAssist.button.offsetY", value, {
+ type = "number", min = -1000, max = 1000,
+ })
+ end,
+ }
+ order = order + 1
+
+ btnArgs.btnResetPosition = {
+ type = "execute",
+ name = L["RESET_POSITION"],
+ desc = L["RESET_POSITION_DESC"],
+ order = order,
+ disabled = function()
+ return not module:GetSetting("rotationAssist.button.enabled", false)
+ end,
+ func = function()
+ module:SetSetting("rotationAssist.button.anchorFrom", "CENTER")
+ module:SetSetting("rotationAssist.button.anchorTo", "CENTER")
+ module:SetSetting("rotationAssist.button.offsetX", 0)
+ module:SetSetting("rotationAssist.button.offsetY", -180)
+ end,
+ }
+
+ args.button = {
+ type = "group",
+ name = L["ROTATION_ASSIST_BUTTON"],
+ order = 3,
+ args = btnArgs,
+ }
+
+ return args
+end
+
function module:BuildOptions()
if not TavernUI.db or not TavernUI.db.profile then
return
@@ -1477,9 +2103,15 @@ function module:BuildOptions()
order = 4,
args = {},
},
+ rotationAssist = {
+ type = "group",
+ name = L["ROTATION_ASSIST"],
+ order = 5,
+ args = BuildRotationAssistOptions(),
+ },
},
}
-
+
local customTabOptions = BuildCustomTabOptions()
for k, v in pairs(customTabOptions) do
options.args.custom.args[k] = v
diff --git a/Modules/uCDM/uCDM_RotationAssist.lua b/Modules/uCDM/uCDM_RotationAssist.lua
new file mode 100644
index 0000000..bbf33c9
--- /dev/null
+++ b/Modules/uCDM/uCDM_RotationAssist.lua
@@ -0,0 +1,861 @@
+local TavernUI = LibStub("AceAddon-3.0"):GetAddon("TavernUI")
+local module = TavernUI:GetModule("uCDM", true)
+
+if not module then return end
+
+--[[
+ RotationAssist - Highlights next recommended spell via C_AssistedCombat
+
+ Three independent features sharing one ticker:
+ 1. CDM Icon Highlighting - border on Essential/Utility viewer icons
+ 2. Action Bar Highlighting - border on action bar buttons
+ 3. Standalone Button - movable icon with GCD swipe and keybind
+]]
+
+local RotationAssist = {}
+
+-- Shared state
+local lastSpellID = nil
+local inCombat = false
+local updateTimer = nil
+local initialized = false
+
+-- Overlay caches
+local cdmOverlays = {} -- frame -> overlay
+local actionBarOverlays = {} -- button -> overlay
+
+-- Action button cache
+local cachedActionButtons = nil
+local actionButtonsCached = false
+
+-- Standalone button
+local assistButton = nil
+
+local GCD_SPELL_ID = 61304
+
+--------------------------------------------------------------------------------
+-- Helpers
+--------------------------------------------------------------------------------
+
+local function GetSetting(path, default)
+ return module:GetSetting("rotationAssist." .. path, default)
+end
+
+local function IsAPIAvailable()
+ return C_AssistedCombat and C_AssistedCombat.GetNextCastSpell
+end
+
+local function GetNextSpellID()
+ if not IsAPIAvailable() then return nil end
+ local ok, spellID = pcall(C_AssistedCombat.GetNextCastSpell)
+ if ok and spellID and spellID ~= 0 then
+ return spellID
+ end
+ return nil
+end
+
+local function ShouldRun()
+ if not module:IsEnabled() then return false end
+ if not IsAPIAvailable() then return false end
+
+ local essEnabled = GetSetting("cdmHighlight.essential.enabled", false)
+ local utilEnabled = GetSetting("cdmHighlight.utility.enabled", false)
+ local abEnabled = GetSetting("actionBarHighlight.enabled", false)
+ local btnEnabled = GetSetting("button.enabled", false)
+
+ return essEnabled or utilEnabled or abEnabled or btnEnabled
+end
+
+--------------------------------------------------------------------------------
+-- Inner Border Overlay (4-edge texture approach, no BackdropTemplate)
+--------------------------------------------------------------------------------
+
+local function CreateOverlay(parent, frameLevel)
+ local overlay = CreateFrame("Frame", nil, parent)
+ overlay:SetAllPoints(parent)
+ overlay:SetFrameLevel((frameLevel or parent:GetFrameLevel()) + 15)
+
+ local edges = {}
+ for _, edge in ipairs({"TOP", "BOTTOM", "LEFT", "RIGHT"}) do
+ edges[edge] = overlay:CreateTexture(nil, "OVERLAY")
+ edges[edge]:SetColorTexture(0, 1, 0.84, 0.8)
+ end
+
+ local fill = overlay:CreateTexture(nil, "OVERLAY")
+ fill:SetAllPoints(overlay)
+ fill:SetColorTexture(0, 1, 0.84, 0.4)
+ fill:Hide()
+
+ overlay._edges = edges
+ overlay._fill = fill
+ overlay._thickness = 2
+ overlay._style = "border"
+
+ function overlay:SetBorderColor(r, g, b, a)
+ for _, tex in pairs(self._edges) do
+ tex:SetColorTexture(r, g, b, a or 1)
+ end
+ self._fill:SetColorTexture(r, g, b, (a and a * 0.5) or 0.4)
+ end
+
+ function overlay:SetStyle(style)
+ self._style = style
+ if style == "glow" then
+ self._fill:Show()
+ for _, tex in pairs(self._edges) do tex:Hide() end
+ else -- "border"
+ self._fill:Hide()
+ for _, tex in pairs(self._edges) do tex:Show() end
+ end
+ end
+
+ function overlay:SetBorderSize(px)
+ self._thickness = px
+ self:UpdateEdges()
+ end
+
+ function overlay:UpdateEdges()
+ local t = self._thickness
+ local edges = self._edges
+
+ edges.TOP:ClearAllPoints()
+ edges.TOP:SetPoint("TOPLEFT", self, "TOPLEFT", 0, 0)
+ edges.TOP:SetPoint("TOPRIGHT", self, "TOPRIGHT", 0, 0)
+ edges.TOP:SetHeight(t)
+
+ edges.BOTTOM:ClearAllPoints()
+ edges.BOTTOM:SetPoint("BOTTOMLEFT", self, "BOTTOMLEFT", 0, 0)
+ edges.BOTTOM:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", 0, 0)
+ edges.BOTTOM:SetHeight(t)
+
+ edges.LEFT:ClearAllPoints()
+ edges.LEFT:SetPoint("TOPLEFT", self, "TOPLEFT", 0, -t)
+ edges.LEFT:SetPoint("BOTTOMLEFT", self, "BOTTOMLEFT", 0, t)
+ edges.LEFT:SetWidth(t)
+
+ edges.RIGHT:ClearAllPoints()
+ edges.RIGHT:SetPoint("TOPRIGHT", self, "TOPRIGHT", 0, -t)
+ edges.RIGHT:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", 0, t)
+ edges.RIGHT:SetWidth(t)
+ end
+
+ overlay:UpdateEdges()
+ overlay:Hide()
+ return overlay
+end
+
+--------------------------------------------------------------------------------
+-- CDM Icon Highlighting
+--------------------------------------------------------------------------------
+
+local function GetCDMOverlay(frame)
+ if cdmOverlays[frame] then return cdmOverlays[frame] end
+ local overlay = CreateOverlay(frame)
+ cdmOverlays[frame] = overlay
+ return overlay
+end
+
+local function MatchesSpellID(item, nextSpellID)
+ if not item or not nextSpellID then return false end
+
+ -- Direct spell ID match
+ if item.spellID and item.spellID == nextSpellID then return true end
+
+ -- Override: does item's spell override TO the next spell?
+ if item.spellID and C_Spell.GetOverrideSpell then
+ local ok, overrideID = pcall(C_Spell.GetOverrideSpell, item.spellID)
+ if ok and overrideID and overrideID == nextSpellID then return true end
+ end
+
+ -- Reverse: does the next spell override TO the item's spell?
+ if item.spellID and C_Spell.GetOverrideSpell then
+ local ok, overrideID = pcall(C_Spell.GetOverrideSpell, nextSpellID)
+ if ok and overrideID and overrideID == item.spellID then return true end
+ end
+
+ return false
+end
+
+local function UpdateViewerHighlight(viewerKey, nextSpellID)
+ local settings = GetSetting("cdmHighlight." .. viewerKey)
+ if not settings or not settings.enabled then
+ -- Hide all overlays for this viewer
+ if module.ItemRegistry then
+ local items = module.ItemRegistry.GetItemsForViewer(viewerKey)
+ for _, item in ipairs(items) do
+ if item.frame and cdmOverlays[item.frame] then
+ cdmOverlays[item.frame]:Hide()
+ end
+ end
+ end
+ return
+ end
+
+ local color = settings.color or {r = 0, g = 1, b = 0.84, a = 0.8}
+ local thickness = settings.thickness or 2
+ local style = settings.style or "border"
+
+ if not module.ItemRegistry then return end
+ local items = module.ItemRegistry.GetItemsForViewer(viewerKey)
+
+ for _, item in ipairs(items) do
+ if item.frame then
+ local overlay = GetCDMOverlay(item.frame)
+ if nextSpellID and MatchesSpellID(item, nextSpellID) then
+ overlay:SetBorderColor(color.r, color.g, color.b, color.a)
+ overlay:SetBorderSize(thickness)
+ overlay:SetStyle(style)
+ overlay:Show()
+ else
+ overlay:Hide()
+ end
+ end
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Action Bar Highlighting
+--------------------------------------------------------------------------------
+
+local function BuildActionButtonCache()
+ if actionButtonsCached then return end
+
+ cachedActionButtons = {}
+ local added = {}
+
+ -- Scan globals for action buttons
+ for globalName, frame in pairs(_G) do
+ if type(globalName) == "string" and type(frame) == "table" and not added[frame] then
+ if type(frame.GetObjectType) == "function" and (frame.action or (frame.GetAction and type(frame.GetAction) == "function")) then
+ if globalName:match("ActionButton%d+$") or (globalName:match("Button%d+$") and globalName:match("Bar")) then
+ cachedActionButtons[#cachedActionButtons + 1] = frame
+ added[frame] = true
+ end
+ end
+ end
+ end
+
+ -- Blizzard bars
+ local barPrefixes = {
+ "ActionButton",
+ "MultiBarBottomLeftButton",
+ "MultiBarBottomRightButton",
+ "MultiBarRightButton",
+ "MultiBarLeftButton",
+ }
+ for _, prefix in ipairs(barPrefixes) do
+ for i = 1, 12 do
+ local button = _G[prefix .. i]
+ if button and not added[button] then
+ cachedActionButtons[#cachedActionButtons + 1] = button
+ added[button] = true
+ end
+ end
+ end
+
+ -- Bartender4
+ for i = 1, 120 do
+ local button = _G["BT4Button" .. i]
+ if button and not added[button] then
+ cachedActionButtons[#cachedActionButtons + 1] = button
+ added[button] = true
+ end
+ end
+
+ -- Dominos
+ for i = 1, 120 do
+ local button = _G["DominosActionButton" .. i]
+ if button and not added[button] then
+ cachedActionButtons[#cachedActionButtons + 1] = button
+ added[button] = true
+ end
+ end
+
+ -- ElvUI
+ for bar = 1, 10 do
+ for i = 1, 12 do
+ local button = _G["ElvUI_Bar" .. bar .. "Button" .. i]
+ if button and not added[button] then
+ cachedActionButtons[#cachedActionButtons + 1] = button
+ added[button] = true
+ end
+ end
+ end
+
+ actionButtonsCached = true
+end
+
+local function GetActionBarOverlay(button)
+ if actionBarOverlays[button] then return actionBarOverlays[button] end
+ local overlay = CreateOverlay(button)
+ actionBarOverlays[button] = overlay
+ return overlay
+end
+
+local function GetActionSlot(button)
+ local buttonName = button and button.GetName and button:GetName()
+ local action
+
+ if buttonName and buttonName:match("^BT4Button") then
+ action = button._state_action
+ if (not action or action == 0) and button.GetAction then
+ local ok, aType, actionSlot = pcall(function()
+ local t, s = button:GetAction()
+ return t, s
+ end)
+ if ok and aType == "action" and type(actionSlot) == "number" then
+ action = actionSlot
+ end
+ end
+ else
+ if type(button.action) == "number" then
+ action = button.action
+ end
+ if (not action or action == 0) and button.GetAction then
+ local ok, r1, r2 = pcall(function()
+ local a, b = button:GetAction()
+ return a, b
+ end)
+ if ok then
+ if type(r2) == "number" and (r1 == "action" or r1 == nil) then
+ action = r2
+ elseif type(r1) == "number" then
+ action = r1
+ end
+ end
+ end
+ end
+
+ if not action or action == 0 then return nil end
+ return action
+end
+
+local function ActionButtonMatchesSpell(button, nextSpellID)
+ local action = GetActionSlot(button)
+ if not action then return false end
+
+ local ok, actionType, id = pcall(GetActionInfo, action)
+ if not ok or not actionType then return false end
+
+ if actionType == "spell" and id then
+ if id == nextSpellID then return true end
+ -- Check override spells in both directions
+ if C_Spell.GetOverrideSpell then
+ local ok2, overrideID = pcall(C_Spell.GetOverrideSpell, id)
+ if ok2 and overrideID and overrideID == nextSpellID then return true end
+ local ok3, reverseID = pcall(C_Spell.GetOverrideSpell, nextSpellID)
+ if ok3 and reverseID and reverseID == id then return true end
+ end
+ return false
+ elseif actionType == "macro" and id then
+ -- Check macro spell via API first
+ local ok2, macroSpell = pcall(GetMacroSpell, id)
+ if ok2 and macroSpell and macroSpell == nextSpellID then return true end
+
+ -- Parse macro body for spell matches
+ local ok3, macroName, macroIcon, macroBody = pcall(GetMacroInfo, id)
+ if ok3 and macroBody then
+ for line in macroBody:gmatch("[^\r\n]+") do
+ local spellName = nil
+
+ -- Match /cast or /use commands
+ local castMatch = line:match("^%s*/[cC][aA][sS][tT]%s+(.*)")
+ local useMatch = line:match("^%s*/[uU][sS][eE]%s+(.*)")
+ local afterCmd = castMatch or useMatch
+
+ if afterCmd then
+ -- Strip conditionals [...]
+ afterCmd = afterCmd:gsub("%[.-%]", "")
+ -- Get first spell name/id (before semicolons or end of line)
+ spellName = afterCmd:match("^%s*(.-)%s*[;]") or afterCmd:match("^%s*(.-)%s*$")
+ if spellName then
+ spellName = strtrim(spellName)
+ end
+ end
+
+ if spellName and spellName ~= "" and spellName ~= "?" then
+ local spellIDFromName = tonumber(spellName)
+ if spellIDFromName then
+ if spellIDFromName == nextSpellID then return true end
+ else
+ local ok4, spellInfo = pcall(C_Spell.GetSpellInfo, spellName)
+ if ok4 and spellInfo and spellInfo.spellID == nextSpellID then
+ return true
+ end
+ end
+ end
+ end
+ end
+ return false
+ end
+
+ return false
+end
+
+local function UpdateActionBarHighlight(nextSpellID)
+ local settings = GetSetting("actionBarHighlight")
+ if not settings or not settings.enabled then
+ -- Hide all action bar overlays
+ for _, overlay in pairs(actionBarOverlays) do
+ overlay:Hide()
+ end
+ return
+ end
+
+ BuildActionButtonCache()
+ if not cachedActionButtons then return end
+
+ local color = settings.color or {r = 0, g = 1, b = 0.84, a = 0.8}
+ local thickness = settings.thickness or 2
+ local style = settings.style or "border"
+
+ for _, button in ipairs(cachedActionButtons) do
+ local overlay = GetActionBarOverlay(button)
+ if nextSpellID and ActionButtonMatchesSpell(button, nextSpellID) then
+ overlay:SetBorderColor(color.r, color.g, color.b, color.a)
+ overlay:SetBorderSize(thickness)
+ overlay:SetStyle(style)
+ overlay:Show()
+ else
+ overlay:Hide()
+ end
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Standalone Rotation Assist Button
+--------------------------------------------------------------------------------
+
+local function ApplyButtonPosition()
+ if not assistButton then return end
+ local anchorFrom = GetSetting("button.anchorFrom", "CENTER")
+ local anchorTo = GetSetting("button.anchorTo", "CENTER")
+ local x = GetSetting("button.offsetX", 0)
+ local y = GetSetting("button.offsetY", -180)
+ assistButton:ClearAllPoints()
+ assistButton:SetPoint(anchorFrom, UIParent, anchorTo, x, y)
+end
+
+local function CreateAssistButton()
+ if assistButton then return assistButton end
+
+ local btn = CreateFrame("Button", "TavernUI_RotationAssistButton", UIParent)
+ btn:SetSize(56, 56)
+ btn:SetFrameStrata("MEDIUM")
+ btn:SetClampedToScreen(true)
+ btn:SetMovable(true)
+ btn:EnableMouse(true)
+
+ -- Icon
+ local icon = btn:CreateTexture(nil, "ARTWORK")
+ icon:SetAllPoints(btn)
+ btn.Icon = icon
+
+ -- Cooldown (GCD swipe)
+ local cooldown = CreateFrame("Cooldown", nil, btn, "CooldownFrameTemplate")
+ cooldown:SetAllPoints(btn)
+ cooldown:SetDrawEdge(false)
+ cooldown:SetDrawBling(false)
+ cooldown:SetDrawSwipe(true)
+ cooldown:SetHideCountdownNumbers(true)
+ btn.Cooldown = cooldown
+
+ -- Border overlay (4-edge)
+ local border = CreateOverlay(btn)
+ border:SetBorderColor(0, 0, 0, 1)
+ border:SetBorderSize(2)
+ btn.Border = border
+
+ -- Keybind text
+ local keybindText = TavernUI:CreateFontString(btn, 13)
+ keybindText:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2)
+ keybindText:SetJustifyH("RIGHT")
+ btn.KeybindText = keybindText
+
+ -- Drag handling
+ btn:RegisterForDrag("LeftButton")
+ btn:SetScript("OnDragStart", function(self)
+ if not GetSetting("button.isLocked", true) then
+ self:StartMoving()
+ end
+ end)
+ btn:SetScript("OnDragStop", function(self)
+ self:StopMovingOrSizing()
+ -- Compute offset relative to the configured anchor point on UIParent
+ local anchorFrom = GetSetting("button.anchorFrom", "CENTER")
+ local anchorTo = GetSetting("button.anchorTo", "CENTER")
+ -- Re-anchor so the frame stores clean offset values
+ local cx, cy = self:GetCenter()
+ if cx and cy then
+ self:ClearAllPoints()
+ self:SetPoint(anchorFrom, UIParent, anchorTo, 0, 0)
+ local ax, ay = self:GetCenter()
+ self:ClearAllPoints()
+ if ax and ay then
+ local dx = cx - ax
+ local dy = cy - ay
+ module:SetSetting("rotationAssist.button.offsetX", math.floor(dx + 0.5))
+ module:SetSetting("rotationAssist.button.offsetY", math.floor(dy + 0.5))
+ end
+ ApplyButtonPosition()
+ end
+ end)
+
+ btn:Hide()
+ assistButton = btn
+ return btn
+end
+
+local function ApplyButtonSettings()
+ local btn = CreateAssistButton()
+
+ local size = GetSetting("button.iconSize", 56)
+ btn:SetSize(size, size)
+
+ local strata = GetSetting("button.frameStrata", "MEDIUM")
+ btn:SetFrameStrata(strata)
+
+ -- Border
+ if GetSetting("button.showBorder", true) then
+ local borderColor = GetSetting("button.borderColor", {r = 0, g = 0, b = 0, a = 1})
+ local borderThickness = GetSetting("button.borderThickness", 2)
+ btn.Border:SetBorderColor(borderColor.r, borderColor.g, borderColor.b, borderColor.a)
+ btn.Border:SetBorderSize(borderThickness)
+ btn.Border:Show()
+ else
+ btn.Border:Hide()
+ end
+
+ -- GCD swipe
+ local swipeEnabled = GetSetting("button.cooldownSwipeEnabled", true)
+ btn.Cooldown:SetDrawSwipe(swipeEnabled)
+
+ -- Keybind text
+ local showKeybind = GetSetting("button.showKeybind", true)
+ if showKeybind then
+ local keybindColor = GetSetting("button.keybindColor", {r = 1, g = 1, b = 1, a = 1})
+ local keybindSize = GetSetting("button.keybindSize", 13)
+ local keybindPoint = GetSetting("button.keybindPoint", "BOTTOMRIGHT")
+ local keybindOffsetX = GetSetting("button.keybindOffsetX", -2)
+ local keybindOffsetY = GetSetting("button.keybindOffsetY", 2)
+
+ TavernUI:ApplyFont(btn.KeybindText, btn, keybindSize)
+ btn.KeybindText:SetTextColor(keybindColor.r, keybindColor.g, keybindColor.b, keybindColor.a)
+ btn.KeybindText:ClearAllPoints()
+ btn.KeybindText:SetPoint(keybindPoint, btn, keybindPoint, keybindOffsetX, keybindOffsetY)
+ else
+ btn.KeybindText:Hide()
+ end
+
+ ApplyButtonPosition()
+end
+
+local function UpdateButtonVisibility()
+ if not assistButton then return end
+
+ if not GetSetting("button.enabled", false) then
+ assistButton:Hide()
+ return
+ end
+
+ local mode = GetSetting("button.visibility", "always")
+
+ if mode == "always" then
+ assistButton:Show()
+ elseif mode == "combat" then
+ if inCombat then
+ assistButton:Show()
+ else
+ assistButton:Hide()
+ end
+ elseif mode == "hostile" then
+ local exists = UnitExists("target")
+ local canAttack = UnitCanAttack("player", "target")
+ if exists and canAttack then
+ assistButton:Show()
+ else
+ assistButton:Hide()
+ end
+ end
+end
+
+local function UpdateButtonIcon(spellID)
+ if not assistButton then return end
+
+ if not spellID then
+ assistButton.Icon:SetTexture(nil)
+ assistButton.KeybindText:SetText("")
+ return
+ end
+
+ -- Set icon texture
+ local ok, spellInfo = pcall(C_Spell.GetSpellInfo, spellID)
+ if ok and spellInfo then
+ local iconID = spellInfo.iconID or spellInfo.originalIconID
+ if iconID then
+ assistButton.Icon:SetTexture(iconID)
+ end
+ end
+
+ -- Usability tinting
+ local ok2, isUsable = pcall(C_Spell.IsSpellUsable, spellID)
+ if ok2 then
+ if isUsable then
+ assistButton.Icon:SetVertexColor(1, 1, 1, 1)
+ else
+ assistButton.Icon:SetVertexColor(0.4, 0.4, 0.4, 1)
+ end
+ else
+ assistButton.Icon:SetVertexColor(1, 1, 1, 1)
+ end
+
+ -- Keybind text
+ if GetSetting("button.showKeybind", true) and module.Keybinds then
+ local keybind = module.Keybinds.GetSpellKeybind(spellID)
+ assistButton.KeybindText:SetText(keybind or "")
+ if keybind then
+ assistButton.KeybindText:Show()
+ else
+ assistButton.KeybindText:Hide()
+ end
+ end
+end
+
+local function UpdateGCDCooldown()
+ if not assistButton or not assistButton.Cooldown then return end
+ if not GetSetting("button.cooldownSwipeEnabled", true) then return end
+
+ local ok, cdInfo = pcall(C_Spell.GetSpellCooldown, GCD_SPELL_ID)
+ if ok and cdInfo and cdInfo.startTime and cdInfo.duration and cdInfo.duration > 0 then
+ assistButton.Cooldown:SetCooldown(cdInfo.startTime, cdInfo.duration)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Shared Update Loop
+--------------------------------------------------------------------------------
+
+local function DoUpdate()
+ if not ShouldRun() then return end
+
+ local nextSpellID = GetNextSpellID()
+
+ -- Track whether the spell changed (for debug/future use)
+ local changed = (nextSpellID ~= lastSpellID)
+ lastSpellID = nextSpellID
+
+ -- Always update all features every tick to keep state current.
+ -- The ticker rate (0.1s combat, 0.25s idle) provides throttling.
+
+ -- CDM highlights
+ if GetSetting("cdmHighlight.essential.enabled", false) then
+ UpdateViewerHighlight("essential", nextSpellID)
+ end
+ if GetSetting("cdmHighlight.utility.enabled", false) then
+ UpdateViewerHighlight("utility", nextSpellID)
+ end
+
+ -- Action bar highlights
+ if GetSetting("actionBarHighlight.enabled", false) then
+ UpdateActionBarHighlight(nextSpellID)
+ end
+
+ -- Standalone button
+ if GetSetting("button.enabled", false) then
+ UpdateButtonIcon(nextSpellID)
+ if assistButton and assistButton:IsShown() then
+ UpdateGCDCooldown()
+ end
+ end
+end
+
+local function StopTicker()
+ if updateTimer then
+ updateTimer:Cancel()
+ updateTimer = nil
+ end
+end
+
+local function StartTicker()
+ StopTicker()
+ if not ShouldRun() then return end
+
+ local function Tick()
+ if not ShouldRun() then
+ StopTicker()
+ return
+ end
+
+ DoUpdate()
+
+ local interval = inCombat and 0.1 or 0.25
+ updateTimer = C_Timer.NewTimer(interval, Tick)
+ end
+
+ Tick()
+end
+
+--------------------------------------------------------------------------------
+-- Cleanup
+--------------------------------------------------------------------------------
+
+local function HideAllHighlights()
+ -- CDM overlays
+ for _, overlay in pairs(cdmOverlays) do
+ overlay:Hide()
+ end
+
+ -- Action bar overlays
+ for _, overlay in pairs(actionBarOverlays) do
+ overlay:Hide()
+ end
+
+ -- Button
+ if assistButton then
+ assistButton:Hide()
+ end
+
+ lastSpellID = nil
+end
+
+--------------------------------------------------------------------------------
+-- Refresh (apply all settings, restart ticker)
+--------------------------------------------------------------------------------
+
+local function Refresh()
+ if not module:IsEnabled() then
+ HideAllHighlights()
+ StopTicker()
+ return
+ end
+
+ if not ShouldRun() then
+ HideAllHighlights()
+ StopTicker()
+ return
+ end
+
+ -- Apply button settings if enabled
+ if GetSetting("button.enabled", false) then
+ ApplyButtonSettings()
+ UpdateButtonVisibility()
+ elseif assistButton then
+ assistButton:Hide()
+ end
+
+ -- If CDM highlights are disabled, hide their overlays
+ if not GetSetting("cdmHighlight.essential.enabled", false) then
+ UpdateViewerHighlight("essential", nil)
+ end
+ if not GetSetting("cdmHighlight.utility.enabled", false) then
+ UpdateViewerHighlight("utility", nil)
+ end
+
+ -- If action bar highlight is disabled, hide overlays
+ if not GetSetting("actionBarHighlight.enabled", false) then
+ UpdateActionBarHighlight(nil)
+ end
+
+ -- Force a fresh update cycle
+ lastSpellID = nil
+ StartTicker()
+end
+
+--------------------------------------------------------------------------------
+-- Initialization
+--------------------------------------------------------------------------------
+
+function RotationAssist.Initialize()
+ if initialized then return end
+ initialized = true
+
+ local eventFrame = CreateFrame("Frame")
+ eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
+ eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
+ eventFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
+ eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
+ eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
+ eventFrame:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
+ eventFrame:RegisterEvent("UPDATE_BINDINGS")
+
+ eventFrame:SetScript("OnEvent", function(self, event)
+ if event == "PLAYER_ENTERING_WORLD" then
+ inCombat = InCombatLockdown()
+ actionButtonsCached = false
+ C_Timer.After(1.0, Refresh)
+ elseif event == "PLAYER_REGEN_DISABLED" then
+ inCombat = true
+ UpdateButtonVisibility()
+ elseif event == "PLAYER_REGEN_ENABLED" then
+ inCombat = false
+ UpdateButtonVisibility()
+ elseif event == "PLAYER_TARGET_CHANGED" then
+ UpdateButtonVisibility()
+ lastSpellID = nil
+ elseif event == "ACTIONBAR_SLOT_CHANGED" or event == "UPDATE_BINDINGS" then
+ actionButtonsCached = false
+ elseif event == "SPELL_UPDATE_COOLDOWN" then
+ if assistButton and assistButton:IsShown() then
+ UpdateGCDCooldown()
+ end
+ end
+ end)
+
+ -- Watch for setting changes
+ local settingPaths = {
+ "rotationAssist.cdmHighlight.essential.enabled",
+ "rotationAssist.cdmHighlight.essential.color",
+ "rotationAssist.cdmHighlight.essential.thickness",
+ "rotationAssist.cdmHighlight.essential.style",
+ "rotationAssist.cdmHighlight.utility.enabled",
+ "rotationAssist.cdmHighlight.utility.color",
+ "rotationAssist.cdmHighlight.utility.thickness",
+ "rotationAssist.cdmHighlight.utility.style",
+ "rotationAssist.actionBarHighlight.enabled",
+ "rotationAssist.actionBarHighlight.color",
+ "rotationAssist.actionBarHighlight.thickness",
+ "rotationAssist.actionBarHighlight.style",
+ "rotationAssist.button.enabled",
+ "rotationAssist.button.isLocked",
+ "rotationAssist.button.iconSize",
+ "rotationAssist.button.visibility",
+ "rotationAssist.button.frameStrata",
+ "rotationAssist.button.showBorder",
+ "rotationAssist.button.borderThickness",
+ "rotationAssist.button.borderColor",
+ "rotationAssist.button.cooldownSwipeEnabled",
+ "rotationAssist.button.showKeybind",
+ "rotationAssist.button.keybindSize",
+ "rotationAssist.button.keybindColor",
+ "rotationAssist.button.keybindPoint",
+ "rotationAssist.button.keybindOffsetX",
+ "rotationAssist.button.keybindOffsetY",
+ "rotationAssist.button.anchorFrom",
+ "rotationAssist.button.anchorTo",
+ "rotationAssist.button.offsetX",
+ "rotationAssist.button.offsetY",
+ }
+
+ for _, path in ipairs(settingPaths) do
+ module:WatchSetting(path, function()
+ Refresh()
+ end)
+ end
+
+ -- Profile change handler
+ module:RegisterMessage("TavernUI_ProfileChanged", function()
+ HideAllHighlights()
+ lastSpellID = nil
+ C_Timer.After(0.3, Refresh)
+ end)
+end
+
+-- Public API for options/refresh
+RotationAssist.Refresh = Refresh
+RotationAssist.HideAllHighlights = HideAllHighlights
+
+--------------------------------------------------------------------------------
+-- Export
+--------------------------------------------------------------------------------
+
+module.RotationAssist = RotationAssist