diff --git a/API.lua b/API.lua
index c59a631..df41ebe 100644
--- a/API.lua
+++ b/API.lua
@@ -1533,18 +1533,12 @@ do -- Quest
local TextModifier = TextModifier_None;
local function GetModifiedQuestText(method)
- local text = GetQuestText(method);
- if text then
- return TextModifier(text)
- end
+ return TextModifier(GetQuestText(method))
end
API.GetModifiedQuestText = GetModifiedQuestText;
local function GetModifiedGossipText()
- local text = GetGossipText();
- if text then
- return TextModifier(text)
- end
+ return TextModifier(GetGossipText());
end
API.GetModifiedGossipText = GetModifiedGossipText;
@@ -1552,11 +1546,6 @@ do -- Quest
TextModifier = modifierFunc or TextModifier_None;
end
addon.SetDialogueTextModifier = SetDialogueTextModifier;
-
- addon.SetChatTextModifier = function(modifierFunc)
- modifierFunc("test");
- CallbackRegistry:Trigger("SetChatTextModifier", modifierFunc or TextModifier_None);
- end
end
@@ -2520,7 +2509,33 @@ do -- Tooltip
end
API.GetRewardItemLevelDelta = GetRewardItemLevelDelta;
+ -- Armor subClassID: 1=Cloth, 2=Leather, 3=Mail, 4=Plate
+ local CLASS_ARMOR_TYPE = {
+ WARRIOR = 4, PALADIN = 4, DEATHKNIGHT = 4,
+ HUNTER = 3, SHAMAN = 3, EVOKER = 3,
+ ROGUE = 2, MONK = 2, DRUID = 2, DEMONHUNTER = 2,
+ MAGE = 1, PRIEST = 1, WARLOCK = 1,
+ };
+
+ local function IsEquipmentForPlayerClass(item)
+ local _, _, _, _, _, classID, subClassID = GetItemInfoInstant(item);
+ -- For armor pieces with a specific armor type (cloth/leather/mail/plate),
+ -- reject items that don't match the player's armor proficiency
+ if classID == 4 and subClassID >= 1 and subClassID <= 4 then
+ local _, playerClass = UnitClass("player");
+ local expectedType = playerClass and CLASS_ARMOR_TYPE[playerClass];
+ if expectedType and subClassID ~= expectedType then
+ return false
+ end
+ end
+ return true
+ end
+ API.IsEquipmentForPlayerClass = IsEquipmentForPlayerClass;
+
local function IsItemAnUpgrade(newLink)
+ if not IsEquipmentForPlayerClass(newLink) then
+ return false, true
+ end
local delta, isReady = GetMaxEquippedItemLevelDelta(newLink)
return (delta and delta > 0), isReady
end
diff --git a/Code/GossipData/ItemData_Container.lua b/Code/GossipData/ItemData_Container.lua
index f89aa76..6e22144 100644
--- a/Code/GossipData/ItemData_Container.lua
+++ b/Code/GossipData/ItemData_Container.lua
@@ -27,6 +27,8 @@ local MiscUsableItem = {
--Some items don't align to the pattern (Flags_0 is 0x0)
[142447] = true, --Torn Sack of Pet Supplies
[184866] = true, --Grummlepouch
+ [257023] = true, --Preyseeker's Adventurer Chest
+ [264274] = true, --Fabled Adventurer's Cache
};
function API.IsContainerItem(itemID)
diff --git a/Code/Settings/Settings.lua b/Code/Settings/Settings.lua
index 0227cd6..7b60e3d 100644
--- a/Code/Settings/Settings.lua
+++ b/Code/Settings/Settings.lua
@@ -493,6 +493,18 @@ local function QuestItemDisplayPosition_Validation()
return f and f:IsUsingCustomPosition()
end
+local function QuickSlot_Move_OnClick()
+ addon.QuickSlotManager:ToggleEditMode();
+end
+
+local function QuickSlot_Reset_OnClick()
+ addon.QuickSlotManager:ResetPosition();
+end
+
+local function QuickSlotPosition_Validation()
+ return addon.QuickSlotManager:IsUsingCustomPosition()
+end
+
local function RPAddOn_Validation()
return addon.GetInstalledRPAddOnName() ~= nil
end
@@ -711,6 +723,11 @@ local Schematic = { --Scheme
{type = "Subheader", name = L["Quest"]},
{type = "Checkbox", name = L["Valuable Reward Popup"], description = L["Valuable Reward Popup Desc"], dbKey = "QuickSlotQuestReward", preview = "QuickSlotQuestReward", ratio = 2},
+ {type = "Checkbox", name = L["Always-On Loot Popup"], description = L["Always-On Loot Popup Desc"], dbKey = "QuickSlotAlwaysOn", requiredParentValueAnd = {QuickSlotQuestReward = true}},
+ {type = "Checkbox", name = L["Upgrades And Containers Only"], description = L["Upgrades And Containers Only Desc"], dbKey = "QuickSlotPriorityOnly", requiredParentValueAnd = {QuickSlotQuestReward = true, QuickSlotAlwaysOn = true}},
+ {type = "Checkbox", name = L["Include Collectibles"], description = L["Include Collectibles Desc"], dbKey = "QuickSlotCollectibleHighPriority", requiredParentValueAnd = {QuickSlotQuestReward = true, QuickSlotAlwaysOn = true}},
+ {type = "Custom", name = L["Move Position"], icon = "Settings-Move.png", onClickFunc = QuickSlot_Move_OnClick, requiredParentValueAnd = {QuickSlotQuestReward = true}},
+ {type = "Custom", name = L["Reset Position"], icon = "Settings-Reset.png", validationFunc = QuickSlotPosition_Validation, onClickFunc = QuickSlot_Reset_OnClick, requireSameParentValue = true},
{type = "Checkbox", name = L["Auto Complete Quest"], description = L["Auto Complete Quest Desc"], dbKey = "AutoCompleteQuest", preview = "QuestAutoComplete", ratio = 2},
{type = "Checkbox", name = L["Press Key To Use Item"], tooltip = UseItemHotkey_Tooltip, dbKey = "QuickSlotUseHotkey", requiredParentValueOr = {QuickSlotQuestReward = true, AutoCompleteQuest = true}, branchLevel = 0},
diff --git a/Code/SupportedAddOns/SupportedAddOns.xml b/Code/SupportedAddOns/SupportedAddOns.xml
index 6d4dfa0..7e8865a 100644
--- a/Code/SupportedAddOns/SupportedAddOns.xml
+++ b/Code/SupportedAddOns/SupportedAddOns.xml
@@ -7,6 +7,7 @@
+
diff --git a/Code/SupportedAddOns/Zygor.lua b/Code/SupportedAddOns/Zygor.lua
new file mode 100644
index 0000000..8268884
--- /dev/null
+++ b/Code/SupportedAddOns/Zygor.lua
@@ -0,0 +1,154 @@
+-- Zygor Guides Item Score Integration
+local _, addon = ...
+local API = addon.API;
+
+
+do
+ local ADDON_NAME = "ZygorGuidesViewer";
+
+ local requiredMethods = {
+ "ZGV";
+ };
+
+ local function OnAddOnLoaded()
+ local ZGV = _G.ZGV;
+ if not (ZGV and ZGV.ItemScore and ZGV.ItemScore.Upgrades) then return end;
+
+ local ItemScore = ZGV.ItemScore;
+ local Upgrades = ItemScore.Upgrades;
+ local tooltip = addon.SharedTooltip;
+ local floor = math.floor;
+
+ local function get_change(old, new)
+ if old and old > 0 then
+ return floor(((new * 100 / old) - 100) * 100) / 100
+ else
+ return 100
+ end
+ end
+
+ -- Override upgrade detection for reward item arrows
+ API.IsItemAnUpgrade_External = function(itemLink)
+ if not ItemScore.ActiveRuleSet then
+ return API.IsItemAnUpgrade(itemLink)
+ end
+ local isUpgrade, slot, change, score, comment = Upgrades:IsUpgrade(itemLink);
+ if comment == "not scored" or comment == "no link" then
+ -- For unscored trinkets, fall back to ilvl-based comparison.
+ -- Zygor does not score trinkets, but ilvl is a useful proxy.
+ -- This does not imply full stat/effect evaluation.
+ local itemDetails = ItemScore:GetItemDetails(itemLink, "temporary");
+ if itemDetails and itemDetails.type == "INVTYPE_TRINKET" then
+ return API.IsItemAnUpgrade(itemLink)
+ end
+ return nil, false
+ end
+ return isUpgrade, true
+ end
+
+ -- Append a single slot's upgrade/downgrade line to the tooltip.
+ local function addSlotLine(self, slotLabel, futurePrefix, stripped, equipped, score)
+ if equipped and (equipped.score or score) then
+ if stripped ~= equipped.itemlink then
+ local change;
+ if equipped.score and ItemScore:IsValidItem(equipped.itemlink) then
+ change = get_change(equipped.artifactscore or equipped.score, score);
+ else
+ change = 100;
+ end
+ local pct = (change > 999) and "999+" or tostring(change);
+ if change > 0 then
+ self:AddLeftLine(" " .. slotLabel .. futurePrefix .. "Upgrade: +" .. pct .. "%", 0, 1, 0, false, nil, 2);
+ elseif change < 0 then
+ self:AddLeftLine(" " .. slotLabel .. futurePrefix .. "Downgrade: " .. pct .. "%", 1, 0, 0, false, nil, 2);
+ else
+ self:AddLeftLine(" " .. slotLabel .. "No change", 0.6, 0.6, 0.6, false, nil, 2);
+ end
+ else
+ self:AddLeftLine(" " .. slotLabel .. "Equipped", 0.6, 0.6, 0.6, false, nil, 2);
+ end
+ elseif (score or 0) > 0 then
+ self:AddLeftLine(" " .. slotLabel .. futurePrefix .. "Upgrade: +100%", 0, 1, 0, false, nil, 2);
+ end
+ end
+
+ -- Add Zygor score info to reward item tooltip
+ function tooltip:ProcessItemExternal(itemLink)
+ if not ItemScore.ActiveRuleSet then return end;
+
+ local item = ItemScore:GetItemDetails(itemLink, "temporary");
+ if not item then return end;
+ if item.type == "INVTYPE_NON_EQUIP_IGNORE" then return end;
+
+ local score, success = ItemScore:GetItemScore(item.itemlink);
+ if not success then return end;
+
+ local valid, validfuture, final = ItemScore:IsValidItem(item.itemlink, "future");
+ if not final then return end;
+
+ local spec = ItemScore.ActiveRuleSet.specname;
+ local specText = spec and (" (" .. spec .. ")") or "";
+
+ self:AddBlankLine();
+ self:AddLeftLine("Zygor ItemScore" .. specText .. ":", 254/255, 97/255, 0, true);
+
+ if valid or validfuture then
+ local futurePrefix = (not valid and validfuture) and "Future " or "";
+ local slot_1, slot_2 = item.slot, item.slot_2;
+ local hasTwoSlots = slot_2 ~= nil;
+ local stripped = item.itemlink;
+
+ addSlotLine(self,
+ hasTwoSlots and "Slot 1: " or "",
+ futurePrefix, stripped,
+ slot_1 and Upgrades.EquippedItems[slot_1],
+ score);
+
+ if hasTwoSlots then
+ addSlotLine(self,
+ "Slot 2: ",
+ futurePrefix, stripped,
+ Upgrades.EquippedItems[slot_2],
+ score);
+ end
+ else
+ if item.type == "INVTYPE_TRINKET" then
+ -- Zygor does not score trinkets; fall back to item level comparison.
+ -- This does not imply full stat/effect evaluation.
+ local newIlvl = API.GetItemLevel(itemLink);
+ if newIlvl and newIlvl > 0 then
+ self:AddLeftLine(" Trinket comparison (item level):", 0.6, 0.6, 0.6, true, nil, 2);
+ for i, slotID in ipairs({13, 14}) do
+ local label = " Trinket " .. i .. ": ";
+ local equippedLink = GetInventoryItemLink("player", slotID);
+ if equippedLink then
+ local equippedIlvl = API.GetItemLevel(equippedLink);
+ if equippedIlvl and equippedIlvl > 0 then
+ local delta = newIlvl - equippedIlvl;
+ if delta > 0 then
+ self:AddLeftLine(label .. "Upgrade: +" .. delta .. " ilvl", 0, 1, 0, false, nil, 2);
+ elseif delta < 0 then
+ self:AddLeftLine(label .. "Downgrade: " .. delta .. " ilvl", 1, 0, 0, false, nil, 2);
+ else
+ self:AddLeftLine(label .. "No change", 0.6, 0.6, 0.6, false, nil, 2);
+ end
+ end
+ else
+ self:AddLeftLine(label .. "Upgrade: empty slot", 0, 1, 0, false, nil, 2);
+ end
+ end
+ else
+ self:AddLeftLine(" Trinkets are not part of the scoring system.", 0.6, 0.6, 0.6, true, nil, 2);
+ end
+ else
+ local specName = ItemScore.playerspecName or (ItemScore.ActiveRuleSet and ItemScore.ActiveRuleSet.specname) or "current spec";
+ self:AddLeftLine(" Not valid for " .. specName, 1, 0, 0, false, nil, 2);
+ end
+ end
+
+ self:Show();
+ end
+ end
+
+ addon.AddSupportedAddOn(ADDON_NAME, OnAddOnLoaded, requiredMethods);
+end
\ No newline at end of file
diff --git a/Code/Widget/WidgetManager.lua b/Code/Widget/WidgetManager.lua
index 6ce2e62..fa8bb17 100644
--- a/Code/Widget/WidgetManager.lua
+++ b/Code/Widget/WidgetManager.lua
@@ -583,6 +583,14 @@ do --Change Main Anchor Position
Text:SetShadowColor(1, 1, 1, 0.5);
Text:SetShadowOffset(2, -2);
Text:SetText(L["Popup Position"]);
+
+ self.tooltipText = L["Drag To Move"].."\n"..L["Right Click To Dismiss"];
+ self:SetScript("OnEnter", function(s)
+ TooltipFrame.ShowWidgetTooltip(s);
+ end);
+ self:SetScript("OnLeave", function()
+ TooltipFrame.HideTooltip();
+ end);
end
function AnchorPosition:OnMouseUp(button)
diff --git a/Code/Widget/Widget_ItemButton.lua b/Code/Widget/Widget_ItemButton.lua
index 9ec35f7..1431727 100644
--- a/Code/Widget/Widget_ItemButton.lua
+++ b/Code/Widget/Widget_ItemButton.lua
@@ -239,11 +239,19 @@ do --Quest Flyout ItemButton
ActionButton:SetFrameLevel(self:GetFrameLevel() + 5);
ActionButton.onEnterCombatCallback = function()
self:SetButtonEnabled(false);
+ self:SetCombatNotice(true);
+ if self.UpdatePauseState then
+ self:UpdatePauseState();
+ end
end;
self:SetButtonEnabled(true);
+ self:SetCombatNotice(false);
return ActionButton
else
self:SetButtonEnabled(false);
+ if InCombatLockdown() then
+ self:SetCombatNotice(true);
+ end
end
end
@@ -322,6 +330,7 @@ do --Quest Flyout ItemButton
self:SetScript("OnUpdate", nil);
self:UnregisterAllEvents();
self:ReleaseActionButton();
+ self:SetCombatNotice(false);
self:OnButtonHide();
end
end
@@ -356,6 +365,22 @@ do --Quest Flyout ItemButton
self.ButtonText:SetText(text);
ThemeUtil:SetFontColor(self.ButtonText, "WarningRed");
end
+
+ function ItemButtonMixin:SetCombatNotice(show)
+ if show then
+ if not self.CombatNoticeText then
+ local f = self:CreateFontString(nil, "OVERLAY");
+ f:SetFontObject("DUIFont_Tooltip_Small");
+ f:SetPoint("TOP", self, "BOTTOM", 0, -4);
+ self.CombatNoticeText = f;
+ end
+ ThemeUtil:SetFontColor(self.CombatNoticeText, "WarningRed");
+ self.CombatNoticeText:SetText(L["Popup Usable After Combat"]);
+ self.CombatNoticeText:Show();
+ elseif self.CombatNoticeText then
+ self.CombatNoticeText:Hide();
+ end
+ end
end
@@ -375,10 +400,13 @@ do --Event Handler, Lazy Update
function ItemButtonMixin:UpdateItem()
local allowPressKeyToUse = self:HasHotkey();
if self.type == "use" then
- if self.itemID then
+ if self.itemID and GetItemCount(self.itemID) > 0 then
self:SetUsableItem(self.itemID, allowPressKeyToUse);
else
self:SetButtonEnabled(false);
+ if self.OnItemConsumed then
+ self:OnItemConsumed();
+ end
end
elseif self.type == "equip" then
if self:IsItemEquipped() then
@@ -403,14 +431,8 @@ do --Event Handler, Lazy Update
else
self:SetMountItem(self.itemID, allowPressKeyToUse);
end
- elseif self.type == "pet" then
- if self:IsKnownPet() then
- self:SetButtonEnabled(false);
- self:SetSuccessText(L["Collection Collected"]);
- self:OnItemKnown();
- else
- self:SetPetItem(self.itemID, allowPressKeyToUse);
- end
+ elseif self.type == "pet" and self.itemID then
+ self:SetPetItem(self.itemID, allowPressKeyToUse);
elseif self.type == "toy" then
if self:IsKnownToy() then
self:SetButtonEnabled(false);
@@ -456,6 +478,17 @@ do --Event Handler, Lazy Update
self:SetEquipItem(self.hyperlink, allowPressKeyToUse);
elseif self.type == "cosmetic" and self.itemID then
self:SetCosmeticItem(self.itemID, allowPressKeyToUse);
+ elseif self.type == "pet" and self.itemID then
+ self:SetPetItem(self.itemID, allowPressKeyToUse);
+ elseif self.type == "mount" and self.itemID then
+ self:SetMountItem(self.itemID, allowPressKeyToUse);
+ elseif self.type == "toy" and self.itemID then
+ self:SetToyItem(self.itemID, allowPressKeyToUse);
+ elseif self.type == "decor" and self.itemID then
+ self:SetDecorItem(self.itemID, allowPressKeyToUse);
+ end
+ if self.UpdatePauseState then
+ self:UpdatePauseState();
end
elseif event == "GLOBAL_MOUSE_DOWN" then
if self.allowRightClickToClose then
@@ -570,16 +603,97 @@ do --Actions Types
end
end
+ local PET_RARITY_COLORS = {
+ [1] = "ff9d9d9d", --Poor (gray)
+ [2] = "ffffffff", --Common (white)
+ [3] = "ff1eff00", --Uncommon (green)
+ [4] = "ff0070dd", --Rare (blue)
+ [5] = "ffa335ee", --Epic (purple)
+ [6] = "ffff8000", --Legendary (orange)
+ };
+
+ --Pet rarity from GetPetStats is 1-indexed; maps to item quality via rarity-1
+ local PET_RARITY_TO_QUALITY = { [1] = 0, [2] = 1, [3] = 2, [4] = 3, [5] = 4, [6] = 5 };
+
+ local function GetPetCollectionInfo(itemID)
+ if not (C_PetJournal and C_PetJournal.GetPetInfoByItemID) then return end;
+
+ local speciesID = select(13, C_PetJournal.GetPetInfoByItemID(itemID));
+ if not speciesID or speciesID == 0 then return end;
+
+ local owned, maxOwned = C_PetJournal.GetNumCollectedInfo(speciesID);
+ if not owned then return end;
+
+ local qualities = {};
+ if owned > 0 then
+ local numPets = C_PetJournal.GetNumPets();
+ for i = 1, numPets do
+ local petID, sid = C_PetJournal.GetPetInfoByIndex(i);
+ if sid == speciesID and petID then
+ local _, _, _, _, rarity = C_PetJournal.GetPetStats(petID);
+ if rarity then
+ qualities[#qualities + 1] = rarity;
+ end
+ end
+ end
+ table.sort(qualities);
+ end
+
+ return owned, maxOwned, qualities, speciesID;
+ end
+
+ local function BuildPetCollectionText(name, owned, maxOwned, qualities)
+ local pips = "";
+ -- Filled pips for owned qualities
+ for _, rarity in ipairs(qualities) do
+ local color = PET_RARITY_COLORS[rarity] or "ffffffff";
+ pips = pips .. "|c" .. color .. "\226\128\162|r"; --• filled (U+2022)
+ end
+ -- Empty pips for remaining capacity
+ for _ = 1, maxOwned - owned do
+ pips = pips .. "|cff666666\226\128\162|r"; --• empty (U+2022)
+ end
+ if pips ~= "" then
+ pips = pips .. " ";
+ end
+ return string.format("%s %s%d/%d", name or "", pips, owned, maxOwned);
+ end
+
function ItemButtonMixin:SetPetItem(item, allowPressKeyToUse)
self:SetUsableItem(item, allowPressKeyToUse);
self.type = "pet";
- if self:IsKnownPet() then
- self:SetButtonEnabled(false);
- self:SetSuccessText(L["Collection Collected"]);
- self:OnItemKnown();
- else
- self:RegisterEvent("NEW_PET_ADDED");
+
+ if self.itemID then
+ local owned, maxOwned, qualities, speciesID = GetPetCollectionInfo(self.itemID);
+ if owned and maxOwned then
+ local name = GetItemNameByID(self.itemID) or "";
+ if owned >= maxOwned then
+ --Maxed out on this species
+ self:SetButtonEnabled(false);
+ self:ReleaseActionButton();
+ self:SetButtonText(BuildPetCollectionText(name, owned, maxOwned, qualities));
+ ThemeUtil:SetFontColor(self.ButtonText, "WarningRed");
+
+ --Use the highest owned quality for the button color
+ local bestRarity = qualities[#qualities];
+ if bestRarity then
+ self:SetColorByQuality(PET_RARITY_TO_QUALITY[bestRarity] or 1);
+ end
+ return;
+ elseif owned > 0 then
+ --Has some but not maxed
+ self:SetButtonText(BuildPetCollectionText(name, owned, maxOwned, qualities));
+
+ --Color the button border by the best owned quality
+ local bestRarity = qualities[#qualities];
+ if bestRarity then
+ self:SetColorByQuality(PET_RARITY_TO_QUALITY[bestRarity] or 1);
+ end
+ end
+ end
end
+
+ self:RegisterEvent("NEW_PET_ADDED");
end
function ItemButtonMixin:SetToyItem(item, allowPressKeyToUse)
@@ -634,16 +748,6 @@ do --Determine if the item is learned/used
return isCollected
end
- function ItemButtonMixin:IsKnownPet()
- if self.itemID then
- local creatureName = C_PetJournal.GetPetInfoByItemID(self.itemID);
- if creatureName then
- local _, petGUID = C_PetJournal.FindPetIDByName(creatureName);
- return petGUID ~= nil
- end
- end
- end
-
function ItemButtonMixin:IsKnownToy()
if self.itemID then
return PlayerHasToy(self.itemID)
diff --git a/Code/Widget/Widget_QuestItemDisplay.lua b/Code/Widget/Widget_QuestItemDisplay.lua
index 92bd19a..546366f 100644
--- a/Code/Widget/Widget_QuestItemDisplay.lua
+++ b/Code/Widget/Widget_QuestItemDisplay.lua
@@ -725,10 +725,17 @@ function QuestItemDisplay:OnHide()
end
function QuestItemDisplay:OnEnter()
+ if self.isEditMode then
+ self.tooltipText = L["Drag To Move"].."\n"..L["Right Click To Dismiss"];
+ addon.SharedTooltip.ShowWidgetTooltip(self);
+ end
self.CloseButton:PauseAutoCloseTimer(true);
end
function QuestItemDisplay:OnLeave()
+ if self.isEditMode then
+ addon.SharedTooltip.HideTooltip();
+ end
self.CloseButton:PauseAutoCloseTimer(false);
end
diff --git a/Code/Widget/Widget_QuickSlot.lua b/Code/Widget/Widget_QuickSlot.lua
index 511c960..4ec7a62 100644
--- a/Code/Widget/Widget_QuickSlot.lua
+++ b/Code/Widget/Widget_QuickSlot.lua
@@ -12,6 +12,19 @@ local COUNTDOWN_IDLE = 4; --When the user doesn't do anything
local COUNTDOWN_COMPLETE_AUTO = 2; --When the item is auto equipped by game
local COUNTDOWN_COMPLETE_MANUAL = 1; --When the item is equipped by clicks
+local DUPLICATE_SUPPRESS_SECONDS = 5;
+local DISMISSED_COOLDOWN_SECONDS = 30;
+local DEFERRED_RETRY_INTERVAL = 0.5;
+local DEFERRED_MAX_RETRIES = 3;
+
+local DEBUG_QUICKSLOT = false;
+
+local function DebugLog(...)
+ if DEBUG_QUICKSLOT then
+ print("|cfffe6100[QuickSlot]|r", ...);
+ end
+end
+
local QuickSlotManager = CreateFrame("Frame");
addon.QuickSlotManager = QuickSlotManager;
WidgetManager:AddLootMessageProcessor(QuickSlotManager, "ItemLink");
@@ -33,6 +46,240 @@ local SupportedItemTypes = {
decor = true,
};
+local CandidateFilter = {};
+do
+ local recent_loot = {}; -- [itemLink] = lastSeenTime
+ local recently_handled = {}; -- [itemLink] = lastDismissedOrEquippedTime
+
+ function CandidateFilter:IsRecentLoot(itemLink)
+ local lastSeen = recent_loot[itemLink];
+ if lastSeen and (GetTime() - lastSeen < DUPLICATE_SUPPRESS_SECONDS) then
+ DebugLog("Rejected duplicate:", itemLink);
+ return true
+ end
+ return false
+ end
+
+ function CandidateFilter:IsRecentlyHandled(itemLink)
+ local lastHandled = recently_handled[itemLink];
+ if lastHandled and (GetTime() - lastHandled < DISMISSED_COOLDOWN_SECONDS) then
+ DebugLog("Rejected recently handled:", itemLink);
+ return true
+ end
+ return false
+ end
+
+ function CandidateFilter:RecordLoot(itemLink)
+ recent_loot[itemLink] = GetTime();
+ end
+
+ function CandidateFilter:RecordHandled(itemLink)
+ recently_handled[itemLink] = GetTime();
+ end
+
+ -- Prune stale entries periodically to avoid unbounded growth.
+ function CandidateFilter:Prune()
+ local now = GetTime();
+ for link, t in pairs(recent_loot) do
+ if now - t > DUPLICATE_SUPPRESS_SECONDS then
+ recent_loot[link] = nil;
+ end
+ end
+ for link, t in pairs(recently_handled) do
+ if now - t > DISMISSED_COOLDOWN_SECONDS then
+ recently_handled[link] = nil;
+ end
+ end
+ end
+end
+
+local HIGH_PRIORITY_TYPES = {
+ equipment = true,
+ container = true,
+};
+
+local COLLECTIBLE_TYPES = {
+ toy = true,
+ pet = true,
+ decor = true,
+};
+
+local function IsHighPriority(classification)
+ if HIGH_PRIORITY_TYPES[classification] then
+ return true
+ end
+ if COLLECTIBLE_TYPES[classification] and addon.GetDBBool("QuickSlotCollectibleHighPriority") then
+ return true
+ end
+ return false
+end
+
+local QueueManager = {};
+do
+ local highQueue = {}; -- equipment upgrades, containers (+ optionally toys, pets, decor)
+ local lowQueue = {}; -- cosmetics, mounts, pets, toys, decor
+ local isProcessing = false;
+
+ function QueueManager:Enqueue(itemLink, classification)
+ local entry = {
+ itemLink = itemLink,
+ classification = classification,
+ enqueueTime = GetTime(),
+ };
+
+ if IsHighPriority(classification) then
+ table.insert(highQueue, entry);
+ DebugLog("Enqueued HIGH:", classification, itemLink);
+ else
+ table.insert(lowQueue, entry);
+ DebugLog("Enqueued LOW:", classification, itemLink);
+ end
+
+ if not isProcessing then
+ self:ProcessNext();
+ end
+ end
+
+ function QueueManager:Peek()
+ return highQueue[1] or lowQueue[1]
+ end
+
+ function QueueManager:Dequeue()
+ if #highQueue > 0 then
+ return table.remove(highQueue, 1)
+ elseif #lowQueue > 0 then
+ return table.remove(lowQueue, 1)
+ end
+ end
+
+ function QueueManager:IsEmpty()
+ return #highQueue == 0 and #lowQueue == 0
+ end
+
+ function QueueManager:Clear()
+ wipe(highQueue);
+ wipe(lowQueue);
+ isProcessing = false;
+ end
+
+ -- Revalidate: item still in bags, still an upgrade (for equipment), still usable.
+ function QueueManager:IsStillEligible(entry)
+ if not HasItem(entry.itemLink) then
+ DebugLog("Revalidation failed (not in bags):", entry.itemLink);
+ return false
+ end
+ if entry.classification == "equipment" then
+ local isUpgrade = API.IsItemAnUpgrade_External(entry.itemLink);
+ if not isUpgrade then
+ DebugLog("Revalidation failed (no longer upgrade):", entry.itemLink);
+ return false
+ end
+ end
+ return true
+ end
+
+ function QueueManager:ProcessNext()
+ while not self:IsEmpty() do
+ local entry = self:Dequeue();
+ if entry and self:IsStillEligible(entry) then
+ isProcessing = true;
+ DebugLog("Showing popup:", entry.classification, entry.itemLink);
+ QuickSlotManager:AddItemButtonByType(entry.classification, entry.itemLink);
+ return
+ else
+ DebugLog("Skipped on revalidation:", entry and entry.itemLink or "nil");
+ end
+ end
+ isProcessing = false;
+ DebugLog("Queue drained");
+ end
+
+ function QueueManager:OnPopupDismissed(itemLink)
+ isProcessing = false;
+ if itemLink then
+ CandidateFilter:RecordHandled(itemLink);
+ end
+ CandidateFilter:Prune();
+ self:ProcessNext();
+ end
+
+ function QueueManager:SetProcessing(state)
+ isProcessing = state;
+ end
+end
+
+local DeferredResolver = {};
+do
+ local pending = {}; -- { itemLink, retryCount, classification }
+
+ function DeferredResolver:Add(itemLink)
+ table.insert(pending, {
+ itemLink = itemLink,
+ retryCount = 0,
+ classification = nil,
+ });
+ DebugLog("Deferred:", itemLink);
+ self:EnsureTimer();
+ end
+
+ function DeferredResolver:EnsureTimer()
+ if not self.timer then
+ self.timer = C_Timer.NewTicker(DEFERRED_RETRY_INTERVAL, function()
+ self:Resolve();
+ end);
+ end
+ end
+
+ function DeferredResolver:StopTimer()
+ if self.timer then
+ self.timer:Cancel();
+ self.timer = nil;
+ end
+ end
+
+ function DeferredResolver:Resolve()
+ local stillPending = {};
+
+ for _, entry in ipairs(pending) do
+ entry.retryCount = entry.retryCount + 1;
+ local itemClassification = GetItemClassification(entry.itemLink);
+ local shouldAdd = itemClassification and SupportedItemTypes[itemClassification];
+
+ if itemClassification == "equipment" then
+ local isUpgrade, isReady = API.IsItemAnUpgrade_External(entry.itemLink);
+ if not isReady then
+ shouldAdd = nil; -- still not ready
+ else
+ shouldAdd = isUpgrade;
+ end
+ end
+
+ if shouldAdd then
+ local priorityOnly = addon.GetDBBool("QuickSlotPriorityOnly");
+ if (not priorityOnly) or IsHighPriority(itemClassification) then
+ DebugLog("Deferred resolved:", itemClassification, entry.itemLink);
+ QueueManager:Enqueue(entry.itemLink, itemClassification);
+ end
+ elseif entry.retryCount < DEFERRED_MAX_RETRIES and not itemClassification then
+ -- Still no classification data; keep retrying
+ table.insert(stillPending, entry);
+ else
+ DebugLog("Deferred dropped after", entry.retryCount, "retries:", entry.itemLink);
+ end
+ end
+
+ pending = stillPending;
+ if #pending == 0 then
+ self:StopTimer();
+ end
+ end
+
+ function DeferredResolver:Clear()
+ wipe(pending);
+ self:StopTimer();
+ end
+end
+
function QuickSlotManager:ListenLootEvent(state)
if state then
self:RegisterEvent("CHAT_MSG_LOOT");
@@ -41,9 +288,12 @@ function QuickSlotManager:ListenLootEvent(state)
else
self.t = nil;
self.pendingItemLink = nil;
- self.itemClassification = nil;
+ self.pendingClassification = nil;
self:SetScript("OnUpdate", nil);
- self:UnregisterEvent("CHAT_MSG_LOOT");
+ -- Don't unregister CHAT_MSG_LOOT if always-on mode is active
+ if not addon.GetDBBool("QuickSlotAlwaysOn") then
+ self:UnregisterEvent("CHAT_MSG_LOOT");
+ end
self:UnregisterEvent("BAG_UPDATE_DELAYED");
end
end
@@ -52,21 +302,27 @@ function QuickSlotManager:OnEvent(event, ...)
if event == "CHAT_MSG_LOOT" then
self:CHAT_MSG_LOOT(...);
elseif event == "BAG_UPDATE_DELAYED" then
- if self.pendingItemLink and self.itemClassification then
- local success;
-
+ if self.pendingItemLink then
if HasItem(self.pendingItemLink) then
- success = self:AddItemButtonByType(self.itemClassification, self.pendingItemLink);
- end
-
- if success then
self:UnregisterEvent(event);
+
+ if self.pendingClassification then
+ QueueManager:Enqueue(self.pendingItemLink, self.pendingClassification);
+ else
+ -- Classification was unknown at loot time; try deferred resolution
+ DeferredResolver:Add(self.pendingItemLink);
+ end
+
self.pendingItemLink = nil;
- self.itemClassification = nil;
+ self.pendingClassification = nil;
end
end
elseif event == "PLAYER_ENTERING_WORLD" then
- self:PLAYER_ENTERING_WORLD(...);
+ self:UnregisterEvent(event);
+ if addon.GetDBBool("QuickSlotQuestReward") and addon.GetDBBool("QuickSlotAlwaysOn") then
+ self:RegisterEvent("CHAT_MSG_LOOT");
+ DebugLog("Always-on loot listener initialized");
+ end
end
end
QuickSlotManager:SetScript("OnEvent", QuickSlotManager.OnEvent);
@@ -81,7 +337,7 @@ end
function QuickSlotManager:WatchBagItem(itemLink, itemClassification)
self:RegisterEvent("BAG_UPDATE_DELAYED");
self.pendingItemLink = itemLink;
- self.itemClassification = itemClassification;
+ self.pendingClassification = itemClassification;
if self.t then
--Extend unregister countdown in case of lags
self.t = self.t - 1;
@@ -118,24 +374,46 @@ end
function QuickSlotManager:OnItemLooted(itemLink)
--Fired after CHAT_MSG_LOOT, but the item may have not been pushed into the bags yet
- if IsPlayingCutscene() then
- return
- end
+ if IsPlayingCutscene() then return end;
+
+ -- Duplicate/cooldown suppression
+ if CandidateFilter:IsRecentLoot(itemLink) then return end;
+ if CandidateFilter:IsRecentlyHandled(itemLink) then return end;
+ CandidateFilter:RecordLoot(itemLink);
local itemClassification = GetItemClassification(itemLink);
- --print(itemClassification, itemLink); --debug
+ local shouldAdd = itemClassification and SupportedItemTypes[itemClassification];
- local shouldAddItem = itemClassification and SupportedItemTypes[itemClassification];
if itemClassification == "equipment" then
- shouldAddItem = API.IsItemAnUpgrade_External(itemLink);
+ local isUpgrade, isReady = API.IsItemAnUpgrade_External(itemLink);
+ if not isReady then
+ -- Item data not cached yet; defer evaluation
+ if HasItem(itemLink) then
+ DeferredResolver:Add(itemLink);
+ else
+ self:WatchBagItem(itemLink, nil); -- watch for bag arrival, then defer
+ end
+ return
+ end
+ shouldAdd = isUpgrade;
end
- if shouldAddItem then
- if HasItem(itemLink) then
- self:AddItemButtonByType(itemClassification, itemLink);
- else
- self:WatchBagItem(itemLink, itemClassification);
- end
+ if not shouldAdd then
+ DebugLog("Rejected (not eligible):", itemClassification, itemLink);
+ return
+ end
+
+ -- Priority filtering
+ local priorityOnly = addon.GetDBBool("QuickSlotPriorityOnly");
+ if priorityOnly and not IsHighPriority(itemClassification) then
+ DebugLog("Rejected (low priority suppressed):", itemClassification, itemLink);
+ return
+ end
+
+ if HasItem(itemLink) then
+ QueueManager:Enqueue(itemLink, itemClassification);
+ else
+ self:WatchBagItem(itemLink, itemClassification);
end
end
@@ -145,6 +423,7 @@ function QuickSlotManager:AddAutoCloseItemButton(itemLink, setupMethod, isAction
local allowPressKeyToUse = addon.GetDBBool("QuickSlotUseHotkey");
local button = self:GetItemButton();
+ button.currentItemLink = itemLink;
button[setupMethod](button, itemLink, allowPressKeyToUse);
button:ShowButton();
button.CloseButton:SetInteractable(false);
@@ -160,6 +439,9 @@ function QuickSlotManager:AddAutoCloseItemButton(itemLink, setupMethod, isAction
else
button:PlayFlyUpAnimation(true);
end
+ if InCombatLockdown() then
+ button:UpdatePauseState();
+ end
end
end
@@ -200,7 +482,7 @@ do --Add Button Method
end
function QuickSlotManager:AddPet(itemLink)
- self:AddAutoCloseItemButton(itemLink, "SetPetItem", "IsKnownPet");
+ self:AddAutoCloseItemButton(itemLink, "SetPetItem");
end
function QuickSlotManager:AddToy(itemLink)
@@ -260,6 +542,38 @@ do
end
end
CallbackRegistry:Register("SettingChanged.QuickSlotQuestReward", Settings_QuickSlotQuestReward);
+
+ local ALWAYS_ON_ENABLED = false;
+
+ local function Settings_QuickSlotAlwaysOn(state)
+ if state and addon.GetDBBool("QuickSlotQuestReward") then
+ if not ALWAYS_ON_ENABLED then
+ ALWAYS_ON_ENABLED = true;
+ QuickSlotManager:RegisterEvent("CHAT_MSG_LOOT");
+ DebugLog("Always-on loot listener enabled");
+ end
+ else
+ if ALWAYS_ON_ENABLED then
+ ALWAYS_ON_ENABLED = false;
+ -- Only unregister if the quest-completion listener isn't active
+ if not QuickSlotManager.t then
+ QuickSlotManager:UnregisterEvent("CHAT_MSG_LOOT");
+ end
+ QueueManager:Clear();
+ DeferredResolver:Clear();
+ DebugLog("Always-on loot listener disabled");
+ end
+ end
+ end
+ CallbackRegistry:Register("SettingChanged.QuickSlotAlwaysOn", Settings_QuickSlotAlwaysOn);
+
+ -- Also re-evaluate when the parent setting changes
+ CallbackRegistry:Register("SettingChanged.QuickSlotQuestReward", function(state)
+ Settings_QuickSlotAlwaysOn(addon.GetDBBool("QuickSlotAlwaysOn"));
+ end);
+
+ -- Initialize on login
+ QuickSlotManager:RegisterEvent("PLAYER_ENTERING_WORLD");
end
@@ -296,7 +610,45 @@ do --QuestRewardItemButtonMixin
self:SetAllowRightClickToClose(true);
end
+ function QuestRewardItemButtonMixin:UpdatePauseState()
+ self.CloseButton:PauseAutoCloseTimer(self:IsFocused() or InCombatLockdown());
+ end
+
+ local Round = function(v) return math.floor(v + 0.5) end;
+
+ function QuestRewardItemButtonMixin:LoadPosition()
+ local position = addon.GetDBValue("QuickSlotPosition");
+ self:ClearAllPoints();
+ if position then
+ self:SetPoint("LEFT", UIParent, "BOTTOMLEFT", position[1], position[2]);
+ else
+ self:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 196);
+ end
+ end
+
+ function QuestRewardItemButtonMixin:SavePosition()
+ local x = self:GetLeft();
+ local _, y = self:GetCenter();
+ if x and y then
+ addon.SetDBValue("QuickSlotPosition", {Round(x), Round(y)});
+ end
+ end
+
+ function QuestRewardItemButtonMixin:ResetPosition()
+ addon.SetDBValue("QuickSlotPosition", nil);
+ self:LoadPosition();
+ end
+
+ function QuestRewardItemButtonMixin:IsUsingCustomPosition()
+ return addon.GetDBValue("QuickSlotPosition") ~= nil
+ end
+
function QuestRewardItemButtonMixin:OnButtonEnter()
+ if self.isEditMode then
+ self.tooltipText = L["Drag To Move"].."\n"..L["Right Click To Dismiss"];
+ addon.SharedTooltip.ShowWidgetTooltip(self);
+ return
+ end
if self.hyperlink then
self:RegisterEvent("MODIFIER_STATE_CHANGED");
local tooltip;
@@ -310,31 +662,49 @@ do --QuestRewardItemButtonMixin
addon.RewardTooltipCode:ShowHyperlink(self, self.hyperlink)
end
end
- self.CloseButton:PauseAutoCloseTimer(true);
+ self:UpdatePauseState();
end
function QuestRewardItemButtonMixin:OnButtonLeave()
+ if self.isEditMode then
+ addon.SharedTooltip.HideTooltip();
+ return
+ end
addon.RewardTooltipCode:OnLeave();
self:UnregisterEvent("MODIFIER_STATE_CHANGED");
- self.CloseButton:PauseAutoCloseTimer(false);
+ self:UpdatePauseState();
end
function QuestRewardItemButtonMixin:OnButtonMouseDown(button)
-
+ if button == "LeftButton" and (self.isEditMode or IsShiftKeyDown()) and not InCombatLockdown() then
+ self:StartMoving();
+ self.isMoving = true;
+ end
end
function QuestRewardItemButtonMixin:OnButtonMouseUp(button)
-
+ if button == "LeftButton" and self.isMoving then
+ self:StopMovingOrSizing();
+ self.isMoving = nil;
+ self:SavePosition();
+ elseif button == "RightButton" and self.isEditMode then
+ self.isEditMode = nil;
+ self:ClearButton();
+ end
end
function QuestRewardItemButtonMixin:OnButtonHide()
self.CloseButton:StopCountdown();
self.UpgradeArrow:Hide();
+ self.isEditMode = nil;
end
function QuestRewardItemButtonMixin:OnCountdownFinished()
+ local itemLink = self.currentItemLink;
+ self.currentItemLink = nil;
self:FadeOut(0);
self:UnregisterAllEvents();
+ QueueManager:OnPopupDismissed(itemLink);
end
function QuestRewardItemButtonMixin:SetCountdown(second, disableButton)
@@ -354,7 +724,11 @@ do --QuestRewardItemButtonMixin
end
if hideDirectly then
+ local itemLink = self.currentItemLink;
+ self.currentItemLink = nil;
self:ClearButton();
+ QueueManager:OnPopupDismissed(itemLink);
+ return
else
self.CloseButton:SetCountdown(second);
end
@@ -365,6 +739,15 @@ do --QuestRewardItemButtonMixin
if self:IsFocused() then
self:OnEnter();
end
+ elseif event == "NEW_PET_ADDED" and self.type == "pet" then
+ --Pet cage consumed; fade out immediately
+ self:SetButtonEnabled(false);
+ self:SetSuccessText(L["Collection Collected"]);
+ local itemLink = self.currentItemLink;
+ self.currentItemLink = nil;
+ self:FadeOut(0);
+ self:UnregisterAllEvents();
+ QueueManager:OnPopupDismissed(itemLink);
end
end
@@ -394,6 +777,10 @@ do --QuestRewardItemButtonMixin
self:SetCountdown(COUNTDOWN_COMPLETE_MANUAL, true);
end
+ function QuestRewardItemButtonMixin:OnItemConsumed()
+ self:SetCountdown(COUNTDOWN_COMPLETE_MANUAL, true);
+ end
+
function QuickSlotManager:GetItemButton()
if RewardItemButton then
@@ -402,7 +789,9 @@ do --QuestRewardItemButtonMixin
RewardItemButton = API.CreateItemActionButton(nil, QuestRewardItemButtonMixin);
RewardItemButton:Hide();
RewardItemButton:SetFrameStrata("FULLSCREEN_DIALOG");
- RewardItemButton:SetPoint("BOTTOM", nil, "BOTTOM", 0, 196);
+ RewardItemButton:SetMovable(true);
+ RewardItemButton:SetClampedToScreen(true);
+ RewardItemButton:LoadPosition();
RewardItemButton:SetIgnoreParentScale(true);
RewardItemButton:SetIgnoreParentAlpha(true);
end
@@ -415,10 +804,42 @@ do --QuestRewardItemButtonMixin
RewardItemButton:OnCountdownFinished();
RewardItemButton:SetInteractable(false);
else
+ local itemLink = RewardItemButton.currentItemLink;
+ RewardItemButton.currentItemLink = nil;
RewardItemButton:ClearButton();
+ QueueManager:OnPopupDismissed(itemLink);
end
end
end
+
+ function QuickSlotManager:ToggleEditMode()
+ if RewardItemButton and RewardItemButton.isEditMode and RewardItemButton:IsShown() then
+ RewardItemButton.isEditMode = nil;
+ RewardItemButton:ClearButton();
+ return
+ end
+
+ local button = self:GetItemButton();
+ button.isEditMode = true;
+ button:SetIcon(134400);
+ button:SetButtonText(L["Drag To Move"]);
+ button:SetButtonEnabled(false);
+ button:EnableMouse(true);
+ button:EnableMouseMotion(true);
+ button.CloseButton:StopCountdown();
+ button:ShowButton();
+ button:PlayFlyUpAnimation(true);
+ end
+
+ function QuickSlotManager:ResetPosition()
+ if RewardItemButton then
+ RewardItemButton:ResetPosition();
+ end
+ end
+
+ function QuickSlotManager:IsUsingCustomPosition()
+ return addon.GetDBValue("QuickSlotPosition") ~= nil
+ end
end
diff --git a/Initialization.lua b/Initialization.lua
index b0e5a07..23e17c6 100644
--- a/Initialization.lua
+++ b/Initialization.lua
@@ -57,6 +57,9 @@ local DefaultValues = {
QuestItemDisplayHideSeen = false,
QuestItemDisplayDynamicFrameStrata = false,
QuickSlotQuestReward = false,
+ QuickSlotAlwaysOn = false,
+ QuickSlotPriorityOnly = false,
+ QuickSlotCollectibleHighPriority = false,
AutoCompleteQuest = false,
QuickSlotUseHotkey = true,
AutoSelectGossip = false,
@@ -104,6 +107,7 @@ local DefaultValues = {
--WidgetManagerPosition = {x, y};
--QuestItemDisplayPosition = {x, y};
+ --QuickSlotPosition = {x, y};
--Deprecated:
diff --git a/Locales/enUS.lua b/Locales/enUS.lua
index 5f2452e..9ebb481 100644
--- a/Locales/enUS.lua
+++ b/Locales/enUS.lua
@@ -61,6 +61,7 @@ L["Story Progress"] = STORY_PROGRESS or "Story Progress";
L["Quest Complete Alert"] = QUEST_WATCH_POPUP_QUEST_COMPLETE or "Quest Complete!";
L["Item Equipped"] = "Equipped";
L["Collection Collected"] = COLLECTED or "Collected";
+L["Popup Usable After Combat"] = "Usable after combat";
--String Format
L["Format Reputation Reward Tooltip"] = QUEST_REPUTATION_REWARD_TOOLTIP or "Awards %d reputation with the %s";
@@ -101,6 +102,7 @@ L["Option Disabled"] = VIDEO_OPTIONS_DISABLED or "Disabled";
L["Move Position"] = "Move";
L["Reset Position"] = RESET_POSITION or "Reset Position";
L["Drag To Move"] = "Left-click and drag to move the window.";
+L["Right Click To Dismiss"] = "Right-click to dismiss.";
L["Middle Click To Reset Position"] = "Middle-click to reset position.";
L["No Available Choice"] = "No available choice";
@@ -225,6 +227,12 @@ L["Quest Item Display Await World Map Desc"] = "When you open the World Map, tem
L["Quest Item Display Reset Position Desc"] = "Reset the window's position.";
L["Valuable Reward Popup"] = "Valuable Reward Popup";
L["Valuable Reward Popup Desc"] = "When you receive a valuable item like an upgrade, a chest, or an uncollected cosmetic item, show a button that allows you to use it directly.";
+L["Always-On Loot Popup"] = "Always-On Loot Popup";
+L["Always-On Loot Popup Desc"] = "Show the valuable reward popup for items received from any source, not just quest completion. Covers boss drops, world drops, treasure chests, and more.";
+L["Upgrades And Containers Only"] = "Upgrades and Containers Only";
+L["Upgrades And Containers Only Desc"] = "Only show popups for equipment upgrades and openable containers. Suppress cosmetics, mounts, pets, toys, and decor.";
+L["Include Collectibles"] = "Include Toys, Pets & Decor";
+L["Include Collectibles Desc"] = "Treat toys, companion pets, and housing decor as high priority—same as equipment upgrades and containers.";
L["Auto Complete Quest"] = "Auto Complete Quest";
L["Auto Complete Quest Desc"] = "Auto complete the following quest then display the dialogue and rewards in a separate window. If the rewards contain a chest, you can click to open it.\n\n- Candy Bucket (Hallow's End)\n- Khaz Algar Weekly\n- Midsummer Bonfire";
L["Press Key To Use Item"] = "Press Button To Use";
diff --git a/docs/plans/2026-03-06-quickslot-enhanced-design.md b/docs/plans/2026-03-06-quickslot-enhanced-design.md
new file mode 100644
index 0000000..f08545f
--- /dev/null
+++ b/docs/plans/2026-03-06-quickslot-enhanced-design.md
@@ -0,0 +1,127 @@
+# Enhanced QuickSlot: Always-On Upgrade Popup with Queue
+
+## Summary
+
+Extend DialogueUI's QuickSlot system to listen for loot from all sources (not
+just quest completion), queue multiple popup candidates, and handle deferred
+item evaluation gracefully.
+
+## Architecture
+
+Four logical units, all in `Widget_QuickSlot.lua`:
+
+1. **Loot Listener / Source Ingestion** -- captures loot events, extracts item links
+2. **Candidate Filter + Normalization** -- dedup, eligibility, deferred resolution
+3. **Queue Manager** -- two FIFO buckets with priority drain, identity rules
+4. **Popup Renderer / Action Handler** -- existing button UI (unchanged)
+
+## 1. Loot Listener
+
+- `CHAT_MSG_LOOT` is the primary event. It covers most earned loot sources:
+ boss drops, quest rewards, world drops, treasure chests, bonus rolls.
+- Vault coverage is unverified and should be treated as best-effort.
+- New setting `QuickSlotAlwaysOn` (default `false`): when enabled, register
+ `CHAT_MSG_LOOT` permanently on `PLAYER_ENTERING_WORLD`.
+- The existing quest-completion listener remains independent and unchanged.
+
+## 2. Candidate Filter
+
+Pipeline between loot detection and queue:
+
+```
+loot event received
+ -> normalize: extract item link, resolve classification
+ -> reject: no valid item link or classification resolved -> drop
+ -> reject: not in SupportedItemTypes -> drop
+ -> reject: equipment but not an upgrade -> drop
+ -> reject: duplicate (same item_link seen in recent_loot within DUPLICATE_SUPPRESS_SECONDS) -> drop
+ -> reject: recently handled (same item_link in recently_handled within DISMISSED_COOLDOWN_SECONDS) -> drop
+ -> if item info incomplete: enqueue as pending-resolution
+ -> else: enqueue as resolved candidate
+```
+
+### Deferred Item Resolution
+
+- If `GetItemClassification` or `IsItemAnUpgrade_External` returns nil/not-ready,
+ mark the candidate as pending.
+- Retry at `DEFERRED_RETRY_INTERVAL` (0.5s), up to `DEFERRED_MAX_RETRIES` (3).
+- On success: promote to resolved, enqueue normally.
+- On failure after max retries: drop silently (debug log if enabled).
+
+### Suppression Caches
+
+Two separate caches:
+
+- `recent_loot[item_link] = last_seen_time` -- dedup key is `item_link` only;
+ suppress if same link seen within `DUPLICATE_SUPPRESS_SECONDS` (5s).
+- `recently_handled[item_link] = last_dismissed_or_equipped_time` -- suppress if
+ same link dismissed/equipped within `DISMISSED_COOLDOWN_SECONDS` (30s).
+
+## 3. Queue Manager
+
+Two FIFO buckets, high drained before low:
+
+- **High priority**: equipment upgrades, containers (usable items)
+- **Low priority**: cosmetics, mounts, pets, toys, decor
+
+### Display-Time Revalidation
+
+Before showing the next queued item:
+
+- Re-check eligibility (still in bags, still an upgrade, still usable).
+- Skip if already equipped, no longer an upgrade, or item no longer exists.
+
+### Container Chain
+
+When opening a container produces loot, the always-on listener catches the
+contents. Any resulting upgrades wait in queue -- no preemption of the current
+popup.
+
+## 4. Popup Renderer
+
+No changes to the existing `QuestRewardItemButtonMixin` or button creation.
+The queue manager calls `GetItemButton()` and the appropriate setup method
+when it is time to show the next candidate.
+
+On dismiss/equip/use, the queue manager advances to the next item.
+
+## Settings
+
+| Setting | Type | Default | Description |
+|-----------------------|------|---------|--------------------------------------------------|
+| QuickSlotAlwaysOn | bool | false | Listen for loot from all sources |
+| QuickSlotPriorityOnly | bool | false | Only show equipment upgrades and containers |
+
+### Internal Constants
+
+```lua
+DUPLICATE_SUPPRESS_SECONDS = 5
+DISMISSED_COOLDOWN_SECONDS = 30
+DEFERRED_RETRY_INTERVAL = 0.5
+DEFERRED_MAX_RETRIES = 3
+```
+
+## Debug Logging
+
+Gated behind a local `DEBUG` flag (not user-facing):
+
+- Loot event received
+- Candidate rejected (with reason)
+- Deferred retry count
+- Enqueue / dequeue
+- Skipped on revalidation
+
+## Non-Goals
+
+- No detection of AH, vendor, mail, trade, or manual bag moves.
+- No retroactive scan of existing bags when always-on mode is enabled.
+- No automatic equip without user action.
+- No guarantee for uncached items until item data resolves.
+- No modification to existing quest-completion QuickSlot behavior when
+ always-on is disabled.
+
+## Files to Modify
+
+- `Code/Widget/Widget_QuickSlot.lua` -- all four units
+- `Code/Settings/Settings.lua` -- register new settings
+- `Locales/enUS.lua` -- setting labels and descriptions
diff --git a/docs/plans/2026-03-06-quickslot-enhanced.md b/docs/plans/2026-03-06-quickslot-enhanced.md
new file mode 100644
index 0000000..72392b5
--- /dev/null
+++ b/docs/plans/2026-03-06-quickslot-enhanced.md
@@ -0,0 +1,701 @@
+# Enhanced QuickSlot Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Extend QuickSlot to listen for loot from all sources, queue multiple popup candidates with priority, and handle deferred item evaluation.
+
+**Architecture:** Four logical units in Widget_QuickSlot.lua -- loot listener (always-on mode via CHAT_MSG_LOOT), candidate filter (dedup + deferred resolution), queue manager (two FIFO buckets: high/low priority), and the existing popup renderer. New settings registered in Initialization.lua and Settings.lua; locale strings in enUS.lua.
+
+**Tech Stack:** WoW Lua API, DialogueUI addon framework (CallbackRegistry, WidgetManager, settings system)
+
+---
+
+### Task 1: Register new settings and locale strings
+
+**Files:**
+- Modify: `Initialization.lua:59-61`
+- Modify: `Code/Settings/Settings.lua:699-701`
+- Modify: `Locales/enUS.lua:224-232`
+
+**Step 1: Add setting defaults in Initialization.lua**
+
+After line 59 (`QuickSlotQuestReward = false,`), add the new settings. The indentation pattern here uses 4 spaces and sub-options are indented further.
+
+```lua
+ QuickSlotQuestReward = false,
+ QuickSlotAlwaysOn = false,
+ QuickSlotPriorityOnly = false,
+ AutoCompleteQuest = false,
+ QuickSlotUseHotkey = true,
+```
+
+**Step 2: Add locale strings in Locales/enUS.lua**
+
+After line 225 (`L["Valuable Reward Popup Desc"] = ...`), add:
+
+```lua
+L["Always-On Loot Popup"] = "Always-On Loot Popup";
+L["Always-On Loot Popup Desc"] = "Show the valuable reward popup for items received from any source, not just quest completion. Covers boss drops, world drops, treasure chests, and more.";
+L["Upgrades And Containers Only"] = "Upgrades and Containers Only";
+L["Upgrades And Containers Only Desc"] = "Only show popups for equipment upgrades and openable containers. Suppress cosmetics, mounts, pets, toys, and decor.";
+```
+
+**Step 3: Add settings UI entries in Code/Settings/Settings.lua**
+
+After line 699 (the `QuickSlotQuestReward` checkbox), add two new checkboxes. They are children of `QuickSlotQuestReward` -- they require it to be enabled:
+
+```lua
+ {type = "Checkbox", name = L["Valuable Reward Popup"], description = L["Valuable Reward Popup Desc"], dbKey = "QuickSlotQuestReward", preview = "QuickSlotQuestReward", ratio = 2},
+ {type = "Checkbox", name = L["Always-On Loot Popup"], description = L["Always-On Loot Popup Desc"], dbKey = "QuickSlotAlwaysOn", requiredParentValueAnd = {QuickSlotQuestReward = true}},
+ {type = "Checkbox", name = L["Upgrades And Containers Only"], description = L["Upgrades And Containers Only Desc"], dbKey = "QuickSlotPriorityOnly", requiredParentValueAnd = {QuickSlotQuestReward = true, QuickSlotAlwaysOn = true}},
+```
+
+**Step 4: Commit**
+
+```bash
+git add Initialization.lua Code/Settings/Settings.lua Locales/enUS.lua
+git commit -m "feat(quickslot): add settings for always-on loot popup and priority filtering"
+```
+
+---
+
+### Task 2: Add candidate filter with suppression caches and deferred resolution
+
+**Files:**
+- Modify: `Code/Widget/Widget_QuickSlot.lua:1-35` (add new locals and filter module at top of file)
+
+**Step 1: Add constants and suppression caches**
+
+After the existing constants block (lines 11-13: `COUNTDOWN_IDLE`, `COUNTDOWN_COMPLETE_AUTO`, `COUNTDOWN_COMPLETE_MANUAL`), add:
+
+```lua
+local DUPLICATE_SUPPRESS_SECONDS = 5;
+local DISMISSED_COOLDOWN_SECONDS = 30;
+local DEFERRED_RETRY_INTERVAL = 0.5;
+local DEFERRED_MAX_RETRIES = 3;
+
+local DEBUG_QUICKSLOT = false;
+
+local function DebugLog(...)
+ if DEBUG_QUICKSLOT then
+ print("|cfffe6100[QuickSlot]|r", ...);
+ end
+end
+```
+
+**Step 2: Add the CandidateFilter module**
+
+After the `SupportedItemTypes` table (line 34), add the filter module. This sits between loot detection and the queue:
+
+```lua
+local CandidateFilter = {};
+do
+ local recent_loot = {}; -- [itemLink] = lastSeenTime
+ local recently_handled = {}; -- [itemLink] = lastDismissedOrEquippedTime
+
+ function CandidateFilter:IsRecentLoot(itemLink)
+ local lastSeen = recent_loot[itemLink];
+ if lastSeen and (GetTime() - lastSeen < DUPLICATE_SUPPRESS_SECONDS) then
+ DebugLog("Rejected duplicate:", itemLink);
+ return true
+ end
+ return false
+ end
+
+ function CandidateFilter:IsRecentlyHandled(itemLink)
+ local lastHandled = recently_handled[itemLink];
+ if lastHandled and (GetTime() - lastHandled < DISMISSED_COOLDOWN_SECONDS) then
+ DebugLog("Rejected recently handled:", itemLink);
+ return true
+ end
+ return false
+ end
+
+ function CandidateFilter:RecordLoot(itemLink)
+ recent_loot[itemLink] = GetTime();
+ end
+
+ function CandidateFilter:RecordHandled(itemLink)
+ recently_handled[itemLink] = GetTime();
+ end
+
+ -- Prune stale entries periodically to avoid unbounded growth.
+ function CandidateFilter:Prune()
+ local now = GetTime();
+ for link, t in pairs(recent_loot) do
+ if now - t > DUPLICATE_SUPPRESS_SECONDS then
+ recent_loot[link] = nil;
+ end
+ end
+ for link, t in pairs(recently_handled) do
+ if now - t > DISMISSED_COOLDOWN_SECONDS then
+ recently_handled[link] = nil;
+ end
+ end
+ end
+end
+```
+
+**Step 3: Commit**
+
+```bash
+git add Code/Widget/Widget_QuickSlot.lua
+git commit -m "feat(quickslot): add candidate filter with suppression caches and debug logging"
+```
+
+---
+
+### Task 3: Add queue manager with two FIFO buckets
+
+**Files:**
+- Modify: `Code/Widget/Widget_QuickSlot.lua` (add QueueManager module after CandidateFilter)
+
+**Step 1: Add the QueueManager module**
+
+Insert after the CandidateFilter block:
+
+```lua
+local QueueManager = {};
+do
+ local highQueue = {}; -- equipment upgrades, containers
+ local lowQueue = {}; -- cosmetics, mounts, pets, toys, decor
+ local isProcessing = false;
+
+ local HIGH_PRIORITY_TYPES = {
+ equipment = true,
+ container = true,
+ };
+
+ function QueueManager:Enqueue(itemLink, classification)
+ local entry = {
+ itemLink = itemLink,
+ classification = classification,
+ enqueueTime = GetTime(),
+ };
+
+ if HIGH_PRIORITY_TYPES[classification] then
+ table.insert(highQueue, entry);
+ DebugLog("Enqueued HIGH:", classification, itemLink);
+ else
+ table.insert(lowQueue, entry);
+ DebugLog("Enqueued LOW:", classification, itemLink);
+ end
+
+ if not isProcessing then
+ self:ProcessNext();
+ end
+ end
+
+ function QueueManager:Peek()
+ return highQueue[1] or lowQueue[1]
+ end
+
+ function QueueManager:Dequeue()
+ if #highQueue > 0 then
+ return table.remove(highQueue, 1)
+ elseif #lowQueue > 0 then
+ return table.remove(lowQueue, 1)
+ end
+ end
+
+ function QueueManager:IsEmpty()
+ return #highQueue == 0 and #lowQueue == 0
+ end
+
+ function QueueManager:Clear()
+ wipe(highQueue);
+ wipe(lowQueue);
+ isProcessing = false;
+ end
+
+ -- Revalidate: item still in bags, still an upgrade (for equipment), still usable.
+ function QueueManager:IsStillEligible(entry)
+ if not HasItem(entry.itemLink) then
+ DebugLog("Revalidation failed (not in bags):", entry.itemLink);
+ return false
+ end
+ if entry.classification == "equipment" then
+ local isUpgrade = API.IsItemAnUpgrade_External(entry.itemLink);
+ if not isUpgrade then
+ DebugLog("Revalidation failed (no longer upgrade):", entry.itemLink);
+ return false
+ end
+ end
+ return true
+ end
+
+ function QueueManager:ProcessNext()
+ while not self:IsEmpty() do
+ local entry = self:Dequeue();
+ if entry and self:IsStillEligible(entry) then
+ isProcessing = true;
+ DebugLog("Showing popup:", entry.classification, entry.itemLink);
+ QuickSlotManager:AddItemButtonByType(entry.classification, entry.itemLink);
+ return
+ else
+ DebugLog("Skipped on revalidation:", entry and entry.itemLink or "nil");
+ end
+ end
+ isProcessing = false;
+ DebugLog("Queue drained");
+ end
+
+ function QueueManager:OnPopupDismissed(itemLink)
+ isProcessing = false;
+ if itemLink then
+ CandidateFilter:RecordHandled(itemLink);
+ end
+ self:ProcessNext();
+ end
+
+ function QueueManager:SetProcessing(state)
+ isProcessing = state;
+ end
+end
+```
+
+**Step 2: Commit**
+
+```bash
+git add Code/Widget/Widget_QuickSlot.lua
+git commit -m "feat(quickslot): add queue manager with two FIFO priority buckets and revalidation"
+```
+
+---
+
+### Task 4: Add deferred resolution for uncached items
+
+**Files:**
+- Modify: `Code/Widget/Widget_QuickSlot.lua` (add DeferredResolver after QueueManager)
+
+**Step 1: Add the DeferredResolver module**
+
+```lua
+local DeferredResolver = {};
+do
+ local pending = {}; -- { itemLink, retryCount, classification }
+
+ function DeferredResolver:Add(itemLink)
+ table.insert(pending, {
+ itemLink = itemLink,
+ retryCount = 0,
+ classification = nil,
+ });
+ DebugLog("Deferred:", itemLink);
+ self:EnsureTimer();
+ end
+
+ function DeferredResolver:EnsureTimer()
+ if not self.timer then
+ self.timer = C_Timer.NewTicker(DEFERRED_RETRY_INTERVAL, function()
+ self:Resolve();
+ end);
+ end
+ end
+
+ function DeferredResolver:StopTimer()
+ if self.timer then
+ self.timer:Cancel();
+ self.timer = nil;
+ end
+ end
+
+ function DeferredResolver:Resolve()
+ local stillPending = {};
+
+ for _, entry in ipairs(pending) do
+ entry.retryCount = entry.retryCount + 1;
+ local itemClassification = GetItemClassification(entry.itemLink);
+ local shouldAdd = itemClassification and SupportedItemTypes[itemClassification];
+
+ if itemClassification == "equipment" then
+ local isUpgrade, isReady = API.IsItemAnUpgrade_External(entry.itemLink);
+ if not isReady then
+ shouldAdd = nil; -- still not ready
+ else
+ shouldAdd = isUpgrade;
+ end
+ end
+
+ if shouldAdd then
+ local priorityOnly = addon.GetDBBool("QuickSlotPriorityOnly");
+ local isHighPriority = (itemClassification == "equipment" or itemClassification == "container");
+ if (not priorityOnly) or isHighPriority then
+ DebugLog("Deferred resolved:", itemClassification, entry.itemLink);
+ QueueManager:Enqueue(entry.itemLink, itemClassification);
+ end
+ elseif entry.retryCount < DEFERRED_MAX_RETRIES and not itemClassification then
+ -- Still no classification data; keep retrying
+ table.insert(stillPending, entry);
+ else
+ DebugLog("Deferred dropped after", entry.retryCount, "retries:", entry.itemLink);
+ end
+ end
+
+ pending = stillPending;
+ if #pending == 0 then
+ self:StopTimer();
+ end
+ end
+
+ function DeferredResolver:Clear()
+ wipe(pending);
+ self:StopTimer();
+ end
+end
+```
+
+**Step 2: Commit**
+
+```bash
+git add Code/Widget/Widget_QuickSlot.lua
+git commit -m "feat(quickslot): add deferred resolution for uncached item data"
+```
+
+---
+
+### Task 5: Wire up the loot listener with always-on mode
+
+**Files:**
+- Modify: `Code/Widget/Widget_QuickSlot.lua` -- modify `OnItemLooted`, `ListenLootEvent`, and the settings callback block
+
+**Step 1: Modify `OnItemLooted` to use the filter and queue**
+
+Replace the existing `OnItemLooted` function (lines 118-140) with one that pipes through the candidate filter:
+
+```lua
+function QuickSlotManager:OnItemLooted(itemLink)
+ if IsPlayingCutscene() then return end;
+
+ -- Duplicate/cooldown suppression
+ if CandidateFilter:IsRecentLoot(itemLink) then return end;
+ if CandidateFilter:IsRecentlyHandled(itemLink) then return end;
+ CandidateFilter:RecordLoot(itemLink);
+
+ local itemClassification = GetItemClassification(itemLink);
+ local shouldAdd = itemClassification and SupportedItemTypes[itemClassification];
+
+ if itemClassification == "equipment" then
+ local isUpgrade, isReady = API.IsItemAnUpgrade_External(itemLink);
+ if not isReady then
+ -- Item data not cached yet; defer evaluation
+ if HasItem(itemLink) then
+ DeferredResolver:Add(itemLink);
+ else
+ self:WatchBagItem(itemLink, nil); -- watch for bag arrival, then defer
+ end
+ return
+ end
+ shouldAdd = isUpgrade;
+ end
+
+ if not shouldAdd then
+ DebugLog("Rejected (not eligible):", itemClassification, itemLink);
+ return
+ end
+
+ -- Priority filtering
+ local priorityOnly = addon.GetDBBool("QuickSlotPriorityOnly");
+ local isHighPriority = (itemClassification == "equipment" or itemClassification == "container");
+ if priorityOnly and (not isHighPriority) then
+ DebugLog("Rejected (low priority suppressed):", itemClassification, itemLink);
+ return
+ end
+
+ if HasItem(itemLink) then
+ QueueManager:Enqueue(itemLink, itemClassification);
+ else
+ self:WatchBagItem(itemLink, itemClassification);
+ end
+end
+```
+
+**Step 2: Update `WatchBagItem` and `BAG_UPDATE_DELAYED` handler to use queue**
+
+Replace the `BAG_UPDATE_DELAYED` handler in `OnEvent` (lines 54-67) to enqueue instead of directly showing:
+
+```lua
+ elseif event == "BAG_UPDATE_DELAYED" then
+ if self.pendingItemLink then
+ if HasItem(self.pendingItemLink) then
+ self:UnregisterEvent(event);
+
+ if self.pendingClassification then
+ QueueManager:Enqueue(self.pendingItemLink, self.pendingClassification);
+ else
+ -- Classification was unknown at loot time; try deferred resolution
+ DeferredResolver:Add(self.pendingItemLink);
+ end
+
+ self.pendingItemLink = nil;
+ self.pendingClassification = nil;
+ end
+ end
+ elseif event == "PLAYER_ENTERING_WORLD" then
+```
+
+Update `WatchBagItem` to use `pendingClassification` instead of `itemClassification`:
+
+```lua
+function QuickSlotManager:WatchBagItem(itemLink, itemClassification)
+ self:RegisterEvent("BAG_UPDATE_DELAYED");
+ self.pendingItemLink = itemLink;
+ self.pendingClassification = itemClassification;
+ if self.t then
+ self.t = self.t - 1;
+ end
+end
+```
+
+**Step 3: Add the always-on settings callback**
+
+Modify the settings callback block (lines 233-263). Add a new callback for `QuickSlotAlwaysOn` that registers/unregisters the permanent loot listener:
+
+```lua
+do
+ local ALWAYS_ON_ENABLED = false;
+
+ local function Settings_QuickSlotAlwaysOn(state)
+ if state and addon.GetDBBool("QuickSlotQuestReward") then
+ if not ALWAYS_ON_ENABLED then
+ ALWAYS_ON_ENABLED = true;
+ QuickSlotManager:RegisterEvent("CHAT_MSG_LOOT");
+ DebugLog("Always-on loot listener enabled");
+ end
+ else
+ if ALWAYS_ON_ENABLED then
+ ALWAYS_ON_ENABLED = false;
+ -- Only unregister if the quest-completion listener isn't active
+ if not QuickSlotManager.t then
+ QuickSlotManager:UnregisterEvent("CHAT_MSG_LOOT");
+ end
+ QueueManager:Clear();
+ DeferredResolver:Clear();
+ DebugLog("Always-on loot listener disabled");
+ end
+ end
+ end
+ CallbackRegistry:Register("SettingChanged.QuickSlotAlwaysOn", Settings_QuickSlotAlwaysOn);
+
+ -- Also re-evaluate when the parent setting changes
+ CallbackRegistry:Register("SettingChanged.QuickSlotQuestReward", function(state)
+ Settings_QuickSlotAlwaysOn(addon.GetDBBool("QuickSlotAlwaysOn"));
+ end);
+
+ -- Initialize on login
+ QuickSlotManager:RegisterEvent("PLAYER_ENTERING_WORLD");
+end
+```
+
+Add the `PLAYER_ENTERING_WORLD` handler to the `OnEvent` function to initialize always-on mode:
+
+```lua
+ elseif event == "PLAYER_ENTERING_WORLD" then
+ self:UnregisterEvent(event);
+ if addon.GetDBBool("QuickSlotQuestReward") and addon.GetDBBool("QuickSlotAlwaysOn") then
+ self:RegisterEvent("CHAT_MSG_LOOT");
+ DebugLog("Always-on loot listener initialized");
+ end
+ end
+```
+
+**Step 4: Modify `ListenLootEvent` to not stomp always-on mode**
+
+The existing `ListenLootEvent` registers/unregisters `CHAT_MSG_LOOT` for the quest-completion window. When always-on is active, the unregister should be skipped:
+
+```lua
+function QuickSlotManager:ListenLootEvent(state)
+ if state then
+ self:RegisterEvent("CHAT_MSG_LOOT");
+ self.t = 0;
+ self:SetScript("OnUpdate", self.OnUpdate_UnregisterEvents);
+ else
+ self.t = nil;
+ self.pendingItemLink = nil;
+ self.pendingClassification = nil;
+ self:SetScript("OnUpdate", nil);
+ -- Don't unregister CHAT_MSG_LOOT if always-on mode is active
+ if not addon.GetDBBool("QuickSlotAlwaysOn") then
+ self:UnregisterEvent("CHAT_MSG_LOOT");
+ end
+ self:UnregisterEvent("BAG_UPDATE_DELAYED");
+ end
+end
+```
+
+**Step 5: Commit**
+
+```bash
+git add Code/Widget/Widget_QuickSlot.lua
+git commit -m "feat(quickslot): wire loot listener with always-on mode, filter pipeline, and queue"
+```
+
+---
+
+### Task 6: Wire popup dismiss/equip/use callbacks to advance the queue
+
+**Files:**
+- Modify: `Code/Widget/Widget_QuickSlot.lua` -- modify `QuestRewardItemButtonMixin` callbacks and `GetItemButton`/`HideItemButton`
+
+**Step 1: Track current item link on the button**
+
+In `AddAutoCloseItemButton` (lines 142-164), store the item link on the button:
+
+```lua
+function QuickSlotManager:AddAutoCloseItemButton(itemLink, setupMethod, isActionCompleteMethod)
+ local countDownDuration = COUNTDOWN_IDLE;
+ local disableButton;
+ local allowPressKeyToUse = addon.GetDBBool("QuickSlotUseHotkey");
+
+ local button = self:GetItemButton();
+ button.currentItemLink = itemLink;
+ button[setupMethod](button, itemLink, allowPressKeyToUse);
+ -- ... rest unchanged
+```
+
+**Step 2: Advance queue on dismiss/equip**
+
+Modify `OnCountdownFinished` to advance the queue:
+
+```lua
+ function QuestRewardItemButtonMixin:OnCountdownFinished()
+ local itemLink = self.currentItemLink;
+ self.currentItemLink = nil;
+ self:FadeOut(0);
+ self:UnregisterAllEvents();
+ QueueManager:OnPopupDismissed(itemLink);
+ end
+```
+
+Modify `OnItemEquipped` to advance the queue:
+
+```lua
+ function QuestRewardItemButtonMixin:OnItemEquipped()
+ local itemLink = self.currentItemLink;
+ self.currentItemLink = nil;
+ self:ShowUpgradeIcon(false);
+ self:SetCountdown(COUNTDOWN_COMPLETE_MANUAL, true);
+ -- Queue advances when countdown finishes
+ end
+```
+
+Modify `OnItemKnown` similarly:
+
+```lua
+ function QuestRewardItemButtonMixin:OnItemKnown()
+ local itemLink = self.currentItemLink;
+ self.currentItemLink = nil;
+ self:SetCountdown(COUNTDOWN_COMPLETE_MANUAL, true);
+ end
+```
+
+**Step 3: Prune caches periodically**
+
+Add a periodic prune call in `OnUpdate_UnregisterEvents` or use a separate timer. Simplest approach -- prune when the queue drains:
+
+In `QueueManager:OnPopupDismissed`, after processing:
+
+```lua
+ function QueueManager:OnPopupDismissed(itemLink)
+ isProcessing = false;
+ if itemLink then
+ CandidateFilter:RecordHandled(itemLink);
+ end
+ CandidateFilter:Prune();
+ self:ProcessNext();
+ end
+```
+
+**Step 4: Handle HideItemButton to also advance queue**
+
+Modify `HideItemButton`:
+
+```lua
+ function QuickSlotManager:HideItemButton(fadeOut)
+ if RewardItemButton then
+ local itemLink = RewardItemButton.currentItemLink;
+ RewardItemButton.currentItemLink = nil;
+ if fadeOut then
+ RewardItemButton:OnCountdownFinished();
+ RewardItemButton:SetInteractable(false);
+ else
+ RewardItemButton:ClearButton();
+ QueueManager:OnPopupDismissed(itemLink);
+ end
+ end
+ end
+```
+
+**Step 5: Commit**
+
+```bash
+git add Code/Widget/Widget_QuickSlot.lua
+git commit -m "feat(quickslot): wire popup dismiss/equip/use to advance queue"
+```
+
+---
+
+### Task 7: Final cleanup and integration test
+
+**Files:**
+- Modify: `Code/Widget/Widget_QuickSlot.lua` -- update debug test block
+- Modify: `docs/plans/2026-03-06-quickslot-enhanced-design.md` if needed
+
+**Step 1: Update the debug test block**
+
+Replace the commented-out debug block at the bottom of Widget_QuickSlot.lua (lines 425-473) with a more useful test that exercises the queue:
+
+```lua
+do --debug
+ --[[
+ QuickSlotManager:RegisterEvent("PLAYER_ENTERING_WORLD");
+
+ function QuickSlotManager:PLAYER_ENTERING_WORLD()
+ C_Timer.After(3, function()
+ -- Test: simulate two upgrade items arriving in rapid succession
+ DEBUG_QUICKSLOT = true;
+ local item1 = "|Hitem:6070|h";
+ local item2 = "|Hitem:25473|h";
+ QuickSlotManager:OnItemLooted(item1);
+ QuickSlotManager:OnItemLooted(item2);
+ end)
+ end
+ --]]
+end
+```
+
+**Step 2: Review all changes for consistency**
+
+- Verify no orphaned references to `self.itemClassification` (renamed to `self.pendingClassification`)
+- Verify `wipe` is in the local upvalues at the top of the file (line 10 area)
+- Verify `GetTime` and `C_Timer` are accessible (both are global WoW APIs)
+- Verify `API.IsItemAnUpgrade_External` reference works (imported via `local API = addon.API` at top)
+
+**Step 3: Commit**
+
+```bash
+git add Code/Widget/Widget_QuickSlot.lua
+git commit -m "feat(quickslot): update debug test block for queue testing"
+```
+
+**Step 4: Final commit with design doc**
+
+```bash
+git add docs/
+git commit -m "docs: add enhanced quickslot design document"
+```
+
+---
+
+### Post-Implementation: WAU Tracking and PR Preparation
+
+After all tasks are complete:
+
+1. **Add DialogueUI to WAU tracking:**
+ ```bash
+ python3 /mnt/c/Users/phuze/Dropbox/WoWAddons/wau.py add DialogueUI https://github.com/Peterodox/YUI-Dialogue
+ ```
+
+2. **Switch back to the working branch for the user's local install.** The user's local copy needs both features active. Either merge both feature branches into a local `dev` branch, or cherry-pick onto main.
+
+3. **PR preparation:** Each feature branch gets its own PR to the upstream repo. Install `gh` CLI or push branches and create PRs via GitHub web UI.
diff --git a/docs/plans/2026-03-07-pet-pips-combat-notice.md b/docs/plans/2026-03-07-pet-pips-combat-notice.md
new file mode 100644
index 0000000..2cadfc0
--- /dev/null
+++ b/docs/plans/2026-03-07-pet-pips-combat-notice.md
@@ -0,0 +1,213 @@
+# Pet Quality Pips & Combat Lockdown Notice
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Show empty slot indicators for pet collection pips, and display a visible combat notice when the QuickSlot popup can't be used in combat.
+
+**Architecture:** Two independent enhancements to `Widget_ItemButton.lua` with a supporting locale string in `enUS.lua`. Pet pips change is purely text formatting. Combat notice adds a FontString overlay to the item button and integrates with the existing `GetActionButton`/`onEnterCombatCallback` flow.
+
+**Tech Stack:** WoW Lua API, DialogueUI addon framework
+
+---
+
+### Task 1: Add locale string for combat notice
+
+**Files:**
+- Modify: `Locales/enUS.lua:63` (after "Collection Collected" line)
+
+**Step 1: Add the locale string**
+
+After line 63 (`L["Collection Collected"]`), add:
+
+```lua
+L["Popup Usable After Combat"] = "Usable after combat";
+```
+
+**Step 2: Commit**
+
+```
+feat(locale): add combat notice string
+```
+
+---
+
+### Task 2: Add empty pip indicators for pet collection slots
+
+**Files:**
+- Modify: `Code/Widget/Widget_ItemButton.lua:612-622` (`BuildPetCollectionText` function)
+
+**Step 1: Modify `BuildPetCollectionText` to show empty pips**
+
+Replace the current function (lines 612-622):
+
+```lua
+local function BuildPetCollectionText(name, owned, maxOwned, qualities)
+ local pips = "";
+ for _, rarity in ipairs(qualities) do
+ local color = PET_RARITY_COLORS[rarity] or "ffffffff";
+ pips = pips .. "|c" .. color .. "\226\151\143|r"; --● (U+25CF)
+ end
+ if pips ~= "" then
+ pips = pips .. " ";
+ end
+ return string.format("%s %s%d/%d", name or "", pips, owned, maxOwned);
+end
+```
+
+With:
+
+```lua
+local function BuildPetCollectionText(name, owned, maxOwned, qualities)
+ local pips = "";
+ -- Filled pips for owned qualities
+ for _, rarity in ipairs(qualities) do
+ local color = PET_RARITY_COLORS[rarity] or "ffffffff";
+ pips = pips .. "|c" .. color .. "\226\151\143|r"; --● filled (U+25CF)
+ end
+ -- Empty pips for remaining capacity
+ for i = 1, maxOwned - owned do
+ pips = pips .. "|cff666666\226\151\139|r"; --○ empty (U+25CB)
+ end
+ if pips ~= "" then
+ pips = pips .. " ";
+ end
+ return string.format("%s %s%d/%d", name or "", pips, owned, maxOwned);
+end
+```
+
+Key changes:
+- After the owned-quality filled pips loop, add a second loop for `maxOwned - owned` empty pips
+- Empty pips use `\226\151\139` (U+25CB, WHITE CIRCLE ○) in grey (`ff666666`)
+- Example result: `Pet Cage ●○○ 1/3` (● in blue, ○○ in grey)
+
+**Step 2: Commit**
+
+```
+feat(quickslot): show empty pip indicators for pet collection slots
+```
+
+---
+
+### Task 3: Add combat lockdown notice to item button
+
+**Files:**
+- Modify: `Code/Widget/Widget_ItemButton.lua:221-248` (`GetActionButton` method)
+- Modify: `Code/Widget/Widget_ItemButton.lua:311-325` (`ClearButton` method)
+
+**Step 1: Add `SetCombatNotice` method to ItemButtonMixin**
+
+Add this method in the `ItemButtonMixin` block (after `SetFailedText` at line 356, before the `end` closing the block at line 357):
+
+```lua
+ function ItemButtonMixin:SetCombatNotice(show)
+ if show then
+ if not self.CombatNoticeText then
+ local f = self:CreateFontString(nil, "OVERLAY");
+ f:SetFontObject("DUIFont_Tooltip_Small");
+ f:SetPoint("TOP", self, "BOTTOM", 0, -4);
+ self.CombatNoticeText = f;
+ end
+ ThemeUtil:SetFontColor(self.CombatNoticeText, "WarningRed");
+ self.CombatNoticeText:SetText(L["Popup Usable After Combat"]);
+ self.CombatNoticeText:Show();
+ elseif self.CombatNoticeText then
+ self.CombatNoticeText:Hide();
+ end
+ end
+```
+
+**Step 2: Modify `GetActionButton` to show/clear combat notice**
+
+Replace the current `GetActionButton` method (lines 221-248):
+
+```lua
+ function ItemButtonMixin:GetActionButton()
+ local ActionButton = addon.AcquireSecureActionButton("QuestRewardItem");
+ if ActionButton then
+ self.ActionButton = ActionButton;
+ ActionButton:SetScript("OnEnter", function()
+ self:OnEnter();
+ end);
+ ActionButton:SetScript("OnLeave", function()
+ self:OnLeave();
+ end);
+ ActionButton:SetPostClickCallback(function(f, button)
+ self:OnMouseUp(button);
+ if self.PostClick then
+ self:PostClick(button);
+ end
+ end);
+ ActionButton:SetParent(self);
+ ActionButton:SetFrameStrata(self:GetFrameStrata());
+ ActionButton:SetFrameLevel(self:GetFrameLevel() + 5);
+ ActionButton.onEnterCombatCallback = function()
+ self:SetButtonEnabled(false);
+ self:SetCombatNotice(true);
+ end;
+ self:SetButtonEnabled(true);
+ self:SetCombatNotice(false);
+ return ActionButton
+ else
+ self:SetButtonEnabled(false);
+ if InCombatLockdown() then
+ self:SetCombatNotice(true);
+ end
+ end
+ end
+```
+
+Changes from original:
+- `onEnterCombatCallback`: added `self:SetCombatNotice(true)` after disabling button
+- Success path: added `self:SetCombatNotice(false)` to clear notice when button re-enables after combat
+- Failure path during combat: added `self:SetCombatNotice(true)` when `InCombatLockdown()` is true
+
+**Step 3: Clean up combat notice in `ClearButton`**
+
+Add `self:SetCombatNotice(false);` to `ClearButton`, after `self:ReleaseActionButton();` (line 322):
+
+```lua
+ function ItemButtonMixin:ClearButton()
+ self:Hide();
+ self:StopAnimating();
+ if self.hasData then
+ self.hasData = nil;
+ self.t = 0;
+ self.isFadingOut = nil;
+ self.itemID = nil;
+ self.hyperlink = nil;
+ self:SetScript("OnUpdate", nil);
+ self:UnregisterAllEvents();
+ self:ReleaseActionButton();
+ self:SetCombatNotice(false);
+ self:OnButtonHide();
+ end
+ end
+```
+
+**Step 4: Commit**
+
+```
+feat(quickslot): show combat lockdown notice on popup button
+```
+
+---
+
+### Task 4: In-game verification
+
+**Test pet pips:**
+1. Find or cage a companion pet you already own (e.g., own 1/3 of a species)
+2. Loot another cage of the same species from a quest or purchase
+3. Verify the QuickSlot popup shows filled pip(s) in quality color + grey empty pips for remaining slots
+4. Verify the `owned/max` count is correct
+
+**Test combat notice:**
+1. Enter combat (attack a mob)
+2. While in combat, trigger a QuickSlot popup (loot a container, pet cage, etc.)
+3. Verify the popup appears greyed out with "Usable after combat" text below it
+4. Leave combat and verify the button becomes active and the notice disappears
+
+**Test combat-starts-while-showing:**
+1. Trigger a QuickSlot popup outside of combat
+2. Enter combat while the popup is still visible
+3. Verify the button greys out and the "Usable after combat" notice appears
+4. Leave combat and verify it re-enables
diff --git a/docs/plans/2026-03-08-combat-pause-countdown.md b/docs/plans/2026-03-08-combat-pause-countdown.md
new file mode 100644
index 0000000..0506da4
--- /dev/null
+++ b/docs/plans/2026-03-08-combat-pause-countdown.md
@@ -0,0 +1,51 @@
+# Combat Pause for QuickSlot Popup Countdown
+
+## Problem
+
+When a player enters combat while a quickslot popup is showing, the equip/use button correctly disables and shows a "usable after combat" notice. However, the auto-fade countdown timer continues running. The popup can fade away during combat before the player ever gets a chance to interact with it.
+
+## Solution
+
+Pause the auto-fade countdown timer during combat. Resume only when both combat and hover are inactive.
+
+### Key Invariant
+
+The auto-fade timer is paused whenever **either** combat lockdown **or** hover is active, and only resumes when **neither** condition applies.
+
+### Design
+
+**Add `UpdatePauseState()` to `QuestRewardItemButtonMixin`** that derives the pause state from current conditions:
+
+```lua
+function QuestRewardItemButtonMixin:UpdatePauseState()
+ self.CloseButton:PauseAutoCloseTimer(self:IsFocused() or InCombatLockdown());
+end
+```
+
+**Callers:**
+
+| Event | Action |
+|---|---|
+| `OnButtonEnter` | Call `UpdatePauseState()` (replaces direct `PauseAutoCloseTimer(true)`) |
+| `OnButtonLeave` | Call `UpdatePauseState()` (replaces direct `PauseAutoCloseTimer(false)`) |
+| `onEnterCombatCallback` | Call `UpdatePauseState()` (new) |
+| `PLAYER_REGEN_ENABLED` | Call `UpdatePauseState()` after re-enabling button (new) |
+
+**Edge case: popup created during combat.** After `SetCountdown()` is called in `AddAutoCloseItemButton`, if `InCombatLockdown()` is true, immediately pause the timer. The button will already show the combat notice via `GetActionButton()` (line 249). Timer resumes on `PLAYER_REGEN_ENABLED`.
+
+**Edge case: timer nearly expired when combat starts.** Remaining time is frozen exactly where it is. It resumes from that point after combat ends. No restart.
+
+**Guard on `PLAYER_REGEN_ENABLED`.** Before resuming, verify the popup still exists and hasn't been dismissed by another path (close button, queue invalidation, etc.).
+
+### Files Changed
+
+- `Widget_QuickSlot.lua` — add `UpdatePauseState()`, modify `OnButtonEnter`/`OnButtonLeave`, modify `AddAutoCloseItemButton` for combat-created popups
+- `Widget_ItemButton.lua` — call `UpdatePauseState()` from `onEnterCombatCallback` and `PLAYER_REGEN_ENABLED` handler
+
+### What Doesn't Change
+
+- Countdown durations (4s idle, 2s auto-complete, 1s manual)
+- Combat notice text and disabled button appearance
+- Queue system behavior
+- `PauseAutoCloseTimer` implementation in `WidgetManager.lua`
+- `StopCountdown` behavior
diff --git a/docs/plans/2026-03-08-quickslot-draggable-popup.md b/docs/plans/2026-03-08-quickslot-draggable-popup.md
new file mode 100644
index 0000000..82854df
--- /dev/null
+++ b/docs/plans/2026-03-08-quickslot-draggable-popup.md
@@ -0,0 +1,48 @@
+# Draggable QuickSlot Popup
+
+## Problem
+
+The QuickSlot popup position is hardcoded at bottom-center, 196px up. There is no way to reposition it.
+
+## Solution
+
+Make the popup draggable via Shift+drag, with position persistence and settings UI controls.
+
+### Key Invariant
+
+All popup placement flows through `LoadPosition()`. The hardcoded `SetPoint` is only the default fallback when no saved position exists.
+
+### Architecture
+
+- **RewardItemButton** is the correct root frame. Created once, reused across popups. The secure action button is a child frame — parent-level drag does not conflict with the secure click path.
+- **WidgetBaseMixin** methods are mixed onto RewardItemButton, giving it `SavePosition`/`LoadPosition`/`ResetPosition`/`IsUsingCustomPosition` for free.
+- **`OnButtonMouseDown`** (currently empty) hooks Shift+LeftButton to start `DragFrame`.
+
+### Position Persistence
+
+- DB key: `QuickSlotPosition` (nil by default = use hardcoded position)
+- Saved format: `{left, centerY}` matching existing `WidgetBaseMixin` convention
+- Restore: `SetPoint("LEFT", UIParent, "BOTTOMLEFT", x, y)`
+- Default: `SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 196)`
+
+### Edit Mode
+
+`ToggleEditMode()` shows the popup with placeholder content:
+- Title: "Loot Popup"
+- Subtitle: "Shift+Drag to Move"
+- Icon: question mark (134400)
+- Countdown stopped, interactable for drag only
+
+Exiting edit mode clears the placeholder. Showing a real item fully replaces placeholder state.
+
+### Files Changed
+
+- `Initialization.lua` — add `QuickSlotPosition` to `DefaultValues` (nil)
+- `Widget_QuickSlot.lua` — mix in WidgetBaseMixin, override LoadPosition default, hook Shift+drag in OnButtonMouseDown, add ToggleEditMode, replace hardcoded SetPoint with LoadPosition call
+- `Settings.lua` — add "Move Position" / "Reset Position" buttons under QuickSlot group
+
+### What Doesn't Change
+
+- Popup behavior, countdown, queue, combat pause
+- WidgetBaseMixin implementation in WidgetManager.lua
+- Other widget positions