From 04f10b09984276850c1b730e3c76187bef826cdc Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Wed, 27 May 2026 23:11:23 -0500 Subject: [PATCH] Bag Sort --- modules/bags.lua | 337 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 336 insertions(+), 1 deletion(-) diff --git a/modules/bags.lua b/modules/bags.lua index 1ef50a01..64138b5a 100644 --- a/modules/bags.lua +++ b/modules/bags.lua @@ -7,6 +7,270 @@ pfUI:RegisterModule("bags", function () } local scanner = libtipscan:GetScanner("openable") + -- ============================================================================ + -- Bag sorter: consolidates partial stacks then sorts by category/name. + -- Adapted from the algo in Bagnon/lib/BagSort.lua. + -- ============================================================================ + local sorter = CreateFrame("Frame") + sorter.itemGrid = {} + sorter.bagList = nil + local HEARTHSTONE_ITEM_ID = 6948 + + -- Lower prefix = sorted earlier in the bag. + local function SortCategoryPrefix(itemId, itemType, itemSubType, quality) + if itemId == HEARTHSTONE_ITEM_ID then return "00" end + if quality == 0 then return "13" end -- Poor (gray) always last + if itemType == "Weapon" or itemType == "Armor" then + if quality and quality >= 4 then return "01" end -- Epic+ gear + if quality == 3 then return "02" end -- Rare gear + if quality == 2 then return "03" end -- Uncommon gear + return "04" -- Common/poor gear + end + if itemType == "Consumable" then return "05" end + if itemType == "Reagent" then return "06" end + if itemType == "Trade Goods" then return "07" end + if itemType == "Quest" then return "08" end + -- Non-gear items without a specific type, sorted by quality + if quality and quality >= 4 then return "09" end + if quality == 3 then return "10" end + if quality == 2 then return "11" end + return "12" + end + + -- Larger stacks sort first among identically-named items; invert + zero-pad + -- so it sorts lexicographically. + local function SortCountSuffix(count) + local s = "000000" .. (999999 - (count or 0)) + return string.sub(s, -6) + end + + local function SortKey(itemId, name, itype, subtype, quality, count) + return SortCategoryPrefix(itemId, itype, subtype, quality) + .. (itype or "") .. "|" .. (subtype or "") .. "|" .. (name or "zzz") .. "|" .. SortCountSuffix(count) + end + + local function ClearSortData() + sorter.itemGrid = {} + sorter.bagList = nil + sorter:UnregisterEvent("BAG_UPDATE_DELAYED") + sorter:SetScript("OnEvent", nil) + end + + -- Two-pointer consolidation: sorts stacks largest-first, then merges from + -- both ends toward the middle. n is set explicitly so table.getn / table.sort + -- work correctly in Lua 5.0. + local function BuildConsolidateOps(bagList) + local groups = {} + for _, bag in ipairs(bagList) do + for slot = 1, GetContainerNumSlots(bag) do + local itemId = C_Container.GetContainerItemID(bag, slot) + if itemId then + local _, count = GetContainerItemInfo(bag, slot) + count = count or 0 + local maxStack = C_Item.GetItemMaxStackSizeByID(itemId) or 1 + if count > 0 and maxStack > 1 then + if not groups[itemId] then + groups[itemId] = {maxStack=maxStack, n=0} + end + local g = groups[itemId] + g.n = g.n + 1 + g[g.n] = {bag=bag, slot=slot, count=count} + end + end + end + end + + local ops = {} + for _, g in pairs(groups) do + local n = g.n + if n >= 2 then + local maxStack = g.maxStack + table.sort(g, function(a, b) return a.count > b.count end) + local lo, hi = 1, n + while lo < hi do + local space = maxStack - g[lo].count + if space == 0 then + lo = lo + 1 + elseif space >= g[hi].count then + tinsert(ops, { + dstBag = g[lo].bag, dstSlot = g[lo].slot, + srcBag = g[hi].bag, srcSlot = g[hi].slot, + count = g[hi].count, + }) + g[lo].count = g[lo].count + g[hi].count + hi = hi - 1 + else + tinsert(ops, { + dstBag = g[lo].bag, dstSlot = g[lo].slot, + srcBag = g[hi].bag, srcSlot = g[hi].slot, + count = space, + }) + g[hi].count = g[hi].count - space + g[lo].count = maxStack + lo = lo + 1 + end + end + end + end + return ops + end + + local function BuildSortGrid() + local bagList = sorter.bagList + sorter.itemGrid = {} + local normalItems = {} + local poorItems = {} + local bagCount = 0 + local bagSlots = {} + + for _, bag in ipairs(bagList) do + bagCount = bagCount + 1 + local numSlots = GetContainerNumSlots(bag) + bagSlots[bagCount] = numSlots + if numSlots > 0 then + sorter.itemGrid[bag] = {} + for slot = 1, numSlots do + local itemId = C_Container.GetContainerItemID(bag, slot) + if itemId then + local name, _, quality, _, itype, subtype = GetItemInfo(itemId) + local _, count = GetContainerItemInfo(bag, slot) + local item = { + key = SortKey(itemId, name, itype, subtype, quality, count), + srcBag = bag, + srcSlot = slot, + curBag = bag, + curSlot = slot, + } + if quality == 0 then + tinsert(poorItems, item) + else + tinsert(normalItems, item) + end + sorter.itemGrid[bag][slot] = item + end + end + end + end + + table.sort(normalItems, function(a, b) return a.key < b.key end) + -- Sort poor items descending so they read in ascending order when placed + -- back-to-front (last poor item lands on the last slot). + table.sort(poorItems, function(a, b) return a.key > b.key end) + + -- Forward pass: assign normal items from slot 1 of bag 1 onward + local bagIdx, destSlot = 1, 1 + while bagIdx <= bagCount and bagSlots[bagIdx] == 0 do + bagIdx = bagIdx + 1 + end + for _, item in ipairs(normalItems) do + while bagIdx <= bagCount do + if destSlot <= bagSlots[bagIdx] then break end + bagIdx = bagIdx + 1 + destSlot = 1 + end + if bagIdx > bagCount then break end + local grid = sorter.itemGrid[item.srcBag][item.srcSlot] + grid.destBag = bagList[bagIdx] + grid.destSlot = destSlot + destSlot = destSlot + 1 + end + + -- Reverse pass: assign poor items from the last slot of the last bag backward + local rBagIdx = bagCount + local rDestSlot = 0 + while rBagIdx >= 1 do + if bagSlots[rBagIdx] > 0 then rDestSlot = bagSlots[rBagIdx]; break end + rBagIdx = rBagIdx - 1 + end + for _, item in ipairs(poorItems) do + while rBagIdx >= 1 and rDestSlot < 1 do + rBagIdx = rBagIdx - 1 + rDestSlot = rBagIdx >= 1 and bagSlots[rBagIdx] or 0 + end + if rBagIdx < 1 then break end + local grid = sorter.itemGrid[item.srcBag][item.srcSlot] + grid.destBag = bagList[rBagIdx] + grid.destSlot = rDestSlot + rDestSlot = rDestSlot - 1 + end + end + + -- Build the sort grid (planned from current bag state) and fire every swap + -- needed to reach it. Items track their live position via curBag/curSlot so + -- we never read it back from a grid we're mutating. + local function RunSortPhase() + BuildSortGrid() + + -- Snapshot items that need to move before any swap runs — iterating + -- pairs() while mutating itemGrid is undefined in Lua 5.0. + local toMove = {} + for _, bagGrid in pairs(sorter.itemGrid) do + for _, info in pairs(bagGrid) do + if info.destBag and (info.destBag ~= info.curBag or info.destSlot ~= info.curSlot) then + tinsert(toMove, info) + end + end + end + + for _, info in ipairs(toMove) do + local curBag, curSlot = info.curBag, info.curSlot + local dBag, dSlot = info.destBag, info.destSlot + if dBag ~= curBag or dSlot ~= curSlot then + local _, _, lock1 = GetContainerItemInfo(curBag, curSlot) + local _, _, lock2 = GetContainerItemInfo(dBag, dSlot) + if not (lock1 or lock2) then + local displaced = sorter.itemGrid[dBag][dSlot] + C_Container.SwapItems(curBag, curSlot, dBag, dSlot) + sorter.itemGrid[dBag][dSlot] = info + sorter.itemGrid[curBag][curSlot] = displaced + info.curBag, info.curSlot = dBag, dSlot + if displaced then + displaced.curBag, displaced.curSlot = curBag, curSlot + end + end + end + end + + ClearSortData() + end + + -- Unregister immediately so the swaps we're about to fire don't re-enter + -- this handler via their own BAG_UPDATEs. + local function SorterOnEvent() + if event == "BAG_UPDATE_DELAYED" then + sorter:UnregisterEvent("BAG_UPDATE_DELAYED") + sorter:SetScript("OnEvent", nil) + RunSortPhase() + end + end + + local function StartSort(bagList) + ClearSortData() + sorter.bagList = bagList + + -- Phase 1: fire every consolidation op in a single batch. + local ops = BuildConsolidateOps(bagList) + local fired = false + for _, op in ipairs(ops) do + local _, _, lock1 = GetContainerItemInfo(op.srcBag, op.srcSlot) + local _, _, lock2 = GetContainerItemInfo(op.dstBag, op.dstSlot) + if not (lock1 or lock2) then + C_Container.MoveItem(op.srcBag, op.srcSlot, op.dstBag, op.dstSlot, op.count) + fired = true + end + end + + if fired then + -- Wait for the server to confirm the merges so the sort grid reads + -- accurate slot contents. + sorter:SetScript("OnEvent", SorterOnEvent) + sorter:RegisterEvent("BAG_UPDATE_DELAYED") + return + end + + RunSortPhase() + end + -- function to detect openable items in inventory local openable = { bag = nil, slot = nil, icon = nil } local function GetNextOpenable() @@ -963,6 +1227,42 @@ pfUI:RegisterModule("bags", function () end) end + -- sort button (anchors to keys, which is the always-present leftmost + -- button in the dynamic chain — picklock and disenchant are class-gated) + if not frame.sort then + frame.sort = CreateFrame("Button", "pfBagSort", frame) + frame.sort:SetPoint("TOPRIGHT", frame.keys, "TOPLEFT", -default_border*3, 0) + CreateBackdrop(frame.sort, default_border) + frame.sort:SetHeight(12) + frame.sort:SetWidth(12) + frame.sort.texture = frame.sort:CreateTexture("pfBagSortIcon") + frame.sort.texture:SetTexture(pfUI.media["img:sort"]) + frame.sort.texture:ClearAllPoints() + frame.sort.texture:SetPoint("TOPLEFT", frame.sort, "TOPLEFT", 2, -2) + frame.sort.texture:SetPoint("BOTTOMRIGHT", frame.sort, "BOTTOMRIGHT", -2, 2) + frame.sort.texture:SetVertexColor(.25,.25,.25,1) + + frame.sort:SetScript("OnEnter", function () + frame.sort.backdrop:SetBackdropBorderColor(1,1,.25,1) + frame.sort.texture:SetVertexColor(1,1,.25,1) + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Sort Bags") + GameTooltip:Show() + end) + + frame.sort:SetScript("OnLeave", function () + CreateBackdrop(frame.sort, default_border) + frame.sort.texture:SetVertexColor(.25,.25,.25,1) + if GameTooltip:IsOwned(this) then + GameTooltip:Hide() + end + end) + + frame.sort:SetScript("OnClick", function() + StartSort({0, 1, 2, 3, 4}) + end) + end + -- gold string if not frame.gold and (C.appearance.bags.movable == "1" or not pfUI.panel) then frame.gold = CreateFrame("Frame", "pfBagGoldString", frame) @@ -987,7 +1287,7 @@ pfUI:RegisterModule("bags", function () frame.search.db = {} frame.search:SetHeight(12) frame.search:SetPoint("TOPLEFT", frame, "TOPLEFT", default_border, -default_border) - frame.search:SetPoint("TOPRIGHT", frame.keys, "TOPLEFT", -default_border*3, -default_border) + frame.search:SetPoint("TOPRIGHT", frame.sort, "TOPLEFT", -default_border*3, -default_border) CreateBackdrop(frame.search, default_border) frame.search.edit = CreateFrame("EditBox", "pfUIBagSearch", frame.search, "InputBoxTemplate") @@ -1165,6 +1465,41 @@ pfUI:RegisterModule("bags", function () end end) end + + -- sort button (bank) + if not frame.sort then + frame.sort = CreateFrame("Button", "pfBankSort", frame) + frame.sort:SetPoint("TOPRIGHT", frame.bags, "TOPLEFT", -default_border*3, 0) + CreateBackdrop(frame.sort, default_border) + frame.sort:SetHeight(12) + frame.sort:SetWidth(12) + frame.sort.texture = frame.sort:CreateTexture("pfBankSortIcon") + frame.sort.texture:SetTexture(pfUI.media["img:sort"]) + frame.sort.texture:ClearAllPoints() + frame.sort.texture:SetPoint("TOPLEFT", frame.sort, "TOPLEFT", 2, -2) + frame.sort.texture:SetPoint("BOTTOMRIGHT", frame.sort, "BOTTOMRIGHT", -2, 2) + frame.sort.texture:SetVertexColor(.25,.25,.25,1) + + frame.sort:SetScript("OnEnter", function () + frame.sort.backdrop:SetBackdropBorderColor(1,1,.25,1) + frame.sort.texture:SetVertexColor(1,1,.25,1) + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Sort Bank") + GameTooltip:Show() + end) + + frame.sort:SetScript("OnLeave", function () + CreateBackdrop(frame.sort, default_border) + frame.sort.texture:SetVertexColor(.25,.25,.25,1) + if GameTooltip:IsOwned(this) then + GameTooltip:Hide() + end + end) + + frame.sort:SetScript("OnClick", function() + StartSort({-1, 5, 6, 7, 8, 9, 10}) + end) + end end end end) \ No newline at end of file