From 76927c2c18aa9fbdfa9585fc3c9cd47a0c510397 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Wed, 5 Aug 2026 02:49:18 -0500
Subject: [PATCH] Add pfUI-native macro icon picker
Replace Blizzard's MacroPopupFrame (the name + icon dialog) with a
custom pfUI picker driven by IconDataProviderMixin, so the full spell +
item + loose icon set is available instead of the stock spell-only list.
It has a searchable, filterable icon grid and saves through
C_Macro.CreateMacro / C_Macro.EditMacro, which take the icon as a texture
string -- so arbitrary icons (including index-less INV_* item icons)
persist. The macro-frame API is identical on the modern (Turtle/Octo)
and stock-vanilla macro UIs, so the picker works on both with no client
detection.
The new macroicons module takes over the New / Change-Icon buttons and
retires the stock popup; the blizzard macro skin now only skins
MacroPopupFrame when that module is disabled.
---
init/modules.xml | 1 +
modules/macroicons.lua | 331 +++++++++++++++++++++++++++++++++++++++
skins/blizzard/macro.lua | 50 +++---
3 files changed, 358 insertions(+), 24 deletions(-)
create mode 100644 modules/macroicons.lua
diff --git a/init/modules.xml b/init/modules.xml
index 67111e42..182c993a 100644
--- a/init/modules.xml
+++ b/init/modules.xml
@@ -71,6 +71,7 @@
+
diff --git a/modules/macroicons.lua b/modules/macroicons.lua
new file mode 100644
index 00000000..b966ce45
--- /dev/null
+++ b/modules/macroicons.lua
@@ -0,0 +1,331 @@
+-- Macro icon picker
+-- Replaces Blizzard's MacroPopupFrame (the "name + icon" dialog opened by the
+-- New / Change-Icon buttons) with a pfUI-native picker driven by
+-- IconDataProviderMixin, so the full spell + item + loose icon set is
+-- available instead of the stock spell-only list. Saving goes through
+-- C_Macro.CreateMacro / C_Macro.EditMacro, which take the icon as a texture
+-- string -- so arbitrary icons (including index-less INV_* item icons) persist
+-- on both the modern (Turtle/Octo) and stock-vanilla macro UIs. The surrounding
+-- MacroFrame (list + body editor) is Blizzard's, already skinned elsewhere.
+pfUI:RegisterModule("macroicons", function ()
+ HookAddonOrVariable("Blizzard_MacroUI", function()
+ local QUESTION_MARK = "INTERFACE\\ICONS\\INV_MISC_QUESTIONMARK"
+
+ local ICON_GRID_COLS = 10
+ local ICON_GRID_ROWS = 8
+ local ICON_BTN_SIZE = 36
+ local ICON_BTN_PAD = 6
+
+ -- Shared picker state (forward-declared so the grid/filter closures below
+ -- can capture them before RefreshIconGrid is assigned).
+ local provider = nil
+ local selectedIconPath = QUESTION_MARK
+ local currentFilter = "all"
+ local searchText = ""
+ local filtered = nil -- provider indices matching the search, or nil when empty
+ local RefreshIconGrid, RebuildFilter
+
+ local function EnsureProvider()
+ if not provider then
+ -- Spellbook extra type leads with class-relevant spell/talent icons.
+ provider = CreateAndInitFromMixin(IconDataProviderMixin, IconDataProviderExtraType.Spellbook)
+ end
+ end
+
+ -- ============================================================
+ -- Popup frame
+ -- ============================================================
+
+ local picker = CreateFrame("Frame", "pfMacroIconPicker", UIParent)
+ picker:SetFrameStrata("DIALOG")
+ picker:SetSize(472, 498)
+ -- Starts anchored to the right of the macro panel (re-anchored at open
+ -- once the skin's backdrop exists); dragging pins it in place after.
+ picker:SetPoint("BOTTOMLEFT", MacroFrame, "BOTTOMRIGHT", 8, 0)
+ picker:Hide()
+ CreateBackdrop(picker, nil, nil, .9)
+ CreateBackdropShadow(picker)
+ picker:EnableMouse(true)
+ picker:SetMovable(true)
+ picker:RegisterForDrag("LeftButton")
+ picker:SetScript("OnDragStart", function() this:StartMoving() end)
+ picker:SetScript("OnDragStop", function()
+ this:StopMovingOrSizing()
+ this.userMoved = true
+ end)
+
+ picker.title = picker:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ picker.title:SetPoint("TOP", picker, "TOP", 0, -10)
+
+ picker.nameLabel = picker:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ picker.nameLabel:SetPoint("TOPLEFT", picker, "TOPLEFT", 14, -34)
+ picker.nameLabel:SetText(MACRO_POPUP_TEXT)
+
+ picker.editbox = CreateFrame("EditBox", "pfMacroIconPickerName", picker, "InputBoxTemplate")
+ picker.editbox:SetSize(280, 20)
+ picker.editbox:SetPoint("TOPLEFT", picker, "TOPLEFT", 14, -52)
+ picker.editbox:SetAutoFocus(false)
+ picker.editbox:SetMaxLetters(16)
+ CreateBackdrop(picker.editbox)
+
+ picker.selectedPreview = CreateFrame("Frame", nil, picker)
+ picker.selectedPreview:SetSize(42, 42)
+ picker.selectedPreview:SetPoint("TOPRIGHT", picker, "TOPRIGHT", -14, -44)
+ CreateBackdrop(picker.selectedPreview)
+ picker.selectedPreview.tex = picker.selectedPreview:CreateTexture(nil, "ARTWORK")
+ picker.selectedPreview.tex:SetAllPoints(picker.selectedPreview)
+ picker.selectedPreview.tex:SetTexCoord(.08, .92, .08, .92)
+
+ picker.iconLabel = picker:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ picker.iconLabel:SetPoint("TOPLEFT", picker, "TOPLEFT", 14, -100)
+ picker.iconLabel:SetText(T["Choose an Icon:"] or "Choose an Icon:")
+
+ -- Icon grid + scroll
+ local iconScroll = CreateFrame("ScrollFrame", "pfMacroIconScroll", picker, "FauxScrollFrameTemplate")
+ iconScroll:SetPoint("TOPLEFT", picker, "TOPLEFT", 14, -130)
+ iconScroll:SetWidth(ICON_GRID_COLS * (ICON_BTN_SIZE + ICON_BTN_PAD) - ICON_BTN_PAD)
+ iconScroll:SetHeight(ICON_GRID_ROWS * (ICON_BTN_SIZE + ICON_BTN_PAD) - ICON_BTN_PAD)
+
+ local scrollbar = _G["pfMacroIconScrollScrollBar"]
+ scrollbar:ClearAllPoints()
+ scrollbar:SetPoint("TOPLEFT", iconScroll, "TOPRIGHT", 8, -16)
+ scrollbar:SetPoint("BOTTOMLEFT", iconScroll, "BOTTOMRIGHT", 8, 16)
+ SkinScrollbar(scrollbar)
+
+ -- Filter dropdown: All / Spells / Items
+ local filterDropdown = CreateFrame("Frame", "pfMacroIconFilter", picker, "UIDropDownMenuTemplate")
+ filterDropdown:SetPoint("TOPRIGHT", picker, "TOPRIGHT", 0, -94)
+ local function ApplyFilter(value)
+ currentFilter = value
+ UIDropDownMenu_SetSelectedValue(filterDropdown, value)
+ if provider then
+ if value == "spells" then provider:SetIconTypes({ IconDataProviderIconType.Spell })
+ elseif value == "items" then provider:SetIconTypes({ IconDataProviderIconType.Item })
+ else provider:SetIconTypes(nil) end
+ RebuildFilter()
+ RefreshIconGrid()
+ end
+ end
+ UIDropDownMenu_Initialize(filterDropdown, function()
+ local info
+ info = {}; info.text = T["All Icons"] or "All Icons"; info.value = "all"
+ info.func = function() ApplyFilter("all") end
+ info.checked = currentFilter == "all"
+ UIDropDownMenu_AddButton(info)
+ info = {}; info.text = T["Spells"] or "Spells"; info.value = "spells"
+ info.func = function() ApplyFilter("spells") end
+ info.checked = currentFilter == "spells"
+ UIDropDownMenu_AddButton(info)
+ info = {}; info.text = T["Items"] or "Items"; info.value = "items"
+ info.func = function() ApplyFilter("items") end
+ info.checked = currentFilter == "items"
+ UIDropDownMenu_AddButton(info)
+ end)
+ UIDropDownMenu_SetWidth(120, filterDropdown)
+ UIDropDownMenu_SetSelectedValue(filterDropdown, "all")
+ SkinDropDown(filterDropdown)
+
+ local iconButtons = {}
+ for r = 1, ICON_GRID_ROWS do
+ for c = 1, ICON_GRID_COLS do
+ local i = (r - 1) * ICON_GRID_COLS + c
+ local btn = CreateFrame("Button", nil, picker)
+ btn:SetSize(ICON_BTN_SIZE, ICON_BTN_SIZE)
+ btn:SetPoint("TOPLEFT", iconScroll, "TOPLEFT", (c-1) * (ICON_BTN_SIZE + ICON_BTN_PAD), -(r-1) * (ICON_BTN_SIZE + ICON_BTN_PAD))
+ CreateBackdrop(btn)
+ btn.texture = btn:CreateTexture(nil, "ARTWORK")
+ btn.texture:SetAllPoints(btn)
+ btn.texture:SetTexCoord(.08, .92, .08, .92)
+ btn:SetScript("OnClick", function()
+ if this.iconIndex and provider then
+ local path = provider:GetIconByIndex(this.iconIndex)
+ if path then selectedIconPath = path end
+ RefreshIconGrid()
+ end
+ end)
+ btn:SetScript("OnEnter", function()
+ if not this.iconIndex or not provider then return end
+ local path = provider:GetIconByIndex(this.iconIndex)
+ if type(path) == "string" then
+ local name = string.gsub(path, "^.-INTERFACE\\\\ICONS\\\\", "")
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ GameTooltip:SetText(name)
+ GameTooltip:Show()
+ end
+ end)
+ btn:SetScript("OnLeave", GameTooltip_Hide)
+ iconButtons[i] = btn
+ end
+ end
+
+ -- Normalize a texture to an uppercase basename so selection matching
+ -- works across sources: GetMacroInfo returns "Interface\Icons\"
+ -- while provider paths are uppercase-prefixed for base icons and
+ -- mixed-case for spellbook extras.
+ local function IconKey(path)
+ if type(path) ~= "string" then return path end
+ return string.gsub(string.upper(path), "^.*[\\/]", "")
+ end
+
+ -- Rebuild the search-filtered index list (provider indices whose icon
+ -- basename contains the search text); nil when the search box is empty.
+ function RebuildFilter()
+ EnsureProvider()
+ if searchText == "" then
+ filtered = nil
+ return
+ end
+ filtered = {}
+ for i = 1, provider:GetNumIcons() do
+ local path = provider:GetIconByIndex(i)
+ if type(path) == "string" and string.find(IconKey(path), searchText, 1, true) then
+ table.insert(filtered, i)
+ end
+ end
+ end
+
+ function RefreshIconGrid()
+ EnsureProvider()
+ local numIcons = filtered and table.getn(filtered) or provider:GetNumIcons()
+ local numRows = math.ceil(numIcons / ICON_GRID_COLS)
+ FauxScrollFrame_Update(iconScroll, numRows, ICON_GRID_ROWS, ICON_BTN_SIZE + ICON_BTN_PAD)
+ local offset = FauxScrollFrame_GetOffset(iconScroll)
+ for i = 1, ICON_GRID_ROWS * ICON_GRID_COLS do
+ local listIdx = i + offset * ICON_GRID_COLS
+ local btn = iconButtons[i]
+ if listIdx <= numIcons then
+ btn:Show()
+ local providerIdx = filtered and filtered[listIdx] or listIdx
+ btn.iconIndex = providerIdx
+ local path = provider:GetIconByIndex(providerIdx)
+ btn.texture:SetTexture(path)
+ if IconKey(path) == IconKey(selectedIconPath) then
+ btn.backdrop:SetBackdropBorderColor(1, 0.82, 0, 1)
+ else
+ btn.backdrop:SetBackdropBorderColor(pfUI.cache.er, pfUI.cache.eg, pfUI.cache.eb, pfUI.cache.ea)
+ end
+ else
+ btn:Hide()
+ btn.iconIndex = nil
+ end
+ end
+ picker.selectedPreview.tex:SetTexture(selectedIconPath)
+ end
+
+ iconScroll:SetScript("OnVerticalScroll", function()
+ FauxScrollFrame_OnVerticalScroll(ICON_BTN_SIZE + ICON_BTN_PAD, function() RefreshIconGrid() end)
+ end)
+
+ -- ============================================================
+ -- Open / save
+ -- ============================================================
+
+ -- Blizzard's popup disables these while it is open; mirror that so the
+ -- underlying frame can't be double-driven, then let MacroFrame_Update
+ -- restore the correct states on close.
+ local function SetMacroButtons(enabled)
+ local m = enabled and "Enable" or "Disable"
+ MacroNewButton[m](MacroNewButton)
+ MacroEditButton[m](MacroEditButton)
+ MacroDeleteButton[m](MacroDeleteButton)
+ end
+
+ local function OpenMacroPopup(mode)
+ if mode == "edit" and MacroFrame.selectedMacro then
+ picker.mode = "edit"
+ local name, texture = GetMacroInfo(MacroFrame.selectedMacro)
+ picker.editbox:SetText(name or "")
+ selectedIconPath = texture or QUESTION_MARK
+ picker.title:SetText(T["Edit Macro"] or "Edit Macro")
+ else
+ picker.mode = "new"
+ picker.editbox:SetText("")
+ selectedIconPath = QUESTION_MARK
+ picker.title:SetText(T["New Macro"] or "New Macro")
+ end
+ picker.search:SetText("")
+ if not picker.userMoved then
+ picker:ClearAllPoints()
+ picker:SetPoint("BOTTOMLEFT", MacroFrame.backdrop or MacroFrame, "BOTTOMRIGHT", 8, 0)
+ end
+ SetMacroButtons(false)
+ picker:Show()
+ picker.editbox:SetFocus()
+ RefreshIconGrid()
+ end
+
+ local function SaveMacroPopup()
+ local text = strtrim(picker.editbox:GetText())
+ if text == "" then return end
+
+ if picker.mode == "edit" and MacroFrame.selectedMacro then
+ C_Macro.EditMacro(MacroFrame.selectedMacro, text, selectedIconPath, nil)
+ MacroFrame_SelectMacro(MacroFrame.selectedMacro)
+ else
+ local idx = C_Macro.CreateMacro(text, selectedIconPath, nil, (MacroFrame.macroBase or 0) > 0)
+ if idx then MacroFrame_SelectMacro(idx) end
+ end
+ picker:Hide()
+ end
+
+ picker:SetScript("OnHide", function()
+ if provider then provider:Release(); provider = nil end
+ MacroFrame_Update()
+ end)
+
+ picker.editbox:SetScript("OnEnterPressed", SaveMacroPopup)
+ picker.editbox:SetScript("OnEscapePressed", function() picker:Hide() end)
+
+ -- OK / Cancel
+ local okay = CreateFrame("Button", "pfMacroIconPickerOkay", picker, "UIPanelButtonTemplate")
+ okay:SetSize(80, 22)
+ okay:SetText(OKAY)
+ okay:SetScript("OnClick", SaveMacroPopup)
+ SkinButton(okay)
+
+ local cancel = CreateFrame("Button", "pfMacroIconPickerCancel", picker, "UIPanelButtonTemplate")
+ cancel:SetSize(80, 22)
+ cancel:SetText(CANCEL)
+ cancel:SetPoint("BOTTOMRIGHT", picker, "BOTTOMRIGHT", -14, 12)
+ cancel:SetScript("OnClick", function() picker:Hide() end)
+ SkinButton(cancel)
+
+ -- Okay | Cancel, both anchored to the bottom-right corner.
+ okay:SetPoint("BOTTOMRIGHT", cancel, "BOTTOMLEFT", -6, 0)
+
+ -- Search box (bottom-left) filters the grid by icon name.
+ picker.searchLabel = picker:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ picker.searchLabel:SetPoint("BOTTOMLEFT", picker, "BOTTOMLEFT", 16, 18)
+ picker.searchLabel:SetText(SEARCH)
+
+ picker.search = CreateFrame("EditBox", "pfMacroIconSearch", picker, "InputBoxTemplate")
+ picker.search:SetSize(180, 20)
+ picker.search:SetPoint("LEFT", picker.searchLabel, "RIGHT", 10, 0)
+ picker.search:SetAutoFocus(false)
+ CreateBackdrop(picker.search)
+ picker.search:SetScript("OnEscapePressed", function() this:ClearFocus() end)
+ picker.search:SetScript("OnTextChanged", function()
+ searchText = strupper(strtrim(this:GetText()))
+ RebuildFilter()
+ scrollbar:SetValue(0)
+ RefreshIconGrid()
+ end)
+
+ -- ============================================================
+ -- Take over the New / Change-Icon buttons; retire Blizzard's popup
+ -- ============================================================
+
+ MacroNewButton:SetScript("OnClick", function()
+ MacroFrame_SaveMacro()
+ OpenMacroPopup("new")
+ end)
+
+ MacroEditButton:SetScript("OnClick", function()
+ MacroFrame_SaveMacro()
+ OpenMacroPopup("edit")
+ end)
+
+ -- Closing the macro window takes the picker with it.
+ MacroFrame:HookScript("OnHide", function() picker:Hide() end)
+ end)
+end)
diff --git a/skins/blizzard/macro.lua b/skins/blizzard/macro.lua
index ec5638ef..c50477d2 100644
--- a/skins/blizzard/macro.lua
+++ b/skins/blizzard/macro.lua
@@ -60,33 +60,35 @@ pfUI:RegisterSkin("Macro", function ()
MacroFrameScrollFrame:ClearAllPoints()
MacroFrameScrollFrame:SetPoint("TOPLEFT", MacroFrameSelectedMacroBackground, "BOTTOMLEFT", 11, -13)
+ -- The macroicons module replaces MacroPopupFrame with its own picker.
+ -- Only skin the stock popup when that module is disabled.
+ if pfUI_config["disabled"] and pfUI_config["disabled"]["macroicons"] == "1" then
+ StripTextures(MacroPopupFrame)
+ CreateBackdrop(MacroPopupFrame, nil, nil, .75)
+ MacroPopupFrame:SetFrameStrata("DIALOG")
+ MacroPopupFrame:ClearAllPoints()
+ MacroPopupFrame:SetPoint("TOPLEFT", MacroFrame.backdrop, "TOPRIGHT", 2*border, 0)
+ StripTextures(MacroPopupScrollFrame)
+ SkinScrollbar(MacroPopupScrollFrameScrollBar)
- StripTextures(MacroPopupFrame)
- CreateBackdrop(MacroPopupFrame, nil, nil, .75)
- MacroPopupFrame:SetFrameStrata("DIALOG")
- MacroPopupFrame:ClearAllPoints()
- MacroPopupFrame:SetPoint("TOPLEFT", MacroFrame.backdrop, "TOPRIGHT", 2*border, 0)
+ MacroPopupEditBox:DisableDrawLayer("BACKGROUND")
+ CreateBackdrop(MacroPopupEditBox, nil, true)
+ MacroPopupEditBox:SetScript("OnEscapePressed", function()
+ MacroPopupFrame:Hide()
+ MacroFrame_Update()
+ end)
- StripTextures(MacroPopupScrollFrame)
- SkinScrollbar(MacroPopupScrollFrameScrollBar)
-
- MacroPopupEditBox:DisableDrawLayer("BACKGROUND")
- CreateBackdrop(MacroPopupEditBox, nil, true)
- MacroPopupEditBox:SetScript("OnEscapePressed", function()
- MacroPopupFrame:Hide()
- MacroFrame_Update()
- end)
-
- for i=1, NUM_MACRO_ICONS_SHOWN do
- local button = _G["MacroPopupButton"..i]
- local icon = _G["MacroPopupButton"..i..'Icon']
- StripTextures(button)
- SkinButton(button, nil, nil, nil, icon)
+ for i=1, NUM_MACRO_ICONS_SHOWN do
+ local button = _G["MacroPopupButton"..i]
+ local icon = _G["MacroPopupButton"..i..'Icon']
+ StripTextures(button)
+ SkinButton(button, nil, nil, nil, icon)
+ end
+ SkinButton(MacroPopupCancelButton)
+ SkinButton(MacroPopupOkayButton)
+ MacroPopupOkayButton:ClearAllPoints()
+ MacroPopupOkayButton:SetPoint("RIGHT", MacroPopupCancelButton, "LEFT", -2*bpad, 0)
end
- SkinButton(MacroPopupCancelButton)
- SkinButton(MacroPopupOkayButton)
- MacroPopupOkayButton:ClearAllPoints()
- MacroPopupOkayButton:SetPoint("RIGHT", MacroPopupCancelButton, "LEFT", -2*bpad, 0)
end)
end)