Files
pfUI/modules/equipmentmanager.lua
T
0cabf2ec29 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>
2026-10-27 11:02:44 -05:00

2183 lines
84 KiB
Lua

-- Equipment Manager module
-- Backport of the 4.3.4 GearManagerDialog UI on top of ClassicAPI's C_EquipmentSet.* API.
pfUI:RegisterModule("equipmentmanager", function()
if not C_EquipmentSet or not C_EquipmentSet.CanUseEquipmentSets() then return end
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()
local selectedSetID = nil
local pendingAction = nil -- "new" | "save" | "rename" — what the popups apply to
local slotOverlays = {} -- [invSlotID] = ignored-overlay texture on the character slot
local popoutButtons = {} -- popout arrow buttons; toggled with the EM frame
local UpdatePopouts -- forward decl; assigned once popouts + flyout exist
-- Pending ignored-slot toggles per set: a slotID here means the
-- effective state is flipped from what's persisted. Committed on
-- Save, cleared on delete.
local pendingIgnoredToggles = {}
local function GetEffectiveIgnored(setID)
local result = {}
if not setID then return result end
local persistent = C_EquipmentSet.GetIgnoredSlots(setID) or {}
for _, s in ipairs(persistent) do result[s] = true end
local pending = pendingIgnoredToggles[setID]
if pending then
for slotID in pairs(pending) do
if result[slotID] then result[slotID] = nil else result[slotID] = true end
end
end
return result
end
local function ToggleIgnoredForSet(setID, slotID)
if not setID then return end
pendingIgnoredToggles[setID] = pendingIgnoredToggles[setID] or {}
if pendingIgnoredToggles[setID][slotID] then
pendingIgnoredToggles[setID][slotID] = nil
else
pendingIgnoredToggles[setID][slotID] = true
end
end
local function SetPopoutReversed(popout, reversed)
if not popout then return end
local nc = reversed and popout.coordReversed or popout.coordNormal
local hc = reversed and popout.coordReversedHi or popout.coordNormalHi
popout:GetNormalTexture():SetTexCoord(unpack(nc))
popout:GetHighlightTexture():SetTexCoord(unpack(hc))
end
local function EquipSet(setID)
if not setID then return end
if C_EquipmentSet.EquipmentSetContainsLockedItems(setID) then
UIErrorsFrame:AddMessage(ERR_CLIENT_LOCKED_OUT, 1, .1, .1, 1)
return
end
ClearCursor()
C_EquipmentSet.UseEquipmentSet(setID)
end
-- ============================================================
-- Main content frame: opens as a sidecar to the right of CharacterFrame
-- (matches ReputationDetailFrame's positioning pattern).
-- ============================================================
local frame = CreateFrame("Frame", "pfEquipmentManagerFrame", CharacterFrame)
frame:SetSize(220, 368)
frame:SetFrameStrata("HIGH")
frame:SetScript("OnShow", function()
this:ClearAllPoints()
if CharacterFrame.backdrop then
frame:SetPoint("TOPLEFT", CharacterFrame.backdrop, "TOPRIGHT", 2*border, -2)
else
frame:SetPoint("TOPLEFT", CharacterFrame, "TOPRIGHT", 0, 0)
end
UpdatePopouts()
end)
CreateBackdrop(frame, nil, nil, .9)
CreateBackdropShadow(frame)
frame:Hide()
frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
frame.title:SetPoint("TOP", frame, "TOP", 0, -10)
frame.title:SetText(EQUIPMENT_MANAGER)
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, -4)
closeBtn:SetScript("OnClick", function() frame:Hide() end)
if SkinCloseButton then SkinCloseButton(closeBtn, frame.backdrop or frame, -6, -6) end
-- ============================================================
-- Toggle button on character pane (above the Hands slot)
-- ============================================================
local toggleBtn = CreateFrame("Button", "pfEqMgrToggleButton", PaperDollFrame)
toggleBtn:SetSize(28, 28)
toggleBtn:SetPoint("BOTTOM", CharacterHandsSlot, "TOP", 0, 4)
toggleBtn:SetNormalTexture(pfUI.path.."\\img\\UI-GearManager-Button")
toggleBtn:SetPushedTexture(pfUI.path.."\\img\\UI-GearManager-Button-Pushed")
toggleBtn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
toggleBtn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(PAPERDOLL_EQUIPMENTMANAGER)
GameTooltip:Show()
end)
toggleBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
toggleBtn:SetScript("OnClick", function()
if frame:IsShown() then
frame:Hide()
else
frame:Show()
pfUI.equipmentmanager.Refresh()
end
end)
local OpenNamePopup -- forward declared; assigned below
-- ============================================================
-- Equip + Save buttons (top of list area)
-- ============================================================
local btnEquip = CreateFrame("Button", "pfEqMgrEquip", frame, "UIPanelButtonTemplate")
btnEquip:SetSize(86, 22); btnEquip:SetText(EQUIPSET_EQUIP)
btnEquip:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -30)
SkinButton(btnEquip)
btnEquip:SetScript("OnClick", function() EquipSet(selectedSetID) end)
local btnSave = CreateFrame("Button", "pfEqMgrSave", frame, "UIPanelButtonTemplate")
btnSave:SetSize(86, 22); btnSave:SetText(SAVE)
btnSave:SetPoint("LEFT", btnEquip, "RIGHT", 6, 0)
SkinButton(btnSave)
btnSave:SetScript("OnClick", function()
if not selectedSetID then return end
local name, icon = C_EquipmentSet.GetEquipmentSetInfo(selectedSetID)
if not name then return end
local targetID = selectedSetID
StaticPopupDialogs["PFUI_EQMGR_SAVE_CONFIRM"] = {
text = string.format(CONFIRM_SAVE_EQUIPMENT_SET, name),
button1 = YES, button2 = NO,
OnAccept = function()
C_EquipmentSet.ClearIgnoredSlotsForSave()
local effective = GetEffectiveIgnored(targetID)
for slotID in pairs(effective) do
C_EquipmentSet.IgnoreSlotForSave(slotID)
end
C_EquipmentSet.SaveEquipmentSet(targetID, icon)
C_EquipmentSet.ClearIgnoredSlotsForSave()
pendingIgnoredToggles[targetID] = nil
pfUI.equipmentmanager.Refresh()
end,
timeout = 0, whileDead = 1, hideOnEscape = 1, exclusive = 1,
}
StaticPopup_Show("PFUI_EQMGR_SAVE_CONFIRM")
end)
-- ============================================================
-- Per-row context menu (single instance, repositioned per click).
-- Triggered by the gear icon on each set row.
-- ============================================================
local rowMenu = CreateFrame("Frame", "pfEqMgrRowMenu", UIParent)
rowMenu:SetFrameStrata("DIALOG")
rowMenu:SetSize(140, 50);
rowMenu:Hide()
CreateBackdrop(rowMenu, nil, nil, .95)
CreateBackdropShadow(rowMenu)
rowMenu:EnableMouse(true)
-- Close on click outside via GLOBAL_MOUSE_DOWN (fires on any click
-- without consuming it). The anchorBtn check defers the gear-click
-- toggle to the gear's own OnClick so opening doesn't trigger close.
rowMenu:RegisterEvent("GLOBAL_MOUSE_DOWN")
rowMenu:SetScript("OnEvent", function()
if not this:IsShown() then return end
if MouseIsOverHitRect(this) then return end
if this.anchorBtn and MouseIsOverHitRect(this.anchorBtn) then return end
this:Hide()
end)
tinsert(UISpecialFrames, "pfEqMgrRowMenu") -- Escape closes it
rowMenu.changeBtn = CreateFrame("Button", nil, rowMenu, "UIPanelButtonTemplate")
rowMenu.changeBtn:SetSize(130, 20);
rowMenu.changeBtn:SetPoint("TOPLEFT", rowMenu, "TOPLEFT", 5, -3)
rowMenu.changeBtn:SetText(EQUIPMENT_SET_EDIT)
SkinButton(rowMenu.changeBtn)
rowMenu.changeBtn:SetScript("OnClick", function()
rowMenu:Hide()
if not rowMenu.targetSetID then return end
local name, icon = C_EquipmentSet.GetEquipmentSetInfo(rowMenu.targetSetID)
selectedSetID = rowMenu.targetSetID
OpenNamePopup("save", name, icon)
end)
rowMenu.deleteBtn = CreateFrame("Button", nil, rowMenu, "UIPanelButtonTemplate")
rowMenu.deleteBtn:SetSize(130, 20);
rowMenu.deleteBtn:SetPoint("TOP", rowMenu.changeBtn, "BOTTOM", 0, -2)
rowMenu.deleteBtn:SetText(DELETE)
SkinButton(rowMenu.deleteBtn)
rowMenu.deleteBtn:SetScript("OnClick", function()
rowMenu:Hide()
if not rowMenu.targetSetID then return end
local targetID = rowMenu.targetSetID
local name = C_EquipmentSet.GetEquipmentSetInfo(targetID)
StaticPopupDialogs["PFUI_EQMGR_DELETE"] = {
text = string.format(CONFIRM_DELETE_EQUIPMENT_SET, name or "?"),
button1 = YES, button2 = NO,
OnAccept = function()
pendingIgnoredToggles[targetID] = nil
C_EquipmentSet.DeleteEquipmentSet(targetID)
if selectedSetID == targetID then selectedSetID = nil end
pfUI.equipmentmanager.Refresh()
end,
timeout = 0, whileDead = 1, hideOnEscape = 1, exclusive = 1,
}
StaticPopup_Show("PFUI_EQMGR_DELETE")
end)
-- ============================================================
-- Set list (left column)
-- ============================================================
local LIST_VISIBLE_ROWS = 7
local LIST_ROW_STRIDE = SET_ROW_HEIGHT + 2
local listFrame = CreateFrame("Frame", nil, frame)
listFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -58)
-- Height fits N rows + (N-1) inter-row gaps + 3px top/bottom padding.
listFrame:SetSize(180, LIST_VISIBLE_ROWS * LIST_ROW_STRIDE + 4)
CreateBackdrop(listFrame, nil, nil, .75)
-- Mouse-wheel scroll: list of [sets..., newSetRow] is virtualized
-- through LIST_VISIBLE_ROWS slots. Refresh() consumes listFrame.offset
-- and the wheel handler clamps + bumps it.
listFrame.offset = 0
listFrame:EnableMouseWheel(true)
listFrame:SetScript("OnMouseWheel", function()
listFrame.offset = listFrame.offset - (arg1 or 0)
pfUI.equipmentmanager.Refresh()
end)
local setRows = {}
local function CreateSetRow()
local row = CreateFrame("Button", nil, listFrame)
row:SetSize(170, SET_ROW_HEIGHT)
-- Position is set per-Refresh based on the row's visible slot;
-- start anchored to avoid uninitialized geometry before first Refresh.
row:SetPoint("TOPLEFT", listFrame, "TOPLEFT", 5, -3)
row.icon = row:CreateTexture(nil, "ARTWORK")
row.icon:SetSize(30, 30)
row.icon:SetPoint("LEFT", row, "LEFT", 2, 0)
row.icon:SetTexCoord(.08, .92, .08, .92)
row.text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
row.text:SetPoint("LEFT", row.icon, "RIGHT", 6, 0)
row.text:SetPoint("RIGHT", row, "RIGHT", -22, 0)
row.text:SetJustifyH("LEFT")
row.highlight = row:CreateTexture(nil, "BACKGROUND")
row.highlight:SetAllPoints(row)
row.highlight:SetTexture(.3, .3, .3, .4)
row.highlight:Hide()
row.gear = CreateFrame("Button", nil, row)
row.gear:SetSize(16, 16);
row.gear:SetPoint("RIGHT", row, "RIGHT", -4, 0)
row.gear.tex = row.gear:CreateTexture(nil, "ARTWORK")
row.gear.tex:SetAllPoints(row.gear)
row.gear.tex:SetTexture(pfUI.path.."\\img\\Gear_64Grey")
row.gear:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square", "ADD")
row.gear:Hide()
row.gear:SetScript("OnClick", function()
if not row.setID then return end
-- Toggle: clicking the gear that owns the open menu closes it.
-- GLOBAL_MOUSE_DOWN's anchorBtn check excludes the gear, so the
-- close has to happen here.
if rowMenu:IsShown() and rowMenu.targetSetID == row.setID then
rowMenu:Hide()
return
end
rowMenu.targetSetID = row.setID
rowMenu.anchorBtn = row.gear
rowMenu:ClearAllPoints()
rowMenu:SetPoint("TOPRIGHT", row.gear, "BOTTOMRIGHT", 0, 0)
rowMenu:Show()
end)
row:SetScript("OnClick", function()
selectedSetID = row.setID
-- Detect double-click manually; vanilla Button has no native event.
local now = GetTime()
if row.lastClick and (now - row.lastClick) < 0.4 then
row.lastClick = nil
EquipSet(row.setID)
else
row.lastClick = now
end
pfUI.equipmentmanager.Refresh()
end)
-- Drag a set onto an action bar. PickupEquipmentSet is newer than
-- the rest of C_EquipmentSet, so dragging stays inert on ClassicAPI
-- builds that don't ship it yet.
if C_EquipmentSet.PickupEquipmentSet then
row:RegisterForDrag("LeftButton")
row:SetScript("OnDragStart", function()
if row.setID then C_EquipmentSet.PickupEquipmentSet(row.setID) end
end)
end
-- Show the gear and tooltip while the cursor is over the row or its
-- gear child. The gear sits inside the row's rectangle, so a single
-- MouseIsOverHitRect(row) check covers both. OnLeave on the row and the
-- gear catches every exit path, so no OnUpdate poll is needed.
local function HideRowHover()
if MouseIsOverHitRect(row) then return end
GameTooltip:Hide()
row.gear:Hide()
end
row:SetScript("OnEnter", function()
if not row.setID then return end
local name = C_EquipmentSet.GetEquipmentSetInfo(row.setID)
if name then
GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
GameTooltip:SetEquipmentSet(name)
GameTooltip:Show()
end
row.gear:Show()
end)
row:SetScript("OnLeave", HideRowHover)
row.gear:SetScript("OnLeave", HideRowHover)
return row
end
-- Rows are allocated lazily in Refresh — ClassicAPI no longer caps
-- the number of equipment sets, so we grow the pool to numSets on
-- demand. The set list is wheel-scrolled, so only LIST_VISIBLE_ROWS
-- are ever positioned in-frame at a time, but each row is bound to a
-- specific set index (row[i] always shows ids[i]).
local newSetRow = CreateFrame("Button", nil, listFrame)
newSetRow:SetSize(170, SET_ROW_HEIGHT);
newSetRow.icon = newSetRow:CreateTexture(nil, "ARTWORK")
newSetRow.icon:SetSize(24, 24);
newSetRow.icon:SetPoint("LEFT", newSetRow, "LEFT", 5, 0)
newSetRow.icon:SetTexture(pfUI.path.."\\img\\Character-Plus")
newSetRow.text = newSetRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
newSetRow.text:SetPoint("LEFT", newSetRow.icon, "RIGHT", 8, 0)
newSetRow.text:SetPoint("RIGHT", newSetRow, "RIGHT", -4, 0)
newSetRow.text:SetJustifyH("LEFT")
newSetRow.text:SetText(PAPERDOLL_NEWEQUIPMENTSET)
newSetRow.text:SetTextColor(0.2, 1, 0.2)
newSetRow.highlight = newSetRow:CreateTexture(nil, "BACKGROUND")
newSetRow.highlight:SetAllPoints(newSetRow)
newSetRow.highlight:SetTexture(.3, .3, .3, .4)
newSetRow.highlight:Hide()
newSetRow:SetScript("OnEnter", function() newSetRow.highlight:Show() end)
newSetRow:SetScript("OnLeave", function() newSetRow.highlight:Hide() end)
newSetRow:SetScript("OnClick", function() OpenNamePopup("new") end)
local function MakeButton(name, label, parent, anchor, ax, ay, width)
local b = CreateFrame("Button", name, parent, "UIPanelButtonTemplate")
b:SetSize(width or 70, 22)
b:SetText(label)
b:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", ax, ay)
SkinButton(b)
return b
end
-- ============================================================
-- Name/icon entry popup
-- ============================================================
local namePopup = CreateFrame("Frame", "pfEqMgrNamePopup", UIParent)
namePopup:SetFrameStrata("DIALOG")
namePopup:SetSize(472, 484)
namePopup:SetPoint("CENTER", UIParent, "CENTER")
namePopup:Hide()
CreateBackdrop(namePopup, nil, nil, .9)
CreateBackdropShadow(namePopup)
namePopup:EnableMouse(true)
namePopup:SetMovable(true)
namePopup:RegisterForDrag("LeftButton")
namePopup:SetScript("OnDragStart", function() this:StartMoving() end)
namePopup:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
-- Equipment seed leads the grid with gear-relevant item icons. The widget
-- owns the name field, preview, grid, filter, and search; this module keeps
-- the OK/Cancel buttons and the create/save/rename flow below.
local iconPicker = CreateIconPicker("pfEqMgrIcon", namePopup,
IconDataProviderExtraType.Equipment, GEARSETS_POPUP_TEXT)
local btnPopupOK = MakeButton("pfEqMgrPopupOK", OKAY, namePopup, namePopup, 14, -340, 80)
local btnPopupCancel = MakeButton("pfEqMgrPopupCancel", CANCEL, namePopup, namePopup, 0, 0, 80)
btnPopupCancel:ClearAllPoints()
btnPopupCancel:SetPoint("BOTTOMRIGHT", namePopup, "BOTTOMRIGHT", -14, 12)
btnPopupCancel:SetScript("OnClick", function() namePopup:Hide() end)
-- Okay | Cancel, both anchored to the bottom-right corner.
btnPopupOK:ClearAllPoints()
btnPopupOK:SetPoint("BOTTOMRIGHT", btnPopupCancel, "BOTTOMLEFT", -6, 0)
btnPopupOK:SetScript("OnClick", function()
local name = namePopup.editbox:GetText()
if not name or name == "" then return end
-- Strip the prefix to match ClassicAPI's persisted short-form basenames.
local iconForSave = string.gsub(iconPicker.GetIcon(), "INTERFACE\\ICONS\\", "")
if pendingAction == "new" then
C_EquipmentSet.CreateEquipmentSet(name, iconForSave)
C_EquipmentSet.ClearIgnoredSlotsForSave()
selectedSetID = C_EquipmentSet.GetEquipmentSetID(name)
elseif pendingAction == "save" and selectedSetID then
-- Commit pending ignored toggles: load the effective list into
-- ClassicAPI's session state, then SaveEquipmentSet captures it.
C_EquipmentSet.ClearIgnoredSlotsForSave()
local effective = GetEffectiveIgnored(selectedSetID)
for slotID in pairs(effective) do
C_EquipmentSet.IgnoreSlotForSave(slotID)
end
C_EquipmentSet.SaveEquipmentSet(selectedSetID, iconForSave)
C_EquipmentSet.ClearIgnoredSlotsForSave()
pendingIgnoredToggles[selectedSetID] = nil
elseif pendingAction == "rename" and selectedSetID then
C_EquipmentSet.ModifyEquipmentSet(selectedSetID, name)
end
namePopup:Hide()
pfUI.equipmentmanager.Refresh()
end)
-- Assignment (not `local function`) so this fills in the forward
-- declaration at the top of the module — closures created earlier
-- (row gear menu, "+ New Set" row, etc.) capture the same upvalue.
function OpenNamePopup(action, prefillName, prefillIcon)
pendingAction = action
namePopup.editbox:SetText(prefillName or "")
if prefillIcon then
-- Stored icons come back either as a full path or a bare basename
-- (see the row rendering in Refresh). Only prepend the prefix when
-- there's no path separator, so a full path isn't double-prefixed.
iconPicker.SetIcon(string.find(prefillIcon, "\\") and prefillIcon
or ("INTERFACE\\ICONS\\" .. prefillIcon))
else
iconPicker.SetIcon(nil)
end
namePopup.search:SetText("")
-- Rename only changes the name, so hide the whole icon-picking area.
iconPicker.SetIconAreaShown(action ~= "rename")
namePopup:Show()
if action ~= "rename" then iconPicker.Refresh() end
namePopup.editbox:SetFocus()
end
-- ============================================================
-- Equipment flyout (per-slot popup)
-- ============================================================
local flyout = CreateFrame("Frame", "pfEqMgrFlyout", UIParent)
-- The name/icon popup is sidecar-only, so it closes with the sidecar.
-- Popout arrows and the flyout are reconciled by UpdatePopouts: by
-- default they follow the sidecar, but with the "Always Show Equipment
-- Slot Flyouts" option they stay while the paperdoll is open.
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()
CreateBackdrop(flyout, nil, nil, .9)
flyout.buttons = {}
local FLYOUT_BTN_SIZE = 36
local FLYOUT_COLS = 5
-- Sentinels for the flyout's virtual entries (negative values never
-- collide with real packed locations from GetInventoryItemsForSlot).
local PLACEINBAGS_LOCATION = -1
local IGNORESLOT_LOCATION = -2 -- "ignore this slot" — shown when not ignored
local UNIGNORESLOT_LOCATION = -3 -- "un-ignore this slot" — shown when ignored
-- Find first empty bag slot and drop the cursor item into it.
local function UnequipToBags(invSlot)
if not GetInventoryItemID("player", invSlot) then return end
ClearCursor()
PickupInventoryItem(invSlot)
if not CursorHasItem() then return end
for bag = 0, 4 do
local nslots = GetContainerNumSlots(bag) or 0
for slot = 1, nslots do
if not C_Container.GetContainerItemID(bag, slot) then
PickupContainerItem(bag, slot)
return
end
end
end
ClearCursor()
UIErrorsFrame:AddMessage(ERR_EQUIPMENT_MANAGER_BAGS_FULL, 1, .1, .1, 1)
end
local function MakeFlyoutButton(i)
local b = CreateFrame("Button", nil, flyout)
b:SetWidth(FLYOUT_BTN_SIZE)
b:SetHeight(FLYOUT_BTN_SIZE)
CreateBackdrop(b)
b.texture = b:CreateTexture(nil, "ARTWORK")
b.texture:SetAllPoints(b)
b.texture:SetTexCoord(.08, .92, .08, .92)
b.count = b:CreateFontString(nil, "OVERLAY", "NumberFontNormal")
b.count:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -2, 2)
b:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
if this.specialAction == "placeInBags" then
GameTooltip:SetText(EQUIPMENT_MANAGER_PLACE_IN_BAGS, 1, 1, 1)
elseif this.specialAction == "ignore" then
GameTooltip:SetText(EQUIPMENT_MANAGER_IGNORE_SLOT, 1, 1, 1)
elseif this.specialAction == "unignore" then
GameTooltip:SetText(EQUIPMENT_MANAGER_UNIGNORE_SLOT, 1, 1, 1)
elseif this.bag then
GameTooltip:SetBagItem(this.bag, this.slot)
elseif this.invSlot then
GameTooltip:SetInventoryItem("player", this.invSlot)
end
GameTooltip:Show()
end)
b:SetScript("OnLeave", GameTooltip_Hide)
b:SetScript("OnClick", function()
if this.specialAction == "placeInBags" then
UnequipToBags(flyout.targetInvSlot)
flyout:Hide()
return
elseif this.specialAction == "ignore" or this.specialAction == "unignore" then
ToggleIgnoredForSet(selectedSetID, flyout.targetInvSlot)
flyout:Hide()
pfUI.equipmentmanager.Refresh()
return
end
ClearCursor()
if this.bag then
PickupContainerItem(this.bag, this.slot)
elseif this.invSlot then
PickupInventoryItem(this.invSlot)
end
if CursorHasItem() then
PickupInventoryItem(flyout.targetInvSlot)
end
flyout:Hide()
end)
return b
end
function pfUI.equipmentmanager.ShowFlyout(slotBtn)
-- Accept either the EM grid btn (has .slotID) or a vanilla
-- CharacterXxxxSlot button (use GetID()).
local invSlot = slotBtn.slotID or slotBtn:GetID()
-- ClassicAPI's GetInventoryItemsForSlot does the eligibility filter
-- (invType → slot compatibility, 2H/finger/trinket rules) for us;
-- the result is `{[packedLocation] = itemLink}`.
local items = {}
GetInventoryItemsForSlot(invSlot, items)
-- Skip the item currently equipped in this slot (it would be a no-op swap).
items[invSlot + ITEM_INVENTORY_LOCATION_PLAYER] = nil
-- Special buttons go FIRST (ignore/un-ignore, then place-in-bags),
-- with eligible items appended after. Layout matches modern WoW.
local ordered = {}
if selectedSetID then
local effective = GetEffectiveIgnored(selectedSetID)
table.insert(ordered, effective[invSlot] and UNIGNORESLOT_LOCATION or IGNORESLOT_LOCATION)
end
if GetInventoryItemID("player", invSlot) then
table.insert(ordered, PLACEINBAGS_LOCATION)
end
-- Eligible items, sorted by packed location for stable display.
local itemLocations = {}
for location in pairs(items) do
table.insert(itemLocations, location)
end
table.sort(itemLocations)
for _, location in ipairs(itemLocations) do
table.insert(ordered, location)
end
flyout.targetInvSlot = invSlot
local num = table.getn(ordered)
while table.getn(flyout.buttons) < num do
flyout.buttons[table.getn(flyout.buttons) + 1] = MakeFlyoutButton(table.getn(flyout.buttons) + 1)
end
for i, b in ipairs(flyout.buttons) do
if i <= num then
local location = ordered[i]
b.specialAction = nil; b.bag = nil; b.slot = nil; b.invSlot = nil
local quality
if location == PLACEINBAGS_LOCATION then
b.specialAction = "placeInBags"
b.texture:SetTexture(pfUI.path.."\\img\\UI-GearManager-ItemIntoBag")
b.count:SetText("")
elseif location == IGNORESLOT_LOCATION then
b.specialAction = "ignore"
b.texture:SetTexture(pfUI.path.."\\img\\UI-GearManager-LeaveItem-Opaque")
b.count:SetText("")
elseif location == UNIGNORESLOT_LOCATION then
b.specialAction = "unignore"
b.texture:SetTexture(pfUI.path.."\\img\\UI-GearManager-Undo")
b.count:SetText("")
else
local loc = EquipmentManager_GetLocationData(location)
if loc.isBags then
b.bag = loc.bag; b.slot = loc.slot
local tex, count, _, q = GetContainerItemInfo(loc.bag, loc.slot)
quality = q
b.texture:SetTexture(tex)
if count and count > 1 then b.count:SetText(count) else b.count:SetText("") end
else -- isPlayer (equipped in some other slot)
b.invSlot = loc.slot
b.texture:SetTexture(GetInventoryItemTexture("player", loc.slot))
b.count:SetText("")
quality = GetInventoryItemQuality("player", loc.slot)
end
end
-- Quality border, matching the character slots
if quality and quality > 0 then
b.backdrop:SetBackdropBorderColor(GetItemQualityColor(quality))
else
b.backdrop:SetBackdropBorderColor(pfUI.cache.er, pfUI.cache.eg, pfUI.cache.eb, pfUI.cache.ea)
end
local col = math.mod(i - 1, FLYOUT_COLS)
local row = math.floor((i - 1) / FLYOUT_COLS)
b:ClearAllPoints()
b:SetPoint("TOPLEFT", flyout, "TOPLEFT", 4 + col * (FLYOUT_BTN_SIZE + 2), -4 - row * (FLYOUT_BTN_SIZE + 2))
b:Show()
else
b:Hide()
end
end
if num == 0 then
flyout:Hide()
return
end
local cols = math.min(num, FLYOUT_COLS)
local rows = math.ceil(num / FLYOUT_COLS)
flyout:SetWidth(cols * (FLYOUT_BTN_SIZE + 2) + 6)
flyout:SetHeight(rows * (FLYOUT_BTN_SIZE + 2) + 6)
flyout:ClearAllPoints()
-- Anchor to the popout button so the flyout sits past it (avoids
-- overlapping the chevron). Falls back to the slot itself for the
-- EM grid btns which don't have a separate popout.
local anchorBtn = slotBtn.popout or slotBtn
if invSlot == 16 or invSlot == 17 or invSlot == 18 then
flyout:SetPoint("BOTTOMLEFT", anchorBtn, "TOPLEFT", 0, 4)
else
local centerX = (anchorBtn:GetLeft() or 0) + anchorBtn:GetWidth()/2
if centerX > GetScreenWidth()/2 then
flyout:SetPoint("TOPRIGHT", anchorBtn, "TOPLEFT", -4, 0)
else
flyout:SetPoint("TOPLEFT", anchorBtn, "TOPRIGHT", 4, 0)
end
end
-- Reverse the chevron on the active popout, restore the previously
-- active one (if any). flyout.currentPopout drives OnHide cleanup.
if flyout.currentPopout and flyout.currentPopout ~= slotBtn.popout then
SetPopoutReversed(flyout.currentPopout, false)
end
flyout.currentPopout = slotBtn.popout
SetPopoutReversed(slotBtn.popout, true)
flyout:Show()
end
flyout:SetScript("OnHide", function()
this.targetInvSlot = nil
if this.currentPopout then
SetPopoutReversed(this.currentPopout, false)
this.currentPopout = nil
end
end)
-- ============================================================
-- Paperdoll slot setup: ignored overlay + popout button per slot.
-- ============================================================
local CHAR_SLOT_NAMES = {
"HeadSlot", "NeckSlot", "ShoulderSlot", "BackSlot", "ChestSlot",
"ShirtSlot", "TabardSlot", "WristSlot", "HandsSlot", "WaistSlot",
"LegsSlot", "FeetSlot", "Finger0Slot", "Finger1Slot",
"Trinket0Slot", "Trinket1Slot", "MainHandSlot", "SecondaryHandSlot",
"RangedSlot",
}
-- Slot ID classification for popout positioning. Weapons sit at the
-- bottom of the paperdoll, so their popout goes on top (chevron up).
-- All other slots get the popout on their right side, chevron right.
local POPOUT_TEX = pfUI.path.."\\img\\UI-GearManager-FlyoutButton"
for _, slotName in ipairs(CHAR_SLOT_NAMES) do
local slot = _G["Character"..slotName]
if slot then
-- "Ignored" overlay rendered on top of the paperdoll slot icon.
-- pfUI loads modules BEFORE skins (pfUI.lua:407-418), so at this
-- point slot.backdrop doesn't exist yet — the character skin
-- creates it later and reparents the item icon to it. A plain
-- texture on `slot` would render BENEATH the backdrop's icon.
-- Wrap the texture in a frame with a higher frame level so it
-- renders above the backdrop regardless of skin-init order.
local overlayFrame = CreateFrame("Frame", nil, slot)
overlayFrame:SetAllPoints(slot)
overlayFrame:SetFrameLevel(slot:GetFrameLevel() + 10)
local overlay = overlayFrame:CreateTexture(nil, "OVERLAY")
overlay:SetAllPoints(overlayFrame)
overlay:SetTexture(pfUI.path.."\\img\\UI-GearManager-LeaveItem-Transparent")
overlayFrame:Hide()
slotOverlays[slot:GetID()] = overlayFrame
-- Popout arrow button — clicking opens the equipment flyout for
-- this slot. Replaces the Alt+click trigger with a discoverable
-- visual affordance matching modern WoW's paperdoll.
local invSlot = slot:GetID()
local popout = CreateFrame("Button", nil, slot)
popout:SetFrameLevel(slot:GetFrameLevel() + 1)
-- Match Blizzard's EquipmentFlyoutPopoutButtonTemplate: 16x32,
-- anchored LEFT to slot's RIGHT (chevron points away from slot
-- toward the flyout's opening side).
popout:SetWidth(16); popout:SetHeight(32)
if INVSLOTS_EQUIPABLE_IN_COMBAT[invSlot] then
-- Weapon row: popout on TOP, vertical orientation.
popout:SetWidth(32); popout:SetHeight(16)
popout:SetPoint("BOTTOM", slot, "TOP", 0, 0)
else
-- All horizontal slots get the popout on the RIGHT edge, chevron
-- points right. Right-column slot popouts extend slightly into
-- the EM sidecar gutter — acceptable, matches modern UX.
popout:SetPoint("LEFT", slot, "RIGHT", 0, 0)
end
popout:SetNormalTexture(POPOUT_TEX)
popout:SetHighlightTexture(POPOUT_TEX, "ADD")
-- Stash both texCoord variants (normal=closed, reversed=open) so
-- SetPopoutReversed can swap between them. Reversed = swap all
-- y-values 0↔0.5 / 0.5↔1, matching modern WoW.
if INVSLOTS_EQUIPABLE_IN_COMBAT[invSlot] then
-- Closed: chevron points UP (toward where the flyout will open).
popout.coordNormal = { 0.15625, 0.84375, 0, 0.5 }
popout.coordReversed = { 0.15625, 0.84375, 0.5, 0 }
popout.coordNormalHi = { 0.15625, 0.84375, 0.5, 1 }
popout.coordReversedHi = { 0.15625, 0.84375, 1, 0.5 }
else
popout.coordNormal = { 0.15625, 0.5, 0.84375, 0.5, 0.15625, 0, 0.84375, 0 }
popout.coordReversed = { 0.15625, 0, 0.84375, 0, 0.15625, 0.5, 0.84375, 0.5 }
popout.coordNormalHi = { 0.15625, 1, 0.84375, 1, 0.15625, 0.5, 0.84375, 0.5 }
popout.coordReversedHi = { 0.15625, 0.5, 0.84375, 0.5, 0.15625, 1, 0.84375, 1 }
end
SetPopoutReversed(popout, false)
slot.popout = popout
popout:SetScript("OnClick", function()
-- Toggle: if the flyout is showing for this same slot, hide it;
-- otherwise (re)open it for the clicked slot.
if flyout:IsShown() and flyout.targetInvSlot == invSlot then
flyout:Hide()
else
pfUI.equipmentmanager.ShowFlyout(slot)
end
end)
popout:Hide()
table.insert(popoutButtons, popout)
end
end
-- Popout arrows follow the EM sidecar by default. With the
-- "Always Show Equipment Slot Flyouts" option they follow the paperdoll
-- instead, so gear can be swapped without opening the equipment manager.
local function PopoutsActive()
if C.character.inventory.equipflyout == "1" then
return PaperDollFrame:IsShown()
end
return frame:IsShown()
end
function UpdatePopouts()
local show = PopoutsActive()
for _, b in ipairs(popoutButtons) do
if show then b:Show() else b:Hide() end
end
if not show then flyout:Hide() end
end
PaperDollFrame:HookScript("OnShow", UpdatePopouts)
PaperDollFrame:HookScript("OnHide", UpdatePopouts)
-- ============================================================
-- Refresh
-- ============================================================
function pfUI.equipmentmanager.Refresh()
local ids = GetVisibleSetIDs()
local numSets = table.getn(ids)
-- Auto-select the first set if none is selected (or the previously
-- selected one was deleted). Falls through to "show equipped" only
-- when zero sets exist.
if numSets > 0 then
local stillExists = false
if selectedSetID then
for _, id in ipairs(ids) do
if id == selectedSetID then stillExists = true; break end
end
end
if not stillExists then selectedSetID = ids[1] end
else
selectedSetID = nil
end
-- Virtual list = sets ++ newSetRow. Clamp the current scroll
-- offset against the visible window before laying out rows so
-- deleting sets snaps the list back into view.
local totalItems = numSets + 1
local maxOffset = math.max(0, totalItems - LIST_VISIBLE_ROWS)
if listFrame.offset > maxOffset then listFrame.offset = maxOffset end
if listFrame.offset < 0 then listFrame.offset = 0 end
local off = listFrame.offset
-- Set rows: lazy-grow the pool to numSets, then position visible
-- ones into slots and hide the rest. Each row stays bound to a
-- specific set index (row[i] always shows ids[i]).
for i = 1, numSets do
if not setRows[i] then setRows[i] = CreateSetRow() end
local row = setRows[i]
local slot = i - off
if slot >= 1 and slot <= LIST_VISIBLE_ROWS then
local setID = ids[i]
local name, icon, _, isEquipped, _, _, _, numMissing = C_EquipmentSet.GetEquipmentSetInfo(setID)
row.setID = setID
-- Color precedence: missing items (red) > equipped (green) > normal (white).
local color = (numMissing and numMissing > 0) and "|cffff5555"
or isEquipped and "|cff33ff33" or "|cffffffff"
row.text:SetText(color .. (name or "?") .. "|r")
if icon then
local full = string.find(icon, "\\") and icon or ("Interface\\Icons\\" .. icon)
row.icon:SetTexture(full)
else
row.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
end
if setID == selectedSetID then row.highlight:Show() else row.highlight:Hide() end
row:ClearAllPoints()
row:SetPoint("TOPLEFT", listFrame, "TOPLEFT", 5, -3 - (slot - 1) * LIST_ROW_STRIDE)
row:Show()
else
row:Hide()
row.setID = nil
end
end
-- Hide pool entries past the current set count (left over from a
-- previous Refresh with more sets — e.g. just deleted one).
for i = numSets + 1, table.getn(setRows) do
setRows[i]:Hide()
setRows[i].setID = nil
end
-- "+ New Set" row occupies virtual index numSets+1.
local newSlot = (numSets + 1) - off
if newSlot >= 1 and newSlot <= LIST_VISIBLE_ROWS then
newSetRow:ClearAllPoints()
newSetRow:SetPoint("TOPLEFT", listFrame, "TOPLEFT", 5, -3 - (newSlot - 1) * LIST_ROW_STRIDE)
newSetRow:Show()
else
newSetRow:Hide()
end
-- Update the ignored-overlay textures attached to each paperdoll
-- slot. Uses effective state (persistent + pending toggles) so
-- uncommitted user changes show immediately.
local setIgnored = selectedSetID and GetEffectiveIgnored(selectedSetID) or {}
for slotID, overlay in pairs(slotOverlays) do
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
-- ============================================================
local events = CreateFrame("Frame")
events:RegisterEvent("EQUIPMENT_SETS_CHANGED")
events:RegisterEvent("EQUIPMENT_SWAP_FINISHED")
events:RegisterEvent("EQUIPMENT_SWAP_PENDING")
events:RegisterEvent("BAG_UPDATE_DELAYED")
events:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
events:SetScript("OnEvent", function()
if not frame:IsShown() then return end
pfUI.equipmentmanager.Refresh()
end)
end)