From 0cabf2ec29ece67301b0e3d68239f9975a27d338 Mon Sep 17 00:00:00 2001 From: Duciel Date: Tue, 27 Oct 2026 17:02:44 +0100 Subject: [PATCH] Add equipment manager auto-switching and "Unused" items panel (#70) * Add equipment manager auto-switching and "Unused" items panel Equipment manager (modules/equipmentmanager.lua): - Add automatic set switching: ordered triggers (mounted, swimming, eating/drinking, shapeshift form/stance, zone, instance type, time of day, resting, stealthed, targeting a boss) equip a set; topmost matching trigger wins, with a 0.5s debounce - Never swap gear in combat, while dead or casting; optionally restore the previous gear via a hidden "pfUI Auto-Restore" set - Add an "Unused" side panel listing equippable items in bags, bank and paperdoll that no set uses, with an ignore list (tools and quest disguises pre-ignored), bank snapshot and bag highlighting - Persist triggers, ignore list and bank snapshot in new per-character SavedVariable pfUI_eqmgr (all three .toc files) - Add Character > Equipment Manager options (autoswitch, announce, minimum item quality, include bank) in gui.lua and config.lua - Hide ignored-slot overlays when the manager frame is closed Add frFR translations for the new equipment manager strings. * equipmentmanager: lean on ClassicAPI for unused scan and autoswitch - Unused panel: list items via GetInventoryItemsForSlot instead of hand-packing locations and walking the bags. - Bank snapshot: record set membership while the bank is open. GetItemLocations reports bank items as missing once it closes, so set items in the bank were listed as unused. - Autoswitch: check CanUseEquipmentSets and the UseEquipmentSet result before marking a trigger applied, and retry when the swap is blocked. - Drive the engine from events (aura, form, zone, resting, target, regen) instead of a 4 Hz ticker; poll only swimming, time and eating. - Detect eating/drinking by the localized names of the Food (433) and Drink (430) auras instead of hardcoded locale strings and icon paths. - Drop presence guards on APIs that always exist; use strsplittable and strtrim for trigger values. * equipmentmanager: wake the dining trigger on player health/mana events Drop dining from the poll: listen to UNIT_HEALTH/UNIT_MANA for the player while a dining trigger is enabled, so taking the set off once full is event-driven. Register UNIT_AURA for the player only. * equipmentmanager: split trigger values with strsplit strsplittable needs ClassicAPI 1.17.11; strsplit has been there since 1.7.3, so this keeps the minimum where it is. * equipmentmanager: let the trigger hint wrap --------- Co-authored-by: Duciel <336374+Duciel@users.noreply.github.com> Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com> --- api/config.lua | 4 + env/translations_frFR.lua | 70 ++ modules/equipmentmanager.lua | 1287 +++++++++++++++++++++++++++++++++- modules/gui.lua | 15 + pfUI.toc | 2 +- pfUI_ClassicAPI.toc | 2 +- pfUI_Turtle.toc | 2 +- 7 files changed, 1376 insertions(+), 6 deletions(-) diff --git a/api/config.lua b/api/config.lua index d1c3805d..d7c9bb47 100644 --- a/api/config.lua +++ b/api/config.lua @@ -976,6 +976,10 @@ function pfUI:LoadConfig() pfUI:UpdateConfig("character", "inventory", "durability", "1") pfUI:UpdateConfig("character", "inventory", "equipflyout", "0") + pfUI:UpdateConfig("equipmentmanager", nil, "autoswitch", "1") + pfUI:UpdateConfig("equipmentmanager", nil, "announce", "1") + pfUI:UpdateConfig("equipmentmanager", nil, "unusedquality", "1") + pfUI:UpdateConfig("equipmentmanager", nil, "unusedbank", "1") pfUI:UpdateConfig("character", "reputation", "repRequired", "1") pfUI:UpdateConfig("thirdparty", nil, "chatbg", "1") pfUI:UpdateConfig("thirdparty", nil, "showmeter", "0") diff --git a/env/translations_frFR.lua b/env/translations_frFR.lua index a4b9b542..4d0901a1 100644 --- a/env/translations_frFR.lua +++ b/env/translations_frFR.lua @@ -890,4 +890,74 @@ pfUI_translation["frFR"] = { ["Zonetime"] = nil, ["Zoom & Fade"] = "Zoom et fondu", ["Zoom Target Nameplate"] = "Zoom la cible des barres de vie flottantes", + ["%d items, %d ignored"] = "%d objets, %d ignorés", + ["Add the current zone"] = "Ajouter la zone actuelle", + ["Announce Automatic Set Switches In Chat"] = "Annoncer les changements automatiques d'ensemble dans le chat", + ["Any Form"] = "N'importe quelle forme", + ["Aquatic Form"] = "Forme aquatique", + ["Automatic Set Switching"] = "Changement automatique d'ensemble", + ["Battle Stance"] = "Posture de combat", + ["Battleground"] = "Champ de bataille", + ["Bear Form"] = "Forme d'ours", + ["Berserker Stance"] = "Posture berserker", + ["Cat Form"] = "Forme de félin", + ["Condition met, but a trigger higher in the list wins"] = "Condition remplie, mais un déclencheur plus haut dans la liste est prioritaire", + ["Create an equipment set first."] = "Créez d'abord un ensemble d'équipement.", + ["Currently active"] = "Actuellement actif", + ["Defensive Stance"] = "Posture défensive", + ["Do Nothing"] = "Ne rien faire", + ["Dungeon"] = "Donjon", + ["Eating / Drinking"] = "Manger / Boire", + ["Enable Automatic Set Switching"] = "Activer le changement automatique d'ensemble", + ["Equip"] = "Équiper", + ["Equip sets automatically when mounted, swimming, eating, in a zone, at a given time and more."] = "Équipe automatiquement des ensembles en monture, en nageant, en mangeant, dans une zone, à une heure donnée, etc.", + ["Equipment Manager"] = "Gestionnaire d'équipement", + ["Equippable items in your bags, bank and paperdoll that no equipment set uses."] = "Objets équipables dans vos sacs, votre banque et sur votre personnage qu'aucun ensemble n'utilise.", + ["Equipping %s"] = "Équipement de %s", + ["Equipping %s (%s)"] = "Équipement de %s (%s)", + ["Every equippable item is part of a set."] = "Tous les objets équipables font partie d'un ensemble.", + ["Ghost Wolf"] = "Loup fantôme", + ["Here"] = "Ici", + ["Ignored (Right-Click to unignore)"] = "Ignoré (clic droit pour ne plus ignorer)", + ["In Bags"] = "Dans les sacs", + ["In Bank"] = "En banque", + ["Include Bank Items"] = "Inclure les objets en banque", + ["Instance Type"] = "Type d'instance", + ["Items Not In Any Set"] = "Objets hors de tout ensemble", + ["Local time window, e.g. 20:00-23:00"] = "Plage horaire locale, ex. 20:00-23:00", + ["Minimum Item Quality"] = "Qualité minimale des objets", + ["Moonkin Form"] = "Forme de sélénien", + ["Mounted"] = "En monture", + ["New Trigger"] = "Nouveau déclencheur", + ["No Form (Caster)"] = "Sans forme (lanceur de sorts)", + ["No set selected"] = "Aucun ensemble choisi", + ["No triggers yet. Click \"New Trigger\" to add one."] = "Aucun déclencheur. Cliquez sur \"Nouveau déclencheur\" pour en ajouter un.", + ["Not part of any equipment set"] = "Ne fait partie d'aucun ensemble", + ["Open World"] = "Monde extérieur", + ["Resting (Inn / City)"] = "Repos (auberge / ville)", + ["Restore Previous Gear"] = "Remettre l'équipement précédent", + ["Restoring previous gear"] = "Remise de l'équipement précédent", + ["Select a trigger to edit it."] = "Sélectionnez un déclencheur pour le modifier.", + ["Selected Trigger"] = "Déclencheur sélectionné", + ["Separate several zones with ;"] = "Séparez plusieurs zones par ;", + ["Shadowform"] = "Forme d'Ombre", + ["Shapeshift Form / Stance"] = "Forme / Posture", + ["Shift-Click: Link Ctrl-Click: Preview\nRight-Click: Ignore / Unignore"] = "Maj-clic : lien Ctrl-clic : aperçu\nClic droit : ignorer / ne plus ignorer", + ["Show Ignored"] = "Afficher les ignorés", + ["Stealth"] = "Camouflage", + ["Stealthed"] = "Camouflé", + ["Swimming"] = "À la nage", + ["Targeting A Boss"] = "Cible un boss", + ["Time Of Day"] = "Heure de la journée", + ["Travel Form"] = "Forme de voyage", + ["Tree of Life Form"] = "Forme d'arbre de vie", + ["Triggers"] = "Déclencheurs", + ["Triggers higher in the list take priority. Gear is never swapped in combat, while dead or while casting: the switch waits until it can happen."] = "Les déclencheurs en haut de la liste sont prioritaires. L'équipement n'est jamais changé en combat, mort ou pendant une incantation : le changement attend que ce soit possible.", + ["Unused"] = "Inutilisés", + ["Value"] = "Valeur", + ["When"] = "Quand", + ["When It Ends"] = "À la fin", + ["Windows may wrap past midnight (22:00-02:00)."] = "Une plage peut passer minuit (22:00-02:00).", + ["Zone"] = "Zone", + ["Zone or subzone name"] = "Nom de zone ou de sous-zone", } diff --git a/modules/equipmentmanager.lua b/modules/equipmentmanager.lua index c116738a..88d308a8 100644 --- a/modules/equipmentmanager.lua +++ b/modules/equipmentmanager.lua @@ -6,6 +6,52 @@ pfUI:RegisterModule("equipmentmanager", function() pfUI.equipmentmanager = {} + -- Per-character storage for the features ClassicAPI doesn't persist + -- itself: automatic-switch triggers (they reference this character's + -- setIDs), the "not in any set" ignore list and a snapshot of the bank + -- so bank items can be listed while the bank is closed. + _G.pfUI_eqmgr = _G.pfUI_eqmgr or {} + local db = _G.pfUI_eqmgr + db.triggers = db.triggers or {} + db.nextID = db.nextID or 1 + db.bank = db.bank or {} + if not db.ignore then + -- Tools and quest disguises that are equippable but never belong in a + -- set (same list Outfitter ships). Users can un-ignore them. + db.ignore = { + [2901] = true, -- Mining Pick + [5956] = true, -- Blacksmith Hammer + [6219] = true, -- Arclight Spanner + [7005] = true, -- Skinning Knife + [7297] = true, -- Morbent's Bane + [10696] = true, -- Enchanted Azsharite Felbane Sword + [10697] = true, -- Enchanted Azsharite Felbane Dagger + [10698] = true, -- Enchanted Azsharite Felbane Staff + [20406] = true, -- Twilight Cultist Mantle + [20407] = true, -- Twilight Cultist Robe + [20408] = true, -- Twilight Cultist Cowl + } + end + + -- Hidden set used by the trigger engine to remember the gear worn before + -- a trigger kicked in ("restore previous gear"). It's GUID-based like any + -- other set, so restoring is a single atomic UseEquipmentSet. Filtered + -- out of every list this module shows and deleted once it's restored. + local RESTORE_SET_NAME = "pfUI Auto-Restore" + + local function IsRestoreSet(setID) + return setID and C_EquipmentSet.GetEquipmentSetInfo(setID) == RESTORE_SET_NAME + end + + -- All set IDs except the hidden restore snapshot. + local function GetVisibleSetIDs() + local result = {} + for _, id in ipairs(C_EquipmentSet.GetEquipmentSetIDs() or {}) do + if not IsRestoreSet(id) then table.insert(result, id) end + end + return result + end + local SET_ROW_HEIGHT = 36 local rawborder, border = GetBorderSize() @@ -68,7 +114,7 @@ pfUI:RegisterModule("equipmentmanager", function() -- ============================================================ local frame = CreateFrame("Frame", "pfEquipmentManagerFrame", CharacterFrame) - frame:SetSize(220, 350) + frame:SetSize(220, 368) frame:SetFrameStrata("HIGH") frame:SetScript("OnShow", function() this:ClearAllPoints() @@ -470,6 +516,9 @@ pfUI:RegisterModule("equipmentmanager", function() frame:SetScript("OnHide", function() namePopup:Hide() UpdatePopouts() + -- The "ignored slot" overlays belong to the selected set, which is + -- only meaningful while the manager is open. + for _, overlay in pairs(slotOverlays) do overlay:Hide() end end) flyout:SetFrameStrata("DIALOG") flyout:Hide() @@ -796,7 +845,7 @@ pfUI:RegisterModule("equipmentmanager", function() -- ============================================================ function pfUI.equipmentmanager.Refresh() - local ids = C_EquipmentSet.GetEquipmentSetIDs() or {} + local ids = GetVisibleSetIDs() local numSets = table.getn(ids) -- Auto-select the first set if none is selected (or the previously @@ -875,14 +924,1246 @@ pfUI:RegisterModule("equipmentmanager", function() -- uncommitted user changes show immediately. local setIgnored = selectedSetID and GetEffectiveIgnored(selectedSetID) or {} for slotID, overlay in pairs(slotOverlays) do - if selectedSetID and setIgnored[slotID] then overlay:Show() else overlay:Hide() end + if frame:IsShown() and selectedSetID and setIgnored[slotID] then overlay:Show() else overlay:Hide() end end local hasSelection = selectedSetID and true or false if hasSelection then btnEquip:Enable(); btnSave:Enable() else btnEquip:Disable(); btnSave:Disable() end + + pfUI.equipmentmanager.RefreshExtras() end + -- ============================================================ + -- Shared helpers for the extra panels (unused items / triggers) + -- ============================================================ + + local function CreateCheckbox(parent, label) + local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate") + cb:SetSize(20, 20) + SkinCheckbox(cb, 14) + if label then + cb.label = cb:CreateFontString(nil, "OVERLAY", "GameFontWhite") + cb.label:SetPoint("LEFT", cb, "RIGHT", 4, 0) + cb.label:SetText(label) + end + return cb + end + + local function CreateSmallButton(parent, dir, size) + local b = CreateFrame("Button", nil, parent) + b:SetSize(size or 14, size or 14) + SkinArrowButton(b, dir, size or 14) + return b + end + + -- Dropdown entries carry a `.value`; select one by value instead of index. + local function SelectDropdownValue(dd, value) + local menu = dd:GetMenu() + for i, entry in ipairs(menu or {}) do + if entry.value == value then + dd:SetSelection(i) + return + end + end + dd.id = nil + dd.text:SetText("") + end + + local function Print(msg) + DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccpf|cffffffffUI|r: " .. msg) + end + + -- Side panels open to the right of the equipment manager. Only one is + -- shown at a time; they're children of the sidecar so they close with it. + local subPanels = {} + local function CreateSubPanel(name, width, height, titleText) + local p = CreateFrame("Frame", name, frame) + p:SetSize(width, height) + p:SetFrameStrata("HIGH") + p:SetPoint("TOPLEFT", frame, "TOPRIGHT", 2*border + 2, 0) + p:EnableMouse(true) + CreateBackdrop(p, nil, nil, .9) + CreateBackdropShadow(p) + p:Hide() + + p.title = p:CreateFontString(nil, "OVERLAY", "GameFontNormal") + p.title:SetPoint("TOP", p, "TOP", 0, -10) + p.title:SetText(titleText) + + p.close = CreateFrame("Button", nil, p, "UIPanelCloseButton") + p.close:SetPoint("TOPRIGHT", p, "TOPRIGHT", -4, -4) + p.close:SetScript("OnClick", function() p:Hide() end) + SkinCloseButton(p.close, p.backdrop or p, -6, -6) + + table.insert(subPanels, p) + return p + end + + local function ToggleSubPanel(p) + if p:IsShown() then + p:Hide() + return + end + for _, other in ipairs(subPanels) do other:Hide() end + p:Show() + end + + -- ============================================================ + -- Items not in any set + -- ============================================================ + + local WHERE_ORDER = { equipped = 1, bags = 2, bank = 3 } + local BANK_BAGS = { -1, 5, 6, 7, 8, 9, 10 } -- main bank, bank bags + + local bankOpen = false + + -- Returns the list of equippable items that no equipment set references, + -- plus how many of those are on the ignore list. Ignored items are only + -- included in the list when includeIgnored is set. + local function ScanUnusedItems(includeIgnored) + local minQuality = tonumber(C.equipmentmanager.unusedquality) + local result, numIgnored = {}, 0 + + local function Consider(entry) + entry.icon = C_Item.GetItemIconByID(entry.itemID) + entry.quality = C_Item.GetItemQualityByID(entry.itemID) or 1 + if entry.quality < minQuality then return end + if db.ignore[entry.itemID] then + numIgnored = numIgnored + 1 + if not includeIgnored then return end + entry.ignored = true + end + table.insert(result, entry) + end + + -- Packed location of every item some set uses. Paperdoll and bag items + -- always resolve, so these match GetInventoryItemsForSlot's keys. + local inSet = {} + for _, setID in ipairs(GetVisibleSetIDs()) do + for _, location in pairs(C_EquipmentSet.GetItemLocations(setID)) do + inSet[location] = true + end + end + + -- Everything on the paperdoll or in the bags that fits a gear slot, + -- keyed by packed location (rings and trinkets fit two slots). + local items = {} + for invSlot = 1, 19 do GetInventoryItemsForSlot(invSlot, items) end + + for location, link in pairs(items) do + if not inSet[location] then + local loc = EquipmentManager_GetLocationData(location) + Consider({ + where = loc.isBags and "bags" or "equipped", + bag = loc.bag, slot = loc.slot, + itemID = (C_Item.GetItemInfoInstant(link)), link = link, + }) + end + end + + if C.equipmentmanager.unusedbank == "1" then + for _, item in ipairs(db.bank) do + if not item.inSet then + Consider({ where = "bank", bag = item.bag, slot = item.slot, itemID = item.id, link = item.link }) + end + end + end + + table.sort(result, function(a, b) + if a.where ~= b.where then return WHERE_ORDER[a.where] < WHERE_ORDER[b.where] end + if a.quality ~= b.quality then return a.quality > b.quality end + if a.itemID ~= b.itemID then return a.itemID < b.itemID end + if a.bag ~= b.bag then return (a.bag or 0) < (b.bag or 0) end + return a.slot < b.slot + end) + + return result, numIgnored + end + + -- Snapshot the bank while it's open so its items stay listed afterwards. + -- Set membership is recorded now as well: ClassicAPI only resolves set + -- items in the bank while the bank window is open. + local function ScanBank() + local snapshot = {} + for _, bag in ipairs(BANK_BAGS) do + for slot = 1, GetContainerNumSlots(bag) do + local itemID = C_Container.GetContainerItemID(bag, slot) + if itemID then + table.insert(snapshot, { + id = itemID, link = GetContainerItemLink(bag, slot), bag = bag, slot = slot, + inSet = C_Container.GetContainerItemEquipmentSetInfo(bag, slot), + }) + end + end + end + db.bank = snapshot + end + + local UNUSED_COLS, UNUSED_ROWS = 7, 8 + local UNUSED_SIZE, UNUSED_GAP = 30, 4 + local UNUSED_STRIDE = UNUSED_SIZE + UNUSED_GAP + + local unusedPanel = CreateSubPanel("pfEqMgrUnusedFrame", + UNUSED_COLS * UNUSED_STRIDE + 24, frame:GetHeight(), T["Items Not In Any Set"]) + unusedPanel.offset = 0 -- first visible row + + unusedPanel.info = unusedPanel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + unusedPanel.info:SetPoint("TOPLEFT", unusedPanel, "TOPLEFT", 14, -34) + unusedPanel.info:SetJustifyH("LEFT") + + unusedPanel.showIgnored = CreateCheckbox(unusedPanel) + unusedPanel.showIgnored:SetPoint("TOPRIGHT", unusedPanel, "TOPRIGHT", -12, -29) + unusedPanel.showIgnored.label = unusedPanel.showIgnored:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + unusedPanel.showIgnored.label:SetPoint("RIGHT", unusedPanel.showIgnored, "LEFT", -4, 0) + unusedPanel.showIgnored.label:SetText(T["Show Ignored"]) + + local unusedGrid = CreateFrame("Frame", nil, unusedPanel) + unusedGrid:SetPoint("TOPLEFT", unusedPanel, "TOPLEFT", 12 + UNUSED_GAP/2, -54) + unusedGrid:SetSize(UNUSED_COLS * UNUSED_STRIDE, UNUSED_ROWS * UNUSED_STRIDE) + unusedGrid:EnableMouseWheel(true) + + unusedPanel.empty = unusedPanel:CreateFontString(nil, "OVERLAY", "GameFontDisable") + unusedPanel.empty:SetPoint("CENTER", unusedGrid, "CENTER", 0, 0) + unusedPanel.empty:SetWidth(UNUSED_COLS * UNUSED_STRIDE - 10) + unusedPanel.empty:SetText(T["Every equippable item is part of a set."]) + + unusedPanel.hint = unusedPanel:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + unusedPanel.hint:SetPoint("BOTTOMLEFT", unusedPanel, "BOTTOMLEFT", 14, 12) + unusedPanel.hint:SetPoint("BOTTOMRIGHT", unusedPanel, "BOTTOMRIGHT", -14, 12) + unusedPanel.hint:SetJustifyH("LEFT") + unusedPanel.hint:SetText(T["Shift-Click: Link Ctrl-Click: Preview\nRight-Click: Ignore / Unignore"]) + + local RefreshUnusedPanel -- forward decl + local unusedButtons = {} + + -- Bag highlight: while the panel is open, items of the list glow in + -- pfUI's bag and bank frames and every other item is dimmed. + local bagHighlight = { active = false, locs = {}, hooked = false } + + local function ApplyBagHighlight(bag, slot) + local entry = pfUI.bags and pfUI.bags[bag] and pfUI.bags[bag].slots[slot] + if not entry or not entry.frame then return end + local f = entry.frame + local o = f.pfEqMgrUnused + + if not bagHighlight.active or not f.hasItem then + if o then o:Hide() end + return + end + + if not o then + o = CreateFrame("Frame", nil, f) + o:SetAllPoints(f) + o:SetFrameLevel(f:GetFrameLevel() + 3) + + o.dim = o:CreateTexture(nil, "OVERLAY") + o.dim:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + o.dim:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1) + o.dim:SetTexture(0, 0, 0, .7) + + o.glow = o:CreateTexture(nil, "OVERLAY") + o.glow:SetTexture("Interface\\Buttons\\UI-ActionButton-Border") + o.glow:SetBlendMode("ADD") + o.glow:SetVertexColor(.2, 1, .2, 1) + o.glow:SetPoint("CENTER", f, "CENTER", 0, 0) + + f.pfEqMgrUnused = o + end + + if bagHighlight.locs[bag] and bagHighlight.locs[bag][slot] then + local w = f:GetWidth() + if w and w > 0 then o.glow:SetSize(w * 1.8, w * 1.8) end + o.dim:Hide() + o.glow:Show() + else + o.glow:Hide() + o.dim:Show() + end + o:Show() + end + + local function UpdateBagHighlights() + -- Only pfUI's own bag frames are decorated (the bags module may be off). + if not pfUI.bag or not pfUI.bags then return end + if not bagHighlight.hooked and pfUI.bag.UpdateSlot then + -- Re-apply whenever pfUI redraws a slot (item moved, bag reopened...). + hooksecurefunc(pfUI.bag, "UpdateSlot", function(_, bag, slot) + if bagHighlight.active then ApplyBagHighlight(bag, slot) end + end) + bagHighlight.hooked = true + end + for bag, data in pairs(pfUI.bags) do + if data and data.slots then + for slot in pairs(data.slots) do ApplyBagHighlight(bag, slot) end + end + end + end + + local WHERE_TAG = { equipped = "|cff33ff33E|r", bags = "", bank = "|cff66aaffB|r" } + local WHERE_TEXT = { equipped = T["Equipped"], bags = T["In Bags"], bank = T["In Bank"] } + + local function CreateUnusedButton(i) + local b = CreateFrame("Button", nil, unusedGrid) + b:SetSize(UNUSED_SIZE, UNUSED_SIZE) + local col = math.mod(i - 1, UNUSED_COLS) + local row = math.floor((i - 1) / UNUSED_COLS) + b:SetPoint("TOPLEFT", unusedGrid, "TOPLEFT", col * UNUSED_STRIDE + UNUSED_GAP/2, -row * UNUSED_STRIDE - UNUSED_GAP/2) + CreateBackdrop(b) + b:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + b.icon = b:CreateTexture(nil, "ARTWORK") + b.icon:SetAllPoints(b) + b.icon:SetTexCoord(.08, .92, .08, .92) + + b.tag = b:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall") + b.tag:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -1, 1) + + b:SetScript("OnEnter", function() + local e = this.entry + if not e then return end + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + if e.where == "equipped" then + GameTooltip:SetInventoryItem("player", e.slot) + elseif e.where == "bags" then + GameTooltip:SetBagItem(e.bag, e.slot) + else + GameTooltip:SetHyperlink(e.link) + end + GameTooltip:AddLine(" ") + GameTooltip:AddLine(T["Not part of any equipment set"] .. " - " .. WHERE_TEXT[e.where], 1, .82, 0) + if e.ignored then + GameTooltip:AddLine(T["Ignored (Right-Click to unignore)"], .6, .6, .6) + end + GameTooltip:Show() + end) + b:SetScript("OnLeave", GameTooltip_Hide) + + b:SetScript("OnClick", function() + local e = this.entry + if not e then return end + if arg1 == "RightButton" then + db.ignore[e.itemID] = (not db.ignore[e.itemID]) and true or nil + GameTooltip:Hide() + pfUI.equipmentmanager.Refresh() + elseif IsShiftKeyDown() then + if ChatFrameEditBox:IsVisible() then ChatFrameEditBox:Insert(e.link) end + elseif IsControlKeyDown() then + DressUpItemLink(e.link) + end + end) + + return b + end + + unusedGrid:SetScript("OnMouseWheel", function() + unusedPanel.offset = unusedPanel.offset - (arg1 or 0) + RefreshUnusedPanel() + end) + + function RefreshUnusedPanel() + local list, numIgnored = ScanUnusedItems(unusedPanel.showIgnored:GetChecked() and true or nil) + local total = table.getn(list) + local numShownIgnored = unusedPanel.showIgnored:GetChecked() and numIgnored or 0 + unusedPanel.info:SetText(string.format(T["%d items, %d ignored"], total - numShownIgnored, numIgnored)) + + local totalRows = math.ceil(total / UNUSED_COLS) + local maxOffset = math.max(0, totalRows - UNUSED_ROWS) + if unusedPanel.offset > maxOffset then unusedPanel.offset = maxOffset end + if unusedPanel.offset < 0 then unusedPanel.offset = 0 end + local first = unusedPanel.offset * UNUSED_COLS + + for i = 1, UNUSED_COLS * UNUSED_ROWS do + local e = list[first + i] + local b = unusedButtons[i] + if e then + if not b then + b = CreateUnusedButton(i) + unusedButtons[i] = b + end + b.entry = e + b.icon:SetTexture(e.icon or "Interface\\Icons\\INV_Misc_QuestionMark") + b.icon:SetDesaturated(e.ignored and true or false) + b.icon:SetAlpha(e.ignored and .5 or 1) + b.tag:SetText(WHERE_TAG[e.where] or "") + local r, g, bl = GetItemQualityColor(e.quality or 1) + if b.backdrop then b.backdrop:SetBackdropBorderColor(r, g, bl, 1) end + b:Show() + elseif b then + b.entry = nil + b:Hide() + end + end + + if total == 0 then unusedPanel.empty:Show() else unusedPanel.empty:Hide() end + + bagHighlight.active = true + bagHighlight.locs = {} + for _, e in ipairs(list) do + if e.bag then + bagHighlight.locs[e.bag] = bagHighlight.locs[e.bag] or {} + bagHighlight.locs[e.bag][e.slot] = true + end + end + UpdateBagHighlights() + end + + unusedPanel.showIgnored:SetScript("OnClick", function() RefreshUnusedPanel() end) + unusedPanel:SetScript("OnShow", function() RefreshUnusedPanel() end) + unusedPanel:SetScript("OnHide", function() + bagHighlight.active = false + UpdateBagHighlights() + end) + + local bankEvents = CreateFrame("Frame") + bankEvents:RegisterEvent("BANKFRAME_OPENED") + bankEvents:RegisterEvent("BANKFRAME_CLOSED") + bankEvents:RegisterEvent("PLAYERBANKSLOTS_CHANGED") + bankEvents:RegisterEvent("BAG_UPDATE_DELAYED") + bankEvents:RegisterEvent("EQUIPMENT_SETS_CHANGED") + bankEvents:SetScript("OnEvent", function() + if event == "BANKFRAME_OPENED" then + bankOpen = true + elseif event == "BANKFRAME_CLOSED" then + bankOpen = false + return + end + if not bankOpen then return end + ScanBank() + if frame:IsShown() then pfUI.equipmentmanager.Refresh() end + end) + + -- ============================================================ + -- Automatic set switching (triggers) + -- ============================================================ + + local _, playerClass = UnitClass("player") + + -- Shapeshift form IDs as GetShapeshiftFormID reports them on this client + -- (SpellShapeshiftForm.dbc, incl. Turtle's custom forms). A value can + -- list several IDs so e.g. "Bear Form" covers Dire Bear Form too. + local FORM_OPTIONS = { + DRUID = { + { value = "0", text = T["No Form (Caster)"] }, + { value = "5,8", text = T["Bear Form"] }, + { value = "1", text = T["Cat Form"] }, + { value = "3,11", text = T["Travel Form"] }, + { value = "4", text = T["Aquatic Form"] }, + { value = "31", text = T["Moonkin Form"] }, + { value = "2,9", text = T["Tree of Life Form"] }, + }, + WARRIOR = { + { value = "17", text = T["Battle Stance"] }, + { value = "18", text = T["Defensive Stance"] }, + { value = "19", text = T["Berserker Stance"] }, + }, + ROGUE = { { value = "30", text = T["Stealth"] } }, + SHAMAN = { { value = "16", text = T["Ghost Wolf"] } }, + PRIEST = { { value = "28", text = T["Shadowform"] } }, + } + local myForms = FORM_OPTIONS[playerClass] + if myForms then table.insert(myForms, { value = "any", text = T["Any Form"] }) end + + local INSTANCE_OPTIONS = { + { value = "party", text = T["Dungeon"] }, + { value = "raid", text = T["Raid"] }, + { value = "pvp", text = T["Battleground"] }, + { value = "none", text = T["Open World"] }, + } + + -- value: nil (no parameter), "list" (dropdown from .options) or "text". + local TRIGGER_TYPES = { + { key = "mounted", text = T["Mounted"] }, + { key = "swimming", text = T["Swimming"] }, + { key = "dining", text = T["Eating / Drinking"] }, + { key = "resting", text = T["Resting (Inn / City)"] }, + { key = "stealth", text = T["Stealthed"] }, + { key = "form", text = T["Shapeshift Form / Stance"], value = "list", options = myForms }, + { key = "instance", text = T["Instance Type"], value = "list", options = INSTANCE_OPTIONS }, + { key = "zone", text = T["Zone"], value = "text" }, + { key = "boss", text = T["Targeting A Boss"] }, + { key = "time", text = T["Time Of Day"], value = "text" }, + } + local TRIGGER_TYPE = {} + for _, info in ipairs(TRIGGER_TYPES) do TRIGGER_TYPE[info.key] = info end + + local function DefaultTriggerValue(key) + if key == "form" then return myForms and myForms[1].value end + if key == "instance" then return "raid" end + if key == "zone" then return GetRealZoneText() end + if key == "time" then return "20:00-23:00" end + end + + local function DescribeTrigger(t) + local info = TRIGGER_TYPE[t.type] + if not info then return "?" end + if info.value == "list" then + for _, o in ipairs(info.options or {}) do + if o.value == t.value then return info.text .. ": " .. o.text end + end + return info.text + elseif info.value == "text" then + return info.text .. ": " .. (t.value or "") + end + return info.text + end + + -- Condition checks --------------------------------------------- + + -- Every food and drink buff shares the name of the base Food (433) or + -- Drink (430) aura, so their localized names identify all of them. + local DINING_AURAS = { C_Spell.GetSpellName(433), C_Spell.GetSpellName(430) } + + -- Like Outfitter: the dining set stays on only while there's still + -- health or mana to regenerate, so it comes off as soon as you're full. + local function IsDining() + local healthFull = UnitHealth("player") >= UnitHealthMax("player") + local manaFull = UnitPowerType("player") ~= 0 or UnitMana("player") >= UnitManaMax("player") + if healthFull and manaFull then return false end + for _, name in ipairs(DINING_AURAS) do + if C_UnitAuras.GetAuraDataBySpellName("player", name, "HELPFUL") then return true end + end + return false + end + + local function InForm(value) + local formID = GetShapeshiftFormID() or 0 + if value == "any" then return formID ~= 0 end + for _, id in ipairs({ strsplit(",", value) }) do + if tonumber(id) == formID then return true end + end + return false + end + + -- Zone trigger values are "zone; subzone; ..." as typed or added with "Here". + local function ZoneEntries(value) + local entries = {} + for _, entry in ipairs({ strsplit(";", value or "") }) do + entry = strtrim(entry):lower() + if entry ~= "" then table.insert(entries, entry) end + end + return entries + end + + local function InZone(value) + local zone, subzone, minimap = GetRealZoneText():lower(), GetSubZoneText():lower(), GetMinimapZoneText():lower() + for _, entry in ipairs(ZoneEntries(value)) do + if entry == zone or entry == subzone or entry == minimap then return true end + end + return false + end + + local function ParseTimeWindow(value) + local _, _, h1, m1, h2, m2 = (value or ""):find("^%s*(%d%d?):(%d%d)%s*%-%s*(%d%d?):(%d%d)%s*$") + if not h1 then return nil end + return tonumber(h1) * 60 + tonumber(m1), tonumber(h2) * 60 + tonumber(m2) + end + + local function InTimeWindow(value) + local from, to = ParseTimeWindow(value) + if not from then return false end + local now = tonumber(date("%H")) * 60 + tonumber(date("%M")) + if from <= to then return now >= from and now < to end + return now >= from or now < to -- window wraps past midnight + end + + local function TargetIsBoss() + if not UnitExists("target") or UnitIsDead("target") then return false end + if not UnitCanAttack("player", "target") or UnitIsPlayer("target") then return false end + return UnitClassification("target") == "worldboss" or UnitLevel("target") == -1 + end + + local function ConditionActive(t) + local key = t.type + if key == "mounted" then return IsMounted() and true or false + elseif key == "swimming" then return IsSwimming() and true or false + elseif key == "dining" then return IsDining() + elseif key == "resting" then return IsResting() and true or false + elseif key == "stealth" then return IsStealthed() and true or false + elseif key == "form" then return InForm(t.value) + elseif key == "instance" then + local _, instanceType = GetInstanceInfo() + return instanceType == t.value + elseif key == "zone" then return InZone(t.value) + elseif key == "boss" then return TargetIsBoss() + elseif key == "time" then return InTimeWindow(t.value) + end + return false + end + + -- Engine --------------------------------------------------------- + + -- forceApply: the active trigger was edited, equip its (new) set again. + local engine = { ready = false, timer = nil, poll = nil, forceApply = nil } + + local function FindTrigger(id) + if not id then return nil end + for i, t in ipairs(db.triggers) do + if t.id == id then return t, i end + end + end + + local function SetExists(setID) + return setID and C_EquipmentSet.GetEquipmentSetInfo(setID) and not IsRestoreSet(setID) + end + + local function TriggerUsable(t) + return t.enabled and SetExists(t.setID) and TRIGGER_TYPE[t.type] and true or false + end + + -- Highest-priority (topmost) enabled trigger whose condition holds. + local function GetActiveTrigger() + for _, t in ipairs(db.triggers) do + if TriggerUsable(t) and ConditionActive(t) then return t end + end + end + + -- Whether the applied trigger differs from the one that should be, and + -- the one that should be. + local function NeedsSwitch() + local best = GetActiveTrigger() + return engine.forceApply or (best and best.id or 0) ~= (db.applied or 0), best + end + + -- Gear can't change in combat or while dead, swapping mid-cast would + -- interrupt it, and only one set swap runs at a time: the switch waits. + local function SwapBlocked(setID) + if UnitAffectingCombat("player") or UnitIsDeadOrGhost("player") then return true end + if C_Spell.UnitCastingInfo("player") or C_Spell.UnitChannelInfo("player") then return true end + if CursorHasItem() or not C_EquipmentSet.CanUseEquipmentSets() then return true end + return C_EquipmentSet.EquipmentSetContainsLockedItems(setID) + end + + -- A swap that's blocked or refused is retried by scheduling again. + local function TryEquip(setID) + return not SwapBlocked(setID) and C_EquipmentSet.UseEquipmentSet(setID) + end + + -- Remember the gear worn before the first trigger fires. Only slots that + -- some trigger set actually changes are recorded, so restoring doesn't + -- undo unrelated gear changes made while the trigger was active. + local function TakeRestoreSnapshot() + local touched = {} + for _, t in ipairs(db.triggers) do + if TriggerUsable(t) then + local ignored = {} + for _, s in ipairs(C_EquipmentSet.GetIgnoredSlots(t.setID) or {}) do ignored[s] = true end + for s = 1, 19 do + if not ignored[s] then touched[s] = true end + end + end + end + + C_EquipmentSet.ClearIgnoredSlotsForSave() + for s = 1, 19 do + if not touched[s] then C_EquipmentSet.IgnoreSlotForSave(s) end + end + local id = C_EquipmentSet.GetEquipmentSetID(RESTORE_SET_NAME) + if id then + C_EquipmentSet.SaveEquipmentSet(id) + else + C_EquipmentSet.CreateEquipmentSet(RESTORE_SET_NAME, "Spell_Nature_TimeStop") + end + C_EquipmentSet.ClearIgnoredSlotsForSave() + end + + local function Announce(msg) + if C.equipmentmanager.announce == "1" then Print(msg) end + end + + local function SetName(setID) + return (C_EquipmentSet.GetEquipmentSetInfo(setID)) or "?" + end + + local RefreshTriggerPanel -- forward decl + local Schedule -- forward decl + + local function Evaluate() + if not engine.ready or C.equipmentmanager.autoswitch ~= "1" then return end + + local needed, best = NeedsSwitch() + if not needed then return end + local appliedID = db.applied or 0 + + if best then + if SwapBlocked(best.setID) then return Schedule() end + -- Only the first trigger in a chain records what to go back to. + if appliedID == 0 then TakeRestoreSnapshot() end + if not C_EquipmentSet.UseEquipmentSet(best.setID) then return Schedule() end + db.applied = best.id + Announce(string.format(T["Equipping %s (%s)"], SetName(best.setID), DescribeTrigger(best))) + else + local prev = FindTrigger(appliedID) + local leave = prev and prev.leave or "restore" + local restoreID = C_EquipmentSet.GetEquipmentSetID(RESTORE_SET_NAME) + if leave == "restore" and restoreID then + if not TryEquip(restoreID) then return Schedule() end + Announce(T["Restoring previous gear"]) + elseif type(leave) == "number" and SetExists(leave) then + if not TryEquip(leave) then return Schedule() end + Announce(string.format(T["Equipping %s"], SetName(leave))) + end + -- The swap copied the item GUIDs when it started; the set can go. + if restoreID then C_EquipmentSet.DeleteEquipmentSet(restoreID) end + db.applied = nil + end + + engine.forceApply = nil + RefreshTriggerPanel() + end + + -- Switch half a second after something changed, so bobbing at the water + -- surface or a quick target swap doesn't thrash gear. Also retries a + -- swap that's blocked. + function Schedule() + if engine.timer then return end + engine.timer = C_Timer.NewTimer(0.5, function() + engine.timer = nil + Evaluate() + end) + end + + -- Swimming and the time of day fire no event, so those are polled while + -- a trigger needs them. + local POLLED = { swimming = true, time = true } + + local engineEvents = CreateFrame("Frame") + + -- Call after any change to the triggers or the autoswitch setting. + local function UpdateEngine() + local poll, dining = false, false + if C.equipmentmanager.autoswitch == "1" then + for _, t in ipairs(db.triggers) do + if t.enabled and POLLED[t.type] then poll = true end + if t.enabled and t.type == "dining" then dining = true end + end + end + + -- The dining set comes off once health and mana are full; these fire + -- constantly in combat, so only listen while a dining trigger exists. + if dining then + engineEvents:RegisterUnitEvent("UNIT_HEALTH", "player") + engineEvents:RegisterUnitEvent("UNIT_MANA", "player") + else + engineEvents:UnregisterEvent("UNIT_HEALTH") + engineEvents:UnregisterEvent("UNIT_MANA") + end + + if poll and not engine.poll then + engine.poll = C_Timer.NewTicker(0.5, function() + if NeedsSwitch() then Schedule() end + end) + elseif not poll and engine.poll then + engine.poll:Cancel() + engine.poll = nil + end + Schedule() + end + + engineEvents:RegisterEvent("PLAYER_ENTERING_WORLD") + engineEvents:RegisterEvent("PLAYER_LEAVING_WORLD") + engineEvents:RegisterUnitEvent("UNIT_AURA", "player") -- mounted, stealth, dining + engineEvents:RegisterEvent("UPDATE_SHAPESHIFT_FORM") + engineEvents:RegisterEvent("ZONE_CHANGED") + engineEvents:RegisterEvent("ZONE_CHANGED_INDOORS") + engineEvents:RegisterEvent("ZONE_CHANGED_NEW_AREA") + engineEvents:RegisterEvent("PLAYER_UPDATE_RESTING") + engineEvents:RegisterEvent("PLAYER_TARGET_CHANGED") + engineEvents:RegisterEvent("PLAYER_REGEN_ENABLED") -- a boss target died mid-fight + engineEvents:SetScript("OnEvent", function() + if event == "PLAYER_LEAVING_WORLD" then + engine.ready = false + elseif event == "PLAYER_ENTERING_WORLD" then + -- Zone/instance info is unreliable right after a loading screen. + engine.ready = false + C_Timer.After(3, function() + engine.ready = true + Schedule() + end) + else + Schedule() + end + end) + + -- ============================================================ + -- Trigger editor panel + -- ============================================================ + + local TRIGGER_ROWS, TRIGGER_ROW_H = 5, 24 + local TRIGGER_STRIDE = TRIGGER_ROW_H + 2 + local triggerPanel = CreateSubPanel("pfEqMgrTriggerFrame", 310, 404, T["Automatic Set Switching"]) + local selectedTriggerID = nil + triggerPanel.offset = 0 + + local cbAuto = CreateCheckbox(triggerPanel, T["Enable Automatic Set Switching"]) + cbAuto:SetPoint("TOPLEFT", triggerPanel, "TOPLEFT", 12, -30) + cbAuto:SetScript("OnClick", function() + C.equipmentmanager.autoswitch = this:GetChecked() and "1" or "0" + UpdateEngine() + end) + + local triggerList = CreateFrame("Frame", nil, triggerPanel) + triggerList:SetPoint("TOPLEFT", triggerPanel, "TOPLEFT", 14, -56) + triggerList:SetSize(282, TRIGGER_ROWS * TRIGGER_STRIDE + 4) + CreateBackdrop(triggerList, nil, nil, .75) + triggerList:EnableMouseWheel(true) + triggerList:SetScript("OnMouseWheel", function() + triggerPanel.offset = triggerPanel.offset - (arg1 or 0) + RefreshTriggerPanel() + end) + + triggerList.empty = triggerList:CreateFontString(nil, "OVERLAY", "GameFontDisable") + triggerList.empty:SetPoint("CENTER", triggerList, "CENTER", 0, 0) + triggerList.empty:SetWidth(260) + triggerList.empty:SetText(T["No triggers yet. Click \"New Trigger\" to add one."]) + + local function MoveTrigger(index, delta) + local target = index + delta + if target < 1 or target > table.getn(db.triggers) then return end + db.triggers[index], db.triggers[target] = db.triggers[target], db.triggers[index] + UpdateEngine() + RefreshTriggerPanel() + end + + local triggerRows = {} + for i = 1, TRIGGER_ROWS do + local row = CreateFrame("Button", nil, triggerList) + row:SetSize(272, TRIGGER_ROW_H) + row:SetPoint("TOPLEFT", triggerList, "TOPLEFT", 5, -3 - (i - 1) * TRIGGER_STRIDE) + + row.highlight = row:CreateTexture(nil, "BACKGROUND") + row.highlight:SetAllPoints(row) + row.highlight:SetTexture(.3, .3, .3, .4) + row.highlight:Hide() + + row.check = CreateCheckbox(row) + row.check:SetPoint("LEFT", row, "LEFT", 0, 0) + row.check:SetScript("OnClick", function() + if not row.trigger then return end + row.trigger.enabled = this:GetChecked() and true or false + UpdateEngine() + RefreshTriggerPanel() + end) + + row.delete = CreateSmallButton(row, "close", 14) + row.delete:SetPoint("RIGHT", row, "RIGHT", -2, 0) + row.down = CreateSmallButton(row, "down", 14) + row.down:SetPoint("RIGHT", row.delete, "LEFT", -4, 0) + row.up = CreateSmallButton(row, "up", 14) + row.up:SetPoint("RIGHT", row.down, "LEFT", -4, 0) + + row.up:SetScript("OnClick", function() if row.index then MoveTrigger(row.index, -1) end end) + row.down:SetScript("OnClick", function() if row.index then MoveTrigger(row.index, 1) end end) + row.delete:SetScript("OnClick", function() + if not row.index then return end + local t = table.remove(db.triggers, row.index) + if t and t.id == selectedTriggerID then selectedTriggerID = nil end + UpdateEngine() + RefreshTriggerPanel() + end) + + row.text = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + row.text:SetPoint("LEFT", row.check, "RIGHT", 4, 0) + row.text:SetPoint("RIGHT", row.up, "LEFT", -4, 0) + row.text:SetJustifyH("LEFT") + + row:SetScript("OnClick", function() + if not row.trigger then return end + selectedTriggerID = row.trigger.id + RefreshTriggerPanel() + end) + + row:SetScript("OnEnter", function() + if not row.trigger then return end + GameTooltip:SetOwner(row, "ANCHOR_RIGHT") + GameTooltip:SetText(DescribeTrigger(row.trigger), 1, 1, 1) + local t = row.trigger + local equip = SetExists(t.setID) and SetName(t.setID) or ("|cffff5555" .. T["No set selected"] .. "|r") + GameTooltip:AddDoubleLine(T["Equip"], equip, .7, .7, .7, 1, 1, 1) + local leave = T["Restore Previous Gear"] + if t.leave == "none" then leave = T["Do Nothing"] + elseif type(t.leave) == "number" then leave = SetExists(t.leave) and SetName(t.leave) or "?" end + GameTooltip:AddDoubleLine(T["When It Ends"], leave, .7, .7, .7, 1, 1, 1) + if db.applied == t.id then + GameTooltip:AddLine(T["Currently active"], .2, 1, .2) + elseif TriggerUsable(t) and ConditionActive(t) then + GameTooltip:AddLine(T["Condition met, but a trigger higher in the list wins"], 1, .82, 0) + end + GameTooltip:Show() + end) + row:SetScript("OnLeave", function() GameTooltip:Hide() end) + + triggerRows[i] = row + end + + local btnNewTrigger = CreateFrame("Button", nil, triggerPanel, "UIPanelButtonTemplate") + btnNewTrigger:SetSize(110, 22) + btnNewTrigger:SetPoint("TOPLEFT", triggerList, "BOTTOMLEFT", 0, -6) + btnNewTrigger:SetText(T["New Trigger"]) + SkinButton(btnNewTrigger) + btnNewTrigger:SetScript("OnClick", function() + local ids = GetVisibleSetIDs() + local t = { + id = db.nextID, + enabled = true, + type = "mounted", + setID = ids[1], + leave = "restore", + } + db.nextID = db.nextID + 1 + table.insert(db.triggers, t) + selectedTriggerID = t.id + -- Scroll so the new trigger is visible. + triggerPanel.offset = math.max(0, table.getn(db.triggers) - TRIGGER_ROWS) + UpdateEngine() + RefreshTriggerPanel() + end) + + -- Editor for the selected trigger + local editor = CreateFrame("Frame", nil, triggerPanel) + editor:SetPoint("TOPLEFT", btnNewTrigger, "BOTTOMLEFT", 0, -12) + editor:SetSize(282, 108) + + editor.header = editor:CreateFontString(nil, "OVERLAY", "GameFontNormal") + editor.header:SetPoint("TOPLEFT", editor, "TOPLEFT", 0, 0) + editor.header:SetText(T["Selected Trigger"]) + + local EDITOR_ROW_Y = { -18, -44, -70, -96 } + local function EditorLabel(text, row) + local fs = editor:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + fs:SetPoint("TOPLEFT", editor, "TOPLEFT", 0, EDITOR_ROW_Y[row] - 4) + fs:SetText(text) + return fs + end + + local function EditorDropdown(row) + local dd = CreateDropDownButton(nil, editor) + dd:SetWidth(190) + dd:SetPoint("TOPRIGHT", editor, "TOPRIGHT", 0, EDITOR_ROW_Y[row]) + return dd + end + + local function SelectedTrigger() + return FindTrigger(selectedTriggerID) + end + + local function OnTriggerEdited() + -- Re-evaluate from scratch: the edit may change which trigger wins, + -- and an edited active trigger may now point at another set. + if selectedTriggerID and selectedTriggerID == db.applied then engine.forceApply = true end + UpdateEngine() + RefreshTriggerPanel() + end + + EditorLabel(T["When"], 1) + local ddType = EditorDropdown(1) + ddType:SetMenu(function() + local menu = {} + for _, info in ipairs(TRIGGER_TYPES) do + if info.value ~= "list" or info.options then + local key = info.key + table.insert(menu, { text = info.text, value = key, func = function() + local t = SelectedTrigger() + if not t or t.type == key then return end + t.type = key + t.value = DefaultTriggerValue(key) + OnTriggerEdited() + end }) + end + end + return menu + end) + + local valueLabel = EditorLabel(T["Value"], 2) + local ddValue = EditorDropdown(2) + ddValue:SetMenu(function() + local menu = {} + local t = SelectedTrigger() + local info = t and TRIGGER_TYPE[t.type] + for _, o in ipairs(info and info.options or {}) do + local value = o.value + table.insert(menu, { text = o.text, value = value, func = function() + local t = SelectedTrigger() + if not t then return end + t.value = value + OnTriggerEdited() + end }) + end + return menu + end) + + local editValue = CreateTextBox(nil, editor) + editValue:SetHeight(20) + editValue:SetTextInsets(4, 4, 2, 2) + editValue:SetScript("OnTextChanged", function() + if this.updating then return end + local t = SelectedTrigger() + if not t then return end + t.value = this:GetText() + Schedule() + end) + editValue:SetScript("OnEnterPressed", function() + this:ClearFocus() + RefreshTriggerPanel() + end) + editValue:SetScript("OnEditFocusGained", function() this.hasFocus = true end) + editValue:SetScript("OnEditFocusLost", function() + this.hasFocus = nil + RefreshTriggerPanel() + end) + editValue:SetScript("OnEnter", function() + local t = SelectedTrigger() + if not t then return end + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + if t.type == "zone" then + GameTooltip:SetText(T["Zone or subzone name"], 1, 1, 1) + GameTooltip:AddLine(T["Separate several zones with ;"], .8, .8, .8, 1) + else + GameTooltip:SetText(T["Local time window, e.g. 20:00-23:00"], 1, 1, 1) + GameTooltip:AddLine(T["Windows may wrap past midnight (22:00-02:00)."], .8, .8, .8, 1) + end + GameTooltip:Show() + end) + editValue:SetScript("OnLeave", function() GameTooltip:Hide() end) + + local btnCurrentZone = CreateFrame("Button", nil, editor, "UIPanelButtonTemplate") + btnCurrentZone:SetSize(56, 20) + btnCurrentZone:SetPoint("TOPRIGHT", editor, "TOPRIGHT", 0, EDITOR_ROW_Y[2]) + btnCurrentZone:SetText(T["Here"]) + SkinButton(btnCurrentZone) + btnCurrentZone:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(T["Add the current zone"], 1, 1, 1) + GameTooltip:AddLine(GetRealZoneText() .. ((GetSubZoneText() or "") ~= "" and (" / " .. GetSubZoneText()) or ""), .8, .8, .8) + GameTooltip:Show() + end) + btnCurrentZone:SetScript("OnLeave", function() GameTooltip:Hide() end) + btnCurrentZone:SetScript("OnClick", function() + local t = SelectedTrigger() + if not t then return end + -- Shift adds the subzone instead of the zone. + local here = IsShiftKeyDown() and GetSubZoneText() or GetRealZoneText() + if here == "" then here = GetRealZoneText() end + local entries = ZoneEntries(t.value) + if table.getn(entries) == 0 then + t.value = here + else + for _, entry in ipairs(entries) do + if entry == here:lower() then return end + end + t.value = t.value .. "; " .. here + end + OnTriggerEdited() + end) + + editValue:SetPoint("TOPLEFT", editor, "TOPRIGHT", -190, EDITOR_ROW_Y[2]) + editValue:SetPoint("BOTTOMRIGHT", editor, "TOPRIGHT", 0, EDITOR_ROW_Y[2] - 20) + + EditorLabel(T["Equip"], 3) + local ddSet = EditorDropdown(3) + ddSet:SetMenu(function() + local menu = {} + for _, id in ipairs(GetVisibleSetIDs()) do + local setID = id + table.insert(menu, { text = SetName(setID), value = setID, func = function() + local t = SelectedTrigger() + if not t then return end + t.setID = setID + OnTriggerEdited() + end }) + end + return menu + end) + + EditorLabel(T["When It Ends"], 4) + local ddLeave = EditorDropdown(4) + ddLeave:SetMenu(function() + local function Choose(value) + return function() + local t = SelectedTrigger() + if not t then return end + t.leave = value + OnTriggerEdited() + end + end + local menu = { + { text = T["Restore Previous Gear"], value = "restore", func = Choose("restore") }, + { text = T["Do Nothing"], value = "none", func = Choose("none") }, + } + for _, id in ipairs(GetVisibleSetIDs()) do + table.insert(menu, { text = T["Equip"] .. " " .. SetName(id), value = id, func = Choose(id) }) + end + return menu + end) + + editor.noSets = editor:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + editor.noSets:SetPoint("TOPRIGHT", editor, "TOPRIGHT", 0, EDITOR_ROW_Y[3] - 4) + editor.noSets:SetText(T["Create an equipment set first."]) + + local triggerHint = triggerPanel:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + triggerHint:SetPoint("BOTTOMLEFT", triggerPanel, "BOTTOMLEFT", 14, 12) + triggerHint:SetPoint("BOTTOMRIGHT", triggerPanel, "BOTTOMRIGHT", -14, 12) + triggerHint:SetHeight(40) -- room to wrap to three lines below the editor + triggerHint:SetJustifyH("LEFT") + triggerHint:SetJustifyV("BOTTOM") + triggerHint:SetText(T["Triggers higher in the list take priority. Gear is never swapped in combat, while dead or while casting: the switch waits until it can happen."]) + + editor.placeholder = triggerPanel:CreateFontString(nil, "OVERLAY", "GameFontDisable") + editor.placeholder:SetPoint("TOPLEFT", editor, "TOPLEFT", 0, -30) + editor.placeholder:SetPoint("TOPRIGHT", editor, "TOPRIGHT", 0, -30) + editor.placeholder:SetText(T["Select a trigger to edit it."]) + + local function RefreshTriggerRows() + cbAuto:SetChecked(C.equipmentmanager.autoswitch == "1") + + local num = table.getn(db.triggers) + local maxOffset = math.max(0, num - TRIGGER_ROWS) + if triggerPanel.offset > maxOffset then triggerPanel.offset = maxOffset end + if triggerPanel.offset < 0 then triggerPanel.offset = 0 end + + for i, row in ipairs(triggerRows) do + local index = i + triggerPanel.offset + local t = db.triggers[index] + if t then + row.trigger = t + row.index = index + row.check:SetChecked(t.enabled) + + local color = "|cffffffff" + if not t.enabled then + color = "|cff808080" + elseif not SetExists(t.setID) then + color = "|cffff5555" + elseif db.applied == t.id then + color = "|cff33ff33" + elseif ConditionActive(t) then + color = "|cffffd100" + end + local target = SetExists(t.setID) and SetName(t.setID) or T["No set selected"] + row.text:SetText(color .. DescribeTrigger(t) .. "|r |cffaaaaaa>|r " .. color .. target .. "|r") + + if t.id == selectedTriggerID then row.highlight:Show() else row.highlight:Hide() end + if index > 1 then row.up:Enable() else row.up:Disable() end + if index < num then row.down:Enable() else row.down:Disable() end + row:Show() + else + row.trigger = nil + row.index = nil + row:Hide() + end + end + if num == 0 then triggerList.empty:Show() else triggerList.empty:Hide() end + end + + function RefreshTriggerPanel() + if not triggerPanel:IsShown() then return end + RefreshTriggerRows() + + local num = table.getn(db.triggers) + local t = SelectedTrigger() + if not t then + editor:Hide() + editor.placeholder:SetShown(num > 0) + return + end + editor:Show() + editor.placeholder:Hide() + + SelectDropdownValue(ddType, t.type) + + local info = TRIGGER_TYPE[t.type] + local kind = info and info.value + valueLabel:SetShown(kind ~= nil) + ddValue:SetShown(kind == "list") + editValue:SetShown(kind == "text") + btnCurrentZone:SetShown(t.type == "zone") + if kind == "list" then + SelectDropdownValue(ddValue, t.value) + elseif kind == "text" then + editValue:ClearAllPoints() + editValue:SetPoint("TOPLEFT", editor, "TOPRIGHT", -190, EDITOR_ROW_Y[2]) + if t.type == "zone" then + editValue:SetPoint("BOTTOMRIGHT", btnCurrentZone, "BOTTOMLEFT", -4, 0) + else + editValue:SetPoint("BOTTOMRIGHT", editor, "TOPRIGHT", 0, EDITOR_ROW_Y[2] - 20) + end + if not editValue.hasFocus then + editValue.updating = true + editValue:SetText(t.value or "") + editValue.updating = nil + end + local valid = t.type ~= "time" or ParseTimeWindow(t.value) + editValue:SetTextColor(1, valid and 1 or .3, valid and 1 or .3) + end + + local hasSets = table.getn(GetVisibleSetIDs()) > 0 + ddSet:SetShown(hasSets) + editor.noSets:SetShown(not hasSets) + SelectDropdownValue(ddSet, t.setID) + SelectDropdownValue(ddLeave, t.leave or "restore") + end + + triggerPanel:SetScript("OnShow", function() RefreshTriggerPanel() end) + -- Row colors show which conditions currently hold; keep them live. + triggerPanel:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + (arg1 or 0) + if this.elapsed < 1 then return end + this.elapsed = 0 + RefreshTriggerRows() + end) + triggerPanel:SetScript("OnHide", function() + for _, dd in ipairs({ ddType, ddValue, ddSet, ddLeave }) do dd:HideMenu() end + editValue:ClearFocus() + end) + + -- ============================================================ + -- Sidecar buttons that open the panels + -- ============================================================ + + local btnUnused = CreateFrame("Button", "pfEqMgrUnused", frame, "UIPanelButtonTemplate") + btnUnused:SetSize(86, 22) + btnUnused:SetPoint("TOPLEFT", listFrame, "BOTTOMLEFT", 0, -8) + btnUnused:SetText(T["Unused"]) + SkinButton(btnUnused) + btnUnused:SetScript("OnClick", function() ToggleSubPanel(unusedPanel) end) + btnUnused:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(T["Items Not In Any Set"], 1, 1, 1) + GameTooltip:AddLine(T["Equippable items in your bags, bank and paperdoll that no equipment set uses."], .8, .8, .8, 1) + GameTooltip:Show() + end) + btnUnused:SetScript("OnLeave", function() GameTooltip:Hide() end) + + local btnTriggers = CreateFrame("Button", "pfEqMgrTriggers", frame, "UIPanelButtonTemplate") + btnTriggers:SetSize(86, 22) + btnTriggers:SetPoint("LEFT", btnUnused, "RIGHT", 6, 0) + btnTriggers:SetText(T["Triggers"]) + SkinButton(btnTriggers) + btnTriggers:SetScript("OnClick", function() ToggleSubPanel(triggerPanel) end) + btnTriggers:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(T["Automatic Set Switching"], 1, 1, 1) + GameTooltip:AddLine(T["Equip sets automatically when mounted, swimming, eating, in a zone, at a given time and more."], .8, .8, .8, 1) + GameTooltip:Show() + end) + btnTriggers:SetScript("OnLeave", function() GameTooltip:Hide() end) + + -- Called at the end of every Refresh(). + function pfUI.equipmentmanager.RefreshExtras() + local list = ScanUnusedItems() + btnUnused:SetText(T["Unused"] .. " (" .. table.getn(list) .. ")") + if unusedPanel:IsShown() then RefreshUnusedPanel() end + if triggerPanel:IsShown() then RefreshTriggerPanel() end + end + + -- pfUI GUI hook (U["equipmentmanager"]). + function pfUI.equipmentmanager.UpdateConfig() + UpdateEngine() + if frame:IsShown() then pfUI.equipmentmanager.Refresh() end + end + + UpdateEngine() + -- ============================================================ -- Events -- ============================================================ diff --git a/modules/gui.lua b/modules/gui.lua index 24e1ebe5..62dfb4f4 100644 --- a/modules/gui.lua +++ b/modules/gui.lua @@ -2428,6 +2428,21 @@ pfUI:RegisterModule("gui", function () CreateConfig(nil, T["Always Show Equipment Slot Flyouts"], C.character.inventory, "equipflyout", "checkbox") end) + CreateGUIEntry(T["Character"], T["Equipment Manager"], function() + CreateConfig(nil, T["Automatic Set Switching"], nil, nil, "header") + CreateConfig(U["equipmentmanager"], T["Enable Automatic Set Switching"], C.equipmentmanager, "autoswitch", "checkbox") + CreateConfig(U["equipmentmanager"], T["Announce Automatic Set Switches In Chat"], C.equipmentmanager, "announce", "checkbox") + CreateConfig(nil, T["Items Not In Any Set"], nil, nil, "header") + CreateConfig(U["equipmentmanager"], T["Minimum Item Quality"], C.equipmentmanager, "unusedquality", "dropdown", { + "0:" .. ITEM_QUALITY0_DESC, + "1:" .. ITEM_QUALITY1_DESC, + "2:" .. ITEM_QUALITY2_DESC, + "3:" .. ITEM_QUALITY3_DESC, + "4:" .. ITEM_QUALITY4_DESC, + }) + CreateConfig(U["equipmentmanager"], T["Include Bank Items"], C.equipmentmanager, "unusedbank", "checkbox") + end) + CreateGUIEntry(T["Character"], T["Reputation"], function() CreateConfig(nil, T["Show Reputation Remaining"], C.character.reputation, "repRequired", "checkbox") end) diff --git a/pfUI.toc b/pfUI.toc index 2c1e3cb7..56e53869 100644 --- a/pfUI.toc +++ b/pfUI.toc @@ -5,7 +5,7 @@ ## Notes-ruRU: Полная замена пользовательского интерфейса. ## Version: @project-version@ ## SavedVariables: pfUI_profiles, pfUI_addon_profiles, pfUI_cache, pfUI_throttle -## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB +## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB, pfUI_eqmgr ## X-Website: https://github.com/brues-code/pfUI # ClassicAPI-less fallback. ClassicAPI redirects this read to pfUI_Turtle.toc diff --git a/pfUI_ClassicAPI.toc b/pfUI_ClassicAPI.toc index 5443ff8b..25a1738b 100644 --- a/pfUI_ClassicAPI.toc +++ b/pfUI_ClassicAPI.toc @@ -5,7 +5,7 @@ ## Notes-ruRU: Полная замена пользовательского интерфейса. ## Version: @project-version@ ## SavedVariables: pfUI_profiles, pfUI_addon_profiles, pfUI_cache, pfUI_throttle -## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB +## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB, pfUI_eqmgr ## X-Website: https://github.com/brues-code/pfUI # Selected by ClassicAPI on every non-Turtle client. Turtle clients get diff --git a/pfUI_Turtle.toc b/pfUI_Turtle.toc index 8e121236..5319bbcf 100644 --- a/pfUI_Turtle.toc +++ b/pfUI_Turtle.toc @@ -5,7 +5,7 @@ ## Notes-ruRU: Полная замена пользовательского интерфейса. ## Version: @project-version@ ## SavedVariables: pfUI_profiles, pfUI_addon_profiles, pfUI_cache, pfUI_throttle -## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB +## SavedVariablesPerCharacter: pfUI_config, pfUI_init, pfUI_playerDB, pfUI_eqmgr ## X-Website: https://github.com/brues-code/pfUI # Selected by ClassicAPI on Turtle WoW, where it wins over pfUI_ClassicAPI.toc.