diff --git a/EUI_KeybindMode.lua b/EUI_KeybindMode.lua new file mode 100644 index 0000000..0ce7270 --- /dev/null +++ b/EUI_KeybindMode.lua @@ -0,0 +1,1016 @@ +-------------------------------------------------------------------------------- +-- EUI_KeybindMode.lua +-- Fast hover-to-bind keybinding mode for EllesmereUI action bars +-------------------------------------------------------------------------------- +local _, ns = ... + +local EllesmereUI = EllesmereUI +local PP = nil -- resolved on first use (EllesmereUI.PP) + +-- Lua APIs +local pairs, ipairs, type = pairs, ipairs, type +local CreateFrame = CreateFrame +local InCombatLockdown = InCombatLockdown +local SetBinding, GetBindingKey, SaveBindings = SetBinding, GetBindingKey, SaveBindings +local GetBindingAction = GetBindingAction + +-- Debug log (silent by default; set EllesmereUIDB.keybindDebug = true to enable) +local function DebugLog(msg) + if not (EllesmereUIDB and EllesmereUIDB.keybindDebug) then return end + print("|cff0cd29f[KB]|r " .. msg) +end + +-- State +local isActive = false +local keybindFrame = nil -- main overlay frame +local buttonOverlays = {} -- array of overlay frames +local filterPills = {} -- bar filter pill frames +local dimmedBars = {} -- [barKey] = true if dimmed +local hoveredOverlay = nil -- currently hovered button overlay +local pendingConflict = nil -- { overlay, keyCombo, existingAction } awaiting confirm + +-- Bar config: maps barKey to WoW binding command prefix +local BINDING_MAP = { + MainBar = "ACTIONBUTTON", + Bar2 = "MULTIACTIONBAR1BUTTON", + Bar3 = "MULTIACTIONBAR2BUTTON", + Bar4 = "MULTIACTIONBAR3BUTTON", + Bar5 = "MULTIACTIONBAR4BUTTON", + Bar6 = "MULTIACTIONBAR5BUTTON", + Bar7 = "MULTIACTIONBAR6BUTTON", + Bar8 = "MULTIACTIONBAR7BUTTON", + StanceBar = "SHAPESHIFTBUTTON", + PetBar = "BONUSACTIONBUTTON", +} + +-- Ordered bar keys for filter pills +local BAR_ORDER = { + "MainBar", "Bar2", "Bar3", "Bar4", "Bar5", + "Bar6", "Bar7", "Bar8", "StanceBar", "PetBar", +} + +-- Display names for filter pills +local BAR_LABELS = { + MainBar = "Main Bar", + Bar2 = "Bar 2", + Bar3 = "Bar 3", + Bar4 = "Bar 4", + Bar5 = "Bar 5", + Bar6 = "Bar 6", + Bar7 = "Bar 7", + Bar8 = "Bar 8", + StanceBar = "Stance", + PetBar = "Pet", +} + +-- Colors (resolve dynamically; fall back to teal if GetAccentColor not yet available) +local ACCENT_R, ACCENT_G, ACCENT_B = 0.047, 0.824, 0.624 +local WARN_R, WARN_G, WARN_B = 1.0, 0.706, 0.235 +local FONT_PATH = "Interface\\AddOns\\EllesmereUI\\media\\fonts\\Expressway.ttf" +local MEDIA_PATH = "Interface\\AddOns\\EllesmereUI\\media\\" + +local function RefreshAccent() + if EllesmereUI.GetAccentColor then + ACCENT_R, ACCENT_G, ACCENT_B = EllesmereUI.GetAccentColor() + end +end + +-------------------------------------------------------------------------------- +-- Keybind Profile Commands +-- All action bar binding commands we track for profile snapshots +-------------------------------------------------------------------------------- +local TRACKED_COMMANDS = {} +do + local barCounts = { + MainBar = 12, Bar2 = 12, Bar3 = 12, Bar4 = 12, + Bar5 = 12, Bar6 = 12, Bar7 = 12, Bar8 = 12, + StanceBar = 10, PetBar = 10, + } + for _, barKey in ipairs(BAR_ORDER) do + local prefix = BINDING_MAP[barKey] + local count = barCounts[barKey] or 12 + for i = 1, count do + TRACKED_COMMANDS[#TRACKED_COMMANDS + 1] = prefix .. i + end + end +end + +-------------------------------------------------------------------------------- +-- Keybind Profiles — Snapshot / Restore +-------------------------------------------------------------------------------- + +function EllesmereUI:SnapshotKeybinds(profileName) + if not profileName or profileName == "" then return end + if not EllesmereUIDB then EllesmereUIDB = {} end + if not EllesmereUIDB.keybindProfiles then EllesmereUIDB.keybindProfiles = {} end + + local binds = {} + for _, cmd in ipairs(TRACKED_COMMANDS) do + local key1, key2 = GetBindingKey(cmd) + if key1 then + binds[cmd] = key1 + if key2 then + binds[cmd .. "_2"] = key2 + end + end + end + EllesmereUIDB.keybindProfiles[profileName] = { binds = binds } +end + +function EllesmereUI:RestoreKeybinds(profileName) + if not profileName or profileName == "" then return end + if not EllesmereUIDB or not EllesmereUIDB.keybindProfiles then return end + local profile = EllesmereUIDB.keybindProfiles[profileName] + if not profile or not profile.binds then return end + + for _, cmd in ipairs(TRACKED_COMMANDS) do + local key1, key2 = GetBindingKey(cmd) + if key1 then SetBinding(key1, nil) end + if key2 then SetBinding(key2, nil) end + end + + for cmd, key in pairs(profile.binds) do + if not cmd:match("_2$") then + SetBinding(key, cmd) + local key2 = profile.binds[cmd .. "_2"] + if key2 then + SetBinding(key2, cmd) + end + end + end + + SaveBindings(2) +end + +function EllesmereUI:GetCurrentSpecKeybindProfile() + local specIdx = GetSpecialization and GetSpecialization() + if not specIdx or specIdx < 1 then return nil end + local specID = GetSpecializationInfo(specIdx) + if not specID then return nil end + + if not EllesmereUIDB then EllesmereUIDB = {} end + if not EllesmereUIDB.specKeybindProfiles then EllesmereUIDB.specKeybindProfiles = {} end + + local profileName = EllesmereUIDB.specKeybindProfiles[specID] + return profileName, specID +end + +function EllesmereUI:AssignKeybindProfileToSpec(profileName, specID) + if not EllesmereUIDB then EllesmereUIDB = {} end + if not EllesmereUIDB.specKeybindProfiles then EllesmereUIDB.specKeybindProfiles = {} end + EllesmereUIDB.specKeybindProfiles[specID] = profileName +end + +-------------------------------------------------------------------------------- +-- Forward declarations +-------------------------------------------------------------------------------- +local OpenKeybindMode, CloseKeybindMode + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- +function EllesmereUI:ToggleKeybindMode() + if isActive then + CloseKeybindMode() + else + OpenKeybindMode() + end +end + +-------------------------------------------------------------------------------- +-- Slash command +-------------------------------------------------------------------------------- +SLASH_EUIKEYBIND1 = "/euikeybind" +SlashCmdList.EUIKEYBIND = function() + if InCombatLockdown() then + print("|cffff6060[EllesmereUI]|r Cannot enter Keybind Mode during combat.") + return + end + EllesmereUI:ToggleKeybindMode() +end + +-------------------------------------------------------------------------------- +-- Overlay Frame +-------------------------------------------------------------------------------- +local function CreateOverlayFrame() + if keybindFrame then return end + PP = PP or EllesmereUI.PP + + keybindFrame = CreateFrame("Frame", "EllesmereKeybindMode", UIParent) + keybindFrame:SetFrameStrata("FULLSCREEN_DIALOG") + keybindFrame:SetAllPoints(UIParent) + keybindFrame:EnableMouse(false) + keybindFrame:SetAlpha(0) + + local overlay = keybindFrame:CreateTexture(nil, "BACKGROUND") + overlay:SetAllPoints() + overlay:SetColorTexture(0.02, 0.03, 0.04, 0.80) + keybindFrame._overlay = overlay +end + +local function DestroyOverlayFrame() + if not keybindFrame then return end + keybindFrame:Hide() + keybindFrame:SetParent(nil) + keybindFrame = nil +end + +-------------------------------------------------------------------------------- +-- Fade Animations +-------------------------------------------------------------------------------- +local function FadeIn(onComplete) + if not keybindFrame then return end + keybindFrame:Show() + local elapsed = 0 + local duration = 0.3 + keybindFrame:SetScript("OnUpdate", function(self, dt) + elapsed = elapsed + dt + if elapsed >= duration then + self:SetAlpha(1) + self:SetScript("OnUpdate", nil) + if onComplete then onComplete() end + return + end + self:SetAlpha(elapsed / duration) + end) +end + +local function FadeOut(onComplete) + if not keybindFrame then return end + local elapsed = 0 + local duration = 0.2 + keybindFrame:SetScript("OnUpdate", function(self, dt) + elapsed = elapsed + dt + if elapsed >= duration then + self:SetAlpha(0) + self:SetScript("OnUpdate", nil) + if onComplete then onComplete() end + return + end + self:SetAlpha(1 - (elapsed / duration)) + end) +end + +-------------------------------------------------------------------------------- +-- HUD Bar +-------------------------------------------------------------------------------- +local hudFrame = nil + +local function CreateHUD() + if hudFrame then hudFrame:Show(); return end + RefreshAccent() + + hudFrame = CreateFrame("Frame", nil, keybindFrame) + hudFrame:SetSize(680, 40) + hudFrame:SetPoint("TOP", UIParent, "TOP", 0, -18) + hudFrame:SetFrameLevel(keybindFrame:GetFrameLevel() + 10) + + local bg = hudFrame:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.035, 0.055, 0.075, 0.95) + + EllesmereUI.MakeBorder(hudFrame, ACCENT_R, ACCENT_G, ACCENT_B, 0.35) + + -- Accent glow along top edge + local topGlow = hudFrame:CreateTexture(nil, "BACKGROUND", nil, 1) + topGlow:SetHeight(1) + topGlow:SetPoint("TOPLEFT", hudFrame, "TOPLEFT", 1, -1) + topGlow:SetPoint("TOPRIGHT", hudFrame, "TOPRIGHT", -1, -1) + topGlow:SetColorTexture(ACCENT_R, ACCENT_G, ACCENT_B, 0.40) + + -- Keybind icon (left side) + local hudIcon = hudFrame:CreateTexture(nil, "OVERLAY") + hudIcon:SetSize(16, 16) + hudIcon:SetPoint("LEFT", hudFrame, "LEFT", 14, 0) + hudIcon:SetTexture(MEDIA_PATH .. "icons\\eui-keybind-2.png") + hudIcon:SetVertexColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.9) + + -- Title + local label = hudFrame:CreateFontString(nil, "OVERLAY") + label:SetFont(FONT_PATH, 13, "") + label:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 1) + label:SetPoint("LEFT", hudIcon, "RIGHT", 8, 0) + label:SetText("KEYBIND MODE") + + -- Separator + local sep1 = hudFrame:CreateFontString(nil, "OVERLAY") + sep1:SetFont(FONT_PATH, 11, "") + sep1:SetTextColor(1, 1, 1, 0.15) + sep1:SetPoint("LEFT", label, "RIGHT", 12, 0) + sep1:SetText("|") + + -- Instructions (hoverable) + local instr = hudFrame:CreateFontString(nil, "OVERLAY") + instr:SetFont(FONT_PATH, 10, "") + instr:SetTextColor(1, 1, 1, 0.45) + instr:SetPoint("LEFT", sep1, "RIGHT", 12, 0) + instr:SetText("Hover + press key | ESC unbind | ESC exit") + + local instrHit = CreateFrame("Frame", nil, hudFrame) + instrHit:SetPoint("TOPLEFT", instr, "TOPLEFT", -4, 4) + instrHit:SetPoint("BOTTOMRIGHT", instr, "BOTTOMRIGHT", 4, -4) + instrHit:SetScript("OnEnter", function() + instr:SetTextColor(1, 1, 1, 0.7) + EllesmereUI.ShowWidgetTooltip(instr, + "How to use Keybind Mode:\n\n" + .. "1. Hover over any action button\n" + .. "2. Press a key (or key combo) to bind it\n" + .. "3. Press ESC while hovering to clear a binding\n" + .. "4. Press ESC with nothing hovered to exit\n\n" + .. "Mouse buttons (including Button4/5) also work.\n" + .. "Conflicts are shown in orange \226\128\148 press the same key again to override.", + { anchor = "below" }) + end) + instrHit:SetScript("OnLeave", function() + instr:SetTextColor(1, 1, 1, 0.45) + EllesmereUI.HideWidgetTooltip() + end) + instrHit:SetMouseClickEnabled(false) + + -- Close button (right side) + local closeBtn = CreateFrame("Button", nil, hudFrame) + closeBtn:SetSize(24, 24) + closeBtn:SetPoint("RIGHT", hudFrame, "RIGHT", -10, 0) + closeBtn:SetFrameLevel(hudFrame:GetFrameLevel() + 2) + + local closeTex = closeBtn:CreateFontString(nil, "OVERLAY") + closeTex:SetFont(FONT_PATH, 14, "") + closeTex:SetTextColor(1, 1, 1, 0.35) + closeTex:SetAllPoints() + closeTex:SetText("X") + + closeBtn:SetScript("OnEnter", function(self) + closeTex:SetTextColor(1, 0.35, 0.35, 0.9) + EllesmereUI.ShowWidgetTooltip(self, "Close Keybind Mode\nBindings are saved automatically.", { anchor = "below" }) + end) + closeBtn:SetScript("OnLeave", function() + closeTex:SetTextColor(1, 1, 1, 0.35) + EllesmereUI.HideWidgetTooltip() + end) + closeBtn:SetScript("OnClick", function() CloseKeybindMode() end) + + -- Separator before profile + local sep2 = hudFrame:CreateFontString(nil, "OVERLAY") + sep2:SetFont(FONT_PATH, 11, "") + sep2:SetTextColor(1, 1, 1, 0.15) + sep2:SetPoint("RIGHT", closeBtn, "LEFT", -10, 0) + sep2:SetText("|") + + -- Profile indicator (hoverable) + local profFrame = CreateFrame("Frame", nil, hudFrame) + profFrame:SetSize(140, 24) + profFrame:SetPoint("RIGHT", sep2, "LEFT", -8, 0) + profFrame:SetFrameLevel(hudFrame:GetFrameLevel() + 2) + + local profIcon = profFrame:CreateTexture(nil, "OVERLAY") + profIcon:SetSize(14, 14) + profIcon:SetPoint("LEFT", profFrame, "LEFT", 0, 0) + + local profLabel = profFrame:CreateFontString(nil, "OVERLAY") + profLabel:SetFont(FONT_PATH, 11, "") + profLabel:SetPoint("LEFT", profIcon, "RIGHT", 6, 0) + profLabel:SetPoint("RIGHT", profFrame, "RIGHT", 0, 0) + profLabel:SetJustifyH("LEFT") + profLabel:SetWordWrap(false) + + -- Resolve spec info for profile display + local specIdx = GetSpecialization and GetSpecialization() + local specName, specIconPath + if specIdx and specIdx > 0 then + local _, sName, _, sIcon = GetSpecializationInfo(specIdx) + specName = sName + specIconPath = sIcon + end + + if specIconPath then + profIcon:SetTexture(specIconPath) + profIcon:SetAlpha(0.8) + else + profIcon:SetTexture(MEDIA_PATH .. "icons\\eui-keybind-2.png") + profIcon:SetVertexColor(1, 1, 1, 0.4) + end + + local profileName = EllesmereUI:GetCurrentSpecKeybindProfile() + if profileName then + profLabel:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.9) + profLabel:SetText(profileName) + else + profLabel:SetTextColor(1, 1, 1, 0.35) + profLabel:SetText(specName or "No Profile") + end + + profFrame:SetScript("OnEnter", function(self) + profLabel:SetAlpha(1) + profIcon:SetAlpha(1) + local tip + if profileName then + tip = "Keybind Profile: |cff0cd29f" .. profileName .. "|r\n\n" + .. "Bindings will be saved to this profile when you close Keybind Mode." + else + tip = "No keybind profile exists for " .. (specName or "this spec") .. " yet.\n\n" + .. "Your current bindings will be saved as a new profile when you close Keybind Mode." + end + EllesmereUI.ShowWidgetTooltip(self, tip, { anchor = "below" }) + end) + profFrame:SetScript("OnLeave", function() + profLabel:SetAlpha(0.9) + profIcon:SetAlpha(0.8) + EllesmereUI.HideWidgetTooltip() + end) + profFrame:SetMouseClickEnabled(false) +end + +local function DestroyHUD() + if hudFrame then + hudFrame:Hide() + hudFrame:SetParent(nil) + hudFrame = nil + end +end + +-------------------------------------------------------------------------------- +-- Filter Pills +-------------------------------------------------------------------------------- +local pillContainer = nil + +local function UpdatePillVisual(pill, active) + if active then + pill._bg:SetColorTexture(ACCENT_R, ACCENT_G, ACCENT_B, 0.15) + pill._label:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 1) + pill._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.5) + -- Show glow + if pill._glow then pill._glow:Show() end + -- Start subtle pulse + if not pill._pulsing then + pill._pulsing = true + pill._pulseElapsed = 0 + pill:SetScript("OnUpdate", function(self, dt) + self._pulseElapsed = (self._pulseElapsed or 0) + dt + -- Gentle sine wave: border alpha oscillates 0.35 to 0.65 + local t = math.sin(self._pulseElapsed * 2.2) * 0.5 + 0.5 + local alpha = 0.35 + t * 0.30 + self._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, alpha) + if self._glow then + self._glow:SetAlpha(0.15 + t * 0.15) + end + end) + end + else + pill._bg:SetColorTexture(1, 1, 1, 0.03) + pill._label:SetTextColor(1, 1, 1, 0.3) + pill._border:SetColor(1, 1, 1, 0.12) + -- Hide glow, stop pulse + if pill._glow then pill._glow:Hide() end + pill._pulsing = false + pill:SetScript("OnUpdate", nil) + end +end + +local function UpdateBarDimming(barKey) + local isDimmed = dimmedBars[barKey] + for _, ov in ipairs(buttonOverlays) do + if ov._barKey == barKey then + if isDimmed then + ov:SetAlpha(0.25) + ov:EnableMouse(false) + else + ov:SetAlpha(1) + ov:EnableMouse(true) + end + end + end +end + +local function CreateFilterPills() + if pillContainer then pillContainer:Show(); return end + + pillContainer = CreateFrame("Frame", nil, keybindFrame) + pillContainer:SetSize(1, 28) + pillContainer:SetPoint("TOP", hudFrame, "BOTTOM", 0, -10) + pillContainer:SetFrameLevel(keybindFrame:GetFrameLevel() + 10) + + local EAB = EllesmereUI.Lite.GetAddon("EllesmereUIActionBars", true) + + local pills = {} + local totalWidth = 0 + + for _, barKey in ipairs(BAR_ORDER) do + local barFrame + if EAB and EAB._barFrames then + barFrame = EAB._barFrames[barKey] + end + if barFrame and barFrame:IsShown() then + local pill = CreateFrame("Button", nil, pillContainer) + pill:SetSize(70, 24) + pill:SetFrameLevel(pillContainer:GetFrameLevel() + 1) + pill._barKey = barKey + + local bg = pill:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + pill._bg = bg + + local label = pill:CreateFontString(nil, "OVERLAY") + label:SetFont(FONT_PATH, 11, "") + label:SetPoint("CENTER") + label:SetText(BAR_LABELS[barKey] or barKey) + pill._label = label + + pill._border = EllesmereUI.MakeBorder(pill, ACCENT_R, ACCENT_G, ACCENT_B, 0.5) + + -- Soft glow behind pill (slightly larger, blurred look) + local glow = pill:CreateTexture(nil, "BACKGROUND", nil, -1) + glow:SetPoint("TOPLEFT", pill, "TOPLEFT", -4, 4) + glow:SetPoint("BOTTOMRIGHT", pill, "BOTTOMRIGHT", 4, -4) + glow:SetColorTexture(ACCENT_R, ACCENT_G, ACCENT_B, 0.20) + pill._glow = glow + + dimmedBars[barKey] = false + + pill:SetScript("OnClick", function(self) + dimmedBars[barKey] = not dimmedBars[barKey] + UpdatePillVisual(self, not dimmedBars[barKey]) + UpdateBarDimming(barKey) + end) + + pill:SetScript("OnEnter", function(self) + local barLabel = BAR_LABELS[barKey] or barKey + local state = dimmedBars[barKey] and "dimmed (click to show)" or "visible (click to dim)" + EllesmereUI.ShowWidgetTooltip(self, + barLabel .. " \226\128\148 " .. state .. "\n\n" + .. "Toggle bars to focus on the ones you want to rebind.", + { anchor = "below" }) + end) + pill:SetScript("OnLeave", function() + EllesmereUI.HideWidgetTooltip() + end) + + pills[#pills + 1] = pill + totalWidth = totalWidth + 70 + 6 + end + end + + totalWidth = totalWidth - 6 + local startX = -totalWidth / 2 + for i, pill in ipairs(pills) do + pill:SetPoint("LEFT", pillContainer, "CENTER", startX + (i - 1) * 76, 0) + end + pillContainer:SetSize(totalWidth, 28) + + filterPills = pills +end + +local function DestroyFilterPills() + if pillContainer then + pillContainer:Hide() + pillContainer:SetParent(nil) + pillContainer = nil + end + wipe(filterPills) + wipe(dimmedBars) +end + +-------------------------------------------------------------------------------- +-- Button Overlays +-------------------------------------------------------------------------------- + +local function GetBindingCommand(barKey, buttonIndex) + local prefix = BINDING_MAP[barKey] + if not prefix then return nil end + return prefix .. buttonIndex +end + +local function GetKeybindText(barKey, buttonIndex) + local cmd = GetBindingCommand(barKey, buttonIndex) + if not cmd then return "" end + local key1 = GetBindingKey(cmd) + if key1 then + key1 = key1:gsub("SHIFT%-", "S-") + key1 = key1:gsub("CTRL%-", "C-") + key1 = key1:gsub("ALT%-", "A-") + return key1 + end + return "" +end + +-------------------------------------------------------------------------------- +-- Keybind Application +-------------------------------------------------------------------------------- + +local function RefreshOverlayText(ov) + local txt = GetKeybindText(ov._barKey, ov._buttonIndex) + ov._keyText:SetText(txt) + if ov._dash then + ov._dash:SetShown(txt == "") + end +end + +local function ClearPendingConflict(ov) + if pendingConflict and pendingConflict.overlay == ov then + pendingConflict = nil + end + -- Reset tooltip to default + if ov and ov._ttText then + ov._ttText:SetText("Press a key...") + ov._ttText:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 1) + ov._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.8) + end +end + +local function FlashFeedback(ov, text, r, g, b) + -- Show feedback text + ov._ttText:SetText(text) + ov._ttText:SetTextColor(r, g, b, 1) + -- Flash border bright + ov._border:SetColor(r, g, b, 1) + ov._bg:SetColorTexture(r, g, b, 0.20) + -- Fade back after delay + C_Timer.After(0.8, function() + if not isActive then return end + if ov and ov._ttText then + if hoveredOverlay == ov then + ov._ttText:SetText("Press a key...") + ov._ttText:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 1) + ov._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.8) + ov._bg:SetColorTexture(ACCENT_R, ACCENT_G, ACCENT_B, 0.12) + else + ov._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.3) + ov._bg:SetColorTexture(0.05, 0.07, 0.09, 0.55) + end + end + end) +end + +local function DoBindKey(ov, keyCombo) + local cmd = GetBindingCommand(ov._barKey, ov._buttonIndex) + if not cmd then return end + SetBinding(keyCombo, nil) + SetBinding(keyCombo, cmd) + -- Refresh all overlays + for _, other in ipairs(buttonOverlays) do + RefreshOverlayText(other) + end + -- Shorten the display of the key for feedback + local shortKey = keyCombo:gsub("SHIFT%-", "S-"):gsub("CTRL%-", "C-"):gsub("ALT%-", "A-") + FlashFeedback(ov, shortKey .. " Bound!", 0.2, 0.9, 0.4) +end + +local function ApplyKeybind(ov, keyCombo) + local cmd = GetBindingCommand(ov._barKey, ov._buttonIndex) + if not cmd then return end + + -- If we have a pending conflict for THIS overlay with THIS key, confirm it + if pendingConflict and pendingConflict.overlay == ov and pendingConflict.keyCombo == keyCombo then + pendingConflict = nil + DoBindKey(ov, keyCombo) + return + end + + -- Check for conflict + local existingAction = GetBindingAction(keyCombo) + if existingAction and existingAction ~= "" and existingAction ~= cmd then + -- Show warning inline on the tooltip — press same key again to confirm + pendingConflict = { overlay = ov, keyCombo = keyCombo, existingAction = existingAction } + ov._ttText:SetText(keyCombo .. " = " .. existingAction .. "\nPress again to override") + ov._ttText:SetTextColor(WARN_R, WARN_G, WARN_B, 1) + ov._border:SetColor(WARN_R, WARN_G, WARN_B, 0.8) + -- Auto-clear after 3 seconds if they don't confirm + C_Timer.After(3, function() + if not isActive then return end + if pendingConflict and pendingConflict.overlay == ov and pendingConflict.keyCombo == keyCombo then + ClearPendingConflict(ov) + end + end) + return + end + + -- No conflict — bind directly + DoBindKey(ov, keyCombo) +end + +local function ClearKeybind(ov) + local cmd = GetBindingCommand(ov._barKey, ov._buttonIndex) + if not cmd then return end + -- Clear ALL bindings for this command (a button can have multiple keys) + local key1, key2 = GetBindingKey(cmd) + local cleared = false + if key1 then + SetBinding(key1, nil) + cleared = true + end + if key2 then + SetBinding(key2, nil) + cleared = true + end + if cleared then + RefreshOverlayText(ov) + FlashFeedback(ov, "Cleared!", 1, 0.35, 0.35) + end +end + +-------------------------------------------------------------------------------- +-- Button Overlay Creation +-------------------------------------------------------------------------------- + +local function CreateButtonOverlay(btn, barKey, buttonIndex) + local ov = CreateFrame("Frame", nil, keybindFrame) + ov:SetFrameLevel(keybindFrame:GetFrameLevel() + 20) + ov:SetSize(btn:GetWidth(), btn:GetHeight()) + ov:SetPoint("CENTER", btn, "CENTER", 0, 0) + ov:EnableMouse(true) + ov._barKey = barKey + ov._buttonIndex = buttonIndex + ov._actionBtn = btn + + local bg = ov:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.05, 0.07, 0.09, 0.55) + ov._bg = bg + + ov._border = EllesmereUI.MakeBorder(ov, ACCENT_R, ACCENT_G, ACCENT_B, 0.3) + + local keyText = ov:CreateFontString(nil, "OVERLAY") + keyText:SetFont(FONT_PATH, 10, "OUTLINE") + keyText:SetPoint("TOPRIGHT", ov, "TOPRIGHT", -2, -2) + keyText:SetTextColor(1, 1, 1, 0.9) + ov._keyText = keyText + + -- Dash placeholder (always created, shown when unbound) + local dash = ov:CreateFontString(nil, "OVERLAY") + dash:SetFont(FONT_PATH, 10, "") + dash:SetPoint("CENTER") + dash:SetTextColor(1, 1, 1, 0.2) + dash:SetText("\226\128\148") + ov._dash = dash + + local currentBind = GetKeybindText(barKey, buttonIndex) + if currentBind ~= "" then + keyText:SetText(currentBind) + dash:Hide() + else + keyText:SetText("") + dash:Show() + end + + local tooltip = CreateFrame("Frame", nil, ov) + tooltip:SetSize(200, 40) + tooltip:SetPoint("TOP", ov, "BOTTOM", 0, -4) + tooltip:SetFrameLevel(ov:GetFrameLevel() + 5) + tooltip:Hide() + + local ttBg = tooltip:CreateTexture(nil, "BACKGROUND") + ttBg:SetAllPoints() + ttBg:SetColorTexture(0.031, 0.047, 0.063, 0.95) + EllesmereUI.MakeBorder(tooltip, ACCENT_R, ACCENT_G, ACCENT_B, 0.5) + + local ttText = tooltip:CreateFontString(nil, "OVERLAY") + ttText:SetFont(FONT_PATH, 10, "") + ttText:SetPoint("CENTER") + ttText:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 1) + ttText:SetText("Press a key...") + ov._tooltip = tooltip + ov._ttText = ttText + + -- Register all mouse buttons for binding + ov:RegisterForDrag("LeftButton", "RightButton", "MiddleButton", "Button4", "Button5") + ov:SetScript("OnMouseDown", function(self, button) + if not hoveredOverlay or hoveredOverlay ~= self then return end + -- Map WoW button names to binding format + local btnMap = { + LeftButton = "BUTTON1", + RightButton = "BUTTON2", + MiddleButton = "BUTTON3", + Button4 = "BUTTON4", + Button5 = "BUTTON5", + } + local bindBtn = btnMap[button] + if not bindBtn then return end + + -- Build combo with modifiers + local combo = "" + if IsShiftKeyDown() then combo = combo .. "SHIFT-" end + if IsControlKeyDown() then combo = combo .. "CTRL-" end + if IsAltKeyDown() then combo = combo .. "ALT-" end + combo = combo .. bindBtn + + DebugLog("MOUSE=" .. combo .. " bar=" .. self._barKey .. " idx=" .. self._buttonIndex) + ApplyKeybind(self, combo) + end) + + ov:SetScript("OnEnter", function(self) + if dimmedBars[self._barKey] then return end + hoveredOverlay = self + self._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.8) + self._bg:SetColorTexture(ACCENT_R, ACCENT_G, ACCENT_B, 0.12) + self._tooltip:Show() + DebugLog("HOVER bar=" .. self._barKey .. " idx=" .. self._buttonIndex .. " cmd=" .. (GetBindingCommand(self._barKey, self._buttonIndex) or "nil") .. " btnName=" .. (self._actionBtn:GetName() or "anon") .. " btnID=" .. (self._actionBtn:GetID() or "?")) + end) + + ov:SetScript("OnLeave", function(self) + if hoveredOverlay == self then + hoveredOverlay = nil + end + ClearPendingConflict(self) + self._border:SetColor(ACCENT_R, ACCENT_G, ACCENT_B, 0.3) + self._bg:SetColorTexture(0.05, 0.07, 0.09, 0.55) + self._tooltip:Hide() + self._ttText:SetText("Press a key...") + self._ttText:SetTextColor(ACCENT_R, ACCENT_G, ACCENT_B, 1) + end) + + return ov +end + +local function CreateAllButtonOverlays() + local EAB = EllesmereUI.Lite.GetAddon("EllesmereUIActionBars", true) + if not EAB or not EAB._barButtons then return end + + for _, barKey in ipairs(BAR_ORDER) do + local buttons = EAB._barButtons[barKey] + local barFrame = EAB._barFrames and EAB._barFrames[barKey] + if buttons and barFrame and barFrame:IsShown() then + for i, btn in ipairs(buttons) do + if btn and btn:IsShown() then + local ov = CreateButtonOverlay(btn, barKey, i) + buttonOverlays[#buttonOverlays + 1] = ov + DebugLog("OVERLAY bar=" .. barKey .. " idx=" .. i .. " btn=" .. (btn:GetName() or "anon") .. " btnID=" .. (btn:GetID() or "?") .. " cmd=" .. (GetBindingCommand(barKey, i) or "nil")) + end + end + end + end +end + +local function DestroyAllButtonOverlays() + for _, ov in ipairs(buttonOverlays) do + ov:Hide() + ov:SetParent(nil) + end + wipe(buttonOverlays) + hoveredOverlay = nil +end + +-------------------------------------------------------------------------------- +-- Combat auto-exit +-------------------------------------------------------------------------------- +local combatFrame = CreateFrame("Frame") +combatFrame:RegisterEvent("PLAYER_REGEN_DISABLED") +combatFrame:SetScript("OnEvent", function() + if isActive then + CloseKeybindMode() + print("|cffff6060[EllesmereUI]|r Keybind Mode closed \226\128\148 entering combat.") + end +end) + +-------------------------------------------------------------------------------- +-- Login: restore keybind profile for current spec +-------------------------------------------------------------------------------- +local loginFrame = CreateFrame("Frame") +loginFrame:RegisterEvent("PLAYER_LOGIN") +loginFrame:RegisterEvent("PLAYER_REGEN_ENABLED") +loginFrame:SetScript("OnEvent", function(self, event) + if event == "PLAYER_REGEN_ENABLED" then + -- Deferred restore: combat ended, retry if pending + if not self._pendingRestore then return end + self._pendingRestore = false + self:UnregisterEvent("PLAYER_REGEN_ENABLED") + end + + C_Timer.After(1, function() + if InCombatLockdown() then + -- Defer until combat ends — SetBinding is protected + self._pendingRestore = true + return + end + + local profileName, specID = EllesmereUI:GetCurrentSpecKeybindProfile() + if profileName and EllesmereUIDB.keybindProfiles + and EllesmereUIDB.keybindProfiles[profileName] then + EllesmereUI:RestoreKeybinds(profileName) + elseif specID then + local specIdx = GetSpecialization and GetSpecialization() + if specIdx and specIdx > 0 then + local _, specName = GetSpecializationInfo(specIdx) + if specName then + EllesmereUI:SnapshotKeybinds(specName) + EllesmereUI:AssignKeybindProfileToSpec(specName, specID) + end + end + end + end) +end) + +-------------------------------------------------------------------------------- +-- Open / Close +-------------------------------------------------------------------------------- +OpenKeybindMode = function() + if isActive then return end + if InCombatLockdown() then + print("|cffff6060[EllesmereUI]|r Cannot enter Keybind Mode during combat.") + return + end + + RefreshAccent() + isActive = true + + if EllesmereUI.IsShown and EllesmereUI:IsShown() then + EllesmereUI:Toggle() + end + + CreateOverlayFrame() + CreateHUD() + CreateFilterPills() + CreateAllButtonOverlays() + + -- Register with WoW's ESC-to-close system + tinsert(UISpecialFrames, "EllesmereKeybindMode") + + -- Enable mouse to block clicks from reaching the game world + keybindFrame:EnableMouse(true) + + -- Single keyboard handler on the main frame — routes to hoveredOverlay + keybindFrame:EnableKeyboard(true) + keybindFrame:SetScript("OnKeyDown", function(self, key) + -- Ignore lone modifier keys + if key == "LSHIFT" or key == "RSHIFT" + or key == "LCTRL" or key == "RCTRL" + or key == "LALT" or key == "RALT" then + self:SetPropagateKeyboardInput(true) + return + end + + -- ESC: if hovering, unbind; if not, exit + if key == "ESCAPE" then + self:SetPropagateKeyboardInput(false) + if hoveredOverlay then + DebugLog("ESC unbind bar=" .. hoveredOverlay._barKey .. " idx=" .. hoveredOverlay._buttonIndex) + ClearKeybind(hoveredOverlay) + else + CloseKeybindMode() + end + return + end + + -- No overlay hovered — let key pass through to game + if not hoveredOverlay then + self:SetPropagateKeyboardInput(true) + return + end + + -- Consume the key + self:SetPropagateKeyboardInput(false) + + -- Build key combo + local combo = "" + if IsShiftKeyDown() then combo = combo .. "SHIFT-" end + if IsControlKeyDown() then combo = combo .. "CTRL-" end + if IsAltKeyDown() then combo = combo .. "ALT-" end + combo = combo .. key + + DebugLog("KEY=" .. combo .. " bar=" .. hoveredOverlay._barKey .. " idx=" .. hoveredOverlay._buttonIndex .. " cmd=" .. (GetBindingCommand(hoveredOverlay._barKey, hoveredOverlay._buttonIndex) or "nil")) + ApplyKeybind(hoveredOverlay, combo) + end) + + FadeIn() +end + +CloseKeybindMode = function() + if not isActive then return end + isActive = false + pendingConflict = nil + + -- Persist all binding changes made during this session (single disk write) + SaveBindings(2) + + -- Auto-save keybinds to current spec's profile + local profileName, specID = EllesmereUI:GetCurrentSpecKeybindProfile() + if profileName then + EllesmereUI:SnapshotKeybinds(profileName) + elseif specID then + local _, specName = GetSpecializationInfo(GetSpecialization()) + if specName then + EllesmereUI:SnapshotKeybinds(specName) + EllesmereUI:AssignKeybindProfileToSpec(specName, specID) + end + end + + -- Remove from UISpecialFrames + for i = #UISpecialFrames, 1, -1 do + if UISpecialFrames[i] == "EllesmereKeybindMode" then + table.remove(UISpecialFrames, i) + end + end + + if keybindFrame and keybindFrame:IsShown() then + FadeOut(function() + DestroyAllButtonOverlays() + DestroyFilterPills() + DestroyHUD() + DestroyOverlayFrame() + end) + else + -- Frame already hidden by ESC/UISpecialFrames — just clean up + DestroyAllButtonOverlays() + DestroyFilterPills() + DestroyHUD() + DestroyOverlayFrame() + end +end diff --git a/EllesmereUI.lua b/EllesmereUI.lua index 0551a6f..537815c 100644 --- a/EllesmereUI.lua +++ b/EllesmereUI.lua @@ -6485,11 +6485,16 @@ end SLASH_EUIOPTIONS1 = "/eui" SLASH_EUIOPTIONS2 = "/ellesmere" SLASH_EUIOPTIONS3 = "/ellesmereui" -SlashCmdList.EUIOPTIONS = function() +SlashCmdList.EUIOPTIONS = function(msg) if InCombatLockdown() then print("|cffff6060[EllesmereUI]|r Cannot open options during combat.") return end + local cmd = msg and msg:lower():trim() or "" + if cmd == "keybind" or cmd == "kb" then + EllesmereUI:ToggleKeybindMode() + return + end EllesmereUI:Toggle() end diff --git a/EllesmereUI.toc b/EllesmereUI.toc index ee096aa..80e9649 100644 --- a/EllesmereUI.toc +++ b/EllesmereUI.toc @@ -35,3 +35,4 @@ EUI__General_Options.lua EUI_UnlockMode.lua EUI_PartyMode_Options.lua EllesmereUI_Glows.lua +EUI_KeybindMode.lua diff --git a/EllesmereUIActionBars/EUI_ActionBars_Options.lua b/EllesmereUIActionBars/EUI_ActionBars_Options.lua index d062138..a9a82af 100644 --- a/EllesmereUIActionBars/EUI_ActionBars_Options.lua +++ b/EllesmereUIActionBars/EUI_ActionBars_Options.lua @@ -1365,6 +1365,280 @@ initFrame:SetScript("OnEvent", function(self) return not SB().bgEnabled end + ----------------------------------------------------------------------- + -- KEYBINDS section + ----------------------------------------------------------------------- + _, h = W:SectionHeader(parent, "KEYBINDS", y); y = y - h + + ----------------------------------------------------------------------- + -- Keybind Mode — wide accent button with icon + ----------------------------------------------------------------------- + do + local CONTENT_PAD = 45 + local FONT = (EllesmereUI and EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("actionBars")) + or "Interface\\AddOns\\EllesmereUI\\media\\fonts\\Expressway.ttf" + local MEDIA = "Interface\\AddOns\\EllesmereUI\\media\\" + local aR, aG, aB = EllesmereUI.GetAccentColor() + + local BTN_W, BTN_H = 280, 36 + local ROW_H = BTN_H + 20 + + local row = CreateFrame("Frame", nil, parent) + row:SetSize(parent:GetWidth() - CONTENT_PAD * 2, ROW_H) + row:SetPoint("TOPLEFT", parent, "TOPLEFT", CONTENT_PAD, y) + + local kbBtn = CreateFrame("Button", nil, row) + kbBtn:SetSize(BTN_W, BTN_H) + kbBtn:SetPoint("LEFT", row, "LEFT", 20, 0) + kbBtn:SetFrameLevel(row:GetFrameLevel() + 2) + + local btnBg = kbBtn:CreateTexture(nil, "BACKGROUND") + btnBg:SetAllPoints() + btnBg:SetColorTexture(aR, aG, aB, 0.08) + + local brd = EllesmereUI.MakeBorder(kbBtn, aR, aG, aB, 0.30) + + local icon = kbBtn:CreateTexture(nil, "OVERLAY") + icon:SetSize(18, 18) + icon:SetPoint("LEFT", kbBtn, "LEFT", 14, 0) + icon:SetTexture(MEDIA .. "icons\\eui-keybind-2.png") + icon:SetVertexColor(aR, aG, aB, 0.9) + + local label = EllesmereUI.MakeFont(kbBtn, 13, nil, 1, 1, 1) + label:SetAlpha(0.85) + label:SetPoint("LEFT", icon, "RIGHT", 10, 0) + label:SetText("Enter Keybind Mode") + + local hint = EllesmereUI.MakeFont(kbBtn, 10, nil, 1, 1, 1) + hint:SetAlpha(0.35) + hint:SetPoint("RIGHT", kbBtn, "RIGHT", -14, 0) + hint:SetText("/eui kb") + + kbBtn:SetScript("OnEnter", function(self) + btnBg:SetColorTexture(aR, aG, aB, 0.15) + brd:SetColor(aR, aG, aB, 0.55) + label:SetAlpha(1) + icon:SetVertexColor(aR, aG, aB, 1) + EllesmereUI.ShowWidgetTooltip(self, + "Opens the fast keybind overlay. Hover over any action button and " + .. "press a key to bind it instantly.\n\n" + .. "Bindings are saved per-spec automatically when you close the overlay.\n\n" + .. "Shortcut: /eui kb", { anchor = "below" }) + end) + kbBtn:SetScript("OnLeave", function(self) + btnBg:SetColorTexture(aR, aG, aB, 0.08) + brd:SetColor(aR, aG, aB, 0.30) + label:SetAlpha(0.85) + icon:SetVertexColor(aR, aG, aB, 0.9) + EllesmereUI.HideWidgetTooltip() + end) + kbBtn:SetScript("OnClick", function() + EllesmereUI:ToggleKeybindMode() + end) + + y = y - ROW_H + end + + ----------------------------------------------------------------------- + -- Keybind Profiles — active profile + per-spec assignments + -- All dynamic elements refresh via RegisterWidgetRefresh so + -- spec switches update the display without a full page rebuild. + ----------------------------------------------------------------------- + do + local CONTENT_PAD = 45 + local SIDE_PAD = 20 + local FONT = (EllesmereUI and EllesmereUI.GetFontPath and EllesmereUI.GetFontPath("actionBars")) + or "Interface\\AddOns\\EllesmereUI\\media\\fonts\\Expressway.ttf" + local aR, aG, aB = EllesmereUI.GetAccentColor() + + -- Active profile row + local activeRow = CreateFrame("Frame", nil, parent) + local rowW = parent:GetWidth() - CONTENT_PAD * 2 + activeRow:SetSize(rowW, 40) + activeRow:SetPoint("TOPLEFT", parent, "TOPLEFT", CONTENT_PAD, y) + + local arBg = activeRow:CreateTexture(nil, "BACKGROUND") + arBg:SetAllPoints() + arBg:SetColorTexture(0, 0, 0, 0.10) + + local arLabel = EllesmereUI.MakeFont(activeRow, 13, nil, 1, 1, 1) + arLabel:SetAlpha(0.53) + arLabel:SetPoint("LEFT", activeRow, "LEFT", SIDE_PAD, 0) + arLabel:SetText("Active Keybind Profile") + + local arValue = EllesmereUI.MakeFont(activeRow, 13, nil, 1, 1, 1) + arValue:SetPoint("RIGHT", activeRow, "RIGHT", -SIDE_PAD, 0) + + activeRow:SetScript("OnEnter", function(self) + arBg:SetColorTexture(0, 0, 0, 0.18) + EllesmereUI.ShowWidgetTooltip(self, + "The keybind profile currently loaded for your active specialization.\n\n" + .. "Profiles are created and saved automatically the first time you " + .. "enter Keybind Mode on a spec. Switching specs restores each spec's bindings.", + { anchor = "below" }) + end) + activeRow:SetScript("OnLeave", function() + arBg:SetColorTexture(0, 0, 0, 0.10) + EllesmereUI.HideWidgetTooltip() + end) + + y = y - 40 + + -- Per-spec rows — build once, update via refresh callback + local numSpecs = GetNumSpecializations and GetNumSpecializations() or 0 + if not EllesmereUIDB then EllesmereUIDB = {} end + if not EllesmereUIDB.specKeybindProfiles then EllesmereUIDB.specKeybindProfiles = {} end + + -- Store references for refresh + local specRows = {} -- { specID, specName, sIcon, sLabel, sVal, activeMark, sRow } + + for i = 1, numSpecs do + local specID, specName, _, specIcon = GetSpecializationInfo(i) + if specID and specName then + local sRow = CreateFrame("Frame", nil, parent) + sRow:SetSize(rowW, 34) + sRow:SetPoint("TOPLEFT", parent, "TOPLEFT", CONTENT_PAD, y) + + local sBg = sRow:CreateTexture(nil, "BACKGROUND") + sBg:SetAllPoints() + local bgAlphaBase = (i % 2 == 0) and 0.10 or 0.05 + sBg:SetColorTexture(0, 0, 0, bgAlphaBase) + + -- Active spec indicator (always created, shown/hidden by refresh) + local activeMark = sRow:CreateTexture(nil, "OVERLAY") + activeMark:SetSize(2, 20) + activeMark:SetPoint("LEFT", sRow, "LEFT", 4, 0) + activeMark:SetColorTexture(aR, aG, aB, 0.7) + activeMark:Hide() + + local sIcon = sRow:CreateTexture(nil, "OVERLAY") + sIcon:SetSize(16, 16) + sIcon:SetPoint("LEFT", sRow, "LEFT", SIDE_PAD, 0) + sIcon:SetTexture(specIcon) + + local sLabel = EllesmereUI.MakeFont(sRow, 12, nil, 1, 1, 1) + sLabel:SetPoint("LEFT", sIcon, "RIGHT", 8, 0) + sLabel:SetText(specName) + + local sVal = EllesmereUI.MakeFont(sRow, 12, nil, 1, 1, 1) + sVal:SetPoint("RIGHT", sRow, "RIGHT", -SIDE_PAD, 0) + + -- Hover (tooltip is rebuilt dynamically via current state) + sRow:SetScript("OnEnter", function(self) + sBg:SetColorTexture(0, 0, 0, bgAlphaBase + 0.08) + sLabel:SetTextColor(1, 1, 1, 1) + sIcon:SetDesaturated(false); sIcon:SetAlpha(1) + + local curIdx = GetSpecialization and GetSpecialization() or 0 + local curSID = curIdx > 0 and GetSpecializationInfo(curIdx) or nil + local isNowActive = (specID == curSID) + local ap = EllesmereUIDB.specKeybindProfiles and EllesmereUIDB.specKeybindProfiles[specID] + local tip + if ap then + tip = specName .. " uses the \"" .. ap .. "\" keybind profile.\n\n" + .. "This profile was auto-saved the last time you used Keybind Mode on this spec." + else + tip = specName .. " has no keybind profile yet.\n\n" + .. "Enter Keybind Mode while this spec is active to create one automatically." + end + if isNowActive then + tip = tip .. "\n\n|cff0cd29fThis is your current spec.|r" + end + EllesmereUI.ShowWidgetTooltip(self, tip, { anchor = "below" }) + end) + sRow:SetScript("OnLeave", function() + sBg:SetColorTexture(0, 0, 0, bgAlphaBase) + -- Restore correct state via the same logic as refresh + local curIdx = GetSpecialization and GetSpecialization() or 0 + local curSID = curIdx > 0 and GetSpecializationInfo(curIdx) or nil + local isNowActive = (specID == curSID) + if isNowActive then + sLabel:SetTextColor(1, 1, 1, 0.85) + sIcon:SetDesaturated(false); sIcon:SetAlpha(1) + else + sLabel:SetTextColor(1, 1, 1, 0.45) + sIcon:SetDesaturated(true); sIcon:SetAlpha(0.5) + end + EllesmereUI.HideWidgetTooltip() + end) + + specRows[#specRows + 1] = { + specID = specID, specName = specName, + sIcon = sIcon, sLabel = sLabel, sVal = sVal, + activeMark = activeMark, sRow = sRow, + } + + y = y - 34 + end + end + + -- Refresh callback: updates all keybind profile display elements + EllesmereUI.RegisterWidgetRefresh(function() + local curIdx = GetSpecialization and GetSpecialization() or 0 + local curSID = curIdx > 0 and GetSpecializationInfo(curIdx) or nil + + -- Update active profile header + local pName = EllesmereUI:GetCurrentSpecKeybindProfile() + if pName then + arValue:SetTextColor(aR, aG, aB, 1) + arValue:SetText(pName) + else + arValue:SetTextColor(1, 1, 1, 0.3) + arValue:SetText("None") + end + + -- Update per-spec rows + for _, sr in ipairs(specRows) do + local isNowActive = (sr.specID == curSID) + local ap = EllesmereUIDB.specKeybindProfiles + and EllesmereUIDB.specKeybindProfiles[sr.specID] + + -- Active indicator + if isNowActive then + sr.activeMark:Show() + else + sr.activeMark:Hide() + end + + -- Icon + sr.sIcon:SetDesaturated(not isNowActive) + sr.sIcon:SetAlpha(isNowActive and 1 or 0.5) + + -- Label + sr.sLabel:SetTextColor(1, 1, 1, isNowActive and 0.85 or 0.45) + + -- Profile value + if ap then + sr.sVal:SetTextColor(aR, aG, aB, isNowActive and 1 or 0.5) + sr.sVal:SetText(ap) + else + sr.sVal:SetTextColor(1, 1, 1, 0.2) + sr.sVal:SetText("not set") + end + end + end) + + -- Listen for spec changes to trigger a widget refresh + local specWatcher = CreateFrame("Frame") + specWatcher:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED") + specWatcher:SetScript("OnEvent", function(_, _, unit) + if unit ~= "player" then return end + -- Defer slightly so WoW's spec data is fully updated + C_Timer.After(0.2, function() + if EllesmereUI.RefreshPage then + EllesmereUI:RefreshPage() + end + end) + end) + + -- Run refresh once now to set initial state + EllesmereUI:RefreshPage() + + y = y - 8 + end + + _, h = W:Spacer(parent, y, 8); y = y - h + ----------------------------------------------------------------------- -- VISIBILITY ----------------------------------------------------------------------- diff --git a/EllesmereUIActionBars/EllesmereUIActionBars.lua b/EllesmereUIActionBars/EllesmereUIActionBars.lua index ad63846..9206942 100644 --- a/EllesmereUIActionBars/EllesmereUIActionBars.lua +++ b/EllesmereUIActionBars/EllesmereUIActionBars.lua @@ -7398,6 +7398,10 @@ function EAB:FinishSetup() -- Register with unlock mode (deferred to ensure EllesmereUI is loaded) C_Timer_After(0.5, RegisterWithUnlockMode) + + -- Expose bar tables so keybind mode (and other modules) can access them + EAB._barFrames = barFrames + EAB._barButtons = barButtons end ------------------------------------------------------------------------------- diff --git a/EllesmereUI_Profiles.lua b/EllesmereUI_Profiles.lua index 7330329..db68d7b 100644 --- a/EllesmereUI_Profiles.lua +++ b/EllesmereUI_Profiles.lua @@ -1377,6 +1377,48 @@ function EllesmereUI.SwitchProfile(name) RepointAllDBs(name) end +------------------------------------------------------------------------------- +-- SwitchKeybindProfile +-- +-- Snapshots the outgoing spec's keybinds and restores the incoming spec's +-- keybind profile. If no profile exists for the incoming spec, one is +-- created automatically from the current keybinds. +------------------------------------------------------------------------------- +local function SwitchKeybindProfile(outgoingSpecID, incomingSpecID) + if not EllesmereUIDB then return end + if not EllesmereUIDB.specKeybindProfiles then EllesmereUIDB.specKeybindProfiles = {} end + if not EllesmereUIDB.keybindProfiles then EllesmereUIDB.keybindProfiles = {} end + + -- Snapshot outgoing spec's keybinds + if outgoingSpecID then + local outName = EllesmereUIDB.specKeybindProfiles[outgoingSpecID] + if outName then + EllesmereUI:SnapshotKeybinds(outName) + end + end + + -- Resolve incoming profile + local inName = EllesmereUIDB.specKeybindProfiles[incomingSpecID] + if not inName then + -- First time on this spec: create profile from current keybinds + local specIdx = GetSpecialization and GetSpecialization() + local specName + if specIdx and specIdx > 0 then + _, specName = GetSpecializationInfo(specIdx) + end + inName = specName or ("Spec " .. incomingSpecID) + EllesmereUI:SnapshotKeybinds(inName) + EllesmereUIDB.specKeybindProfiles[incomingSpecID] = inName + end + + -- Restore incoming keybinds + if EllesmereUIDB.keybindProfiles[inName] then + EllesmereUI:RestoreKeybinds(inName) + end +end + +EllesmereUI._SwitchKeybindProfile = SwitchKeybindProfile + function EllesmereUI.GetActiveProfileName() local db = GetProfilesDB() return db.activeProfile or "Default" @@ -1457,6 +1499,11 @@ do end end end + -- Always switch keybind profile on deferred spec change + local _, deferredSpecID = ResolveSpecProfile() + if deferredSpecID then + SwitchKeybindProfile(nil, deferredSpecID) + end end return end @@ -1549,6 +1596,7 @@ do return -- same char, same spec, nothing to do end end + local oldSpecID = lastKnownSpecID -- capture before overwrite for keybind switching lastKnownSpecID = specID lastKnownCharKey = charKey @@ -1659,6 +1707,18 @@ do end end end + + -- Switch keybind profile on spec change, independent of UI profile. + -- Delay slightly so Blizzard's action bar texture rebuild finishes first. + if specID and not InCombatLockdown() then + local capturedOld = oldSpecID + local capturedNew = specID + C_Timer.After(0.5, function() + if not InCombatLockdown() then + SwitchKeybindProfile(capturedOld, capturedNew) + end + end) + end end) end diff --git a/docs/superpowers/plans/2026-03-31-prey-hunt-tracker.md b/docs/superpowers/plans/2026-03-31-prey-hunt-tracker.md new file mode 100644 index 0000000..1ada0a1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-prey-hunt-tracker.md @@ -0,0 +1,796 @@ +# EllesmereUIPreyHunt Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an EllesmereUI child addon that tracks Prey Hunt progress with three display modes (smooth bar, stage segments, compact indicator), auto-shows during active hunts, and integrates with unlock mode. + +**Architecture:** Isolated child addon `EllesmereUIPreyHunt/` using `EUILite.NewAddon()`. Data layer reads `C_UIWidgetManager` for hunt stages (with stub detection until Midnight ships). Display frame auto-shows in prey zones, registers with unlock mode for positioning. Options page in EllesmereUI panel. + +**Tech Stack:** WoW Lua, EllesmereUI framework (EUILite, Widgets, PP, MakeBorder, MakeFont, ShowWidgetTooltip, RegisterUnlockElements, RegisterModule) + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| `EllesmereUIPreyHunt/EllesmereUIPreyHunt.toc` | TOC, dependencies, load order | +| `EllesmereUIPreyHunt/EllesmereUIPreyHunt.lua` | Core: data layer, frame, 3 display modes, auto-show, unlock registration | +| `EllesmereUIPreyHunt/EUI_PreyHunt_Options.lua` | Options page: mode picker, size, opacity, label toggle | + +--- + +### Task 1: Create branch and TOC file + +**Files:** +- Create: `EllesmereUIPreyHunt/EllesmereUIPreyHunt.toc` + +- [ ] **Step 1: Create feature branch from main** + +```bash +cd c:/Users/danie/Documents/GitHub/EllesmereUI +git checkout main +git checkout -b prey-hunt +``` + +- [ ] **Step 2: Create the TOC file** + +Create `EllesmereUIPreyHunt/EllesmereUIPreyHunt.toc`: + +``` +## Interface: 120000, 120001 +## Title: |cff0cd29fEllesmereUI|r Prey Hunt +## Category: |cff0cd29fEllesmere|rUI +## Group: EllesmereUI +## Notes: Prey Hunt progress tracker in EllesmereUI style +## Author: bulshack +## Version: 1.0 +## Dependencies: EllesmereUI +## SavedVariables: EllesmereUIPreyHuntDB +## IconTexture: Interface\AddOns\EllesmereUI\media\eg-logo.tga + +EllesmereUIPreyHunt.lua +EUI_PreyHunt_Options.lua +``` + +- [ ] **Step 3: Add to .pkgmeta move-folders** + +Add this line to the `move-folders:` section in `.pkgmeta`: + +```yaml + EllesmereUI/EllesmereUIPreyHunt: EllesmereUIPreyHunt +``` + +- [ ] **Step 4: Commit** + +```bash +git add EllesmereUIPreyHunt/EllesmereUIPreyHunt.toc .pkgmeta +git commit -m "feat(prey-hunt): add TOC and pkgmeta entry" +``` + +--- + +### Task 2: Core addon scaffold — data layer and state + +**Files:** +- Create: `EllesmereUIPreyHunt/EllesmereUIPreyHunt.lua` + +- [ ] **Step 1: Write the full core addon file** + +Create `EllesmereUIPreyHunt/EllesmereUIPreyHunt.lua`: + +```lua +-------------------------------------------------------------------------------- +-- EllesmereUIPreyHunt.lua +-- Prey Hunt progress tracker for EllesmereUI +-------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... +local EPH = EllesmereUI.Lite.NewAddon(ADDON_NAME) +ns.EPH = EPH + +local PP = EllesmereUI.PP +local floor = math.floor + +-------------------------------------------------------------------------------- +-- Constants +-------------------------------------------------------------------------------- +local FONT_PATH = "Interface\\AddOns\\EllesmereUI\\media\\fonts\\Expressway.ttf" +local MEDIA_PATH = "Interface\\AddOns\\EllesmereUI\\media\\" + +-------------------------------------------------------------------------------- +-- Defaults +-------------------------------------------------------------------------------- +local DEFAULTS = { + profile = { + enabled = true, + displayMode = "bar", -- "bar" | "segments" | "compact" + barWidth = 220, + barHeight = 16, + opacity = 1.0, + showLabel = true, + animateFill = true, + }, +} + +-------------------------------------------------------------------------------- +-- Hunt State +-------------------------------------------------------------------------------- +local huntState = { + active = false, + stage = 0, + maxStages = 5, + zoneName = "", + difficulty = "", + widgetID = nil, +} + +-------------------------------------------------------------------------------- +-- Widget Detection (stub — will be wired to real IDs on Midnight PTR/launch) +-- +-- The Prey Hunt system uses C_UIWidgetManager. Until Midnight ships, we +-- provide a stub that can be tested with /ephtest. Replace the body of +-- ScanForPreyHuntWidget() once real widget IDs are known. +-------------------------------------------------------------------------------- + +-- TODO: Replace with real widget type + ID detection when Midnight is live. +-- Expected approach: +-- 1. On zone enter, iterate C_UIWidgetManager.GetAllWidgetsBySetID( +-- C_UIWidgetManager.GetTopCenterWidgetSetID()) +-- 2. Check each widget for prey-hunt-specific visualization type +-- (likely GetStatusBarWidgetVisualizationInfo or +-- GetFillUpFramesWidgetVisualizationInfo) +-- 3. Store the widgetID and read stage/max from the widget data + +local function ScanForPreyHuntWidget() + -- STUB: Always returns nil until we have real widget IDs. + -- When Midnight ships, this will scan C_UIWidgetManager for the + -- prey hunt widget and return { widgetID, stage, maxStages, zoneName, difficulty }. + return nil +end + +local function UpdateHuntStateFromWidget() + local data = ScanForPreyHuntWidget() + if data then + huntState.active = true + huntState.stage = data.stage or 0 + huntState.maxStages = data.maxStages or 5 + huntState.zoneName = data.zoneName or "" + huntState.difficulty = data.difficulty or "" + huntState.widgetID = data.widgetID + return true + end + huntState.active = false + return false +end + +-------------------------------------------------------------------------------- +-- Debug / Test Commands +-------------------------------------------------------------------------------- +local testMode = false +local testStage = 0 +local testMax = 5 + +local function SetTestHunt(stage, maxStages, zone, diff) + testMode = true + huntState.active = true + huntState.stage = stage or 3 + huntState.maxStages = maxStages or 5 + huntState.zoneName = zone or "Dawnbreaker Crest" + huntState.difficulty = diff or "Heroic" +end + +local function ClearTestHunt() + testMode = false + huntState.active = false + huntState.stage = 0 +end + +SLASH_EPHTEST1 = "/ephtest" +SlashCmdList.EPHTEST = function(msg) + local args = msg and msg:trim():lower() or "" + if args == "off" or args == "clear" then + ClearTestHunt() + EPH:Refresh() + print("|cff0cd29f[PreyHunt]|r Test mode off.") + return + end + local stage = tonumber(args) + if stage then + SetTestHunt(stage, testMax) + else + -- Toggle: advance stage or start at 1 + testStage = testStage + 1 + if testStage > testMax then testStage = 1 end + SetTestHunt(testStage, testMax) + end + EPH:Refresh() + print("|cff0cd29f[PreyHunt]|r Test: stage " .. huntState.stage .. "/" .. huntState.maxStages) +end + +-------------------------------------------------------------------------------- +-- Accent Color Helper +-------------------------------------------------------------------------------- +local function GetAccent() + if EllesmereUI.GetAccentColor then + return EllesmereUI.GetAccentColor() + end + return 0.047, 0.824, 0.624 +end + +-------------------------------------------------------------------------------- +-- Main Frame +-------------------------------------------------------------------------------- +local mainFrame = nil +local fillBar, fillAnim +local segmentFrames = {} +local compactIcon, compactText +local stageLabel, infoLabel + +local function GetDB() + return EPH.db and EPH.db.profile +end + +local function CreateMainFrame() + if mainFrame then return end + local db = GetDB() + if not db then return end + + mainFrame = CreateFrame("Frame", "EllesmereUIPreyHuntFrame", UIParent) + mainFrame:SetSize(db.barWidth, db.barHeight) + mainFrame:SetPoint("CENTER", UIParent, "CENTER", 0, -200) + mainFrame:SetFrameStrata("MEDIUM") + mainFrame:SetFrameLevel(5) + mainFrame:Hide() + + -- Background + local bg = mainFrame:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.05, 0.07, 0.09, 0.80) + mainFrame._bg = bg + + -- Border + mainFrame._border = EllesmereUI.MakeBorder(mainFrame, 1, 1, 1, 0.10) + + -- Fill bar (smooth bar mode) + fillBar = mainFrame:CreateTexture(nil, "ARTWORK") + fillBar:SetPoint("TOPLEFT", mainFrame, "TOPLEFT", 1, -1) + fillBar:SetPoint("BOTTOMLEFT", mainFrame, "BOTTOMLEFT", 1, 1) + local aR, aG, aB = GetAccent() + fillBar:SetColorTexture(aR, aG, aB, 0.85) + fillBar:SetWidth(0) + mainFrame._fill = fillBar + + -- Stage label (inside bar, right side) + stageLabel = mainFrame:CreateFontString(nil, "OVERLAY") + stageLabel:SetFont(FONT_PATH, 10, "OUTLINE") + stageLabel:SetPoint("RIGHT", mainFrame, "RIGHT", -6, 0) + stageLabel:SetTextColor(1, 1, 1, 0.9) + mainFrame._stageLabel = stageLabel + + -- Info label (above bar — zone + difficulty) + infoLabel = mainFrame:CreateFontString(nil, "OVERLAY") + infoLabel:SetFont(FONT_PATH, 10, "") + infoLabel:SetPoint("BOTTOMLEFT", mainFrame, "TOPLEFT", 0, 4) + infoLabel:SetTextColor(1, 1, 1, 0.45) + mainFrame._infoLabel = infoLabel + + -- Compact mode: icon + text (hidden by default) + compactIcon = mainFrame:CreateTexture(nil, "OVERLAY") + compactIcon:SetSize(20, 20) + compactIcon:SetPoint("LEFT", mainFrame, "LEFT", 4, 0) + compactIcon:SetTexture(136814) -- generic crosshair icon; replace with prey icon later + compactIcon:SetVertexColor(aR, aG, aB, 0.9) + compactIcon:Hide() + mainFrame._compactIcon = compactIcon + + compactText = mainFrame:CreateFontString(nil, "OVERLAY") + compactText:SetFont(FONT_PATH, 13, "") + compactText:SetPoint("LEFT", compactIcon, "RIGHT", 6, 0) + compactText:SetTextColor(aR, aG, aB, 1) + compactText:Hide() + mainFrame._compactText = compactText + + -- Tooltip on hover + mainFrame:SetScript("OnEnter", function(self) + if not huntState.active then return end + local tip = "Prey Hunt" + if huntState.zoneName ~= "" then + tip = tip .. "\n" .. huntState.zoneName + end + if huntState.difficulty ~= "" then + tip = tip .. " |cff0cd29f(" .. huntState.difficulty .. ")|r" + end + tip = tip .. "\n\nProgress: Stage " .. huntState.stage .. " / " .. huntState.maxStages + if huntState.stage >= huntState.maxStages then + tip = tip .. "\n\n|cff0cd29fPrey location revealed!|r" + else + tip = tip .. "\n\nComplete quests, kill rares, loot treasures,\nand find traps to fill the bar." + end + EllesmereUI.ShowWidgetTooltip(self, tip, { anchor = "below" }) + end) + mainFrame:SetScript("OnLeave", function() + EllesmereUI.HideWidgetTooltip() + end) +end + +-------------------------------------------------------------------------------- +-- Segment Frames (stage segment mode) +-------------------------------------------------------------------------------- +local function CreateOrUpdateSegments() + local db = GetDB() + if not db or not mainFrame then return end + local aR, aG, aB = GetAccent() + local max = huntState.maxStages + if max < 1 then max = 5 end + local gap = 1 + local totalGaps = (max - 1) * gap + 2 -- 1px border each side + local segW = (db.barWidth - totalGaps) / max + local segH = db.barHeight - 2 -- 1px border top/bottom + + -- Hide extras + for i = max + 1, #segmentFrames do + segmentFrames[i]:Hide() + end + + for i = 1, max do + local seg = segmentFrames[i] + if not seg then + seg = mainFrame:CreateTexture(nil, "ARTWORK") + segmentFrames[i] = seg + end + seg:ClearAllPoints() + seg:SetSize(segW, segH) + local xOff = 1 + (i - 1) * (segW + gap) + seg:SetPoint("TOPLEFT", mainFrame, "TOPLEFT", xOff, -1) + + if i <= huntState.stage then + seg:SetColorTexture(aR, aG, aB, 0.85) + else + seg:SetColorTexture(1, 1, 1, 0.04) + end + seg:Show() + end +end + +-------------------------------------------------------------------------------- +-- Display Refresh +-------------------------------------------------------------------------------- +local targetFillWidth = 0 +local currentFillWidth = 0 + +local function RefreshDisplay() + local db = GetDB() + if not db or not mainFrame then return end + if not huntState.active then + mainFrame:Hide() + return + end + + local aR, aG, aB = GetAccent() + local mode = db.displayMode + local barW = db.barWidth + local barH = db.barHeight + + mainFrame:SetSize(barW, barH) + mainFrame:SetAlpha(db.opacity) + + -- Info label + if db.showLabel and huntState.zoneName ~= "" then + local info = huntState.zoneName + if huntState.difficulty ~= "" then + info = info .. " |cff0cd29f" .. huntState.difficulty .. "|r" + end + infoLabel:SetText(info) + infoLabel:Show() + else + infoLabel:Hide() + end + + -- Hide all mode-specific elements first + fillBar:Hide() + stageLabel:Hide() + compactIcon:Hide() + compactText:Hide() + for _, seg in ipairs(segmentFrames) do seg:Hide() end + mainFrame._bg:Show() + mainFrame._border:Show() + + local pct = huntState.maxStages > 0 and (huntState.stage / huntState.maxStages) or 0 + + if mode == "bar" then + -- Smooth bar + fillBar:SetColorTexture(aR, aG, aB, 0.85) + targetFillWidth = (barW - 2) * pct + if not db.animateFill then + currentFillWidth = targetFillWidth + end + fillBar:SetWidth(math.max(currentFillWidth, 0.01)) + fillBar:Show() + + stageLabel:SetText(huntState.stage .. " / " .. huntState.maxStages) + stageLabel:Show() + + elseif mode == "segments" then + -- Stage segments + CreateOrUpdateSegments() + stageLabel:SetText(huntState.stage .. " / " .. huntState.maxStages) + stageLabel:SetPoint("RIGHT", mainFrame, "RIGHT", -6, 0) + stageLabel:Show() + + elseif mode == "compact" then + -- Compact: smaller frame, icon + text + mainFrame:SetSize(100, 28) + mainFrame._bg:SetColorTexture(0.05, 0.07, 0.09, 0.70) + + compactIcon:SetVertexColor(aR, aG, aB, 0.9) + compactIcon:Show() + + compactText:SetTextColor(aR, aG, aB, 1) + compactText:SetText(huntState.stage .. " / " .. huntState.maxStages) + compactText:Show() + + infoLabel:Hide() -- too small for label in compact + end + + mainFrame:Show() +end + +-- Smooth fill animation +local function OnUpdateFill(self, dt) + if not mainFrame or not mainFrame:IsShown() then return end + local db = GetDB() + if not db or db.displayMode ~= "bar" or not db.animateFill then return end + + if math.abs(currentFillWidth - targetFillWidth) > 0.5 then + local speed = 8 -- pixels per second multiplier + local delta = (targetFillWidth - currentFillWidth) * math.min(dt * speed, 1) + currentFillWidth = currentFillWidth + delta + fillBar:SetWidth(math.max(currentFillWidth, 0.01)) + end +end + +-------------------------------------------------------------------------------- +-- Public API +-------------------------------------------------------------------------------- +function EPH:Refresh() + if not testMode then + UpdateHuntStateFromWidget() + end + RefreshDisplay() +end + +-------------------------------------------------------------------------------- +-- Event Handling +-------------------------------------------------------------------------------- +local eventFrame = CreateFrame("Frame") + +local function OnEvent(self, event, ...) + local db = GetDB() + if not db or not db.enabled then + if mainFrame then mainFrame:Hide() end + return + end + + if event == "UPDATE_UI_WIDGET" then + local widgetInfo = ... + -- If we're tracking a specific widget, only refresh on that one + if huntState.widgetID and widgetInfo and widgetInfo.widgetID ~= huntState.widgetID then + return + end + end + + EPH:Refresh() +end + +-------------------------------------------------------------------------------- +-- Unlock Mode Registration +-------------------------------------------------------------------------------- +local function RegisterWithUnlockMode() + if not EllesmereUI or not EllesmereUI.RegisterUnlockElements then return end + + local function S() return GetDB() or {} end + + local function savePos(key, point, relPoint, x, y) + if not point then return end + local db = S() + db.unlockPos = { point = point, relPoint = relPoint or point, x = x, y = y } + if not EllesmereUI._unlockActive and mainFrame then + mainFrame:ClearAllPoints() + mainFrame:SetPoint(point, UIParent, relPoint or point, x, y) + end + end + + local function loadPos() + local pos = S().unlockPos + if not pos then return nil end + return { point = pos.point, relPoint = pos.relPoint or pos.point, x = pos.x, y = pos.y } + end + + local function clearPos() + local db = S() + db.unlockPos = nil + end + + local function applyPos() + local pos = S().unlockPos + if not pos or not mainFrame then return end + mainFrame:ClearAllPoints() + mainFrame:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x, pos.y) + end + + EllesmereUI:RegisterUnlockElements({ + { + key = "PreyHunt", + label = "Prey Hunt", + group = "Prey Hunt", + order = 600, + getFrame = function() return mainFrame end, + getSize = function() + local db = S() + return db.barWidth or 220, db.barHeight or 16 + end, + setWidth = function(_, w) + local db = S() + db.barWidth = floor(w + 0.5) + EPH:Refresh() + end, + setHeight = function(_, h) + local db = S() + db.barHeight = floor(h + 0.5) + EPH:Refresh() + end, + savePos = savePos, + loadPos = loadPos, + clearPos = clearPos, + applyPos = applyPos, + }, + }) +end + +-------------------------------------------------------------------------------- +-- Lifecycle +-------------------------------------------------------------------------------- +function EPH:OnInitialize() + self.db = EllesmereUI.Lite.NewDB("EllesmereUIPreyHuntDB", DEFAULTS, true) + + _G._EPH_AceDB = self.db + _G._EPH_Apply = function() EPH:Refresh() end + _G._EPH_RegisterUnlock = RegisterWithUnlockMode +end + +function EPH:OnEnable() + CreateMainFrame() + + -- Apply saved position + local db = GetDB() + if db and db.unlockPos then + local pos = db.unlockPos + mainFrame:ClearAllPoints() + mainFrame:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x, pos.y) + end + + -- Fill animation ticker + mainFrame:SetScript("OnUpdate", OnUpdateFill) + + -- Events + eventFrame:RegisterEvent("UPDATE_UI_WIDGET") + eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA") + eventFrame:RegisterEvent("ZONE_CHANGED") + eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") + eventFrame:SetScript("OnEvent", OnEvent) + + -- Register with unlock mode (deferred) + C_Timer.After(0.5, RegisterWithUnlockMode) + + -- Initial scan + C_Timer.After(1, function() EPH:Refresh() end) +end +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUIPreyHunt/EllesmereUIPreyHunt.lua +git commit -m "feat(prey-hunt): core addon with data layer, 3 display modes, unlock registration" +``` + +--- + +### Task 3: Options page + +**Files:** +- Create: `EllesmereUIPreyHunt/EUI_PreyHunt_Options.lua` + +- [ ] **Step 1: Write the options page** + +Create `EllesmereUIPreyHunt/EUI_PreyHunt_Options.lua`: + +```lua +-------------------------------------------------------------------------------- +-- EUI_PreyHunt_Options.lua +-- Options page for EllesmereUIPreyHunt +-------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local PAGE_SETTINGS = "Settings" + +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("PLAYER_LOGIN") +initFrame:SetScript("OnEvent", function(self) + self:UnregisterEvent("PLAYER_LOGIN") + + if not EllesmereUI or not EllesmereUI.RegisterModule then return end + + local db + C_Timer.After(0, function() db = _G._EPH_AceDB end) + + local function DB() + if not db then db = _G._EPH_AceDB end + return db and db.profile + end + + local function Refresh() + if _G._EPH_Apply then _G._EPH_Apply() end + end + + --------------------------------------------------------------------------- + -- Page Builder + --------------------------------------------------------------------------- + local function BuildSettingsPage(pageName, parent, yOffset) + local W = EllesmereUI.Widgets + local _, h + local y = yOffset + + --------------------------------------------------------------- + -- DISPLAY section + --------------------------------------------------------------- + _, h = W:SectionHeader(parent, "DISPLAY", y); y = y - h + + -- Row 1: Display Mode | Bar Opacity + _, h = W:DualRow(parent, y, + { type = "dropdown", text = "Display Mode", + values = { bar = "Smooth Bar", segments = "Stage Segments", compact = "Compact" }, + order = { "bar", "segments", "compact" }, + tooltip = "Smooth Bar: animated fill bar.\nStage Segments: divided chunks per stage.\nCompact: small icon with stage count.", + getValue = function() return DB().displayMode end, + setValue = function(v) DB().displayMode = v; Refresh(); EllesmereUI:RefreshPage() end }, + { type = "slider", text = "Opacity", min = 0.3, max = 1.0, step = 0.05, + tooltip = "Overall opacity of the prey hunt tracker.", + getValue = function() return DB().opacity end, + setValue = function(v) DB().opacity = v; Refresh() end } + ); y = y - h + + -- Row 2: Bar Width | Bar Height + _, h = W:DualRow(parent, y, + { type = "slider", text = "Bar Width", min = 100, max = 400, step = 1, + tooltip = "Width of the prey hunt bar in pixels.", + getValue = function() return DB().barWidth end, + setValue = function(v) DB().barWidth = v; Refresh() end }, + { type = "slider", text = "Bar Height", min = 8, max = 32, step = 1, + tooltip = "Height of the prey hunt bar in pixels.", + getValue = function() return DB().barHeight end, + setValue = function(v) DB().barHeight = v; Refresh() end } + ); y = y - h + + --------------------------------------------------------------- + -- OPTIONS section + --------------------------------------------------------------- + _, h = W:SectionHeader(parent, "OPTIONS", y); y = y - h + + -- Row 3: Show Label | Animate Fill + _, h = W:DualRow(parent, y, + { type = "toggle", text = "Show Zone Label", + tooltip = "Display the zone name and difficulty above the bar.", + getValue = function() return DB().showLabel end, + setValue = function(v) DB().showLabel = v; Refresh() end }, + { type = "toggle", text = "Animate Fill", + tooltip = "Smoothly animate the bar fill when the stage changes.", + getValue = function() return DB().animateFill end, + setValue = function(v) DB().animateFill = v; Refresh() end } + ); y = y - h + + --------------------------------------------------------------- + -- TEST section + --------------------------------------------------------------- + _, h = W:SectionHeader(parent, "TESTING", y); y = y - h + + -- Info text + do + local CONTENT_PAD = 45 + local infoRow = CreateFrame("Frame", nil, parent) + infoRow:SetSize(parent:GetWidth() - CONTENT_PAD * 2, 40) + infoRow:SetPoint("TOPLEFT", parent, "TOPLEFT", CONTENT_PAD, y) + + local infoText = EllesmereUI.MakeFont(infoRow, 11, nil, 1, 1, 1) + infoText:SetAlpha(0.45) + infoText:SetPoint("LEFT", infoRow, "LEFT", 20, 0) + infoText:SetText("Type /ephtest to simulate hunt progress. /ephtest off to stop.") + y = y - 40 + end + + return y + end + + --------------------------------------------------------------------------- + -- Register Module + --------------------------------------------------------------------------- + EllesmereUI:RegisterModule("EllesmereUIPreyHunt", { + title = "Prey Hunt", + description = "Track Prey Hunt progress in EllesmereUI style.", + pages = { PAGE_SETTINGS }, + buildPage = function(pageName, parent, yOffset) + if pageName == PAGE_SETTINGS then + return BuildSettingsPage(pageName, parent, yOffset) + end + end, + }) +end) +``` + +- [ ] **Step 2: Commit** + +```bash +git add EllesmereUIPreyHunt/EUI_PreyHunt_Options.lua +git commit -m "feat(prey-hunt): options page with display mode, size, opacity, labels" +``` + +--- + +### Task 4: Deploy to live WoW and test + +**Files:** +- No new files — copy to AddOns folder + +- [ ] **Step 1: Copy addon to WoW AddOns** + +```bash +mkdir -p "D:/World of Warcraft/_retail_/Interface/AddOns/EllesmereUIPreyHunt" +cp "c:/Users/danie/Documents/GitHub/EllesmereUI/EllesmereUIPreyHunt/"* "D:/World of Warcraft/_retail_/Interface/AddOns/EllesmereUIPreyHunt/" +``` + +- [ ] **Step 2: Verify files are in place** + +```bash +ls "D:/World of Warcraft/_retail_/Interface/AddOns/EllesmereUIPreyHunt/" +``` + +Expected output: `EllesmereUIPreyHunt.lua EllesmereUIPreyHunt.toc EUI_PreyHunt_Options.lua` + +- [ ] **Step 3: Test in-game** + +1. `/reload` in WoW +2. Open EllesmereUI options — "Prey Hunt" should appear as a module +3. Type `/ephtest` — the bar should appear with stage 1/5 +4. Type `/ephtest` again — advances to 2/5, 3/5, etc. +5. Type `/ephtest off` — bar hides +6. Change display mode in options — bar style should change +7. Enter unlock mode — "Prey Hunt" element should be draggable +8. Adjust sliders for width/height/opacity — bar updates live + +- [ ] **Step 4: Commit any fixes needed** + +```bash +git add -A +git commit -m "fix(prey-hunt): adjustments from in-game testing" +``` + +--- + +### Task 5: Push and final verification + +- [ ] **Step 1: Push branch** + +```bash +cd c:/Users/danie/Documents/GitHub/EllesmereUI +git push -u origin prey-hunt +``` + +- [ ] **Step 2: Verify branch on GitHub** + +```bash +gh browse -b prey-hunt +``` + +- [ ] **Step 3: Verify module can be disabled** + +In-game: disable "Prey Hunt" in EllesmereUI module list. Confirm the bar disappears and no Lua errors. Re-enable and confirm it comes back. diff --git a/docs/superpowers/specs/2026-03-31-prey-hunt-tracker-design.md b/docs/superpowers/specs/2026-03-31-prey-hunt-tracker-design.md new file mode 100644 index 0000000..a5259ac --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-prey-hunt-tracker-design.md @@ -0,0 +1,196 @@ +# EllesmereUIPreyHunt — Design Spec + +## Overview + +A child addon module that tracks Prey Hunt progress in EllesmereUI style. Displays hunt stage progression as a sleek bar, auto-shows only when the player is in active prey content, integrates with unlock mode for positioning, and can be toggled on/off from the EllesmereUI module system. + +## Goals + +- Track prey hunt stage progression via `C_UIWidgetManager` +- Display progress in three selectable modes: stage segments, smooth bar, compact indicator +- Auto-show when in prey hunt content, auto-hide when not +- Fully movable via EllesmereUI unlock mode +- Toggleable as a module inside EllesmereUI options +- Zero impact on existing EllesmereUI features when disabled + +## Architecture + +### Module Pattern + +- **Addon**: `EllesmereUIPreyHunt/` — independent child addon folder +- **Framework**: `EUILite.NewAddon("EllesmereUIPreyHunt")` + - `OnInitialize`: fires on ADDON_LOADED, loads DB + - `OnEnable`: fires on PLAYER_LOGIN, builds frames, starts listening +- **Database**: `EUILite.NewDB("EllesmereUIDB", defaults)` — settings stored at `EllesmereUIDB.profiles[name].addons.EllesmereUIPreyHunt` +- **Module toggle**: Registers with EllesmereUI module system so it appears in the modules list and can be enabled/disabled from options +- **Unlock mode**: Registers via `EllesmereUI:RegisterUnlockElements()` so the bar is draggable/positionable + +### Files + +| File | Purpose | +|------|---------| +| `EllesmereUIPreyHunt.toc` | TOC with `## Dependencies: EllesmereUI` | +| `EllesmereUIPreyHunt.lua` | Core addon: data layer, frame creation, 3 display modes, auto-show/hide, unlock registration | +| `EUI_PreyHunt_Options.lua` | Options page in EllesmereUI panel: display mode, bar size, opacity | + +### No existing files modified + +This is a fully additive module. No changes to any core EllesmereUI file or any other child addon. + +## Data Layer + +### Source: C_UIWidgetManager + +The Prey Hunt system exposes progress as **stage transitions** through Blizzard's widget system, not raw percentages. + +- **Detection**: Listen to `UPDATE_UI_WIDGET` event, filter for prey hunt widget IDs +- **Stage data**: `C_UIWidgetManager.GetFillUpFramesWidgetVisualizationInfo(widgetID)` returns fill-up frame data with stage/segment info +- **Fallback**: Also check `C_UIWidgetManager.GetStatusBarWidgetVisualizationInfo()` and `C_UIWidgetManager.GetIconAndTextWidgetVisualizationInfo()` as Blizzard may expose hunt data through multiple widget types +- **Discovery**: On zone enter, iterate `C_UIWidgetManager.GetAllWidgetsBySetID(C_UIWidgetManager.GetTopCenterWidgetSetID())` to find active prey hunt widgets + +### Zone Awareness + +- Listen to `ZONE_CHANGED_NEW_AREA`, `ZONE_CHANGED`, `PLAYER_ENTERING_WORLD` +- Query `C_Map.GetBestMapForUnit("player")` to determine if player is in a Midnight prey hunt zone +- Auto-show the bar when: active hunt detected AND in relevant zone +- Auto-hide when: hunt completes, player leaves zone, or no active hunt widget found + +### Hunt State + +``` +state = { + active = bool, -- is a hunt in progress + stage = number, -- current stage (1-based) + maxStages = number, -- total stages in this hunt + zoneName = string, -- zone where hunt is active + difficulty = string, -- "Normal" / "Heroic" / "Nightmare" + widgetID = number, -- tracked widget ID +} +``` + +## Display Modes + +User selects one mode in options. Default: **Smooth Bar**. + +### 1. Smooth Bar (default) + +- Horizontal bar matching EllesmereUI XP/Rep bar style +- Background: dark (`0.05, 0.07, 0.09, 0.80`) +- Fill: accent color via `EllesmereUI.GetAccentColor()` +- Animated fill transitions (lerp over 0.3s when stage changes) +- Fill percentage: `currentStage / maxStages` +- Stage text right-aligned inside bar: "Stage 3/5" +- Bar dimensions default: 220px wide, 16px tall +- Border: 1px, white, 0.10 alpha (EllesmereUI standard) +- Hunt info label above bar: zone name + difficulty in dim text + +### 2. Stage Segments + +- Same bar frame, but divided into `maxStages` equal segments with 1px gaps +- Completed segments: filled with accent color +- Incomplete segments: dark background +- Current segment: partial fill or pulsing accent glow +- No text overlay (clean visual) +- Small stage count below: "3 / 5" in dim text + +### 3. Compact Indicator + +- Small frame: ~32px icon + text beside it +- Icon: prey hunt themed (use Blizzard's hunt texture or a generic eye/crosshair) +- Text: "3/5" in accent color, spec name in dim text below +- Hover tooltip: full hunt details (zone, difficulty, stage, description) +- Tooltip uses `EllesmereUI.ShowWidgetTooltip()` + +## Frame Hierarchy + +``` +EllesmereUIPreyHuntFrame (main anchor, movable via unlock mode) + ├── background texture + ├── fill texture (smooth bar mode) + ├── segment frames[] (stage segment mode) + ├── icon texture (compact mode) + ├── stage label (FontString) + ├── info label (FontString — zone + difficulty) + └── border (via MakeBorder) +``` + +Only the active mode's elements are shown; others are hidden. + +## Options Page + +Registered as a module page in EllesmereUI options panel. + +### Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `enabled` | toggle | true | Module enabled/disabled | +| `displayMode` | dropdown | "bar" | "bar" / "segments" / "compact" | +| `barWidth` | slider | 220 | Bar width in pixels (100-400) | +| `barHeight` | slider | 16 | Bar height in pixels (8-32) | +| `opacity` | slider | 1.0 | Overall frame opacity (0.3-1.0) | +| `showLabel` | toggle | true | Show zone + difficulty label above bar | +| `animateFill` | toggle | true | Animate bar fill transitions | + +### Layout + +Uses `W:SectionHeader`, `W:DualRow`, `W:Dropdown`, `W:Slider`, `W:Toggle` — standard EllesmereUI widget factory. + +## Unlock Mode Integration + +Register with unlock mode so the bar is movable: + +```lua +EllesmereUI:RegisterUnlockElements({ + { + key = "PreyHunt", + label = "Prey Hunt", + frame = mainFrame, + savePosition = function() ... end, + loadPosition = function() ... end, + clearPosition = function() ... end, + } +}) +``` + +Position saved to `EllesmereUIDB.unlockAnchors["PreyHunt"]`. + +## Auto-Show/Hide Logic + +``` +on ZONE_CHANGED / PLAYER_ENTERING_WORLD / UPDATE_UI_WIDGET: + if module disabled → hide, return + scan widgets for prey hunt data + if active hunt found: + update state + show frame + refresh display + else: + hide frame +``` + +No polling — purely event-driven. + +## Visual Style + +- All colors via `EllesmereUI.GetAccentColor()` — matches active theme +- Font: Expressway (via `EllesmereUI.GetFontPath()` or hardcoded path) +- Border: `EllesmereUI.MakeBorder()` at 1px, white, 0.10 alpha +- Tooltips: `EllesmereUI.ShowWidgetTooltip()` / `HideWidgetTooltip()` +- Text labels: `EllesmereUI.MakeFont()` +- Background: dark flat, consistent with action bars / resource bars + +## Edge Cases + +- **No active hunt**: Frame stays hidden. No errors. +- **Hunt completes**: Widget update fires, state clears, frame hides with fade-out. +- **Zone transition during hunt**: Frame hides on zone leave, re-shows on zone return (hunt is still active server-side). +- **Combat**: Bar is display-only (no protected API calls), safe during combat. +- **Module disabled**: All frames hidden, events unregistered, zero overhead. +- **Widget ID changes between patches**: Discovery loop re-scans on each zone enter rather than hardcoding IDs. + +## Branch Strategy + +- New branch `prey-hunt` from `keybind-system` (or from `main` if we want it independent) +- Fully isolated — can be deleted/disabled without affecting keybind work +- If successful, can be proposed as a separate PR to the author