From 1af002fa08b4e1e14e758387543d313a1de02f9d Mon Sep 17 00:00:00 2001 From: Vati Date: Thu, 16 Apr 2026 02:05:51 +0400 Subject: [PATCH] fix: move item to empty category --- Core/Utils.lua | 24 +++++- Guda.toc | 2 +- UI/BagFrame.lua | 168 ++++++++++++++++++++++++++++++---------- UI/ItemButton.lua | 193 +++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 331 insertions(+), 56 deletions(-) diff --git a/Core/Utils.lua b/Core/Utils.lua index e555b39..32fdddf 100644 --- a/Core/Utils.lua +++ b/Core/Utils.lua @@ -97,6 +97,7 @@ local FRAME_BUDGET_SECONDS = 0.1 -- 100ms budget per frame (same as Baganator) local lastEntryTime = 0 local workQueue = {} local workQueueFrame = nil +local workQueuePaused = false -- When true, new QueueWork calls enqueue but don't resume processing -- Report that we're starting work (call at the beginning of expensive operations) -- This resets the frame budget timer @@ -159,8 +160,11 @@ function Utils:QueueWork(callback, context) end) end - -- Show the frame to start processing - workQueueFrame:Show() + -- Show the frame to start processing (unless we're paused — new items + -- stay in the queue and are drained when ResumeWorkQueue is called). + if not workQueuePaused then + workQueueFrame:Show() + end end -- Clear all queued work (useful when frame is hidden) @@ -171,6 +175,22 @@ function Utils:ClearWorkQueue() end end +-- Pause work queue processing (e.g. during a frame drag). Work stays queued +-- (including items QueueWork'd while paused); call ResumeWorkQueue to drain. +function Utils:PauseWorkQueue() + workQueuePaused = true + if workQueueFrame then + workQueueFrame:Hide() + end +end + +function Utils:ResumeWorkQueue() + workQueuePaused = false + if workQueueFrame and table.getn(workQueue) > 0 then + workQueueFrame:Show() + end +end + -- Get the number of items in the work queue (for debugging) function Utils:GetWorkQueueSize() return table.getn(workQueue) diff --git a/Guda.toc b/Guda.toc index 15ec6b2..18d4b7f 100644 --- a/Guda.toc +++ b/Guda.toc @@ -2,7 +2,7 @@ ## Title: Guda ## Notes: All-in-one bag and bank addon for World of Warcraft 1.12.1 (Turtle WoW) ## Author: Vati -## Version: 2.2.8 +## Version: 2.2.9 ## SavedVariables: Guda_DB ## SavedVariablesPerCharacter: Guda_CharDB ## OptionalDeps: pfUI diff --git a/UI/BagFrame.lua b/UI/BagFrame.lua index f9258f9..3b74922 100644 --- a/UI/BagFrame.lua +++ b/UI/BagFrame.lua @@ -60,6 +60,28 @@ local showSoulBag = false -- Toggle for soul bag display local hiddenBags = {} -- Track which bags are hidden (bagID -> true/false) local bagParents = {} -- Per-bag parent frames to carry bagID for Blizzard item button templates local isMerchantOpen = false -- Track whether a vendor window is currently open to prevent auto-closing bags +local isDragging = false -- True while the cursor carries an item and the bag is open in category view +local isFrameMoving = false -- True between StartMoving and StopMovingOrSizing on the bag frame + +function BagFrame:IsDragging() + return isDragging +end + +function BagFrame:IsFrameMoving() + return isFrameMoving +end + +-- Called by the cursor watcher in ItemButton.lua on CURSOR_UPDATE edges. +-- Toggles the dragging flag and triggers a redraw so empty-category drop +-- targets appear/disappear in category view. +function BagFrame:SetDragging(state) + state = state and true or false + if isDragging == state then return end + isDragging = state + if Guda_BagFrame and Guda_BagFrame:IsShown() then + BagFrame:Update() + end +end -- Global click catcher for clearing search focus local clickCatcher = nil @@ -83,6 +105,10 @@ end -- Update usability tints on all visible item buttons local function UpdateAllUsabilityTints() if not Guda_BagFrame or not Guda_BagFrame:IsShown() then return end + -- Don't scan ~80 tooltips synchronously while the user is dragging the + -- frame — it can stall the engine for multiple seconds on cold caches. + -- The caller will reschedule after the drag ends. + if isFrameMoving then return end for _, bagParent in pairs(bagParents) do if bagParent and bagParent.itemButtons then @@ -104,6 +130,11 @@ local function ScheduleDeferredUsabilityCheck() usabilityCheckFrame.elapsed = 0 usabilityCheckFrame.pending = false usabilityCheckFrame:SetScript("OnUpdate", function() + -- Hold the timer at its current elapsed value while the user is + -- dragging the frame. Resuming the countdown during a drag would + -- kick off ~80 synchronous tooltip scans right when the engine + -- is starved for cycles, producing a multi-second apparent freeze. + if isFrameMoving then return end this.elapsed = this.elapsed + arg1 if this.elapsed >= USABILITY_CHECK_DELAY then this:Hide() @@ -800,6 +831,16 @@ function BagFrame:Update() return end + -- Frame is being dragged around the screen: skip the full category rebuild + -- (it can take ~100ms+ on a full inventory and lands inside the native + -- StartMoving loop, producing a multi-second apparent freeze). Refresh + -- lock states cheaply instead; EndFrameMove will run one rebuild on drop + -- to catch up any changes that arrived during the move. + if isFrameMoving then + self:UpdateLockStates() + return + end + local viewType = addon.Modules.DB:GetSetting("bagViewType") or "single" addon:DebugCategory("Update() START: viewType=%s", viewType) @@ -809,8 +850,11 @@ function BagFrame:Update() end -- If cursor is holding an item (mid-drag), only update lock states, don't rebuild UI - -- BUT only if we already have items displayed - otherwise we need to do initial build + -- BUT only if we already have items displayed - otherwise we need to do initial build. + -- Exception: in Category View we DO need the full rebuild so the empty-category + -- drop targets can appear/disappear alongside the drag state. if CursorHasItem and CursorHasItem() then + local inCategoryView = viewType == "category" -- Check if we have any displayed items -- Use itemButtons hash instead of GetChildren() to avoid table allocation local hasDisplayedItems = false @@ -826,11 +870,12 @@ function BagFrame:Update() if hasDisplayedItems then break end end - if hasDisplayedItems then + if hasDisplayedItems and not inCategoryView then self:UpdateLockStates() return end - -- If no items displayed yet, continue with full update + -- If no items displayed yet, or we're in category view (drop-target + -- placeholders need to render), continue with full update end -- Mark all existing buttons as not in use (we'll mark active ones during display) @@ -1122,6 +1167,40 @@ function BagFrame:DisplayItemsByCategory(bagData, isOtherChar, charName) end end + -- Inject drop-target pseudo-items for every currently-empty user-assignable + -- category while the user is dragging. They render at the tail of the bag + -- grid; dropping on one calls CategoryManager:AssignItemToCategory. + -- Excluded: system pseudo-categories (Keyring/Soul Bag/Empty), auto-only + -- categories whose membership is determined by item class (Quiver, + -- Container, Class Items), and EquipSet:* overrides (equipment-set + -- membership is managed via the equipment-set UI, not bag drag). + if not isOtherChar and isDragging and addon.Modules.CategoryManager then + local DROP_TARGET_BLOCKLIST = { + ["Keyring"] = true, ["Soul Bag"] = true, ["Empty"] = true, + ["Quiver"] = true, ["Container"] = true, ["Class Items"] = true, + } + local allCats = addon.Modules.CategoryManager:GetCategories() + local defs = allCats and allCats.definitions or {} + for _, catName in ipairs(Guda_CategoryList) do + if not DROP_TARGET_BLOCKLIST[catName] + and string.sub(catName, 1, 9) ~= "EquipSet:" + and categories[catName] and table.getn(categories[catName]) == 0 + and defs[catName] and defs[catName].enabled ~= false then + local icon = defs[catName].icon or "Interface\\AddOns\\Guda\\Assets\\plus" + table.insert(categories[catName], { + bagID = 0, slotID = 0, + itemData = { + isDropTarget = true, + categoryId = catName, + texture = icon, + name = catName, + quality = 0, + }, + }) + end + end + end + -- Layout (theme-aware padding) local _pad = { startX = 10, startY = -10 } if addon.Modules and addon.Modules.Theme and addon.Modules.Theme.GetFramePadding then @@ -2191,6 +2270,34 @@ local function SaveBagFramePosition() end end +-- Centralized move start/stop so every drag handler sets the same flag and +-- we don't drift across six duplicated call sites. +local function BeginFrameMove() + local bagFrame = getglobal("Guda_BagFrame") + if not bagFrame then return end + isFrameMoving = true + -- Pause the background work queue (CacheWarmer tooltip scans etc.) so it + -- doesn't eat 100ms per render frame and kill drag FPS. + if addon.Modules.Utils and addon.Modules.Utils.PauseWorkQueue then + addon.Modules.Utils:PauseWorkQueue() + end + bagFrame:StartMoving() +end + +local function EndFrameMove() + local bagFrame = getglobal("Guda_BagFrame") + if not bagFrame then return end + bagFrame:StopMovingOrSizing() + isFrameMoving = false + SaveBagFramePosition() + if addon.Modules.Utils and addon.Modules.Utils.ResumeWorkQueue then + addon.Modules.Utils:ResumeWorkQueue() + end + -- One rebuild to catch up anything that was short-circuited during the move. + BagFrame:Update() +end + + -- Create transparent overlay for money tooltip function BagFrame:EnsureMoneyTooltipOverlay() local overlayName = "Guda_BagFrame_MoneyTooltipOverlay" @@ -2224,11 +2331,10 @@ function BagFrame:EnsureMoneyTooltipOverlay() searchBox:ClearFocus() end - local bagFrame = getglobal("Guda_BagFrame") local isLocked = addon.Modules.DB and addon.Modules.DB:GetSetting("lockBags") - if bagFrame and not isLocked and arg1 == "LeftButton" then - bagFrame:StartMoving() + if not isLocked and arg1 == "LeftButton" then + BeginFrameMove() end end) @@ -2238,12 +2344,10 @@ function BagFrame:EnsureMoneyTooltipOverlay() Guda_ShowGoldTrackingMenu(moneyFrame) return end - local bagFrame = getglobal("Guda_BagFrame") local isLocked = addon.Modules.DB and addon.Modules.DB:GetSetting("lockBags") - if bagFrame and not isLocked then - bagFrame:StopMovingOrSizing() - SaveBagFramePosition() + if not isLocked then + EndFrameMove() end end) @@ -3484,17 +3588,12 @@ function BagFrame:UpdateLockState() searchBox:ClearFocus() end - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame and arg1 == "LeftButton" then - bagFrame:StartMoving() + if arg1 == "LeftButton" then + BeginFrameMove() end end) frame:SetScript("OnMouseUp", function() - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame then - bagFrame:StopMovingOrSizing() - SaveBagFramePosition() - end + EndFrameMove() end) end @@ -3506,17 +3605,12 @@ function BagFrame:UpdateLockState() searchBox:ClearFocus() end - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame and arg1 == "LeftButton" then - bagFrame:StartMoving() + if arg1 == "LeftButton" then + BeginFrameMove() end end) toolbar:SetScript("OnMouseUp", function() - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame then - bagFrame:StopMovingOrSizing() - SaveBagFramePosition() - end + EndFrameMove() end) end @@ -3528,17 +3622,12 @@ function BagFrame:UpdateLockState() searchBox:ClearFocus() end - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame and arg1 == "LeftButton" then - bagFrame:StartMoving() + if arg1 == "LeftButton" then + BeginFrameMove() end end) moneyFrame:SetScript("OnMouseUp", function() - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame then - bagFrame:StopMovingOrSizing() - SaveBagFramePosition() - end + EndFrameMove() end) end @@ -3550,17 +3639,12 @@ function BagFrame:UpdateLockState() searchBox:ClearFocus() end - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame and arg1 == "LeftButton" then - bagFrame:StartMoving() + if arg1 == "LeftButton" then + BeginFrameMove() end end) itemContainer:SetScript("OnMouseUp", function() - local bagFrame = getglobal("Guda_BagFrame") - if bagFrame then - bagFrame:StopMovingOrSizing() - SaveBagFramePosition() - end + EndFrameMove() end) end end diff --git a/UI/ItemButton.lua b/UI/ItemButton.lua index ba7b364..dcfd0d4 100644 --- a/UI/ItemButton.lua +++ b/UI/ItemButton.lua @@ -263,30 +263,97 @@ local function IsInCategoryView(isBank) return (addon.Modules.DB:GetSetting(key) or "single") == "category" end --- Auto-clear cursor tracking when cursor is truly empty --- Uses a short delay to avoid clearing during the pickup transition --- (CURSOR_UPDATE fires before the item is fully on the cursor) +-- Auto-clear cursor tracking when cursor is truly empty, and notify BagFrame +-- of drag-state transitions. In 1.12 CURSOR_UPDATE is unreliable for item +-- pickups, so we also poll CursorHasItem() on a throttled OnUpdate — +-- matching the Anniversary version's approach. local cursorWatcher = CreateFrame("Frame") cursorWatcher:RegisterEvent("CURSOR_UPDATE") +cursorWatcher:RegisterEvent("ITEM_LOCK_CHANGED") +cursorWatcher._lastCarrying = false +cursorWatcher._pollAccum = 0 + +local function NotifyDragState() + if addon.Modules.BagFrame and addon.Modules.BagFrame.SetDragging then + -- Suppress drop-target state updates while the user is dragging the + -- bag frame itself — nothing on the cursor there, and Update() is + -- deliberately short-circuited anyway. + if addon.Modules.BagFrame.IsFrameMoving + and addon.Modules.BagFrame:IsFrameMoving() then + return + end + local carrying = CursorHasItem and CursorHasItem() and true or false + if carrying ~= cursorWatcher._lastCarrying then + cursorWatcher._lastCarrying = carrying + addon.Modules.BagFrame:SetDragging(carrying) + end + end +end + cursorWatcher:SetScript("OnEvent", function() - -- Don't clear immediately — wait a frame to let the pickup finish + NotifyDragState() if not cursorItemInfo then HideCategoryDropIndicator() return end - -- Schedule a check next frame this.pendingCheck = true end) cursorWatcher:SetScript("OnUpdate", function() - if not this.pendingCheck then return end - this.pendingCheck = nil - -- Now check if cursor actually has an item - if cursorItemInfo and (not CursorHasItem or not CursorHasItem()) then - cursorItemInfo = nil - HideCategoryDropIndicator() + -- Throttled cursor poll (~10Hz) as a safety net for 1.12's event gaps. + this._pollAccum = (this._pollAccum or 0) + arg1 + if this._pollAccum >= 0.1 then + this._pollAccum = 0 + NotifyDragState() + end + + if this.pendingCheck then + this.pendingCheck = nil + if cursorItemInfo and (not CursorHasItem or not CursorHasItem()) then + cursorItemInfo = nil + HideCategoryDropIndicator() + end end end) +-- Pulsing green glow applied to empty-category drop-target placeholder buttons. +-- Uses an OnUpdate-driven sine wave since vanilla 1.12 has no animation groups. +-- Uses a solid-color texture filling the full button, with additive blend + +-- alpha pulse — looks the same regardless of the user's iconSize setting. +local function EnsureDropTargetGlow(button) + if not button.dropGlow then + local g = button:CreateTexture(nil, "OVERLAY") + g:SetTexture(0.2, 1.0, 0.2, 1) -- solid green; alpha is modulated each frame + g:SetBlendMode("ADD") + g:SetAllPoints(button) -- cover the whole button, scales with iconSize + button.dropGlow = g + + local driver = CreateFrame("Frame", nil, button) + driver._t = 0 + driver:SetScript("OnUpdate", function() + this._t = this._t + arg1 + -- period ~1.2s => 2π/1.2 ≈ 5.24 + local phase = (math.sin(this._t * 5.24) + 1) * 0.5 + if this:GetParent().dropGlow then + -- Subtle pulse: 0.15 → 0.45 alpha with additive blend. + this:GetParent().dropGlow:SetAlpha(0.15 + phase * 0.30) + end + end) + button.dropGlowDriver = driver + end + -- If the button was resized since last show, re-anchor defensively. + button.dropGlow:ClearAllPoints() + button.dropGlow:SetAllPoints(button) + button.dropGlow:Show() + button.dropGlowDriver:Show() +end + +local function StopDropTargetGlow(button) + if button.dropGlow then button.dropGlow:Hide() end + if button.dropGlowDriver then button.dropGlowDriver:Hide() end + button.isDropTarget = false + button.dropTargetCategoryId = nil +end + -- Use shared tooltip from Utils module (retrieved on-demand to ensure Utils is loaded) -- Helper function to check if an item is a quest item @@ -1080,6 +1147,17 @@ function Guda_ItemButton_OnLoad(self) end) self:SetScript("OnClick", function() + -- Click on a drop-target placeholder while carrying an item on the + -- cursor: route to the same handler as OnReceiveDrag (assign to + -- category). Without an item on the cursor a click is a no-op. + if this.isDropTarget then + if CursorHasItem and CursorHasItem() then + local handler = this:GetScript("OnReceiveDrag") + if handler then handler() end + end + return + end + -- Lock/unlock item with Ctrl+Right-Click if IsControlKeyDown() and arg1 == "RightButton" and this.hasItem and not this.otherChar and not this.isReadOnly then local link = GetContainerItemLink(this.bagID, this.slotID) @@ -1240,6 +1318,24 @@ function Guda_ItemButton_OnLoad(self) local info = Guda_GetCursorItemInfo() if not info then return end + -- Empty-category drop target: assign cursor item to this.dropTargetCategoryId + if this.isDropTarget and this.dropTargetCategoryId then + if info.itemID and addon.Modules.CategoryManager then + addon.Modules.CategoryManager:AssignItemToCategory(info.itemID, this.dropTargetCategoryId) + addon:Debug("Assigned item %d to empty category: %s", info.itemID, this.dropTargetCategoryId) + end + if CursorHasItem() then + PickupContainerItem(info.bagID, info.slotID) + end + dropCooldownTime = GetTime() + 0.3 + HideCategoryDropIndicator() + Guda_ClearCursorItem() + if addon.Modules.BagFrame and addon.Modules.BagFrame.Update then + addon.Modules.BagFrame:Update() + end + return + end + local inCatView = IsInCategoryView(this.isBank) if not inCatView then -- Single view: let default swap happen @@ -1337,6 +1433,14 @@ local function ResetButtonVisualState(self) local chargesText = getglobal(self:GetName().."_Charges") if chargesText then chargesText:Hide() end + -- Reset effects the drop-target render may have applied on this pooled button + self:SetAlpha(1) + local iconTex = getglobal(self:GetName().."IconTexture") or getglobal(self:GetName().."Icon") or self.icon or self.Icon + if iconTex then + if iconTex.SetDesaturated then iconTex:SetDesaturated(false) end + iconTex:SetVertexColor(1, 1, 1) + end + -- Clear cooldown overlay local cd = getglobal(self:GetName().."Cooldown") or self.cooldown if cd then @@ -1658,6 +1762,60 @@ function Guda_ItemButton_SetItem(self, bagID, slotID, itemData, isBank, otherCha -- Reset all visual state before reassigning pooled button ResetButtonVisualState(self) + -- Pooled buttons may have carried a drop-target glow from the previous + -- render — clear it before deciding what this button is now. + StopDropTargetGlow(self) + + -- Drop-target pseudo-items (empty category placeholders during drag). + -- Shown with the category's icon, dim+desaturated, with a pulsing green + -- glow. OnReceiveDrag/OnClick will route to AssignItemToCategory. + if itemData and itemData.isDropTarget then + self.bagID = 0 + self.slotID = 0 + self.bagIndex = -100 + self.itemData = itemData + self.isBank = isBank or false + self.otherChar = nil + self.isReadOnly = false + self.isMail = false + self.hasItem = false + self.isDropTarget = true + self.dropTargetCategoryId = itemData.categoryId + + SetupDragDrop(self) + + local iconSize = 37 + if addon and addon.Modules and addon.Modules.Utils and addon.Modules.Utils.SafeCall then + iconSize = addon.Modules.Utils:SafeCall("DB", "GetSetting", "iconSize") or iconSize + end + self:SetWidth(iconSize) + self:SetHeight(iconSize) + + if SetItemButtonTexture then + SetItemButtonTexture(self, itemData.texture) + end + local iconTexture = getglobal(self:GetName().."IconTexture") or getglobal(self:GetName().."Icon") or self.icon or self.Icon + if iconTexture then + iconTexture:SetTexture(itemData.texture) + -- Mirror PositionIconAndBorders() so the category icon fills the + -- full button at the user's configured iconSize. PositionIconAndBorders + -- itself is guarded by `self.hasItem`, which drop-targets don't set. + iconTexture:ClearAllPoints() + iconTexture:SetPoint("CENTER", self, "CENTER", 0, 0) + iconTexture:SetWidth(iconSize) + iconTexture:SetHeight(iconSize) + iconTexture:SetTexCoord(0, 1, 0, 1) + iconTexture:Show() + if iconTexture.SetDesaturated then iconTexture:SetDesaturated(true) end + iconTexture:SetVertexColor(0.6, 1.0, 0.6) + end + if SetItemButtonCount then SetItemButtonCount(self, 0) end + self:SetAlpha(0.85) + + EnsureDropTargetGlow(self) + self:Show() + return + end -- Set button properties self.bagID = bagID @@ -2198,6 +2356,19 @@ end -- OnEnter handler (show tooltip) function Guda_ItemButton_OnEnter(self) addon:Debug("OnEnter FIRED: button=%s CursorHasItem=%s", tostring(self:GetName()), tostring(CursorHasItem and CursorHasItem())) + + -- Drop-target placeholder: simple tooltip, skip the normal item tooltip path. + if self.isDropTarget then + GameTooltip:SetOwner(UIParent, "ANCHOR_NONE") + GameTooltip:ClearAllPoints() + GameTooltip:SetPoint("BOTTOMRIGHT", self, "TOPLEFT", 10, 0) + local catName = self.dropTargetCategoryId or "" + local label = (Guda_L and Guda_L["Drop to assign to %s"]) or "Drop to assign to %s" + GameTooltip:SetText(format(label, catName), 1, 1, 1) + GameTooltip:Show() + return + end + -- Category drag-drop: show "+" indicator when hovering with cursor item in category view local hasCursor = CursorHasItem and CursorHasItem() if hasCursor then