diff --git a/Assets/pin.blp b/Assets/pin.blp new file mode 100644 index 0000000..e6ee043 Binary files /dev/null and b/Assets/pin.blp differ diff --git a/Core/Database.lua b/Core/Database.lua index bedc744..834eb4f 100644 --- a/Core/Database.lua +++ b/Core/Database.lua @@ -162,6 +162,11 @@ function DB:Initialize() Guda_CharDB.setProtectionExceptions = {} end + -- Initialize pinned slots storage + if not Guda_CharDB.pinnedSlots then + Guda_CharDB.pinnedSlots = {} + end + -- Initialize CategoryManager for custom categories if addon.Modules.CategoryManager then addon.Modules.CategoryManager:Initialize() @@ -540,6 +545,37 @@ function DB:IsItemProtected(itemID) return false end +------------------------------------------------- +-- Pinned Slots (per-character, slot-based) +-- Slots pinned by the user are skipped during sorting. +-- Key format: bagID * 1000 + slot +------------------------------------------------- + +function DB:IsPinnedSlot(bagID, slot) + if not bagID or not slot or not Guda_CharDB or not Guda_CharDB.pinnedSlots then return false end + return Guda_CharDB.pinnedSlots[bagID * 1000 + slot] and true or false +end + +function DB:TogglePinnedSlot(bagID, slot) + if not bagID or not slot or not Guda_CharDB then return false end + if not Guda_CharDB.pinnedSlots then + Guda_CharDB.pinnedSlots = {} + end + local key = bagID * 1000 + slot + if Guda_CharDB.pinnedSlots[key] then + Guda_CharDB.pinnedSlots[key] = nil + return false -- unpinned + else + Guda_CharDB.pinnedSlots[key] = true + return true -- pinned + end +end + +function DB:GetPinnedSlotSet() + if not Guda_CharDB or not Guda_CharDB.pinnedSlots then return {} end + return Guda_CharDB.pinnedSlots +end + -- Cleanup old characters (not updated in 90 days) function DB:CleanupOldCharacters() local cutoff = time() - (90 * 24 * 60 * 60) -- 90 days diff --git a/Sorting/SortEngine.lua b/Sorting/SortEngine.lua index 2837ada..9c00dd3 100644 --- a/Sorting/SortEngine.lua +++ b/Sorting/SortEngine.lua @@ -1147,7 +1147,7 @@ end -- PHASE 5: Empty Slot Management & Apply Sort --=========================================================================== -local function CollectItems(bagIDs) +local function CollectItems(bagIDs, pinnedSlots) local items = {} local sequence = 0 -- Add sequence number for stable sort @@ -1156,6 +1156,10 @@ local function CollectItems(bagIDs) if addon.Modules.Utils:IsBagValid(bagID) then for slot = 1, numSlots do + -- Skip pinned slots — they are not touched by sorting + if pinnedSlots and pinnedSlots[bagID * 1000 + slot] then + -- do nothing, slot is pinned + else -- Scan directly from game API instead of cached data local texture, itemCount, locked = GetContainerItemInfo(bagID, slot) local itemLink = GetContainerItemLink(bagID, slot) @@ -1194,6 +1198,7 @@ local function CollectItems(bagIDs) class = category or "", }) end + end -- if pinnedSlots end end end @@ -1201,7 +1206,7 @@ local function CollectItems(bagIDs) return items end -local function BuildTargetPositions(bagIDs, itemCount) +local function BuildTargetPositions(bagIDs, itemCount, pinnedSlots) local positions = {} local index = 1 @@ -1234,8 +1239,11 @@ local function BuildTargetPositions(bagIDs, itemCount) if addon.Modules.Utils:IsBagValid(bagID) then for slot = 1, numSlots do if index <= itemCount then - positions[index] = {bag = bagID, slot = slot} - index = index + 1 + -- Skip pinned slots + if not (pinnedSlots and pinnedSlots[bagID * 1000 + slot]) then + positions[index] = {bag = bagID, slot = slot} + index = index + 1 + end else break end @@ -1402,7 +1410,7 @@ end -- Build tail positions (end-to-start) for a given count across the provided bags, -- starting from the "last" regular bag (lowest priority, then highest bagID), -- and spilling into previous bags when needed. -local function BuildGreyTailPositions(bagIDs, greyCount) +local function BuildGreyTailPositions(bagIDs, greyCount, pinnedSlots) local positions = {} if greyCount <= 0 then return positions end @@ -1439,7 +1447,7 @@ local function BuildGreyTailPositions(bagIDs, greyCount) local tailSlots = {} for _, info in ipairs(ordered) do for slot = info.numSlots, 1, -1 do - if table.getn(tailSlots) < greyCount then + if table.getn(tailSlots) < greyCount and not (pinnedSlots and pinnedSlots[info.bagID * 1000 + slot]) then table.insert(tailSlots, { bag = info.bagID, slot = slot }) else break @@ -1611,6 +1619,9 @@ end function SortEngine:SortBagsPass() local bagIDs = addon.Constants.BAGS + -- Cache pinned slots for this pass + local pinnedSlots = addon.Modules.DB and addon.Modules.DB:GetPinnedSlotSet() or {} + -- Phase 1: Detect specialized bags local containers = DetectSpecializedBags(bagIDs) @@ -1625,10 +1636,10 @@ function SortEngine:SortBagsPass() for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do local specialBags = containers[bagType] for _, bagID in ipairs(specialBags) do - local items = CollectItems({bagID}) + local items = CollectItems({bagID}, pinnedSlots) if table.getn(items) > 0 then items = SortItems(items) - local targetPositions = BuildTargetPositions({bagID}, table.getn(items)) + local targetPositions = BuildTargetPositions({bagID}, table.getn(items), pinnedSlots) local moveCount = ApplySort({bagID}, items, targetPositions) specializedMoves = specializedMoves + moveCount end @@ -1642,7 +1653,7 @@ function SortEngine:SortBagsPass() local regularMoves = 0 local regularBagIDs = containers.regular if table.getn(regularBagIDs) > 0 then - local allItems = CollectItems(regularBagIDs) + local allItems = CollectItems(regularBagIDs, pinnedSlots) local nonGreys, greys = SplitGreyItems(allItems) local combinedItems = {} @@ -1651,7 +1662,7 @@ function SortEngine:SortBagsPass() -- Non-greys: sorted to front positions if table.getn(nonGreys) > 0 then local sortedNonGreys = SortItems(nonGreys) - local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys)) + local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys), pinnedSlots) for i, item in ipairs(sortedNonGreys) do table.insert(combinedItems, item) table.insert(combinedPositions, frontPositions[i]) @@ -1660,7 +1671,7 @@ function SortEngine:SortBagsPass() -- Greys: to tail positions (end->start) if table.getn(greys) > 0 then - local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys)) + local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys), pinnedSlots) for i, item in ipairs(greys) do table.insert(combinedItems, item) table.insert(combinedPositions, tailPositions[i]) @@ -1764,6 +1775,9 @@ function SortEngine:SortBankPass() local bagIDs = addon.Constants.BANK_BAGS + -- Cache pinned slots for this pass + local pinnedSlots = addon.Modules.DB and addon.Modules.DB:GetPinnedSlotSet() or {} + -- Phase 1: Detect specialized bags local containers = DetectSpecializedBags(bagIDs) @@ -1778,10 +1792,10 @@ function SortEngine:SortBankPass() for _, bagType in ipairs({"enchant", "herb", "soul", "quiver", "ammo"}) do local specialBags = containers[bagType] for _, bagID in ipairs(specialBags) do - local items = CollectItems({bagID}) + local items = CollectItems({bagID}, pinnedSlots) if table.getn(items) > 0 then items = SortItems(items) - local targetPositions = BuildTargetPositions({bagID}, table.getn(items)) + local targetPositions = BuildTargetPositions({bagID}, table.getn(items), pinnedSlots) local moved = ApplySort({bagID}, items, targetPositions) specializedMoves = specializedMoves + moved end @@ -1792,7 +1806,7 @@ function SortEngine:SortBankPass() local regularBagIDs = containers.regular local regularMoves = 0 if table.getn(regularBagIDs) > 0 then - local allItems = CollectItems(regularBagIDs) + local allItems = CollectItems(regularBagIDs, pinnedSlots) local nonGreys, greys = SplitGreyItems(allItems) local combinedItems = {} @@ -1800,7 +1814,7 @@ function SortEngine:SortBankPass() if table.getn(nonGreys) > 0 then local sortedNonGreys = SortItems(nonGreys) - local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys)) + local frontPositions = BuildTargetPositions(regularBagIDs, table.getn(sortedNonGreys), pinnedSlots) for i, item in ipairs(sortedNonGreys) do table.insert(combinedItems, item) table.insert(combinedPositions, frontPositions[i]) @@ -1808,7 +1822,7 @@ function SortEngine:SortBankPass() end if table.getn(greys) > 0 then - local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys)) + local tailPositions = BuildGreyTailPositions(regularBagIDs, table.getn(greys), pinnedSlots) for i, item in ipairs(greys) do table.insert(combinedItems, item) table.insert(combinedPositions, tailPositions[i]) diff --git a/UI/ItemButton.lua b/UI/ItemButton.lua index 8a500f5..3a2291f 100644 --- a/UI/ItemButton.lua +++ b/UI/ItemButton.lua @@ -603,6 +603,84 @@ local function HideLockIcon(button) end end +--===================================================== +-- Pin Icon Pool (indicates slot is pinned / sort-protected) +--===================================================== +local pinIconPool = {} + +local function AcquirePinIcon() + local icon = table.remove(pinIconPool) + if not icon then + icon = CreateFrame("Frame", nil, UIParent) + icon:SetFrameStrata("HIGH") + icon:SetWidth(13) + icon:SetHeight(13) + + -- Shadow (behind) + local shadow = icon:CreateTexture(nil, "BACKGROUND") + shadow:SetWidth(15) + shadow:SetHeight(15) + shadow:SetPoint("CENTER", icon, "CENTER", 0, 0) + shadow:SetTexture("Interface\\AddOns\\Guda\\Assets\\pin") + shadow:SetVertexColor(0, 0, 0, 0.9) + icon.shadow = shadow + + -- Icon (front) + local texture = icon:CreateTexture(nil, "OVERLAY") + texture:SetAllPoints(icon) + texture:SetTexture("Interface\\AddOns\\Guda\\Assets\\pin") + icon.texture = texture + end + return icon +end + +local function ReleasePinIcon(icon) + if icon then + icon:Hide() + icon:ClearAllPoints() + table.insert(pinIconPool, icon) + end +end + +local function UpdatePinIcon(button, iconSize) + local DB = addon.Modules.DB + if not DB then return end + + local isPinned = false + if button.bagID and button.slotID and not button.otherChar and not button.isReadOnly then + isPinned = DB:IsPinnedSlot(button.bagID, button.slotID) + end + + if isPinned then + if not button.pinIcon then + button.pinIcon = AcquirePinIcon() + end + local pinSize = math.max(10, math.min(14, iconSize * 0.35)) + button.pinIcon:SetWidth(pinSize) + button.pinIcon:SetHeight(pinSize) + if button.pinIcon.shadow then + button.pinIcon.shadow:SetWidth(pinSize + 2) + button.pinIcon.shadow:SetHeight(pinSize + 2) + end + button.pinIcon:ClearAllPoints() + button.pinIcon:SetPoint("TOPLEFT", button, "TOPLEFT", -2, 2) + button.pinIcon:SetFrameLevel(button:GetFrameLevel() + 5) + button.pinIcon:Show() + else + if button.pinIcon then + ReleasePinIcon(button.pinIcon) + button.pinIcon = nil + end + end +end + +local function HidePinIcon(button) + if button.pinIcon then + ReleasePinIcon(button.pinIcon) + button.pinIcon = nil + end +end + -- Hook UseContainerItem to prevent selling/disenchanting protected items local OriginalUseContainerItem = UseContainerItem UseContainerItem = function(bag, slot, ...) @@ -988,6 +1066,7 @@ function Guda_ItemButton_OnLoad(self) self:SetScript("OnHide", function() HideJunkIcon(this) HideLockIcon(this) + HidePinIcon(this) end) -- Track cursor item on drag start for category drag-drop @@ -1043,6 +1122,30 @@ function Guda_ItemButton_OnLoad(self) return end + -- Pin/unpin slot with Alt+Right-Click + if IsAltKeyDown() and arg1 == "RightButton" and not this.otherChar and not this.isReadOnly then + if this.bagID ~= nil and this.slotID and addon.Modules.DB then + local isNowPinned = addon.Modules.DB:TogglePinnedSlot(this.bagID, this.slotID) + local itemName = "" + if this.hasItem and this.itemData and this.itemData.link then + itemName = " " .. this.itemData.link + end + if isNowPinned then + addon:Print("Slot pinned" .. itemName .. " (skipped during sort)") + else + addon:Print("Slot unpinned" .. itemName) + end + -- Refresh to show/hide pin icon + if addon.Modules.BagFrame and addon.Modules.BagFrame.Update then + addon.Modules.BagFrame:Update() + end + if addon.Modules.BankFrame and addon.Modules.BankFrame.Update then + addon.Modules.BankFrame:Update() + end + end + return + end + if IsAltKeyDown() and arg1 == "LeftButton" and this.hasItem and not this.otherChar and not this.isReadOnly then local link = GetContainerItemLink(this.bagID, this.slotID) if link and addon and addon.Modules and addon.Modules.Utils then @@ -1504,8 +1607,9 @@ local function ClearItemButton(self, emptySlotBg, countText, bagID) -- Hide junk icon HideJunkIcon(self) - -- Hide lock icon + -- Hide lock and pin icons HideLockIcon(self) + HidePinIcon(self) -- Hide normal texture self:SetNormalTexture("") @@ -1647,8 +1751,9 @@ function Guda_ItemButton_SetItem(self, bagID, slotID, itemData, isBank, otherCha end end - -- Update lock icon + -- Update lock and pin icons UpdateLockIcon(self, iconSize) + UpdatePinIcon(self, iconSize) -- Update empty slot bg anchors/texture based on slot style UpdateEmptySlotBackground(self, emptySlotBg, iconSize) @@ -1838,13 +1943,13 @@ function Guda_ItemButton_SetItem(self, bagID, slotID, itemData, isBank, otherCha if not self.categoryMarkIcon then self.categoryMarkIcon = self:CreateTexture(nil, "OVERLAY", 7) end - local markSize = math.max(10, math.floor(iconSize * 0.3)) + 6 + local markSize = math.max(10, math.floor(iconSize * 0.3)) + 3 self.categoryMarkIcon:SetWidth(markSize) self.categoryMarkIcon:SetHeight(markSize) self.categoryMarkIcon:ClearAllPoints() - local markOffX, markOffY = 2, 2 + local markOffX, markOffY = 1, 2 if addon.Modules and addon.Modules.Theme and addon.Modules.Theme:GetSlotStyle() == "square" then - markOffX, markOffY = -2, -2 + markOffX, markOffY = -1, -1 end self.categoryMarkIcon:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", markOffX, markOffY) self.categoryMarkIcon:SetTexture(categoryMarkTexture)