From ac6846025b1e96ad749b8186b48e76c3dbf999c0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 25 Mar 2026 02:20:01 -0400 Subject: [PATCH 01/44] [worktree-rename] Rename addon to EasyFind-tmog-and-toggle-fix Original addon name: EasyFind --- EasyFind.toc => EasyFind-tmog-and-toggle-fix.toc | 2 +- MapSearch.lua | 6 +++--- UI.lua | 2 +- Utils.lua | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename EasyFind.toc => EasyFind-tmog-and-toggle-fix.toc (90%) diff --git a/EasyFind.toc b/EasyFind-tmog-and-toggle-fix.toc similarity index 90% rename from EasyFind.toc rename to EasyFind-tmog-and-toggle-fix.toc index 379783b..a28c9dd 100644 --- a/EasyFind.toc +++ b/EasyFind-tmog-and-toggle-fix.toc @@ -1,5 +1,5 @@ ## Interface: 120005, 120001, 120000 -## Title: EasyFind +## Title: EasyFind (tmog-and-toggle-fix) ## Notes: Search and locate UI elements and map locations with ease ## Author: justawower ## Version: 1.4.0 diff --git a/MapSearch.lua b/MapSearch.lua index 0dbba9b..d0a3d78 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -12,7 +12,7 @@ local mmin, mmax, mpi = Utils.mmin, Utils.mmax, Utils.mpi local pcall, xpcall, tostring = Utils.pcall, Utils.xpcall, Utils.tostring local ErrorHandler = Utils.ErrorHandler -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\textures\\lightning-bolt" local GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT local DEFAULT_OPACITY = ns.DEFAULT_OPACITY @@ -100,7 +100,7 @@ local INDICATOR_STYLES = { preRotated = false, -- Needs mpi rotation to point down }, ["EasyFind Arrow"] = { - texture = "Interface\\AddOns\\EasyFind\\Images\\arrow-hq", + texture = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Images\\arrow-hq", texCoord = nil, preRotated = true, -- Already points down, no rotation needed }, @@ -479,7 +479,7 @@ local function CreateWaypointTracker() local layer = nearTrackFrame:CreateTexture(nil, "OVERLAY", nil, i) layer:SetSize(ringSize, ringSize) layer:SetPoint("CENTER", Minimap, "CENTER", 0, 0) - layer:SetTexture("Interface\\AddOns\\EasyFind\\textures\\near-track-ring") + layer:SetTexture("Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\textures\\near-track-ring") layer:SetBlendMode("ADD") layer:SetVertexColor(1, 1, 0.3, 1) ringLayers[i] = layer diff --git a/UI.lua b/UI.lua index 6929e97..e71508a 100644 --- a/UI.lua +++ b/UI.lua @@ -26,7 +26,7 @@ local IsShiftKeyDown = IsShiftKeyDown local GetCursorPosition = GetCursorPosition local wipe = wipe -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\textures\\lightning-bolt" local REP_BAR_WIDTH = 100 local searchFrame diff --git a/Utils.lua b/Utils.lua index aa05a70..80cdccd 100644 --- a/Utils.lua +++ b/Utils.lua @@ -126,9 +126,9 @@ ns.SEARCHBAR_FONT = "EasyFindSearchFont" -- Custom 3-part search bar border with chamfered corners. -- Fill (BACKGROUND) and border (ARTWORK) use identical shapes from custom TGA textures. -- Fill is tinted black with tunable opacity; border uses Blizzard's action bar gray. -local SEARCH_TEX_FILL = "Interface\\AddOns\\EasyFind\\Textures\\SearchBarFill" -local SEARCH_TEX_BORDER = "Interface\\AddOns\\EasyFind\\Textures\\SearchBarBorder" -local CLEAR_BTN_TEX = "Interface\\AddOns\\EasyFind\\Textures\\clear-button" +local SEARCH_TEX_FILL = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Textures\\SearchBarFill" +local SEARCH_TEX_BORDER = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Textures\\SearchBarBorder" +local CLEAR_BTN_TEX = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Textures\\clear-button" local SEARCH_CAP_W = 8 local SEARCH_TEX_W = 64 local TC_LEFT = {0, SEARCH_CAP_W / SEARCH_TEX_W, 0, 1} From df334cc97e6f3bb0b5db21fe1c074222606ca0d0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 25 Mar 2026 02:25:35 -0400 Subject: [PATCH 02/44] Fix Toggle+Focus keybind not hiding visible bar Previously only hid the bar if the editBox had focus. Now hides whenever the bar is visible, regardless of focus state. --- UI.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UI.lua b/UI.lua index e71508a..c0555eb 100644 --- a/UI.lua +++ b/UI.lua @@ -4050,7 +4050,7 @@ end function UI:ToggleFocus() if not searchFrame then return end if inCombat then return end - if searchFrame:IsShown() and searchFrame.editBox:HasFocus() then + if searchFrame:IsShown() then self:Hide() else self:Show(false) From 5a862d158d69748dd3390e50e049d9724052d416 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 25 Mar 2026 15:03:28 -0400 Subject: [PATCH 03/44] Add missing clear-button texture --- textures/clear-button.tga | Bin 0 -> 16402 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 textures/clear-button.tga diff --git a/textures/clear-button.tga b/textures/clear-button.tga new file mode 100644 index 0000000000000000000000000000000000000000..ba334dcd0e8c8a7780464d58aa58f412a3016838 GIT binary patch literal 16402 zcmeI3Epi({5QVpV1fRfFCgwt721ep03K7XIdG$UUM@@6lnA|qE&DD+l`X82OV7uL3N^hid>78`ZD$l&* z+huuA#>t_@bFc70x|V*Fevy8c{*?Y|m1o}ZjX1<54QXFh9hJY=(v|c{`dRv0nkuBl z2DY$yJx3&Fa z3AV856?{(`7nF|iopHr0-<06J^quspH}Q%1*uWMxvF#NwxaS8-!@R;fz8m&NY0;q^ z7}D^81F%H51I1--?U63*=ChZBF6?G6g@cEf zIv~(9sb?+IhuwUR<)9C{+4FJ$rrvd<;&{qF7Q6Y}$3ZN1v*+dDpLIak*}K?CW5kQy zeD31Fi{0#bH~?Gc9*VxLVt^|SV=?)f58m*QgH-I6FTTXi4?4#J*~w6yaTtDXP0_-kMP)V}_$ zdjF$s1J&+-wfI*|4kP5P?tfOj|D7-nc+UX()-v8TlV|UrYJIft1?Er`XD0RR1IKdE zhaLZrSPs0{qt3tJLOyus0(~6BV&4%E!GRY$c6-a->Ve`i7JBysyEyP-$5*7Q9;9G* zoqxA=fU&^4Cv?a`D)t=#CpbvK?!N!f;;;JtOUnW6pvwDCL0?eq{kMRFYVSV=94yrq za-5M^-&QKb$E^2X0}iMMQ@#sY?EZ7W0sTQrpIEBA%`QIe&os>65~1>8T&VK@55}kP zGe2OC Date: Wed, 25 Mar 2026 15:03:36 -0400 Subject: [PATCH 04/44] Fix options panel rendering behind action bars --- Options.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Options.lua b/Options.lua index 4b3465c..91d77ef 100644 --- a/Options.lua +++ b/Options.lua @@ -516,7 +516,7 @@ function Options:Initialize() else optionsFrame:SetPoint("TOP", UIParent, "TOP", 0, -100) end - optionsFrame:SetFrameStrata("MEDIUM") + optionsFrame:SetFrameStrata("DIALOG") optionsFrame:SetMovable(true) optionsFrame:EnableMouse(true) optionsFrame:SetClampedToScreen(true) @@ -1721,7 +1721,7 @@ function Options:DoResetAll() EasyFind.db.enableMapSearch = true EasyFind.db.globalSearchFilters = { zones = true, dungeons = true, raids = true, delves = true } EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true } - EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, map = false } EasyFind.db.uiMapSearchLocal = true EasyFind.db.alwaysShowRares = false EasyFind.db.optionsPosition = nil @@ -1843,7 +1843,7 @@ function Options:DoResetUI() EasyFind.db.uiResultsWidth = 350 EasyFind.db.uiSearchPosition = nil EasyFind.db.uiResultsHeight = 280 - EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, map = false } EasyFind.db.uiMapSearchLocal = true optionsFrame.directOpenCheckbox:SetChecked(false) @@ -2093,7 +2093,7 @@ function Options:RestoreStandalone() optionsFrame:SetBackdropBorderColor(0.50, 0.48, 0.45, 1.0) optionsFrame:SetParent(UIParent) - optionsFrame:SetFrameStrata("MEDIUM") + optionsFrame:SetFrameStrata("DIALOG") optionsFrame:SetMovable(true) optionsFrame:RegisterForDrag("LeftButton") From a127b58ddc2be0ce34806499a7755771209e3726 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 25 Mar 2026 15:04:17 -0400 Subject: [PATCH 05/44] Add transmog outfit search with click-to-equip Saved transmog outfits appear as searchable entries in the UI search bar. Click to equip via a temporary action slot (PreClick places outfit, UseAction equips, PostClick cleans up). Adds Transmogrification panel entry to open the full wardrobe UI. Adds "tmog" as a keyword across all transmog related entries. Outfits filter defaults to off, togglable in the filter dropdown. --- .luacheckrc | 6 ++- Core.lua | 17 ++++++- Database.lua | 83 ++++++++++++++++++++++++++++++--- UI.lua | 127 ++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 217 insertions(+), 16 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 482581c..7a7f844 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -48,7 +48,7 @@ read_globals = { "CharacterFrame", "PaperDollFrame", "AchievementFrame", "CharacterStatsPane", "PaperDollTitlesPane", "PaperDollEquipmentManagerPane", "CurrencyFrame", - "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", + "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", "TransmogFrame", "PVEFrame", "ReputationFrame", "TokenFrame", "GroupFinderFrame", "LFGListFrame", "HelpFrame", "ClassTalentFrame", "GuildMicroButton", "StoreMicroButton", "PlayerFrame", "StoreFrame", @@ -58,6 +58,7 @@ read_globals = { "PVPQueueFrame", "AchievementFrameCategories_ExpandToCategory", "AchievementFrameCategories_UpdateDataProvider", + "CollectionsJournal_LoadUI", "CollectionsJournal_SetTab", "Transmog_LoadUI", "EncounterJournal_LoadUI", "PanelTemplates_SetTab", "Menu", "ScrollBoxConstants", @@ -66,13 +67,14 @@ read_globals = { "C_EncounterJournal", "C_GossipInfo", "C_MajorFactions", "C_Map", "C_Minimap", "C_MountJournal", "C_Navigation", "C_PetJournal", "C_Reputation", "C_SuperTrack", "C_TaxiMap", "C_Texture", "C_Timer", - "C_ToyBox", "C_VignetteInfo", + "C_ToyBox", "C_TransmogOutfitInfo", "C_VignetteInfo", -- UI utility functions "UIFrameFadeIn", "UIFrameFadeOut", "UIFrameFadeRemoveFrame", "AchievementFrameCategories_SelectElementData", "UnitPopup_ShowMenu", "BattlePetToolTip_ShowLink", "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", + "HasAction", "PlaceAction", "PickupAction", "ClearCursor", -- Data types "UiMapPoint", diff --git a/Core.lua b/Core.lua index 5ce882b..1f3df64 100644 --- a/Core.lua +++ b/Core.lua @@ -103,6 +103,7 @@ local DB_DEFAULTS = { mounts = false, toys = false, pets = false, + outfits = false, map = false, }, uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global @@ -337,7 +338,10 @@ local function OnPlayerLogin() ns.Database:PopulateDynamicToys() SafeAfter(0, function() ns.Database:PopulateDynamicPets() - collectgarbage("collect") + SafeAfter(0, function() + ns.Database:PopulateDynamicOutfits() + collectgarbage("collect") + end) end) end) end) @@ -364,10 +368,13 @@ local function OnPlayerLogin() end end +local outfitRefreshTimer + eventFrame:RegisterEvent("ADDON_LOADED") eventFrame:RegisterEvent("PLAYER_LOGIN") eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") eventFrame:RegisterEvent("PLAYER_LOGOUT") +eventFrame:RegisterEvent("TRANSMOG_OUTFITS_CHANGED") eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) if event == "ADDON_LOADED" and arg1 == ADDON_NAME then OnInitialize() @@ -384,6 +391,14 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) OnPlayerLogin() end self:UnregisterEvent("PLAYER_ENTERING_WORLD") + elseif event == "TRANSMOG_OUTFITS_CHANGED" then + if outfitRefreshTimer then outfitRefreshTimer:Cancel() end + outfitRefreshTimer = C_Timer.NewTimer(0.5, function() + outfitRefreshTimer = nil + if ns.Database and ns.Database.PopulateDynamicOutfits then + ns.Database:PopulateDynamicOutfits() + end + end) elseif event == "PLAYER_LOGOUT" then -- Strip runtime-only fields before SavedVariables serialization if EasyFindDB then diff --git a/Database.lua b/Database.lua index 8a445e1..afcfcec 100644 --- a/Database.lua +++ b/Database.lua @@ -5,7 +5,7 @@ ns.Database = Database local Utils = ns.Utils local ipairs = Utils.ipairs -local tsort, tconcat = Utils.tsort, Utils.tconcat +local tsort, tconcat, tremove = Utils.tsort, Utils.tconcat, Utils.tremove local sfind, slower, ssub = Utils.sfind, Utils.slower, Utils.ssub local mmin, mmax, mabs = Utils.mmin, Utils.mmax, Utils.mabs local C_CurrencyInfo = C_CurrencyInfo @@ -426,6 +426,22 @@ local PET_PROTO = { } local PET_MT = { __index = PET_PROTO } +local OUTFIT_PROTO = { + keywords = {"outfit", "transmog", "tmog", "mog", "appearance", "keymog"}, + keywordsLower = {"outfit", "transmog", "tmog", "mog", "appearance", "keymog"}, + category = "Outfit", + path = {}, + steps = {}, +} +local OUTFIT_MT = { __index = OUTFIT_PROTO } + +-- Outfit click-to-equip uses a single temporary action bar slot. +-- PreClick dynamically places the outfit on this slot, then the secure +-- handler calls UseAction to equip it. The slot is found empty at init +-- and only ever holds the last-clicked outfit. +local outfitTempSlot -- single reserved action slot (nil if none found) +local outfitEntries = {} -- track injected entries for re-population + function Database:PopulateDynamicMounts() if not C_MountJournal or not C_MountJournal.GetMountIDs then return end @@ -539,6 +555,50 @@ function Database:PopulateDynamicPets() if C_PetJournal.SetSearchFilter then C_PetJournal.SetSearchFilter(savedString) end end +-- Called after PLAYER_LOGIN when C_TransmogOutfitInfo is available. +-- Scans the player's saved transmog outfits and injects them into the search database. +function Database:PopulateDynamicOutfits() + if not C_TransmogOutfitInfo or not C_TransmogOutfitInfo.GetOutfitsInfo then return end + + -- Remove previous outfit entries (handles mid-session outfit changes) + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Outfit" then + tremove(uiSearchData, i) + end + end + wipe(outfitEntries) + + -- Find a temp action slot (once). Scan from 180 down for an empty slot. + if not outfitTempSlot then + for slot = 180, 1, -1 do + if not HasAction(slot) then + outfitTempSlot = slot + break + end + end + end + + local outfits = C_TransmogOutfitInfo.GetOutfitsInfo() + if not outfits then return end + + for _, info in ipairs(outfits) do + if not info.isDisabled then + local entry = setmetatable({ + name = info.name, + icon = info.icon, + outfitID = info.outfitID, + nameLower = slower(info.name), + }, OUTFIT_MT) + uiSearchData[#uiSearchData + 1] = entry + outfitEntries[#outfitEntries + 1] = entry + end + end +end + +function Database:GetOutfitTempSlot() + return outfitTempSlot +end + -- TREE FLATTENER -- Walks the tree and produces flat entries for the search/highlight engines. -- Children inherit: buttonFrame, category, and accumulate path + steps from parents. @@ -726,7 +786,7 @@ function Database:BuildUIDatabase() }, { name = "Collections (Achievements)", - keywords = {"collections", "collection", "transmog"}, + keywords = {"collections", "collection", "transmog", "tmog"}, steps = {{ waitForFrame = "AchievementFrame", achievementCategory = "Collections" }}, children = { { @@ -1292,7 +1352,7 @@ function Database:BuildUIDatabase() { name = "Delves (Premade)", keywords = {"delves", "delve group", "delve lfg", "find delve group", "premade delves", "delve"}, steps = {{ waitForFrame = "PVEFrame", searchButtonText = "Delves", text = "Select Delves from the Premade Groups list" }} }, { name = "Dungeons (Premade)", keywords = {"dungeons", "dungeon group", "dungeon lfg", "find dungeon group", "premade dungeons", "m+ group", "mythic group"}, steps = {{ waitForFrame = "PVEFrame", searchButtonText = "Dungeons", text = "Select Dungeons from the Premade Groups list" }} }, { name = "Raids - The War Within (Premade)", keywords = {"raids", "raids the war within", "raid group", "raid lfg", "find raid group", "premade raids", "tww raid", "war within raid", "nerub-ar", "liberation of undermine"}, steps = {{ waitForFrame = "PVEFrame", searchButtonText = "Raids - The War Within", text = "Select Raids - The War Within from the Premade Groups list" }} }, - { name = "Raids - Legacy (Premade)", keywords = {"raids", "raids legacy", "legacy raid", "old raid", "legacy raid group", "legacy lfg", "transmog raid", "mount run"}, steps = {{ waitForFrame = "PVEFrame", searchButtonText = "Raids - Legacy", text = "Select Raids - Legacy from the Premade Groups list" }} }, + { name = "Raids - Legacy (Premade)", keywords = {"raids", "raids legacy", "legacy raid", "old raid", "legacy raid group", "legacy lfg", "transmog raid", "tmog raid", "mount run"}, steps = {{ waitForFrame = "PVEFrame", searchButtonText = "Raids - Legacy", text = "Select Raids - Legacy from the Premade Groups list" }} }, { name = "Custom PvE Group", keywords = {"custom", "custom pve", "custom group", "custom lfg", "pve custom"}, steps = {{ waitForFrame = "PVEFrame", searchButtonText = "Custom", text = "Select Custom from the Premade Groups list" }} }, }, }, @@ -1374,11 +1434,20 @@ function Database:BuildUIDatabase() { name = "Pet Journal", keywords = {"pets", "pet", "battle pets", "companion", "pet collection", "critter", "pet journal"}, category = "Warband Collections", steps = {{ waitForFrame = "CollectionsJournal", tabIndex = 2 }} }, { name = "Toy Box", keywords = {"toys", "toy", "toybox", "toy box", "fun items"}, category = "Warband Collections", steps = {{ waitForFrame = "CollectionsJournal", tabIndex = 3 }} }, { name = "Heirlooms", keywords = {"heirlooms", "heirloom", "leveling gear", "bind on account", "boa"}, category = "Warband Collections", steps = {{ waitForFrame = "CollectionsJournal", tabIndex = 4 }} }, - { name = "Appearances (Transmog)", keywords = {"transmog", "transmogrification", "appearance", "appearances", "wardrobe", "cosmetic", "looks", "mog"}, category = "Warband Collections", steps = {{ waitForFrame = "CollectionsJournal", tabIndex = 5, text = "Click the Appearances tab" }} }, + { name = "Appearances (Transmog)", keywords = {"transmog", "tmog", "transmogrification", "appearance", "appearances", "wardrobe", "cosmetic", "looks", "mog"}, category = "Warband Collections", steps = {{ waitForFrame = "CollectionsJournal", tabIndex = 5, text = "Click the Appearances tab" }} }, { name = "Campsites", keywords = {"campsites", "campsite", "camp", "camping", "rest area"}, category = "Warband Collections", steps = {{ waitForFrame = "CollectionsJournal", tabIndex = 6 }} }, }, }, + -- TRANSMOGRIFICATION + { + name = "Transmogrification", + keywords = {"transmogrification", "transmog", "tmog", "mog", "wardrobe", "outfit", "outfits", "appearance", "keymog"}, + category = "Transmogrification", + icon = { file = 6119963, coords = { 0.0183, 0.2629, 0.0131, 0.5152 } }, + steps = {{ loadTransmog = true }}, + }, + -- ADVENTURE GUIDE { name = "Adventure Guide", @@ -1421,7 +1490,7 @@ function Database:BuildUIDatabase() buttonFrame = "StoreMicroButton", steps = {{ buttonFrame = "StoreMicroButton" }}, children = { - { name = "Shop Appearances", keywords = {"transmog", "appearance"}, category = "Shop", steps = {{ waitForFrame = "StoreFrame", text = "Browse the Appearances section in the shop" }} }, + { name = "Shop Appearances", keywords = {"transmog", "tmog", "appearance"}, category = "Shop", steps = {{ waitForFrame = "StoreFrame", text = "Browse the Appearances section in the shop" }} }, }, }, @@ -2034,7 +2103,9 @@ function Database:SearchUI(query, skipCategories) -- Determine candidate set: incremental (previous matches) or full database local skipKey = skipCategories and ( (skipCategories["Mount"] and "M" or "") .. - (skipCategories["Toy"] and "T" or "") + (skipCategories["Toy"] and "T" or "") .. + (skipCategories["Pet"] and "P" or "") .. + (skipCategories["Outfit"] and "O" or "") ) or "" local searchSet diff --git a/UI.lua b/UI.lua index c0555eb..c827238 100644 --- a/UI.lua +++ b/UI.lua @@ -47,7 +47,8 @@ end -- Copy storable fields from a search entry for SavedVariables pinning. -- Uses explicit field access (not pairs) so metatable __index fields are included. local CLEAN_SIMPLE_FIELDS = {"name", "nameLower", "category", "buttonFrame", "flashLabel", "icon", - "mountID", "spellID", "toyItemID", "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} + "mountID", "spellID", "toyItemID", "petID", "speciesID", "outfitID", + "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} local CLEAN_TABLE_FIELDS = {"path", "steps", "keywords", "keywordsLower"} local function CleanUIForStorage(data) @@ -158,6 +159,7 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon.toyItemID = nil btn.icon.petID = nil btn.icon.spellID = nil + btn.icon.outfitID = nil if btn.iconCooldown then btn.iconCooldown:Hide() end if kind == "atlas" then btn.icon:SetAtlas(value) @@ -1055,6 +1057,7 @@ local UI_FILTER_OPTIONS = { { key = "mounts", label = "Mounts", iconTex = 132261 }, -- Ability_Mount_RidingHorse { key = "toys", label = "Toys", iconTex = 454046 }, -- Trade_Archaeology_ChestofTinyGlassAnimals { key = "pets", label = "Pets", iconTex = 132599 }, -- PetJournalPortrait (Inv_Box_PetCarrier_01) + { key = "outfits", label = "Outfits", iconTex = 132649 }, -- INV_Chest_Cloth_17 } function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) @@ -1384,7 +1387,7 @@ local cachedHierarchical -- last full hierarchical list for re-rendering afte local expandedContainers = {} -- tracks which containers have had children injected -- Reusable tables for grouping results (wiped each search to avoid per-keystroke allocations) -local groupUI, groupMounts, groupToys, groupPets, groupMap = {}, {}, {}, {}, {} +local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupMap = {}, {}, {}, {}, {}, {} local mountSectionHeader = { name = "Mounts", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -1397,6 +1400,10 @@ local petSectionHeader = { name = "Pets", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } +local outfitSectionHeader = { + name = "Outfits", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} local mapSectionHeader = { name = "Map Search", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -1816,7 +1823,29 @@ function UI:CreateResultButton(index) resultRow.text = text resultRow:RegisterForClicks("LeftButtonDown", "RightButtonUp") + -- PreClick: dynamically place outfits on the temp action slot + -- so the secure handler's UseAction can equip them. + resultRow:SetScript("PreClick", function(self, mouseButton) + if mouseButton ~= "LeftButton" then return end + local outfitID = self.data and self.data.outfitID + if not outfitID then return end + local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() + if not tempSlot then return end + if C_TransmogOutfitInfo and C_TransmogOutfitInfo.PickupOutfit then + C_TransmogOutfitInfo.PickupOutfit(outfitID) + PlaceAction(tempSlot) + ClearCursor() + end + end) resultRow:SetScript("PostClick", function(self, mouseButton, down) + -- Clean up temp action slot after outfit equip + if self.data and self.data.outfitID then + local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() + if tempSlot then + PickupAction(tempSlot) + ClearCursor() + end + end -- Right-click: show pin/unpin popup if mouseButton == "RightButton" and self.data then local pinData = self.data @@ -1942,6 +1971,19 @@ function UI:CreateResultButton(index) end end end + -- Outfit tooltip + elseif self.icon.outfitID then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.data and self.data.name or "Outfit") + if C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID then + local activeID = C_TransmogOutfitInfo.GetActiveOutfitID() + if activeID and activeID == self.icon.outfitID then + GameTooltip:AddLine("Currently equipped", 0.3, 1, 0.3) + else + GameTooltip:AddLine("Click to equip", 1, 0.82, 0) + end + end + GameTooltip:Show() end end end) @@ -1954,7 +1996,7 @@ function UI:CreateResultButton(index) self.toyTooltipTicker:Cancel() self.toyTooltipTicker = nil end - if self.data and (self.data.mountID or self.data.toyItemID or self.data.petID) then + if self.data and (self.data.mountID or self.data.toyItemID or self.data.petID or self.data.outfitID) then GameTooltip:Hide() end -- Clear map preview if we were showing one @@ -1993,11 +2035,12 @@ function UI:OnSearchTextChanged(text) local filters = EasyFind.db.uiSearchFilters local skipCategories if filters then - if filters.mounts == false or filters.toys == false or filters.pets == false then + if filters.mounts == false or filters.toys == false or filters.pets == false or filters.outfits == false then skipCategories = {} if filters.mounts == false then skipCategories["Mount"] = true end if filters.toys == false then skipCategories["Toy"] = true end if filters.pets == false then skipCategories["Pet"] = true end + if filters.outfits == false then skipCategories["Outfit"] = true end end end local results = ns.Database:SearchUI(text, skipCategories) @@ -2007,7 +2050,7 @@ function UI:OnSearchTextChanged(text) local filtered = {} for _, r in ipairs(results) do local rd = r.data - if rd and (rd.mountID or rd.toyItemID or rd.petID or rd.mapSearchResult) then + if rd and (rd.mountID or rd.toyItemID or rd.petID or rd.outfitID or rd.mapSearchResult) then filtered[#filtered + 1] = r end end @@ -2037,6 +2080,7 @@ function UI:OnSearchTextChanged(text) wipe(groupMounts) wipe(groupToys) wipe(groupPets) + wipe(groupOutfits) wipe(groupMap) for _, entry in ipairs(hierarchical) do local d = entry.data @@ -2046,6 +2090,8 @@ function UI:OnSearchTextChanged(text) groupToys[#groupToys + 1] = entry elseif d and d.petID then groupPets[#groupPets + 1] = entry + elseif d and d.outfitID then + groupOutfits[#groupOutfits + 1] = entry else groupUI[#groupUI + 1] = entry end @@ -2085,6 +2131,13 @@ function UI:OnSearchTextChanged(text) hierarchical[#hierarchical + 1] = e end end + if #groupOutfits > 0 then + hierarchical[#hierarchical + 1] = outfitSectionHeader + for _, e in ipairs(groupOutfits) do + e.depth = 1 + hierarchical[#hierarchical + 1] = e + end + end if #groupMap > 0 then hierarchical[#hierarchical + 1] = mapSectionHeader for _, e in ipairs(groupMap) do @@ -2349,20 +2402,33 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.selectionHighlight:SetVertexColor(unpack(theme.selectionColor)) resultRow.data = data - -- Set secure action attributes for toys and mounts + -- Set secure action attributes for toys, mounts, and outfits if not InCombatLockdown() then if data and data.toyItemID then resultRow:SetAttribute("type", "toy") resultRow:SetAttribute("toy", data.toyItemID) resultRow:SetAttribute("macrotext", nil) + resultRow:SetAttribute("action", nil) elseif data and data.mountID then resultRow:SetAttribute("type", "macro") resultRow:SetAttribute("macrotext", "/cancelform [form]") resultRow:SetAttribute("toy", nil) + resultRow:SetAttribute("action", nil) + elseif data and data.outfitID then + local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() + if tempSlot then + resultRow:SetAttribute("type", "action") + resultRow:SetAttribute("action", tempSlot) + else + resultRow:SetAttribute("type", nil) + end + resultRow:SetAttribute("toy", nil) + resultRow:SetAttribute("macrotext", nil) else resultRow:SetAttribute("type", nil) resultRow:SetAttribute("toy", nil) resultRow:SetAttribute("macrotext", nil) + resultRow:SetAttribute("action", nil) end end resultRow.isPathNode = entry.isPathNode @@ -2677,7 +2743,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) iconSet = true -- Mount/Toy/Pet leaves: icon goes to right side (same layout as currency icons) - elseif not iconSet and data and (data.mountID or data.toyItemID or data.petID) then + elseif not iconSet and data and (data.mountID or data.toyItemID or data.petID or data.outfitID) then local iconFileID = data.icon local rightOffset = -5 @@ -2693,9 +2759,18 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.icon.toyItemID = data.toyItemID resultRow.icon.petID = data.petID resultRow.icon.spellID = data.spellID + resultRow.icon.outfitID = data.outfitID -- Red tint on mount icons when in combat (can't mount) if data.mountID and InCombatLockdown() then resultRow.icon:SetVertexColor(1, 0.3, 0.3, 1) + -- Green tint on currently equipped outfit + elseif data.outfitID and C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID then + local activeID = C_TransmogOutfitInfo.GetActiveOutfitID() + if activeID and activeID == data.outfitID then + resultRow.icon:SetVertexColor(0.3, 1, 0.3, 1) + else + resultRow.icon:SetVertexColor(1, 1, 1, 1) + end else resultRow.icon:SetVertexColor(1, 1, 1, 1) end @@ -3035,6 +3110,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if not InCombatLockdown() then resultRow:SetAttribute("type", nil) resultRow:SetAttribute("toy", nil) + resultRow:SetAttribute("action", nil) end resultRow.headerGrad:Hide() resultRow.headerTab:Hide() @@ -3434,6 +3510,33 @@ function UI:SelectResult(data) if not data then return end + -- Transmogrification panel: load and show TransmogFrame + if data.steps and data.steps[1] and data.steps[1].loadTransmog then + if not TransmogFrame then + Transmog_LoadUI() + end + if TransmogFrame then + ShowUIPanel(TransmogFrame) + end + return + end + + -- Outfit: PreClick places outfit on temp slot, SecureActionButton equips via UseAction. + -- Fallback opens wardrobe if no temp slot was found. + if data.outfitID then + local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() + if not tempSlot then + if not CollectionsJournal then + CollectionsJournal_LoadUI() + end + if CollectionsJournal then + ShowUIPanel(CollectionsJournal) + CollectionsJournal_SetTab(CollectionsJournal, 5) + end + end + return + end + -- Mount: summon/dismiss (secure macro handles cancelform on click) if data.mountID then if C_MountJournal and C_MountJournal.SummonByID then @@ -3602,6 +3705,16 @@ function UI:DirectOpen(data) for i = start, executeCount do local step = steps[i] + if step.loadTransmog then + if not TransmogFrame then + Transmog_LoadUI() + end + if TransmogFrame then + ShowUIPanel(TransmogFrame) + end + return + end + if step.buttonFrame then -- EncounterJournal: set selectedTab BEFORE opening so Blizzard's -- own init calls SetTab with our value (clean call stack, no taint) From e774b6ae0239616d0df39c87b50ffca8c6c16621 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 25 Mar 2026 15:16:06 -0400 Subject: [PATCH 06/44] Fix outfit equip overwriting user action bar slots Find an empty slot fresh each click instead of caching one at init. Skip class-restricted slot ranges (121-168). Verify placement succeeded before firing UseAction. PostClick always cleans up the slot so it recycles naturally. Falls back to opening TransmogFrame if no slot is available. --- .luacheckrc | 2 +- Database.lua | 32 +++++++++++++------------- UI.lua | 63 +++++++++++++++++++++++++++++++--------------------- 3 files changed, 54 insertions(+), 43 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 7a7f844..00d809c 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -58,7 +58,7 @@ read_globals = { "PVPQueueFrame", "AchievementFrameCategories_ExpandToCategory", "AchievementFrameCategories_UpdateDataProvider", - "CollectionsJournal_LoadUI", "CollectionsJournal_SetTab", "Transmog_LoadUI", + "Transmog_LoadUI", "EncounterJournal_LoadUI", "PanelTemplates_SetTab", "Menu", "ScrollBoxConstants", diff --git a/Database.lua b/Database.lua index afcfcec..1485462 100644 --- a/Database.lua +++ b/Database.lua @@ -435,11 +435,10 @@ local OUTFIT_PROTO = { } local OUTFIT_MT = { __index = OUTFIT_PROTO } --- Outfit click-to-equip uses a single temporary action bar slot. --- PreClick dynamically places the outfit on this slot, then the secure --- handler calls UseAction to equip it. The slot is found empty at init --- and only ever holds the last-clicked outfit. -local outfitTempSlot -- single reserved action slot (nil if none found) +-- Outfit click-to-equip uses a temporary action bar slot. +-- PreClick finds an empty slot, places the outfit, then the secure +-- handler calls UseAction to equip it. PostClick clears the slot. +-- The slot is re-discovered each click to avoid overwriting user actions. local outfitEntries = {} -- track injected entries for re-population function Database:PopulateDynamicMounts() @@ -568,16 +567,6 @@ function Database:PopulateDynamicOutfits() end wipe(outfitEntries) - -- Find a temp action slot (once). Scan from 180 down for an empty slot. - if not outfitTempSlot then - for slot = 180, 1, -1 do - if not HasAction(slot) then - outfitTempSlot = slot - break - end - end - end - local outfits = C_TransmogOutfitInfo.GetOutfitsInfo() if not outfits then return end @@ -595,8 +584,17 @@ function Database:PopulateDynamicOutfits() end end -function Database:GetOutfitTempSlot() - return outfitTempSlot +function Database:FindEmptyActionSlot() + -- Scan from high to low for an empty slot. + -- Skip 121-168: bonus/override/vehicle/stance bars that may reject + -- non-class-specific actions (e.g., totem slots for shamans, + -- stance slots for druids/warriors). + for slot = 180, 169, -1 do + if not HasAction(slot) then return slot end + end + for slot = 120, 1, -1 do + if not HasAction(slot) then return slot end + end end -- TREE FLATTENER diff --git a/UI.lua b/UI.lua index c827238..07d9ff1 100644 --- a/UI.lua +++ b/UI.lua @@ -1823,28 +1823,47 @@ function UI:CreateResultButton(index) resultRow.text = text resultRow:RegisterForClicks("LeftButtonDown", "RightButtonUp") - -- PreClick: dynamically place outfits on the temp action slot - -- so the secure handler's UseAction can equip them. + -- PreClick: find an empty action slot, place the outfit on it, + -- so the secure handler's UseAction can equip it. resultRow:SetScript("PreClick", function(self, mouseButton) if mouseButton ~= "LeftButton" then return end local outfitID = self.data and self.data.outfitID if not outfitID then return end - local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() - if not tempSlot then return end + local tempSlot = ns.Database and ns.Database:FindEmptyActionSlot() + if not tempSlot then + -- No empty slot: clear action attribute so UseAction doesn't fire + -- on a random slot. SelectResult will handle the fallback. + self._outfitSlot = nil + if not InCombatLockdown() then + self:SetAttribute("type", nil) + self:SetAttribute("action", nil) + end + return + end + self._outfitSlot = tempSlot + if not InCombatLockdown() then + self:SetAttribute("action", tempSlot) + end if C_TransmogOutfitInfo and C_TransmogOutfitInfo.PickupOutfit then C_TransmogOutfitInfo.PickupOutfit(outfitID) PlaceAction(tempSlot) ClearCursor() + -- Verify placement succeeded (some slots reject non-class actions) + if not HasAction(tempSlot) then + self._outfitSlot = nil + if not InCombatLockdown() then + self:SetAttribute("type", nil) + self:SetAttribute("action", nil) + end + end end end) resultRow:SetScript("PostClick", function(self, mouseButton, down) -- Clean up temp action slot after outfit equip - if self.data and self.data.outfitID then - local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() - if tempSlot then - PickupAction(tempSlot) - ClearCursor() - end + if self._outfitSlot then + PickupAction(self._outfitSlot) + ClearCursor() + self._outfitSlot = nil end -- Right-click: show pin/unpin popup if mouseButton == "RightButton" and self.data then @@ -2415,13 +2434,9 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow:SetAttribute("toy", nil) resultRow:SetAttribute("action", nil) elseif data and data.outfitID then - local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() - if tempSlot then - resultRow:SetAttribute("type", "action") - resultRow:SetAttribute("action", tempSlot) - else - resultRow:SetAttribute("type", nil) - end + -- type="action" is set here; PreClick sets the actual slot dynamically + resultRow:SetAttribute("type", "action") + resultRow:SetAttribute("action", 0) resultRow:SetAttribute("toy", nil) resultRow:SetAttribute("macrotext", nil) else @@ -3522,16 +3537,14 @@ function UI:SelectResult(data) end -- Outfit: PreClick places outfit on temp slot, SecureActionButton equips via UseAction. - -- Fallback opens wardrobe if no temp slot was found. + -- If no empty slot was available, open the TransmogFrame as fallback. if data.outfitID then - local tempSlot = ns.Database and ns.Database:GetOutfitTempSlot() - if not tempSlot then - if not CollectionsJournal then - CollectionsJournal_LoadUI() + if not ns.Database or not ns.Database:FindEmptyActionSlot() then + if not TransmogFrame then + Transmog_LoadUI() end - if CollectionsJournal then - ShowUIPanel(CollectionsJournal) - CollectionsJournal_SetTab(CollectionsJournal, 5) + if TransmogFrame then + ShowUIPanel(TransmogFrame) end end return From 2dae9d29f0e88db9e4c500934310eae3ad2e592b Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Wed, 25 Mar 2026 21:32:46 -0400 Subject: [PATCH 07/44] Add per-character collection pins, cooldown UX, pin sync Collection pins (mounts, toys, pets, outfits) are now stored per-character. UI pins remain account-wide. Clicking a toy or outfit on cooldown keeps results open instead of closing them. Pinned outfit names and icons sync when outfits are renamed or deleted mid-session. --- .luacheckrc | 6 +- Core.lua | 6 +- UI.lua | 197 ++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 185 insertions(+), 24 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 00d809c..14a594a 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -30,7 +30,7 @@ read_globals = { -- Core API "CreateFrame", "CreateFont", "CreateVector2D", - "GetTime", "GetLocale", "GetCVar", "GetCursorPosition", + "GetTime", "GetLocale", "GetCVar", "GetCursorPosition", "GetRealmName", "GetAddOnMetadata", "GetAtlasInfo", "GetMinimapShape", "GetBindingKey", "GetCurrentBindingSet", "SaveBindings", "SetBinding", "GetCategoryInfo", "GetPlayerFacing", @@ -73,8 +73,8 @@ read_globals = { "UIFrameFadeIn", "UIFrameFadeOut", "UIFrameFadeRemoveFrame", "AchievementFrameCategories_SelectElementData", "UnitPopup_ShowMenu", "BattlePetToolTip_ShowLink", - "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", - "HasAction", "PlaceAction", "PickupAction", "ClearCursor", + "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", "UnitName", + "HasAction", "PlaceAction", "PickupAction", "ClearCursor", "GetActionCooldown", -- Data types "UiMapPoint", diff --git a/Core.lua b/Core.lua index 1f3df64..39066fb 100644 --- a/Core.lua +++ b/Core.lua @@ -64,7 +64,8 @@ local DB_DEFAULTS = { showTruncationMessage = true, -- Show "more results available" message when truncated hardResultsCap = false, -- Hard cap on results (no "more results" message) staticOpacity = false, -- Keep opacity constant while moving - pinnedUIItems = {}, -- Pinned UI search results (persist across sessions) + pinnedUIItems = {}, -- Pinned UI search results (persist across sessions, account-wide) + pinnedUIItemsPerChar = {}, -- Character-specific pins (mounts, toys, pets, outfits) keyed by "Name-Realm" pinnedMapItems = {}, -- Pinned map search results (persist across sessions) pinsCollapsed = false, -- Whether the "Pinned Paths" header is collapsed mapPinsCollapsed = false, -- Whether the map search "Pinned" header is collapsed @@ -398,6 +399,9 @@ eventFrame:SetScript("OnEvent", function(self, event, arg1, arg2) if ns.Database and ns.Database.PopulateDynamicOutfits then ns.Database:PopulateDynamicOutfits() end + if ns.UI and ns.UI.SyncOutfitPins then + ns.UI:SyncOutfitPins() + end end) elseif event == "PLAYER_LOGOUT" then -- Strip runtime-only fields before SavedVariables serialization diff --git a/UI.lua b/UI.lua index 07d9ff1..6506927 100644 --- a/UI.lua +++ b/UI.lua @@ -36,6 +36,8 @@ local MAX_BUTTON_POOL = 50 -- Maximum buttons (scroll handles overflow beyond t local inCombat = false local selectingResult = false -- guard: suppress OnTextChanged re-renders during SelectResult local deferredRepRefreshPending = false -- deferred re-render to let IsTruncated() settle +local outfitCdStart, outfitCdDuration = 0, 0 -- shared outfit swap cooldown +local lastEquippedOutfitID -- tracks most recent equip for immediate green tint -- PIN HELPERS @@ -78,11 +80,62 @@ local function CleanUIForStorage(data) return clean end +-- Collection-type pins (mounts, toys, pets, outfits) are character-specific. +-- All other pins are account-wide. +local function IsCollectionPin(data) + return data and (data.mountID or data.toyItemID or data.petID or data.outfitID) +end + +local charKey -- "Name-Realm", set on first use +local function GetCharKey() + if not charKey then + local name = UnitName("player") + local realm = GetRealmName() + charKey = name and realm and (name .. "-" .. realm) or "Unknown" + end + return charKey +end + +local function GetPinList(data) + if IsCollectionPin(data) then + local key = GetCharKey() + local perChar = EasyFind.db.pinnedUIItemsPerChar + if not perChar[key] then perChar[key] = {} end + return perChar[key] + end + return EasyFind.db.pinnedUIItems +end + +local function GetAllPins() + local all = {} + for _, pin in ipairs(EasyFind.db.pinnedUIItems) do + all[#all + 1] = pin + end + local key = GetCharKey() + local charPins = EasyFind.db.pinnedUIItemsPerChar and EasyFind.db.pinnedUIItemsPerChar[key] + if charPins then + for _, pin in ipairs(charPins) do + all[#all + 1] = pin + end + end + return all +end + local function IsUIItemPinned(data) local key = GetUIPinKey(data) + -- Check both lists for collection pins (may exist in either due to migration) for _, pin in ipairs(EasyFind.db.pinnedUIItems) do if GetUIPinKey(pin) == key then return true end end + if IsCollectionPin(data) then + local charKey = GetCharKey() + local charPins = EasyFind.db.pinnedUIItemsPerChar and EasyFind.db.pinnedUIItemsPerChar[charKey] + if charPins then + for _, pin in ipairs(charPins) do + if GetUIPinKey(pin) == key then return true end + end + end + end return false end @@ -90,11 +143,12 @@ local function PinUIItem(data) if IsUIItemPinned(data) then return end local clean = CleanUIForStorage(data) clean.isPinned = true - tinsert(EasyFind.db.pinnedUIItems, clean) + tinsert(GetPinList(data), clean) end local function UnpinUIItem(data) local key = GetUIPinKey(data) + -- Remove from whichever list contains it local items = EasyFind.db.pinnedUIItems for i = #items, 1, -1 do if GetUIPinKey(items[i]) == key then @@ -102,6 +156,56 @@ local function UnpinUIItem(data) return end end + if IsCollectionPin(data) then + local ck = GetCharKey() + local charPins = EasyFind.db.pinnedUIItemsPerChar and EasyFind.db.pinnedUIItemsPerChar[ck] + if charPins then + for i = #charPins, 1, -1 do + if GetUIPinKey(charPins[i]) == key then + tremove(charPins, i) + return + end + end + end + end +end + +-- Sync pinned outfit names/icons with current outfit data. +-- Called when TRANSMOG_OUTFITS_CHANGED fires (outfits renamed/deleted). +function UI:SyncOutfitPins() + if not C_TransmogOutfitInfo or not C_TransmogOutfitInfo.GetOutfitsInfo then return end + local outfits = C_TransmogOutfitInfo.GetOutfitsInfo() + if not outfits then return end + + -- Build lookup: outfitID -> { name, icon } + local lookup = {} + for _, info in ipairs(outfits) do + lookup[info.outfitID] = info + end + + -- Update both pin lists + local function syncList(pins) + if not pins then return end + for i = #pins, 1, -1 do + local pin = pins[i] + if pin.outfitID then + local info = lookup[pin.outfitID] + if info then + pin.name = info.name + pin.nameLower = info.name:lower() + pin.icon = info.icon + else + -- Outfit was deleted, remove pin + tremove(pins, i) + end + end + end + end + + syncList(EasyFind.db.pinnedUIItems) + local ck = GetCharKey() + local charPins = EasyFind.db.pinnedUIItemsPerChar and EasyFind.db.pinnedUIItemsPerChar[ck] + syncList(charPins) end -- Simple pin context popup (BOTTOMLEFT anchored at cursor so it opens above) @@ -1829,6 +1933,10 @@ function UI:CreateResultButton(index) if mouseButton ~= "LeftButton" then return end local outfitID = self.data and self.data.outfitID if not outfitID then return end + -- Block if outfit swap is on cooldown + if outfitCdStart > 0 and outfitCdDuration - (GetTime() - outfitCdStart) > 0 then + return + end local tempSlot = ns.Database and ns.Database:FindEmptyActionSlot() if not tempSlot then -- No empty slot: clear action attribute so UseAction doesn't fire @@ -1841,6 +1949,7 @@ function UI:CreateResultButton(index) return end self._outfitSlot = tempSlot + self._outfitID = outfitID if not InCombatLockdown() then self:SetAttribute("action", tempSlot) end @@ -1851,6 +1960,7 @@ function UI:CreateResultButton(index) -- Verify placement succeeded (some slots reject non-class actions) if not HasAction(tempSlot) then self._outfitSlot = nil + self._outfitID = nil if not InCombatLockdown() then self:SetAttribute("type", nil) self:SetAttribute("action", nil) @@ -1859,11 +1969,47 @@ function UI:CreateResultButton(index) end end) resultRow:SetScript("PostClick", function(self, mouseButton, down) - -- Clean up temp action slot after outfit equip + -- Block result selection if toy or outfit is on cooldown (keep results open). + -- Must run before the outfit cleanup block which sets the cooldown timestamp. + if self.data and mouseButton == "LeftButton" then + local onCooldown = false + if self.data.toyItemID and GetItemCooldown then + local cdStart, cdDur = GetItemCooldown(self.data.toyItemID) + if cdStart and cdDur and cdDur > 0 then onCooldown = true end + end + if self.data.outfitID and outfitCdStart > 0 then + if outfitCdDuration - (GetTime() - outfitCdStart) > 0 then onCooldown = true end + end + if onCooldown then + if searchFrame and searchFrame.editBox then + searchFrame.editBox:SetFocus() + end + return + end + end + + -- Clean up temp action slot after outfit equip. if self._outfitSlot then - PickupAction(self._outfitSlot) - ClearCursor() + local slot = self._outfitSlot self._outfitSlot = nil + -- Record equip immediately so green tint and cooldown + -- are correct when results re-render (API lags behind). + if self._outfitID then + lastEquippedOutfitID = self._outfitID + outfitCdStart = GetTime() + outfitCdDuration = 4 + self._outfitID = nil + end + -- Delay slot cleanup one frame so UseAction fully completes + C_Timer.After(0, function() + -- Read actual cooldown duration if available + local start, dur = GetActionCooldown(slot) + if start and dur and dur > 0 then + outfitCdStart, outfitCdDuration = start, dur + end + PickupAction(slot) + ClearCursor() + end) end -- Right-click: show pin/unpin popup if mouseButton == "RightButton" and self.data then @@ -1994,13 +2140,13 @@ function UI:CreateResultButton(index) elseif self.icon.outfitID then GameTooltip:SetOwner(self, "ANCHOR_RIGHT") GameTooltip:SetText(self.data and self.data.name or "Outfit") - if C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID then - local activeID = C_TransmogOutfitInfo.GetActiveOutfitID() - if activeID and activeID == self.icon.outfitID then - GameTooltip:AddLine("Currently equipped", 0.3, 1, 0.3) - else - GameTooltip:AddLine("Click to equip", 1, 0.82, 0) - end + local activeID = lastEquippedOutfitID + or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID + and C_TransmogOutfitInfo.GetActiveOutfitID()) + if activeID and activeID == self.icon.outfitID then + GameTooltip:AddLine("Currently equipped", 0.3, 1, 0.3) + else + GameTooltip:AddLine("Click to equip", 1, 0.82, 0) end GameTooltip:Show() end @@ -2165,8 +2311,8 @@ function UI:OnSearchTextChanged(text) end -- Prepend pinned items at the top (always visible regardless of query) - local pins = EasyFind.db.pinnedUIItems - if pins and #pins > 0 then + local pins = GetAllPins() + if #pins > 0 then local pinnedEntries = { -- "Pinned Paths" collapsible header { @@ -2779,8 +2925,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) if data.mountID and InCombatLockdown() then resultRow.icon:SetVertexColor(1, 0.3, 0.3, 1) -- Green tint on currently equipped outfit - elseif data.outfitID and C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID then - local activeID = C_TransmogOutfitInfo.GetActiveOutfitID() + elseif data.outfitID then + local activeID = lastEquippedOutfitID + or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID + and C_TransmogOutfitInfo.GetActiveOutfitID()) if activeID and activeID == data.outfitID then resultRow.icon:SetVertexColor(0.3, 1, 0.3, 1) else @@ -2793,7 +2941,7 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) SetRowIcon(resultRow, "hidden", nil, theme.iconSize) end - -- Toy cooldown sweep overlay + -- Cooldown sweep overlay (toys and outfits) resultRow.amountText:Hide() if data.toyItemID and iconFileID and GetItemCooldown then local startTime, duration = GetItemCooldown(data.toyItemID) @@ -2804,6 +2952,15 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) else resultRow.iconCooldown:Hide() end + elseif data.outfitID and outfitCdStart > 0 then + local remaining = outfitCdDuration - (GetTime() - outfitCdStart) + if remaining > 0 then + resultRow.iconCooldown:SetAllPoints(resultRow.icon) + resultRow.iconCooldown:SetCooldown(outfitCdStart, outfitCdDuration) + resultRow.iconCooldown:Show() + else + resultRow.iconCooldown:Hide() + end else resultRow.iconCooldown:Hide() end @@ -3275,8 +3432,8 @@ end function UI:ShowPinnedItems() if not resultsFrame then return end - local pins = EasyFind.db.pinnedUIItems - if not pins or #pins == 0 then + local pins = GetAllPins() + if #pins == 0 then self:HideResults() return end @@ -3516,6 +3673,8 @@ function UI:ActivateSelected() end function UI:SelectResult(data) + if not data then return end + selectingResult = true searchFrame.editBox:SetText("") searchFrame.editBox:ClearFocus() @@ -3523,8 +3682,6 @@ function UI:SelectResult(data) selectingResult = false self:HideResults() - if not data then return end - -- Transmogrification panel: load and show TransmogFrame if data.steps and data.steps[1] and data.steps[1].loadTransmog then if not TransmogFrame then From 843cb69fb14308546f70cb0104e47d1c99c48ef6 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 26 Mar 2026 15:30:43 -0400 Subject: [PATCH 08/44] Add keyboard Enter support for outfit and toy equip Override-bind Enter to the selected result button when it is an outfit or toy, then propagate the keypress so WoW's secure input system clicks the button directly. This gives keyboard navigation the same secure equip path as mouse clicks. --- UI.lua | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/UI.lua b/UI.lua index 6506927..b707aa4 100644 --- a/UI.lua +++ b/UI.lua @@ -1011,6 +1011,16 @@ function UI:CreateSearchFrame() end navFrame:SetScript("OnKeyDown", function(self, key) + -- Outfits/toys: let Enter propagate to the override binding + -- so the secure action handler fires (same as mouse click). + if key == "ENTER" and selectedIndex > 0 and not InCombatLockdown() then + local selRow = resultButtons[selectedIndex] + local rd = selRow and selRow.data + if rd and (rd.outfitID or rd.toyItemID) then + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + return + end + end HandleNavKeyDown(key) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) end) @@ -3616,6 +3626,21 @@ function UI:UpdateSelectionHighlight(skipRefocus) searchFrame.editBox:SetFocus() end end + + -- Bind Enter to the selected result button when it's an outfit or toy, + -- so the secure action handler fires on keypress (same as mouse click). + if not InCombatLockdown() then + local selRow = selectedIndex > 0 and resultButtons[selectedIndex] + local rd = selRow and selRow.data + if rd and (rd.outfitID or rd.toyItemID) then + local btnName = selRow:GetName() + if btnName then + SetOverrideBindingClick(navFrame, true, "ENTER", btnName, "LeftButton") + end + else + ClearOverrideBindings(navFrame) + end + end end function UI:ActivateSelected() @@ -3693,19 +3718,8 @@ function UI:SelectResult(data) return end - -- Outfit: PreClick places outfit on temp slot, SecureActionButton equips via UseAction. - -- If no empty slot was available, open the TransmogFrame as fallback. - if data.outfitID then - if not ns.Database or not ns.Database:FindEmptyActionSlot() then - if not TransmogFrame then - Transmog_LoadUI() - end - if TransmogFrame then - ShowUIPanel(TransmogFrame) - end - end - return - end + -- Outfit: equip handled by SecureActionButton (mouse click or Enter binding). + if data.outfitID then return end -- Mount: summon/dismiss (secure macro handles cancelform on click) if data.mountID then From 1821390bd444cca819bbe4e04a7a9e36f64a0693 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 26 Mar 2026 15:33:32 -0400 Subject: [PATCH 09/44] Keep keyboard focus on result when outfit/toy is on cooldown --- UI.lua | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/UI.lua b/UI.lua index b707aa4..f74acb7 100644 --- a/UI.lua +++ b/UI.lua @@ -1990,12 +1990,7 @@ function UI:CreateResultButton(index) if self.data.outfitID and outfitCdStart > 0 then if outfitCdDuration - (GetTime() - outfitCdStart) > 0 then onCooldown = true end end - if onCooldown then - if searchFrame and searchFrame.editBox then - searchFrame.editBox:SetFocus() - end - return - end + if onCooldown then return end end -- Clean up temp action slot after outfit equip. From 2c6e7f7c74033d0c4dd9f0ea653648100c778725 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Thu, 26 Mar 2026 15:35:28 -0400 Subject: [PATCH 10/44] Fix mouse click on cooldown not keeping results open --- UI.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/UI.lua b/UI.lua index f74acb7..175d788 100644 --- a/UI.lua +++ b/UI.lua @@ -1990,7 +1990,14 @@ function UI:CreateResultButton(index) if self.data.outfitID and outfitCdStart > 0 then if outfitCdDuration - (GetTime() - outfitCdStart) > 0 then onCooldown = true end end - if onCooldown then return end + if onCooldown then + -- Mouse click: refocus editbox so OnEditFocusLost doesn't hide results. + -- Keyboard (via override binding): navFrame has keyboard, don't steal focus. + if searchFrame and searchFrame.editBox and not navFrame:IsKeyboardEnabled() then + searchFrame.editBox:SetFocus() + end + return + end end -- Clean up temp action slot after outfit equip. From d5d5b99ce54391af77312ffd848f4aa2620194ef Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 27 Mar 2026 12:15:30 -0400 Subject: [PATCH 11/44] Fix pin separator and reduce focus block to two frames Show gray separator between all consecutive pinned items using bottom-anchored logic. Reduce post-creation focus block from 1 second to 2 frames so the search bar is clickable almost immediately after login/reload. --- Core.lua | 2 +- UI.lua | 32 ++++++++++++++++++-------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Core.lua b/Core.lua index 39066fb..64f5312 100644 --- a/Core.lua +++ b/Core.lua @@ -308,7 +308,7 @@ end local SafeAfter = Utils.SafeAfter local function OnPlayerLogin() - SafeAfter(0.5, function() + SafeAfter(0, function() local function SafeInit(mod, name) if not mod then return end local ok, err = xpcall(mod.Initialize, ErrorHandler, mod) diff --git a/UI.lua b/UI.lua index 175d788..51fddb5 100644 --- a/UI.lua +++ b/UI.lua @@ -463,16 +463,17 @@ function UI:Initialize() self:UpdateWidth() self:UpdateFontSize() - -- Block focus during init window - prevents stealing keyboard input on login/reload. - -- Something (possibly the WoW client) focuses visible EditBoxes after creation; - -- blockFocus rejects it in OnEditFocusGained regardless of timing. + -- Block auto-focus on creation - WoW may focus visible EditBoxes after creation. + -- Block for two frames (enough for WoW's auto-focus to fire and get rejected). searchFrame.editBox.blockFocus = true searchFrame.editBox:ClearFocus() - C_Timer.After(1, function() - if searchFrame and searchFrame.editBox then - searchFrame.editBox.blockFocus = nil - searchFrame.editBox:ClearFocus() - end + C_Timer.After(0, function() + C_Timer.After(0, function() + if searchFrame and searchFrame.editBox then + searchFrame.editBox.blockFocus = nil + searchFrame.editBox:ClearFocus() + end + end) end) -- First-time setup overlay for new installs @@ -2722,10 +2723,19 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end -- Separator line between rows (skip for pin header which has its own underline) + -- Separator is anchored at BOTTOM of the row (line below this row). if not entry.isPinHeader and theme.showSeparators then local sc = theme.separatorColor resultRow.separator:SetColorTexture(sc[1], sc[2], sc[3], sc[4]) resultRow.separator:Show() + elseif entry.isPinned and not entry.isPinHeader then + local nextEntry = visible[i + 1] + if nextEntry and nextEntry.isPinned and not nextEntry.isPinHeader then + resultRow.separator:SetColorTexture(0.4, 0.4, 0.4, 0.4) + resultRow.separator:Show() + else + resultRow.separator:Hide() + end else resultRow.separator:Hide() end @@ -3247,12 +3257,6 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local prefix = tconcat(data.path, " > ") resultRow.text:SetText("|cff888888" .. prefix .. " >|r " .. (data.name or "")) end - -- Gray separator between pinned items (skip after header, skip last pin) - local isLastPin = (i == lastPinIndex) - if i > 1 and not isLastPin and visible[i - 1] and not visible[i - 1].isPinHeader then - resultRow.separator:SetColorTexture(0.4, 0.4, 0.4, 0.4) - resultRow.separator:Show() - end end -- Measure text height and expand row if text wraps From da41472d8385e36a60d73772d43cd74dea7ab376 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 27 Mar 2026 16:28:42 -0400 Subject: [PATCH 12/44] [worktree-rename] Rename addon to EasyFind-loot-search Original addon name: EasyFind --- ...Find-tmog-and-toggle-fix.toc => EasyFind-loot-search.toc | 2 +- MapSearch.lua | 6 +++--- UI.lua | 2 +- Utils.lua | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename EasyFind-tmog-and-toggle-fix.toc => EasyFind-loot-search.toc (90%) diff --git a/EasyFind-tmog-and-toggle-fix.toc b/EasyFind-loot-search.toc similarity index 90% rename from EasyFind-tmog-and-toggle-fix.toc rename to EasyFind-loot-search.toc index a28c9dd..537736c 100644 --- a/EasyFind-tmog-and-toggle-fix.toc +++ b/EasyFind-loot-search.toc @@ -1,5 +1,5 @@ ## Interface: 120005, 120001, 120000 -## Title: EasyFind (tmog-and-toggle-fix) +## Title: EasyFind (loot-search) ## Notes: Search and locate UI elements and map locations with ease ## Author: justawower ## Version: 1.4.0 diff --git a/MapSearch.lua b/MapSearch.lua index d0a3d78..f716b44 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -12,7 +12,7 @@ local mmin, mmax, mpi = Utils.mmin, Utils.mmax, Utils.mpi local pcall, xpcall, tostring = Utils.pcall, Utils.xpcall, Utils.tostring local ErrorHandler = Utils.ErrorHandler -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\textures\\lightning-bolt" +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-loot-search\\textures\\lightning-bolt" local GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT local DEFAULT_OPACITY = ns.DEFAULT_OPACITY @@ -100,7 +100,7 @@ local INDICATOR_STYLES = { preRotated = false, -- Needs mpi rotation to point down }, ["EasyFind Arrow"] = { - texture = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Images\\arrow-hq", + texture = "Interface\\AddOns\\EasyFind-loot-search\\Images\\arrow-hq", texCoord = nil, preRotated = true, -- Already points down, no rotation needed }, @@ -479,7 +479,7 @@ local function CreateWaypointTracker() local layer = nearTrackFrame:CreateTexture(nil, "OVERLAY", nil, i) layer:SetSize(ringSize, ringSize) layer:SetPoint("CENTER", Minimap, "CENTER", 0, 0) - layer:SetTexture("Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\textures\\near-track-ring") + layer:SetTexture("Interface\\AddOns\\EasyFind-loot-search\\textures\\near-track-ring") layer:SetBlendMode("ADD") layer:SetVertexColor(1, 1, 0.3, 1) ringLayers[i] = layer diff --git a/UI.lua b/UI.lua index 51fddb5..5ef169e 100644 --- a/UI.lua +++ b/UI.lua @@ -26,7 +26,7 @@ local IsShiftKeyDown = IsShiftKeyDown local GetCursorPosition = GetCursorPosition local wipe = wipe -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\textures\\lightning-bolt" +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-loot-search\\textures\\lightning-bolt" local REP_BAR_WIDTH = 100 local searchFrame diff --git a/Utils.lua b/Utils.lua index 80cdccd..0e3b638 100644 --- a/Utils.lua +++ b/Utils.lua @@ -126,9 +126,9 @@ ns.SEARCHBAR_FONT = "EasyFindSearchFont" -- Custom 3-part search bar border with chamfered corners. -- Fill (BACKGROUND) and border (ARTWORK) use identical shapes from custom TGA textures. -- Fill is tinted black with tunable opacity; border uses Blizzard's action bar gray. -local SEARCH_TEX_FILL = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Textures\\SearchBarFill" -local SEARCH_TEX_BORDER = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Textures\\SearchBarBorder" -local CLEAR_BTN_TEX = "Interface\\AddOns\\EasyFind-tmog-and-toggle-fix\\Textures\\clear-button" +local SEARCH_TEX_FILL = "Interface\\AddOns\\EasyFind-loot-search\\Textures\\SearchBarFill" +local SEARCH_TEX_BORDER = "Interface\\AddOns\\EasyFind-loot-search\\Textures\\SearchBarBorder" +local CLEAR_BTN_TEX = "Interface\\AddOns\\EasyFind-loot-search\\Textures\\clear-button" local SEARCH_CAP_W = 8 local SEARCH_TEX_W = 64 local TC_LEFT = {0, SEARCH_CAP_W / SEARCH_TEX_W, 0, 1} From b30894e19aa859cadc2115ee5823e29489904ec9 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 27 Mar 2026 18:56:57 -0400 Subject: [PATCH 13/44] Add loot search with EJ scan and hierarchical results Scans current-tier dungeons and raids via Encounter Journal API and injects items as searchable entries. Results grouped by slot then instance. Click opens EJ to boss, Shift+click opens dressing room, hover shows item tooltip. Loot filter in dropdown (default off). Includes stat keyword enrichment, slot/source keyword generation, and EJ state save/restore. --- .luacheckrc | 6 ++ Core.lua | 6 +- Database.lua | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++- Options.lua | 4 +- UI.lua | 125 ++++++++++++++++++++++++-- 5 files changed, 378 insertions(+), 10 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 14a594a..2850ff6 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -74,6 +74,12 @@ read_globals = { "AchievementFrameCategories_SelectElementData", "UnitPopup_ShowMenu", "BattlePetToolTip_ShowLink", "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", "UnitName", + "GetItemInfoInstant", "GetItemStats", "GetSpecialization", "GetSpecializationInfo", + "UnitClass", "GetLootSpecialization", "DressUpItemLink", + "EJ_GetCurrentTier", "EJ_SelectTier", "EJ_GetInstanceByIndex", + "EJ_SelectInstance", "EJ_GetEncounterInfoByIndex", "EJ_SelectEncounter", + "EJ_SetDifficulty", "EJ_SetLootFilter", "EJ_SetSlotFilter", + "EJ_GetNumLoot", "EJ_GetLootInfoByIndex", "HasAction", "PlaceAction", "PickupAction", "ClearCursor", "GetActionCooldown", -- Data types diff --git a/Core.lua b/Core.lua index 64f5312..cef892f 100644 --- a/Core.lua +++ b/Core.lua @@ -105,8 +105,10 @@ local DB_DEFAULTS = { toys = false, pets = false, outfits = false, + loot = false, map = false, }, + lootAllSpecs = false, -- Loot search: false = current spec, true = all specs uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global } @@ -341,7 +343,9 @@ local function OnPlayerLogin() ns.Database:PopulateDynamicPets() SafeAfter(0, function() ns.Database:PopulateDynamicOutfits() - collectgarbage("collect") + SafeAfter(0, function() + ns.Database:PopulateDynamicLoot() + end) end) end) end) diff --git a/Database.lua b/Database.lua index 1485462..cd69586 100644 --- a/Database.lua +++ b/Database.lua @@ -435,6 +435,96 @@ local OUTFIT_PROTO = { } local OUTFIT_MT = { __index = OUTFIT_PROTO } +local LOOT_PROTO = { + category = "Loot", + path = {}, + steps = {}, +} +local LOOT_MT = { __index = LOOT_PROTO } + +-- Map equip location strings to user-friendly search keywords +local SLOT_KEYWORDS = { + INVTYPE_HEAD = {"helm", "helmet", "head"}, + INVTYPE_NECK = {"neck", "necklace", "amulet"}, + INVTYPE_SHOULDER = {"shoulder", "shoulders", "pauldrons"}, + INVTYPE_CHEST = {"chest", "chestpiece"}, + INVTYPE_ROBE = {"chest", "robe", "chestpiece"}, + INVTYPE_WAIST = {"waist", "belt"}, + INVTYPE_LEGS = {"legs", "leggings", "pants"}, + INVTYPE_FEET = {"feet", "boots"}, + INVTYPE_WRIST = {"wrist", "bracers"}, + INVTYPE_HAND = {"hands", "gloves", "gauntlets"}, + INVTYPE_FINGER = {"ring", "finger"}, + INVTYPE_TRINKET = {"trinket"}, + INVTYPE_CLOAK = {"cloak", "back", "cape"}, + INVTYPE_WEAPON = {"weapon", "one hand"}, + INVTYPE_WEAPONMAINHAND = {"weapon", "main hand"}, + INVTYPE_WEAPONOFFHAND = {"weapon", "off hand", "offhand"}, + INVTYPE_2HWEAPON = {"weapon", "two hand", "2h"}, + INVTYPE_SHIELD = {"shield", "off hand"}, + INVTYPE_RANGED = {"ranged", "weapon"}, + INVTYPE_RANGEDRIGHT = {"ranged", "weapon", "wand"}, + INVTYPE_HOLDABLE = {"off hand", "held"}, +} + +-- Map item stat keys to search keywords +local STAT_KEYWORD_MAP = { + ITEM_MOD_CRIT_RATING_SHORT = {"crit", "critical strike"}, + ITEM_MOD_HASTE_RATING_SHORT = {"haste"}, + ITEM_MOD_MASTERY_RATING_SHORT = {"mastery"}, + ITEM_MOD_VERSATILITY = {"vers", "versatility"}, + ITEM_MOD_INTELLECT_SHORT = {"int", "intellect"}, + ITEM_MOD_AGILITY_SHORT = {"agi", "agility"}, + ITEM_MOD_STRENGTH_SHORT = {"str", "strength"}, +} + +-- Slot display names for result text +local SLOT_DISPLAY = { + INVTYPE_HEAD = "Head", INVTYPE_NECK = "Neck", INVTYPE_SHOULDER = "Shoulder", + INVTYPE_CHEST = "Chest", INVTYPE_ROBE = "Chest", INVTYPE_WAIST = "Waist", + INVTYPE_LEGS = "Legs", INVTYPE_FEET = "Feet", INVTYPE_WRIST = "Wrist", + INVTYPE_HAND = "Hands", INVTYPE_FINGER = "Ring", INVTYPE_TRINKET = "Trinket", + INVTYPE_CLOAK = "Cloak", INVTYPE_WEAPON = "Weapon", INVTYPE_WEAPONMAINHAND = "Main Hand", + INVTYPE_WEAPONOFFHAND = "Off Hand", INVTYPE_2HWEAPON = "Two-Hand", + INVTYPE_SHIELD = "Shield", INVTYPE_RANGED = "Ranged", INVTYPE_RANGEDRIGHT = "Ranged", + INVTYPE_HOLDABLE = "Off Hand", +} + +-- EJ API compatibility layer (functions migrated from EJ_* to C_EncounterJournal.* in some patches) +local EJ_GetCurrentTier = EJ_GetCurrentTier or (C_EncounterJournal and C_EncounterJournal.GetCurrentTier) +local EJ_SelectTier = EJ_SelectTier or (C_EncounterJournal and C_EncounterJournal.SelectTier) +local EJ_GetInstanceByIndex = EJ_GetInstanceByIndex or (C_EncounterJournal and C_EncounterJournal.GetInstanceByIndex) +local EJ_SelectInstance = EJ_SelectInstance or (C_EncounterJournal and C_EncounterJournal.SelectInstance) +local EJ_GetEncounterInfoByIndex = EJ_GetEncounterInfoByIndex or (C_EncounterJournal and C_EncounterJournal.GetEncounterInfoByIndex) +local EJ_SelectEncounter = EJ_SelectEncounter or (C_EncounterJournal and C_EncounterJournal.SelectEncounter) +local EJ_SetDifficulty = EJ_SetDifficulty or (C_EncounterJournal and C_EncounterJournal.SetDifficulty) +local EJ_SetLootFilter = EJ_SetLootFilter or (C_EncounterJournal and C_EncounterJournal.SetLootFilter) +local EJ_SetSlotFilter = EJ_SetSlotFilter or (C_EncounterJournal and C_EncounterJournal.SetSlotFilter) +local EJ_GetNumLoot = EJ_GetNumLoot or (C_EncounterJournal and C_EncounterJournal.GetNumLoot) +local EJ_GetLootInfoByIndex = EJ_GetLootInfoByIndex or (C_EncounterJournal and C_EncounterJournal.GetLootInfoByIndex) + +local lootEntries = {} -- track injected entries for re-population +local lootScanGeneration = 0 -- cancel stale scans when re-populating + +-- Enrich a loot entry with stat keywords from its item link. +-- Called lazily when the entry first appears in results. +function Database:EnrichLootStats(entry) + if entry._statsEnriched or not entry.lootItemLink then return end + local GetItemStats = GetItemStats or (C_Item and C_Item.GetItemStats) + if not GetItemStats then return end + local stats = GetItemStats(entry.lootItemLink) + if not stats then return end + for statKey, searchWords in pairs(STAT_KEYWORD_MAP) do + if stats[statKey] then + for _, word in ipairs(searchWords) do + entry.keywords[#entry.keywords + 1] = word + entry.keywordsLower[#entry.keywordsLower + 1] = word + end + end + end + entry._statsEnriched = true +end + -- Outfit click-to-equip uses a temporary action bar slot. -- PreClick finds an empty slot, places the outfit, then the secure -- handler calls UseAction to equip it. PostClick clears the slot. @@ -597,6 +687,160 @@ function Database:FindEmptyActionSlot() end end +-- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot +-- and injects searchable entries. Staggered across frames (one instance per frame). +function Database:PopulateDynamicLoot() + if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetNumLoot then return end + if InCombatLockdown() then return end + + -- Remove previous loot entries (handles spec toggle re-scan) + for i = #uiSearchData, 1, -1 do + if uiSearchData[i].category == "Loot" then + tremove(uiSearchData, i) + end + end + wipe(lootEntries) + + -- Bump generation so any in-flight staggered scan aborts + lootScanGeneration = lootScanGeneration + 1 + local myGen = lootScanGeneration + + -- Suppress EJ UI events during scan + local ejFrame = _G["EncounterJournal"] + local savedOnEvent + if ejFrame then + savedOnEvent = ejFrame:GetScript("OnEvent") + ejFrame:SetScript("OnEvent", nil) + end + + -- Save EJ state + local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() + + -- Set class/spec filter + local _, _, classID = UnitClass("player") + local specIndex = GetSpecialization and GetSpecialization() + local specID = specIndex and GetSpecializationInfo and GetSpecializationInfo(specIndex) + if classID and specID then + EJ_SetLootFilter(classID, EasyFind.db.lootAllSpecs and 0 or specID) + end + if EJ_SetSlotFilter then + EJ_SetSlotFilter(0) -- Enum.ItemSlotFilterType.NoFilter = 0 + end + + -- Collect all instances in the current tier + local instances = {} + local function collectInstances(isRaid) + local idx = 1 + while true do + local instID, instName = EJ_GetInstanceByIndex(idx, isRaid) + if not instID then break end + instances[#instances + 1] = { id = instID, name = instName, isRaid = isRaid } + idx = idx + 1 + end + end + collectInstances(false) -- dungeons + collectInstances(true) -- raids + + local seen = {} -- deduplicate by itemID + local SafeAfter = Utils.SafeAfter + local GetItemInfoInstant = GetItemInfoInstant + + local function restoreState() + if savedTier and EJ_SelectTier then EJ_SelectTier(savedTier) end + if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end + end + + local function processInstance(idx) + if myGen ~= lootScanGeneration then return end + if idx > #instances then + restoreState() + collectgarbage("collect") + return + end + + local inst = instances[idx] + EJ_SelectInstance(inst.id) + + -- Set a reasonable difficulty so loot tables are populated + if EJ_SetDifficulty then + if inst.isRaid then + EJ_SetDifficulty(14) -- Normal raid + else + EJ_SetDifficulty(23) -- Mythic dungeon + end + end + + -- Iterate encounters in this instance + local encIdx = 1 + while true do + local encName, _, encID = EJ_GetEncounterInfoByIndex(encIdx) + if not encName then break end + EJ_SelectEncounter(encID) + + local numLoot = EJ_GetNumLoot and EJ_GetNumLoot() or 0 + for li = 1, numLoot do + local lootInfo = EJ_GetLootInfoByIndex and EJ_GetLootInfoByIndex(li) + local itemID = lootInfo and lootInfo.itemID + if itemID and not seen[itemID] then + seen[itemID] = true + local itemName = lootInfo.name + local _, _, _, equipLoc, instIcon = GetItemInfoInstant(itemID) + local icon = lootInfo.icon or instIcon + if itemName and itemName ~= "" then + -- Build keywords + local kw = {"loot", "drop", "gear", "equipment"} + -- Slot keywords + local slotKws = equipLoc and SLOT_KEYWORDS[equipLoc] + if slotKws then + for _, w in ipairs(slotKws) do kw[#kw + 1] = w end + end + -- Source keywords (boss name words, instance name words) + if encName then + for w in encName:lower():gmatch("%a+") do kw[#kw + 1] = w end + end + if inst.name then + for w in inst.name:lower():gmatch("%a+") do kw[#kw + 1] = w end + end + -- Lower copy for search scoring + local kwLower = {} + for ki = 1, #kw do kwLower[ki] = kw[ki] end + + local itemLink = lootInfo.link + local entry = setmetatable({ + name = itemName, + nameLower = slower(itemName), + icon = icon, + itemID = itemID, + encounterID = encID, + instanceID = inst.id, + keywords = kw, + keywordsLower = kwLower, + lootItemLink = itemLink, + lootSlotName = equipLoc and SLOT_DISPLAY[equipLoc], + lootSourceName = encName, + lootInstanceName = inst.name, + lootSourceType = inst.isRaid and "Raid" or "Dungeon", + }, LOOT_MT) + + -- Enrich with stat keywords if item link is available + if itemLink then + Database:EnrichLootStats(entry) + end + + uiSearchData[#uiSearchData + 1] = entry + lootEntries[#lootEntries + 1] = entry + end + end + end + encIdx = encIdx + 1 + end + + SafeAfter(0, function() processInstance(idx + 1) end) + end + + processInstance(1) +end + -- TREE FLATTENER -- Walks the tree and produces flat entries for the search/highlight engines. -- Children inherit: buttonFrame, category, and accumulate path + steps from parents. @@ -2103,7 +2347,8 @@ function Database:SearchUI(query, skipCategories) (skipCategories["Mount"] and "M" or "") .. (skipCategories["Toy"] and "T" or "") .. (skipCategories["Pet"] and "P" or "") .. - (skipCategories["Outfit"] and "O" or "") + (skipCategories["Outfit"] and "O" or "") .. + (skipCategories["Loot"] and "L" or "") ) or "" local searchSet diff --git a/Options.lua b/Options.lua index 91d77ef..daef26f 100644 --- a/Options.lua +++ b/Options.lua @@ -1721,7 +1721,7 @@ function Options:DoResetAll() EasyFind.db.enableMapSearch = true EasyFind.db.globalSearchFilters = { zones = true, dungeons = true, raids = true, delves = true } EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true } - EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, map = false } EasyFind.db.uiMapSearchLocal = true EasyFind.db.alwaysShowRares = false EasyFind.db.optionsPosition = nil @@ -1843,7 +1843,7 @@ function Options:DoResetUI() EasyFind.db.uiResultsWidth = 350 EasyFind.db.uiSearchPosition = nil EasyFind.db.uiResultsHeight = 280 - EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, map = false } + EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, map = false } EasyFind.db.uiMapSearchLocal = true optionsFrame.directOpenCheckbox:SetChecked(false) diff --git a/UI.lua b/UI.lua index 5ef169e..7004984 100644 --- a/UI.lua +++ b/UI.lua @@ -50,6 +50,7 @@ end -- Uses explicit field access (not pairs) so metatable __index fields are included. local CLEAN_SIMPLE_FIELDS = {"name", "nameLower", "category", "buttonFrame", "flashLabel", "icon", "mountID", "spellID", "toyItemID", "petID", "speciesID", "outfitID", + "itemID", "encounterID", "instanceID", "lootSlotName", "lootSourceName", "lootInstanceName", "lootSourceType", "factionID", "hasRepBar", "canQueue", "isPvP", "isPvE"} local CLEAN_TABLE_FIELDS = {"path", "steps", "keywords", "keywordsLower"} @@ -80,10 +81,11 @@ local function CleanUIForStorage(data) return clean end --- Collection-type pins (mounts, toys, pets, outfits) are character-specific. +-- Collection-type pins (mounts, toys, pets, outfits, loot) are character-specific. -- All other pins are account-wide. local function IsCollectionPin(data) - return data and (data.mountID or data.toyItemID or data.petID or data.outfitID) + return data and (data.mountID or data.toyItemID or data.petID or data.outfitID + or (data.itemID and data.category == "Loot")) end local charKey -- "Name-Realm", set on first use @@ -264,6 +266,7 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon.petID = nil btn.icon.spellID = nil btn.icon.outfitID = nil + btn.icon.lootItemID = nil if btn.iconCooldown then btn.iconCooldown:Hide() end if kind == "atlas" then btn.icon:SetAtlas(value) @@ -1173,6 +1176,7 @@ local UI_FILTER_OPTIONS = { { key = "toys", label = "Toys", iconTex = 454046 }, -- Trade_Archaeology_ChestofTinyGlassAnimals { key = "pets", label = "Pets", iconTex = 132599 }, -- PetJournalPortrait (Inv_Box_PetCarrier_01) { key = "outfits", label = "Outfits", iconTex = 132649 }, -- INV_Chest_Cloth_17 + { key = "loot", label = "Loot", iconTex = 132281 }, -- INV_Sword_04 } function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) @@ -1502,7 +1506,7 @@ local cachedHierarchical -- last full hierarchical list for re-rendering afte local expandedContainers = {} -- tracks which containers have had children injected -- Reusable tables for grouping results (wiped each search to avoid per-keystroke allocations) -local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupMap = {}, {}, {}, {}, {}, {} +local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupLoot, groupMap = {}, {}, {}, {}, {}, {}, {} local mountSectionHeader = { name = "Mounts", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -1519,6 +1523,10 @@ local outfitSectionHeader = { name = "Outfits", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, } +local lootSectionHeader = { + name = "Loot", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} local mapSectionHeader = { name = "Map Search", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -2162,6 +2170,16 @@ function UI:CreateResultButton(index) GameTooltip:AddLine("Click to equip", 1, 0.82, 0) end GameTooltip:Show() + -- Loot item tooltip + elseif self.icon.lootItemID then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + local itemLink = self.data and self.data.lootItemLink + if itemLink then + GameTooltip:SetHyperlink(itemLink) + else + GameTooltip:SetItemByID(self.icon.lootItemID) + end + GameTooltip:Show() end end end) @@ -2174,7 +2192,8 @@ function UI:CreateResultButton(index) self.toyTooltipTicker:Cancel() self.toyTooltipTicker = nil end - if self.data and (self.data.mountID or self.data.toyItemID or self.data.petID or self.data.outfitID) then + if self.data and (self.data.mountID or self.data.toyItemID or self.data.petID or self.data.outfitID + or (self.data.itemID and self.data.category == "Loot")) then GameTooltip:Hide() end -- Clear map preview if we were showing one @@ -2213,12 +2232,13 @@ function UI:OnSearchTextChanged(text) local filters = EasyFind.db.uiSearchFilters local skipCategories if filters then - if filters.mounts == false or filters.toys == false or filters.pets == false or filters.outfits == false then + if filters.mounts == false or filters.toys == false or filters.pets == false or filters.outfits == false or filters.loot == false then skipCategories = {} if filters.mounts == false then skipCategories["Mount"] = true end if filters.toys == false then skipCategories["Toy"] = true end if filters.pets == false then skipCategories["Pet"] = true end if filters.outfits == false then skipCategories["Outfit"] = true end + if filters.loot == false then skipCategories["Loot"] = true end end end local results = ns.Database:SearchUI(text, skipCategories) @@ -2228,7 +2248,7 @@ function UI:OnSearchTextChanged(text) local filtered = {} for _, r in ipairs(results) do local rd = r.data - if rd and (rd.mountID or rd.toyItemID or rd.petID or rd.outfitID or rd.mapSearchResult) then + if rd and (rd.mountID or rd.toyItemID or rd.petID or rd.outfitID or (rd.itemID and rd.category == "Loot") or rd.mapSearchResult) then filtered[#filtered + 1] = r end end @@ -2259,6 +2279,7 @@ function UI:OnSearchTextChanged(text) wipe(groupToys) wipe(groupPets) wipe(groupOutfits) + wipe(groupLoot) wipe(groupMap) for _, entry in ipairs(hierarchical) do local d = entry.data @@ -2270,6 +2291,8 @@ function UI:OnSearchTextChanged(text) groupPets[#groupPets + 1] = entry elseif d and d.outfitID then groupOutfits[#groupOutfits + 1] = entry + elseif d and d.itemID and d.category == "Loot" then + groupLoot[#groupLoot + 1] = entry else groupUI[#groupUI + 1] = entry end @@ -2316,6 +2339,49 @@ function UI:OnSearchTextChanged(text) hierarchical[#hierarchical + 1] = e end end + if #groupLoot > 0 then + hierarchical[#hierarchical + 1] = lootSectionHeader + -- Group loot results: slot -> instance -> items + local slotGroups = {} + local slotOrder = {} + for _, e in ipairs(groupLoot) do + local slot = (e.data and e.data.lootSlotName) or "Other" + if not slotGroups[slot] then + slotGroups[slot] = {} + slotOrder[#slotOrder + 1] = slot + end + slotGroups[slot][#slotGroups[slot] + 1] = e + end + for _, slot in ipairs(slotOrder) do + -- Slot sub-header + hierarchical[#hierarchical + 1] = { + name = slot, depth = 1, isPathNode = true, + isMatch = false, isSectionHeader = false, + } + -- Group by instance within slot + local instGroups = {} + local instOrder = {} + for _, e in ipairs(slotGroups[slot]) do + local inst = (e.data and e.data.lootInstanceName) or "Unknown" + if not instGroups[inst] then + instGroups[inst] = {} + instOrder[#instOrder + 1] = inst + end + instGroups[inst][#instGroups[inst] + 1] = e + end + for _, inst in ipairs(instOrder) do + -- Instance sub-header + hierarchical[#hierarchical + 1] = { + name = inst, depth = 2, isPathNode = true, + isMatch = false, isSectionHeader = false, + } + for _, e in ipairs(instGroups[inst]) do + e.depth = 3 + hierarchical[#hierarchical + 1] = e + end + end + end + end if #groupMap > 0 then hierarchical[#hierarchical + 1] = mapSectionHeader for _, e in ipairs(groupMap) do @@ -2993,6 +3059,34 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) iconSet = true + -- Loot items: icon on right with source name inline + elseif not iconSet and data and data.itemID and data.category == "Loot" then + local iconFileID = data.icon + if iconFileID then + resultRow.icon:SetTexture(nil) + resultRow.icon:SetTexCoord(0, 1, 0, 1) + resultRow.icon:SetTexture(iconFileID) + resultRow.icon:SetSize(theme.iconSize or 16, theme.iconSize or 16) + resultRow.icon:ClearAllPoints() + resultRow.icon:SetPoint("RIGHT", resultRow, "RIGHT", -5, 0) + resultRow.icon:Show() + resultRow.icon.lootItemID = data.itemID + resultRow.icon:SetVertexColor(1, 1, 1, 1) + else + SetRowIcon(resultRow, "hidden", nil, theme.iconSize) + end + resultRow.amountText:Hide() + resultRow.iconCooldown:Hide() + -- Show source info after item name + if data.lootSourceName then + resultRow.text:SetText(data.name .. " |cff888888" .. data.lootSourceName .. "|r") + end + local indentPixels = depth * indPx + 4 + resultRow.text:ClearAllPoints() + resultRow.text:SetPoint("LEFT", resultRow, "LEFT", indentPixels, 0) + resultRow.text:SetPoint("RIGHT", resultRow.icon, "LEFT", -4, 0) + iconSet = true + -- Map search results: left-side category icon + nav pin on right elseif not iconSet and data and data.mapSearchResult then resultRow.amountText:Hide() @@ -3727,6 +3821,25 @@ function UI:SelectResult(data) -- Outfit: equip handled by SecureActionButton (mouse click or Enter binding). if data.outfitID then return end + -- Loot: Shift+click opens dressing room, regular click opens EJ to boss + if data.itemID and data.category == "Loot" then + if IsShiftKeyDown() and data.lootItemLink then + DressUpItemLink(data.lootItemLink) + else + EncounterJournal_LoadUI() + if data.instanceID and EJ_SelectInstance then + EJ_SelectInstance(data.instanceID) + end + if data.encounterID and EJ_SelectEncounter then + EJ_SelectEncounter(data.encounterID) + end + if EncounterJournal then + ShowUIPanel(EncounterJournal) + end + end + return + end + -- Mount: summon/dismiss (secure macro handles cancelform on click) if data.mountID then if C_MountJournal and C_MountJournal.SummonByID then From fb8dac5b5310e6c4b04c03ba1993092a68fe3539 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 27 Mar 2026 20:26:50 -0400 Subject: [PATCH 14/44] Fix loot scan API order, add typed keyword matching Resolve EJ APIs after EncounterJournal_LoadUI (not file scope). Match WhereLoot's API call order: SelectInstance, SelectEncounter, SetDifficulty, SetSlotFilter, SetLootFilter per encounter. Use Enum.ItemSlotFilterType.NoFilter instead of 0. Separate loot keywords by type (slot, stat, source) for toggle-aware scoring. Search by Slot only matches slot keywords, Search by Stats only matches stat keywords. Multi-word queries assign each word to its best keyword type. --- Core.lua | 3 + Database.lua | 221 +++++++++++++++++++++++++++++++++++++-------------- UI.lua | 78 ++++++++++++++++++ 3 files changed, 243 insertions(+), 59 deletions(-) diff --git a/Core.lua b/Core.lua index cef892f..0ce3fc7 100644 --- a/Core.lua +++ b/Core.lua @@ -109,6 +109,9 @@ local DB_DEFAULTS = { map = false, }, lootAllSpecs = false, -- Loot search: false = current spec, true = all specs + lootSearchSlots = true, -- Loot search: match by slot keywords (ring, helm, etc.) + lootSearchStats = true, -- Loot search: match by stat keywords (haste, crit, etc.) + lootUpgradesOnly = false, -- Loot search: only show items above equipped ilvl uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global } diff --git a/Database.lua b/Database.lua index cd69586..55f5b10 100644 --- a/Database.lua +++ b/Database.lua @@ -436,9 +436,10 @@ local OUTFIT_PROTO = { local OUTFIT_MT = { __index = OUTFIT_PROTO } local LOOT_PROTO = { - category = "Loot", - path = {}, - steps = {}, + category = "Loot", + path = {}, + steps = {}, + lootEntry = true, -- use loot-specific scoring (toggle-aware keyword matching) } local LOOT_MT = { __index = LOOT_PROTO } @@ -490,18 +491,12 @@ local SLOT_DISPLAY = { INVTYPE_HOLDABLE = "Off Hand", } --- EJ API compatibility layer (functions migrated from EJ_* to C_EncounterJournal.* in some patches) -local EJ_GetCurrentTier = EJ_GetCurrentTier or (C_EncounterJournal and C_EncounterJournal.GetCurrentTier) -local EJ_SelectTier = EJ_SelectTier or (C_EncounterJournal and C_EncounterJournal.SelectTier) -local EJ_GetInstanceByIndex = EJ_GetInstanceByIndex or (C_EncounterJournal and C_EncounterJournal.GetInstanceByIndex) -local EJ_SelectInstance = EJ_SelectInstance or (C_EncounterJournal and C_EncounterJournal.SelectInstance) -local EJ_GetEncounterInfoByIndex = EJ_GetEncounterInfoByIndex or (C_EncounterJournal and C_EncounterJournal.GetEncounterInfoByIndex) -local EJ_SelectEncounter = EJ_SelectEncounter or (C_EncounterJournal and C_EncounterJournal.SelectEncounter) -local EJ_SetDifficulty = EJ_SetDifficulty or (C_EncounterJournal and C_EncounterJournal.SetDifficulty) -local EJ_SetLootFilter = EJ_SetLootFilter or (C_EncounterJournal and C_EncounterJournal.SetLootFilter) -local EJ_SetSlotFilter = EJ_SetSlotFilter or (C_EncounterJournal and C_EncounterJournal.SetSlotFilter) -local EJ_GetNumLoot = EJ_GetNumLoot or (C_EncounterJournal and C_EncounterJournal.GetNumLoot) -local EJ_GetLootInfoByIndex = EJ_GetLootInfoByIndex or (C_EncounterJournal and C_EncounterJournal.GetLootInfoByIndex) +-- EJ API compatibility: some functions migrated from EJ_* globals to C_EncounterJournal.*. +-- Prefer C_EncounterJournal (current) over globals (may be stale wrappers). +-- Resolved at call time because C_EJ functions may not exist until EncounterJournal_LoadUI(). +local function EJ(name) + return (C_EncounterJournal and C_EncounterJournal[name]) or _G["EJ_" .. name] +end local lootEntries = {} -- track injected entries for re-population local lootScanGeneration = 0 -- cancel stale scans when re-populating @@ -510,18 +505,19 @@ local lootScanGeneration = 0 -- cancel stale scans when re-populating -- Called lazily when the entry first appears in results. function Database:EnrichLootStats(entry) if entry._statsEnriched or not entry.lootItemLink then return end - local GetItemStats = GetItemStats or (C_Item and C_Item.GetItemStats) - if not GetItemStats then return end - local stats = GetItemStats(entry.lootItemLink) + local GetItemStatsFn = GetItemStats or (C_Item and C_Item.GetItemStats) + if not GetItemStatsFn then return end + local stats = GetItemStatsFn(entry.lootItemLink) if not stats then return end + local statKw = entry.lootStatKw or {} for statKey, searchWords in pairs(STAT_KEYWORD_MAP) do if stats[statKey] then for _, word in ipairs(searchWords) do - entry.keywords[#entry.keywords + 1] = word - entry.keywordsLower[#entry.keywordsLower + 1] = word + statKw[#statKw + 1] = word end end end + entry.lootStatKw = statKw entry._statsEnriched = true end @@ -690,9 +686,31 @@ end -- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot -- and injects searchable entries. Staggered across frames (one instance per frame). function Database:PopulateDynamicLoot() - if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetNumLoot then return end if InCombatLockdown() then return end + -- EJ loot tables require the UI to be loaded first + if not EncounterJournal then + EncounterJournal_LoadUI() + end + + -- Resolve EJ APIs after UI load (some live on C_EncounterJournal, not global) + local EJ_GetCurrentTier = EJ("GetCurrentTier") + local EJ_SelectTier = EJ("SelectTier") + local EJ_GetInstanceByIndex = EJ("GetInstanceByIndex") + local EJ_SelectInstance = EJ("SelectInstance") + local EJ_GetEncounterInfoByIndex = EJ("GetEncounterInfoByIndex") + local EJ_SelectEncounter = EJ("SelectEncounter") + local EJ_SetDifficulty = EJ("SetDifficulty") + local EJ_SetLootFilter = EJ("SetLootFilter") + local EJ_SetSlotFilter = EJ("SetSlotFilter") + local EJ_GetNumLoot = EJ("GetNumLoot") + local EJ_GetLootInfoByIndex = EJ("GetLootInfoByIndex") + + if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetNumLoot or not EJ_GetLootInfoByIndex then + print("|cFFFF4444[EF Loot]|r Scan aborted: EJ APIs not available after LoadUI") + return + end + -- Remove previous loot entries (handles spec toggle re-scan) for i = #uiSearchData, 1, -1 do if uiSearchData[i].category == "Loot" then @@ -716,16 +734,10 @@ function Database:PopulateDynamicLoot() -- Save EJ state local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() - -- Set class/spec filter + -- Class/spec for loot filter (applied per-encounter, not upfront) local _, _, classID = UnitClass("player") local specIndex = GetSpecialization and GetSpecialization() local specID = specIndex and GetSpecializationInfo and GetSpecializationInfo(specIndex) - if classID and specID then - EJ_SetLootFilter(classID, EasyFind.db.lootAllSpecs and 0 or specID) - end - if EJ_SetSlotFilter then - EJ_SetSlotFilter(0) -- Enum.ItemSlotFilterType.NoFilter = 0 - end -- Collect all instances in the current tier local instances = {} @@ -740,6 +752,7 @@ function Database:PopulateDynamicLoot() end collectInstances(false) -- dungeons collectInstances(true) -- raids + print("|cFF55CCFF[EF Loot]|r Scan starting: " .. #instances .. " instances") local seen = {} -- deduplicate by itemID local SafeAfter = Utils.SafeAfter @@ -754,57 +767,62 @@ function Database:PopulateDynamicLoot() if myGen ~= lootScanGeneration then return end if idx > #instances then restoreState() + print("|cFF55CCFF[EF Loot]|r Scan complete: " .. #lootEntries .. " items from " .. #instances .. " instances") collectgarbage("collect") return end local inst = instances[idx] - EJ_SelectInstance(inst.id) - - -- Set a reasonable difficulty so loot tables are populated - if EJ_SetDifficulty then - if inst.isRaid then - EJ_SetDifficulty(14) -- Normal raid - else - EJ_SetDifficulty(23) -- Mythic dungeon - end - end + EJ_SelectInstance(inst.id) -- select once to populate encounter list -- Iterate encounters in this instance local encIdx = 1 while true do local encName, _, encID = EJ_GetEncounterInfoByIndex(encIdx) if not encName then break end + + -- EJ is stateful: must set instance, encounter, difficulty, + -- slot filter, THEN loot filter in this exact order. + EJ_SelectInstance(inst.id) EJ_SelectEncounter(encID) + if EJ_SetDifficulty then + EJ_SetDifficulty(inst.isRaid and 15 or 2) + end + if EJ_SetSlotFilter then + EJ_SetSlotFilter(Enum.ItemSlotFilterType.NoFilter) + end + if EJ_SetLootFilter and classID and specID then + EJ_SetLootFilter(classID, EasyFind.db.lootAllSpecs and 0 or specID) + end - local numLoot = EJ_GetNumLoot and EJ_GetNumLoot() or 0 - for li = 1, numLoot do - local lootInfo = EJ_GetLootInfoByIndex and EJ_GetLootInfoByIndex(li) - local itemID = lootInfo and lootInfo.itemID + -- Iterate loot (break on nil, not GetNumLoot — more reliable) + local li = 1 + while true do + local lootInfo = EJ_GetLootInfoByIndex(li) + if not lootInfo or not lootInfo.name then break end + local itemID = lootInfo.itemID if itemID and not seen[itemID] then seen[itemID] = true local itemName = lootInfo.name local _, _, _, equipLoc, instIcon = GetItemInfoInstant(itemID) local icon = lootInfo.icon or instIcon if itemName and itemName ~= "" then - -- Build keywords - local kw = {"loot", "drop", "gear", "equipment"} - -- Slot keywords - local slotKws = equipLoc and SLOT_KEYWORDS[equipLoc] - if slotKws then - for _, w in ipairs(slotKws) do kw[#kw + 1] = w end + -- Build typed keyword arrays for toggle-aware scoring + local slotKws = {} + local slotKwVals = equipLoc and SLOT_KEYWORDS[equipLoc] + if slotKwVals then + for _, w in ipairs(slotKwVals) do slotKws[#slotKws + 1] = w end end - -- Source keywords (boss name words, instance name words) + + local sourceKws = {} if encName then - for w in encName:lower():gmatch("%a+") do kw[#kw + 1] = w end + for w in encName:lower():gmatch("%a+") do sourceKws[#sourceKws + 1] = w end end if inst.name then - for w in inst.name:lower():gmatch("%a+") do kw[#kw + 1] = w end + for w in inst.name:lower():gmatch("%a+") do sourceKws[#sourceKws + 1] = w end end - -- Lower copy for search scoring - local kwLower = {} - for ki = 1, #kw do kwLower[ki] = kw[ki] end + -- Flat keywords/keywordsLower kept empty (lootEntry uses typed arrays) local itemLink = lootInfo.link local entry = setmetatable({ name = itemName, @@ -813,8 +831,11 @@ function Database:PopulateDynamicLoot() itemID = itemID, encounterID = encID, instanceID = inst.id, - keywords = kw, - keywordsLower = kwLower, + keywords = {}, + keywordsLower = {}, + lootSlotKw = slotKws, + lootSourceKw = sourceKws, + lootStatKw = {}, lootItemLink = itemLink, lootSlotName = equipLoc and SLOT_DISPLAY[equipLoc], lootSourceName = encName, @@ -831,6 +852,7 @@ function Database:PopulateDynamicLoot() lootEntries[#lootEntries + 1] = entry end end + li = li + 1 end encIdx = encIdx + 1 end @@ -2369,10 +2391,91 @@ function Database:SearchUI(query, skipCategories) if not (skipCategories and skipCategories[data.category]) and not (data.available and not data.available()) then local nameLower = data.nameLower - local score = Database:ScoreName(nameLower, query, queryLen, queryWords) + local score + if data.lootEntry then + -- Loot: toggle-aware keyword matching. + -- Each query word is matched against the best keyword TYPE + -- (slot or stat), then the item must satisfy all matched types. + score = 0 + local slotOn = EasyFind.db.lootSearchSlots ~= false + local statOn = EasyFind.db.lootSearchStats ~= false + + if not slotOn and not statOn then + -- No toggles: match by item name prefix or source keywords + if ssub(nameLower, 1, queryLen) == query then + score = 100 + end + if data.lootSourceKw then + for qi = 1, #queryWords do + local qw = queryWords[qi] + local qwLen = #qw + for ki = 1, #data.lootSourceKw do + local kw = data.lootSourceKw[ki] + if kw == qw then + score = score + 80; break + elseif ssub(kw, 1, qwLen) == qw then + score = score + 70; break + end + end + end + end + else + -- Score each query word against each keyword type separately. + -- Assign word to whichever type scores higher. + -- Item must match ALL assigned types. + local slotMatched, statMatched = true, true + local needsSlot, needsStat = false, false + local totalScore = 0 + + for qi = 1, #queryWords do + local qw = queryWords[qi] + local qwLen = #qw + local slotScore, statScore = 0, 0 + + -- Score against slot keywords + if slotOn and data.lootSlotKw then + for ki = 1, #data.lootSlotKw do + local kw = data.lootSlotKw[ki] + if kw == qw then + slotScore = mmax(slotScore, qwLen <= 3 and 140 or 80) + elseif ssub(kw, 1, qwLen) == qw then + slotScore = mmax(slotScore, 70) + end + end + end + + -- Score against stat keywords + if statOn and data.lootStatKw then + for ki = 1, #data.lootStatKw do + local kw = data.lootStatKw[ki] + if kw == qw then + statScore = mmax(statScore, qwLen <= 3 and 140 or 80) + elseif ssub(kw, 1, qwLen) == qw then + statScore = mmax(statScore, 70) + end + end + end - -- Keyword matching (additive) - score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + -- Assign word to best matching type + if slotScore == 0 and statScore == 0 then + -- Word matches neither type — item doesn't match + totalScore = 0 + break + elseif slotScore >= statScore then + needsSlot = true + totalScore = totalScore + slotScore + else + needsStat = true + totalScore = totalScore + statScore + end + end + + score = totalScore + end + else + score = Database:ScoreName(nameLower, query, queryLen, queryWords) + score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) + end if score >= 30 then results[#results + 1] = { data = data, score = score } diff --git a/UI.lua b/UI.lua index 7004984..15c813e 100644 --- a/UI.lua +++ b/UI.lua @@ -1305,6 +1305,66 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.mapSubRowIdx = origMapRowIdx end + -- Loot: indented sub-options for search mode and spec toggle + if opt.key == "loot" then + local SUB_INDENT = 24 + local lootSubDefs = { + { dbKey = "lootSearchSlots", label = "Search by Slot" }, + { dbKey = "lootSearchStats", label = "Search by Stats" }, + { dbKey = "lootUpgradesOnly", label = "Upgrades Only" }, + { dbKey = "lootAllSpecs", label = "All Specs" }, + } + local lootSubRows = {} + for si, sub in ipairs(lootSubDefs) do + local subRow = CreateFrame("CheckButton", nil, dropdown) + subRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) + subRow:SetHitRectInsets(0, 0, 0, 0) + + subRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + subRow:GetNormalTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + subRow:GetNormalTexture():ClearAllPoints() + subRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + + subRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + subRow:GetCheckedTexture():SetSize(CHECK_SIZE, CHECK_SIZE) + subRow:GetCheckedTexture():ClearAllPoints() + subRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + + local subLabel = subRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + subLabel:SetPoint("LEFT", subRow:GetNormalTexture(), "RIGHT", 4, 0) + subLabel:SetText(sub.label) + + local subHL = subRow:CreateTexture(nil, "HIGHLIGHT") + subHL:SetAllPoints() + subHL:SetColorTexture(1, 1, 1, 0.1) + + subRow.dbKey = sub.dbKey + lootSubRows[si] = subRow + + subRow:SetScript("OnClick", function(self) + EasyFind.db[sub.dbKey] = self:GetChecked() + -- Spec, slot, or stat toggle changes require loot re-scan + if sub.dbKey == "lootAllSpecs" or sub.dbKey == "lootSearchSlots" or sub.dbKey == "lootSearchStats" then + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + end + + row.lootSubRows = lootSubRows + row.updateLootToggle = function() + local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false + for _, sr in ipairs(lootSubRows) do + sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) + sr:SetShown(lootChecked) + end + end + end + local highlight = row:CreateTexture(nil, "HIGHLIGHT") highlight:SetAllPoints() highlight:SetColorTexture(1, 1, 1, 0.1) @@ -1321,6 +1381,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local filters = EasyFind.db.uiSearchFilters filters[opt.key] = self:GetChecked() if self.updateMapToggle then self.updateMapToggle() end + if self.updateLootToggle then self.updateLootToggle() end LayoutDropdown() if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -1359,6 +1420,20 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end end + -- Loot sub-rows + if row.lootSubRows then + local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false + for _, sr in ipairs(row.lootSubRows) do + if lootChecked then + sr:ClearAllPoints() + sr:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) + sr:Show() + y = y - ROW_HEIGHT + else + sr:Hide() + end + end + end end dropdown:SetSize(DROPDOWN_WIDTH, -y + PADDING_BOTTOM) end @@ -1380,6 +1455,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end local mapRow = checkRows["map"] if mapRow and mapRow.updateMapToggle then mapRow.updateMapToggle() end + local lootRow = checkRows["loot"] + if lootRow and lootRow.updateLootToggle then lootRow.updateLootToggle() end LayoutDropdown() if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -1393,6 +1470,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) for key, row in pairs(checkRows) do row:SetChecked(filters[key] ~= false) if row.updateMapToggle then row.updateMapToggle() end + if row.updateLootToggle then row.updateLootToggle() end end LayoutDropdown() end) From c8338dee6d8b4d62a5de0a60713c4c2daeca46ab Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Fri, 27 Mar 2026 21:53:06 -0400 Subject: [PATCH 15/44] Add spec/class selector flyout for loot search (WIP) Class list with hover-to-expand spec submenus. Spec selector row shown under Loot filter with separator. Multi-spec scan support in Database.lua. Known issues: hover detection and submenu opening need debugging. --- .luacheckrc | 2 + Core.lua | 2 +- Database.lua | 60 ++++---- UI.lua | 399 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 433 insertions(+), 30 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 2850ff6..44a6855 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -76,6 +76,8 @@ read_globals = { "GetUnitSpeed", "GetItemCooldown", "EJ_GetInstanceInfo", "UnitName", "GetItemInfoInstant", "GetItemStats", "GetSpecialization", "GetSpecializationInfo", "UnitClass", "GetLootSpecialization", "DressUpItemLink", + "GetNumClasses", "GetClassInfo", "GetNumSpecializationsForClassID", + "GetSpecializationInfoForClassID", "RAID_CLASS_COLORS", "EJ_GetCurrentTier", "EJ_SelectTier", "EJ_GetInstanceByIndex", "EJ_SelectInstance", "EJ_GetEncounterInfoByIndex", "EJ_SelectEncounter", "EJ_SetDifficulty", "EJ_SetLootFilter", "EJ_SetSlotFilter", diff --git a/Core.lua b/Core.lua index 0ce3fc7..d8b154d 100644 --- a/Core.lua +++ b/Core.lua @@ -108,7 +108,7 @@ local DB_DEFAULTS = { loot = false, map = false, }, - lootAllSpecs = false, -- Loot search: false = current spec, true = all specs + lootSpecs = nil, -- Loot search: nil = current spec only, table of {classID, specID} pairs when customized lootSearchSlots = true, -- Loot search: match by slot keywords (ring, helm, etc.) lootSearchStats = true, -- Loot search: match by stat keywords (haste, crit, etc.) lootUpgradesOnly = false, -- Loot search: only show items above equipped ilvl diff --git a/Database.lua b/Database.lua index 55f5b10..d7ded7c 100644 --- a/Database.lua +++ b/Database.lua @@ -734,10 +734,22 @@ function Database:PopulateDynamicLoot() -- Save EJ state local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() - -- Class/spec for loot filter (applied per-encounter, not upfront) - local _, _, classID = UnitClass("player") - local specIndex = GetSpecialization and GetSpecialization() - local specID = specIndex and GetSpecializationInfo and GetSpecializationInfo(specIndex) + -- Build list of {classID, specID} pairs to scan + local specPairs = {} + local lootSpecs = EasyFind.db.lootSpecs + if lootSpecs and #lootSpecs > 0 then + for _, pair in ipairs(lootSpecs) do + specPairs[#specPairs + 1] = pair + end + else + -- Default: current spec only + local _, _, cid = UnitClass("player") + local si = GetSpecialization and GetSpecialization() + local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) + if cid and sid then + specPairs[1] = { classID = cid, specID = sid } + end + end -- Collect all instances in the current tier local instances = {} @@ -781,26 +793,25 @@ function Database:PopulateDynamicLoot() local encName, _, encID = EJ_GetEncounterInfoByIndex(encIdx) if not encName then break end - -- EJ is stateful: must set instance, encounter, difficulty, - -- slot filter, THEN loot filter in this exact order. - EJ_SelectInstance(inst.id) - EJ_SelectEncounter(encID) - if EJ_SetDifficulty then - EJ_SetDifficulty(inst.isRaid and 15 or 2) - end - if EJ_SetSlotFilter then - EJ_SetSlotFilter(Enum.ItemSlotFilterType.NoFilter) - end - if EJ_SetLootFilter and classID and specID then - EJ_SetLootFilter(classID, EasyFind.db.lootAllSpecs and 0 or specID) - end + -- Scan loot for each selected spec (dedup across specs via seen[]) + for _, sp in ipairs(specPairs) do + EJ_SelectInstance(inst.id) + EJ_SelectEncounter(encID) + if EJ_SetDifficulty then + EJ_SetDifficulty(inst.isRaid and 15 or 2) + end + if EJ_SetSlotFilter then + EJ_SetSlotFilter(Enum.ItemSlotFilterType.NoFilter) + end + if EJ_SetLootFilter then + EJ_SetLootFilter(sp.classID, sp.specID) + end - -- Iterate loot (break on nil, not GetNumLoot — more reliable) - local li = 1 - while true do - local lootInfo = EJ_GetLootInfoByIndex(li) - if not lootInfo or not lootInfo.name then break end - local itemID = lootInfo.itemID + local li = 1 + while true do + local lootInfo = EJ_GetLootInfoByIndex(li) + if not lootInfo or not lootInfo.name then break end + local itemID = lootInfo.itemID if itemID and not seen[itemID] then seen[itemID] = true local itemName = lootInfo.name @@ -853,7 +864,8 @@ function Database:PopulateDynamicLoot() end end li = li + 1 - end + end + end -- specPairs loop encIdx = encIdx + 1 end diff --git a/UI.lua b/UI.lua index 15c813e..acca5bd 100644 --- a/UI.lua +++ b/UI.lua @@ -1236,6 +1236,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local label = row:CreateFontString(nil, "ARTWORK", "GameFontHighlight") label:SetPoint("LEFT", row:GetNormalTexture(), "RIGHT", 4, 0) label:SetText(opt.label) + row.label = label if opt.iconAtlas or opt.iconTex then local icon = row:CreateTexture(nil, "ARTWORK") @@ -1312,7 +1313,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) { dbKey = "lootSearchSlots", label = "Search by Slot" }, { dbKey = "lootSearchStats", label = "Search by Stats" }, { dbKey = "lootUpgradesOnly", label = "Upgrades Only" }, - { dbKey = "lootAllSpecs", label = "All Specs" }, } local lootSubRows = {} for si, sub in ipairs(lootSubDefs) do @@ -1343,8 +1343,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subRow:SetScript("OnClick", function(self) EasyFind.db[sub.dbKey] = self:GetChecked() - -- Spec, slot, or stat toggle changes require loot re-scan - if sub.dbKey == "lootAllSpecs" or sub.dbKey == "lootSearchSlots" or sub.dbKey == "lootSearchStats" then + -- Slot or stat toggle changes require loot re-scan + if sub.dbKey == "lootSearchSlots" or sub.dbKey == "lootSearchStats" then if ns.Database and ns.Database.PopulateDynamicLoot then ns.Database:PopulateDynamicLoot() end @@ -1355,13 +1355,390 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end) end + -- Spec/class selector row (clickable label, opens flyout) + local specRow = CreateFrame("Button", nil, dropdown) + specRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) + local specLabel = specRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + specLabel:SetPoint("LEFT", 8, 0) + local specHL = specRow:CreateTexture(nil, "HIGHLIGHT") + specHL:SetAllPoints() + specHL:SetColorTexture(1, 1, 1, 0.1) + local specArrow = specRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + specArrow:SetPoint("RIGHT", -4, 0) + specArrow:SetText(">") + + -- Build class/spec data + local CLASS_COLORS = RAID_CLASS_COLORS + local allClassSpecs = {} + for classIdx = 1, GetNumClasses() do + local className, classFile, classID = GetClassInfo(classIdx) + if className then + local specs = {} + for specIdx = 1, GetNumSpecializationsForClassID(classID) do + local sid, sname, _, sicon = GetSpecializationInfoForClassID(classID, specIdx) + if sid then + specs[#specs + 1] = { specID = sid, specName = sname, specIcon = sicon } + end + end + if #specs > 0 then + allClassSpecs[#allClassSpecs + 1] = { + classID = classID, className = className, + classFile = classFile, specs = specs, + } + end + end + end + + -- Spec flyout: compact class list, hover opens spec sub-flyout + local FLYOUT_WIDTH = 160 + local FLYOUT_ROW_H = 20 + local SUBFLYOUT_WIDTH = 150 + + local BACKDROP_DEF = { + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + } + + local specFlyout = CreateFrame("Frame", "EasyFindLootSpecFlyout", UIParent, "BackdropTemplate") + specFlyout:SetFrameStrata("FULLSCREEN_DIALOG") + specFlyout:SetFrameLevel(10000) + specFlyout:Hide() + specFlyout:EnableMouse(true) + specFlyout:SetBackdrop(BACKDROP_DEF) + + local specSubFlyout = CreateFrame("Frame", "EasyFindLootSpecSubFlyout", UIParent, "BackdropTemplate") + specSubFlyout:SetFrameStrata("FULLSCREEN_DIALOG") + specSubFlyout:SetFrameLevel(10001) + specSubFlyout:Hide() + specSubFlyout:EnableMouse(true) + specSubFlyout:SetBackdrop(BACKDROP_DEF) + + local specCheckRows = {} -- all {classID, specID, checkRow} + local activeSubClass = nil -- which class sub-flyout is showing + + -- Helper: get selected spec pairs + local function GetSelectedSpecs() + local selected = {} + for _, scr in ipairs(specCheckRows) do + if scr:GetChecked() then + selected[#selected + 1] = { classID = scr._classID, specID = scr._specID } + end + end + return selected + end + + -- Helper: update spec label on the selector row + local function UpdateSpecLabel() + local lbl = row.specSelectLabel + if not lbl then return end + if row.label then row.label:SetText("Loot") end + local lootSpecs = EasyFind.db.lootSpecs + if not lootSpecs or #lootSpecs == 0 then + local si = GetSpecialization and GetSpecialization() + local _, sname + if si then _, sname = GetSpecializationInfo(si) end + local _, classFile = UnitClass("player") + local cc = classFile and CLASS_COLORS[classFile] + local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" + lbl:SetText(colorStr .. (sname or "Current Spec") .. "|r") + elseif #lootSpecs == 1 then + local sp = lootSpecs[1] + local sname, classFile + for _, cls in ipairs(allClassSpecs) do + if cls.classID == sp.classID then + classFile = cls.classFile + for _, s in ipairs(cls.specs) do + if s.specID == sp.specID then sname = s.specName; break end + end + break + end + end + local cc = classFile and CLASS_COLORS[classFile] + local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" + lbl:SetText(colorStr .. (sname or "?") .. "|r") + else + local totalSpecs = 0 + for _, cls in ipairs(allClassSpecs) do totalSpecs = totalSpecs + #cls.specs end + if #lootSpecs >= totalSpecs then + lbl:SetText("All Specs") + elseif #lootSpecs <= 3 then + local names = {} + for _, sp in ipairs(lootSpecs) do + for _, cls in ipairs(allClassSpecs) do + if cls.classID == sp.classID then + for _, s in ipairs(cls.specs) do + if s.specID == sp.specID then names[#names + 1] = s.specName; break end + end + break + end + end + end + lbl:SetText(tconcat(names, ", ")) + else + lbl:SetText(#lootSpecs .. " selected") + end + end + end + + -- Apply selection, re-scan + local function ApplySpecSelection() + local selected = GetSelectedSpecs() + EasyFind.db.lootSpecs = #selected > 0 and selected or nil + UpdateSpecLabel() + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end + + -- Sync checkboxes from saved data + local function SyncFlyoutState() + local lootSpecs = EasyFind.db.lootSpecs + for _, scr in ipairs(specCheckRows) do + local checked = false + if lootSpecs then + for _, sp in ipairs(lootSpecs) do + if sp.classID == scr._classID and sp.specID == scr._specID then + checked = true; break + end + end + end + scr:SetChecked(checked) + end + end + + -- Build spec sub-flyout rows per class (created once, shown/hidden on hover) + local classSubRows = {} -- classID -> { rows } + for _, cls in ipairs(allClassSpecs) do + local rows = {} + for _, spec in ipairs(cls.specs) do + local sRow = CreateFrame("CheckButton", nil, specSubFlyout) + sRow:SetSize(SUBFLYOUT_WIDTH - 16, FLYOUT_ROW_H) + sRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") + sRow:GetNormalTexture():SetSize(14, 14) + sRow:GetNormalTexture():ClearAllPoints() + sRow:GetNormalTexture():SetPoint("LEFT", 4, 0) + sRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") + sRow:GetCheckedTexture():SetSize(14, 14) + sRow:GetCheckedTexture():ClearAllPoints() + sRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + local sLbl = sRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + sLbl:SetPoint("LEFT", sRow:GetNormalTexture(), "RIGHT", 4, 0) + sLbl:SetText(spec.specName) + local sHL = sRow:CreateTexture(nil, "HIGHLIGHT") + sHL:SetAllPoints() + sHL:SetColorTexture(1, 1, 1, 0.1) + sRow._classID = cls.classID + sRow._specID = spec.specID + sRow:Hide() + sRow:SetScript("OnClick", function() + ApplySpecSelection() + end) + rows[#rows + 1] = sRow + specCheckRows[#specCheckRows + 1] = sRow + end + classSubRows[cls.classID] = rows + end + + -- Show sub-flyout for a class, anchored to the class row + local function ShowSubFlyout(classID, anchorRow) + if activeSubClass == classID and specSubFlyout:IsShown() then return end + activeSubClass = classID + SyncFlyoutState() + -- Hide all sub-rows, show only this class + for cid, rows in pairs(classSubRows) do + for _, r in ipairs(rows) do r:SetShown(cid == classID) end + end + local rows = classSubRows[classID] + if not rows or #rows == 0 then specSubFlyout:Hide(); return end + local y = -6 + for _, r in ipairs(rows) do + r:ClearAllPoints() + r:SetPoint("TOPLEFT", 8, y) + y = y - FLYOUT_ROW_H + end + specSubFlyout:SetSize(SUBFLYOUT_WIDTH, -y + 6) + specSubFlyout:ClearAllPoints() + specSubFlyout:SetPoint("TOPLEFT", anchorRow, "TOPRIGHT", 2, 6) + specSubFlyout:Show() + end + + -- Build main flyout rows: Current Spec, Toggle All, then class names + local flyoutRows = {} + local y = -6 + + -- "Current Spec" row + local curSpecBtn = CreateFrame("Button", nil, specFlyout) + curSpecBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) + local csLbl = curSpecBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + csLbl:SetPoint("LEFT", 8, 0) + csLbl:SetText("Current Spec") + local csHL2 = curSpecBtn:CreateTexture(nil, "HIGHLIGHT") + csHL2:SetAllPoints() + csHL2:SetColorTexture(1, 1, 1, 0.1) + curSpecBtn:SetScript("OnClick", function() + EasyFind.db.lootSpecs = nil + SyncFlyoutState() + ApplySpecSelection() + end) + curSpecBtn:SetScript("OnEnter", function() specSubFlyout:Hide(); activeSubClass = nil end) + flyoutRows[#flyoutRows + 1] = curSpecBtn + + -- "Toggle All" row + local toggleAllBtn = CreateFrame("Button", nil, specFlyout) + toggleAllBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) + local taLbl = toggleAllBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + taLbl:SetPoint("LEFT", 8, 0) + taLbl:SetText("Toggle All") + local taHL2 = toggleAllBtn:CreateTexture(nil, "HIGHLIGHT") + taHL2:SetAllPoints() + taHL2:SetColorTexture(1, 1, 1, 0.1) + toggleAllBtn:SetScript("OnClick", function() + local allChecked = true + for _, scr in ipairs(specCheckRows) do + if not scr:GetChecked() then allChecked = false; break end + end + for _, scr in ipairs(specCheckRows) do scr:SetChecked(not allChecked) end + ApplySpecSelection() + end) + toggleAllBtn:SetScript("OnEnter", function() specSubFlyout:Hide(); activeSubClass = nil end) + flyoutRows[#flyoutRows + 1] = toggleAllBtn + + -- Class rows (hover to open spec sub-flyout) + for _, cls in ipairs(allClassSpecs) do + local clsBtn = CreateFrame("Button", nil, specFlyout) + clsBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) + local clsLbl = clsBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + clsLbl:SetPoint("LEFT", 8, 0) + local cc = CLASS_COLORS[cls.classFile] + local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "|cffffffff" + clsLbl:SetText(colorStr .. cls.className .. "|r") + local clsArrow = clsBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + clsArrow:SetPoint("RIGHT", -4, 0) + clsArrow:SetText(">") + local clsHL = clsBtn:CreateTexture(nil, "HIGHLIGHT") + clsHL:SetAllPoints() + clsHL:SetColorTexture(1, 1, 1, 0.1) + clsBtn:SetScript("OnEnter", function(self) + ShowSubFlyout(cls.classID, self) + end) + flyoutRows[#flyoutRows + 1] = clsBtn + end + + -- Layout main flyout + local function LayoutFlyout() + local fy = -6 + for _, r in ipairs(flyoutRows) do + r:ClearAllPoints() + r:SetPoint("TOPLEFT", 8, fy) + r:Show() + fy = fy - FLYOUT_ROW_H + end + specFlyout:SetSize(FLYOUT_WIDTH, -fy + 6) + end + + -- Spec selector row: clickable label (no checkbox), first item under Loot + specRow:Hide() + local specSelectRow = CreateFrame("Button", nil, dropdown) + specSelectRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) + local specSelectLabel = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + specSelectLabel:SetPoint("LEFT", 8, 0) + local specSelectArrow = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + specSelectArrow:SetPoint("RIGHT", -4, 0) + specSelectArrow:SetText(">") + local specSelectHL = specSelectRow:CreateTexture(nil, "HIGHLIGHT") + specSelectHL:SetAllPoints() + specSelectHL:SetColorTexture(1, 1, 1, 0.1) + + local specSep = specSelectRow:CreateTexture(nil, "ARTWORK") + specSep:SetHeight(1) + specSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) + specSep:SetPoint("BOTTOMLEFT", specSelectRow, "BOTTOMLEFT", 0, -2) + specSep:SetPoint("BOTTOMRIGHT", specSelectRow, "BOTTOMRIGHT", 0, -2) + + -- Hover opens flyout, click also works as fallback + local function OpenSpecFlyout() + if specFlyout:IsShown() then return end + SyncFlyoutState() + LayoutFlyout() + specFlyout:ClearAllPoints() + specFlyout:SetPoint("TOPLEFT", specSelectRow, "TOPRIGHT", 2, 0) + specFlyout:Show() + end + specSelectRow:SetScript("OnEnter", function() OpenSpecFlyout() end) + specSelectRow:SetScript("OnClick", function() + if specFlyout:IsShown() then + specFlyout:Hide() + specSubFlyout:Hide() + else + OpenSpecFlyout() + end + end) + + row.specSelectRow = specSelectRow + row.specSelectLabel = specSelectLabel + + -- Close flyouts when dropdown hides or clicking outside + dropdown:HookScript("OnHide", function() + specSubFlyout:Hide() + specFlyout:Hide() + end) + -- Also close sub-flyout when leaving both the class row and sub-flyout area + specSubFlyout:SetScript("OnUpdate", function(self) + if not self:IsMouseOver() and not specFlyout:IsMouseOver() and not dropdown:IsMouseOver() then + -- Brief delay to allow mouse to travel between panels + if not self._leaveTimer then + self._leaveTimer = C_Timer.NewTimer(0.3, function() + self._leaveTimer = nil + if not self:IsMouseOver() and not specFlyout:IsMouseOver() then + self:Hide() + activeSubClass = nil + end + end) + end + else + if self._leaveTimer then + self._leaveTimer:Cancel() + self._leaveTimer = nil + end + end + end) + specFlyout:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + specFlyout:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + specSubFlyout:Hide() + end) + specFlyout:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() and not specSubFlyout:IsMouseOver() and not dropdown:IsMouseOver() then + self:Hide() + end + end + end) + + row.specFlyout = specFlyout + row.allClassSpecs = allClassSpecs + row.lootSubRows = lootSubRows row.updateLootToggle = function() local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false for _, sr in ipairs(lootSubRows) do - sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) + if sr.dbKey and sr.SetChecked then + sr:SetChecked(EasyFind.db[sr.dbKey] ~= false) + end sr:SetShown(lootChecked) end + if row.specSelectRow then + row.specSelectRow:SetShown(lootChecked) + end + UpdateSpecLabel() + if not lootChecked then specFlyout:Hide() end end end @@ -1420,9 +1797,21 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end end - -- Loot sub-rows + -- Loot sub-rows: spec selector first, then separator, then checkboxes if row.lootSubRows then local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false + -- Spec selector row (first, with separator) + if row.specSelectRow then + if lootChecked then + row.specSelectRow:ClearAllPoints() + row.specSelectRow:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) + row.specSelectRow:Show() + y = y - ROW_HEIGHT - 4 -- extra 4px for the separator line + else + row.specSelectRow:Hide() + end + end + -- Checkbox sub-options for _, sr in ipairs(row.lootSubRows) do if lootChecked then sr:ClearAllPoints() From c67dcdbaac962c46a51158ce47b2ca3878816920 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 28 Mar 2026 02:09:08 -0400 Subject: [PATCH 16/44] WIP: spec/class selector and DevLog auto-export Spec flyout not yet functional (frame-level issues). DevLog now auto-saves to EasyFindDevDB.lastOutput. All dev modules updated to use DevLog. Frame inspector uses DevLog for quick inspect output. --- UI.lua | 65 +++++++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/UI.lua b/UI.lua index acca5bd..dbdb6aa 100644 --- a/UI.lua +++ b/UI.lua @@ -1363,9 +1363,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local specHL = specRow:CreateTexture(nil, "HIGHLIGHT") specHL:SetAllPoints() specHL:SetColorTexture(1, 1, 1, 0.1) - local specArrow = specRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + local specArrow = specRow:CreateTexture(nil, "ARTWORK") + specArrow:SetSize(12, 12) specArrow:SetPoint("RIGHT", -4, 0) - specArrow:SetText(">") + specArrow:SetAtlas("ForwardArrowIcon") -- Build class/spec data local CLASS_COLORS = RAID_CLASS_COLORS @@ -1389,32 +1390,14 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - -- Spec flyout: compact class list, hover opens spec sub-flyout + -- Spec flyout: built lazily on first open (avoids frame-level issues at load time) + local specFlyout, specSubFlyout + local specCheckRows = {} + local activeSubClass = nil local FLYOUT_WIDTH = 160 local FLYOUT_ROW_H = 20 local SUBFLYOUT_WIDTH = 150 - local BACKDROP_DEF = { - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - } - - local specFlyout = CreateFrame("Frame", "EasyFindLootSpecFlyout", UIParent, "BackdropTemplate") - specFlyout:SetFrameStrata("FULLSCREEN_DIALOG") - specFlyout:SetFrameLevel(10000) - specFlyout:Hide() - specFlyout:EnableMouse(true) - specFlyout:SetBackdrop(BACKDROP_DEF) - - local specSubFlyout = CreateFrame("Frame", "EasyFindLootSpecSubFlyout", UIParent, "BackdropTemplate") - specSubFlyout:SetFrameStrata("FULLSCREEN_DIALOG") - specSubFlyout:SetFrameLevel(10001) - specSubFlyout:Hide() - specSubFlyout:EnableMouse(true) - specSubFlyout:SetBackdrop(BACKDROP_DEF) - local specCheckRows = {} -- all {classID, specID, checkRow} local activeSubClass = nil -- which class sub-flyout is showing @@ -1556,9 +1539,11 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local rows = classSubRows[classID] if not rows or #rows == 0 then specSubFlyout:Hide(); return end local y = -6 + local subBtnLevel = specSubFlyout:GetFrameLevel() + 10 for _, r in ipairs(rows) do r:ClearAllPoints() - r:SetPoint("TOPLEFT", 8, y) + r:SetPoint("TOPLEFT", specSubFlyout, "TOPLEFT", 8, y) + r:SetFrameLevel(subBtnLevel) y = y - FLYOUT_ROW_H end specSubFlyout:SetSize(SUBFLYOUT_WIDTH, -y + 6) @@ -1617,13 +1602,20 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local cc = CLASS_COLORS[cls.classFile] local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "|cffffffff" clsLbl:SetText(colorStr .. cls.className .. "|r") - local clsArrow = clsBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + local clsArrow = clsBtn:CreateTexture(nil, "ARTWORK") + clsArrow:SetSize(12, 12) clsArrow:SetPoint("RIGHT", -4, 0) - clsArrow:SetText(">") + clsArrow:SetAtlas("ForwardArrowIcon") local clsHL = clsBtn:CreateTexture(nil, "HIGHLIGHT") clsHL:SetAllPoints() clsHL:SetColorTexture(1, 1, 1, 0.1) + -- Debug: red border to verify button bounds + local dbg = clsBtn:CreateTexture(nil, "BACKGROUND") + dbg:SetAllPoints() + dbg:SetColorTexture(1, 0, 0, 0.15) + clsBtn:SetScript("OnEnter", function(self) + print("[SpecFlyout] ENTER: " .. cls.className) ShowSubFlyout(cls.classID, self) end) flyoutRows[#flyoutRows + 1] = clsBtn @@ -1632,9 +1624,12 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Layout main flyout local function LayoutFlyout() local fy = -6 + local btnLevel = specFlyout:GetFrameLevel() + 10 + DevLog("[SpecFlyout] Layout: " .. #flyoutRows .. " rows, btnLevel=" .. btnLevel) for _, r in ipairs(flyoutRows) do r:ClearAllPoints() - r:SetPoint("TOPLEFT", 8, fy) + r:SetPoint("TOPLEFT", specFlyout, "TOPLEFT", 8, fy) + r:SetFrameLevel(btnLevel) r:Show() fy = fy - FLYOUT_ROW_H end @@ -1647,9 +1642,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specSelectRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) local specSelectLabel = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") specSelectLabel:SetPoint("LEFT", 8, 0) - local specSelectArrow = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + local specSelectArrow = specSelectRow:CreateTexture(nil, "ARTWORK") + specSelectArrow:SetSize(12, 12) specSelectArrow:SetPoint("RIGHT", -4, 0) - specSelectArrow:SetText(">") + specSelectArrow:SetAtlas("ForwardArrowIcon") local specSelectHL = specSelectRow:CreateTexture(nil, "HIGHLIGHT") specSelectHL:SetAllPoints() specSelectHL:SetColorTexture(1, 1, 1, 0.1) @@ -1670,6 +1666,15 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specFlyout:Show() end specSelectRow:SetScript("OnEnter", function() OpenSpecFlyout() end) + specSelectRow:SetScript("OnLeave", function() + C_Timer.After(0.15, function() + if specFlyout:IsShown() and not specFlyout:IsMouseOver() + and not specSelectRow:IsMouseOver() + and not (specSubFlyout:IsShown() and specSubFlyout:IsMouseOver()) then + specFlyout:Hide() + end + end) + end) specSelectRow:SetScript("OnClick", function() if specFlyout:IsShown() then specFlyout:Hide() From 30f580e8b91cf2794886965fd54c939d56fcc042 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 28 Mar 2026 19:02:31 -0400 Subject: [PATCH 17/44] Fix spec/class flyout hover and add spec icons Root cause: BackdropTemplate intercepts mouse events at the same frame level as child buttons. Fixed by using plain Frame with manual background texture instead of BackdropTemplate for the flyout panels. Buttons now receive OnEnter properly. Added spec icons next to spec names in the sub-flyout. Replaced text arrows with ForwardArrowIcon atlas. --- UI.lua | 68 +++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/UI.lua b/UI.lua index dbdb6aa..242ebef 100644 --- a/UI.lua +++ b/UI.lua @@ -1390,16 +1390,49 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - -- Spec flyout: built lazily on first open (avoids frame-level issues at load time) - local specFlyout, specSubFlyout - local specCheckRows = {} - local activeSubClass = nil local FLYOUT_WIDTH = 160 local FLYOUT_ROW_H = 20 - local SUBFLYOUT_WIDTH = 150 + local SUBFLYOUT_WIDTH = 170 + + local BACKDROP_DEF = { + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", + edgeFile = TOOLTIP_BORDER, + edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + } + + local specFlyout = CreateFrame("Frame", "EasyFindSpecFlyout", UIParent) + specFlyout:SetFrameStrata("FULLSCREEN_DIALOG") + specFlyout:EnableMouse(true) + specFlyout:Hide() + local flyoutBg = specFlyout:CreateTexture(nil, "BACKGROUND") + flyoutBg:SetAllPoints() + flyoutBg:SetColorTexture(0.1, 0.1, 0.1, 0.95) + local flyoutBorder = CreateFrame("Frame", nil, specFlyout, "BackdropTemplate") + flyoutBorder:SetAllPoints() + flyoutBorder:SetFrameLevel(specFlyout:GetFrameLevel()) + flyoutBorder:SetBackdrop({ + edgeFile = TOOLTIP_BORDER, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) - local specCheckRows = {} -- all {classID, specID, checkRow} - local activeSubClass = nil -- which class sub-flyout is showing + local specSubFlyout = CreateFrame("Frame", "EasyFindSpecSubFlyout", UIParent) + specSubFlyout:SetFrameStrata("FULLSCREEN_DIALOG") + specSubFlyout:EnableMouse(true) + specSubFlyout:Hide() + local subBg = specSubFlyout:CreateTexture(nil, "BACKGROUND") + subBg:SetAllPoints() + subBg:SetColorTexture(0.1, 0.1, 0.1, 0.95) + local subBorder = CreateFrame("Frame", nil, specSubFlyout, "BackdropTemplate") + subBorder:SetAllPoints() + subBorder:SetFrameLevel(specSubFlyout:GetFrameLevel()) + subBorder:SetBackdrop({ + edgeFile = TOOLTIP_BORDER, edgeSize = 16, + insets = { left = 4, right = 4, top = 4, bottom = 4 }, + }) + + local specCheckRows = {} + local activeSubClass = nil -- Helper: get selected spec pairs local function GetSelectedSpecs() @@ -1501,6 +1534,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) for _, spec in ipairs(cls.specs) do local sRow = CreateFrame("CheckButton", nil, specSubFlyout) sRow:SetSize(SUBFLYOUT_WIDTH - 16, FLYOUT_ROW_H) + sRow:SetFrameLevel(specSubFlyout:GetFrameLevel() + 10) sRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") sRow:GetNormalTexture():SetSize(14, 14) sRow:GetNormalTexture():ClearAllPoints() @@ -1509,8 +1543,12 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) sRow:GetCheckedTexture():SetSize(14, 14) sRow:GetCheckedTexture():ClearAllPoints() sRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) + local sIcon = sRow:CreateTexture(nil, "ARTWORK") + sIcon:SetSize(14, 14) + sIcon:SetPoint("LEFT", sRow:GetNormalTexture(), "RIGHT", 4, 0) + if spec.specIcon then sIcon:SetTexture(spec.specIcon) end local sLbl = sRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - sLbl:SetPoint("LEFT", sRow:GetNormalTexture(), "RIGHT", 4, 0) + sLbl:SetPoint("LEFT", sIcon, "RIGHT", 4, 0) sLbl:SetText(spec.specName) local sHL = sRow:CreateTexture(nil, "HIGHLIGHT") sHL:SetAllPoints() @@ -1559,6 +1597,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- "Current Spec" row local curSpecBtn = CreateFrame("Button", nil, specFlyout) curSpecBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) + curSpecBtn:SetFrameLevel(specFlyout:GetFrameLevel() + 10) local csLbl = curSpecBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") csLbl:SetPoint("LEFT", 8, 0) csLbl:SetText("Current Spec") @@ -1576,6 +1615,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- "Toggle All" row local toggleAllBtn = CreateFrame("Button", nil, specFlyout) toggleAllBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) + toggleAllBtn:SetFrameLevel(specFlyout:GetFrameLevel() + 10) local taLbl = toggleAllBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") taLbl:SetPoint("LEFT", 8, 0) taLbl:SetText("Toggle All") @@ -1597,6 +1637,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) for _, cls in ipairs(allClassSpecs) do local clsBtn = CreateFrame("Button", nil, specFlyout) clsBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) + clsBtn:SetFrameLevel(specFlyout:GetFrameLevel() + 10) + clsBtn:EnableMouse(true) + clsBtn:RegisterForClicks("AnyUp") local clsLbl = clsBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") clsLbl:SetPoint("LEFT", 8, 0) local cc = CLASS_COLORS[cls.classFile] @@ -1609,23 +1652,18 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local clsHL = clsBtn:CreateTexture(nil, "HIGHLIGHT") clsHL:SetAllPoints() clsHL:SetColorTexture(1, 1, 1, 0.1) - -- Debug: red border to verify button bounds - local dbg = clsBtn:CreateTexture(nil, "BACKGROUND") - dbg:SetAllPoints() - dbg:SetColorTexture(1, 0, 0, 0.15) - clsBtn:SetScript("OnEnter", function(self) - print("[SpecFlyout] ENTER: " .. cls.className) ShowSubFlyout(cls.classID, self) end) flyoutRows[#flyoutRows + 1] = clsBtn end + specFlyout:Hide() -- hide after construction + -- Layout main flyout local function LayoutFlyout() local fy = -6 local btnLevel = specFlyout:GetFrameLevel() + 10 - DevLog("[SpecFlyout] Layout: " .. #flyoutRows .. " rows, btnLevel=" .. btnLevel) for _, r in ipairs(flyoutRows) do r:ClearAllPoints() r:SetPoint("TOPLEFT", specFlyout, "TOPLEFT", 8, fy) From fb35b4ea3f2d1e9ef7851b03ef7a27720a7a008d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 28 Mar 2026 19:04:35 -0400 Subject: [PATCH 18/44] Fix flyout backgrounds, auto-close on result, remove scan spam Inset background textures inside border edges. Close spec flyouts when search results hide. Remove debug print statements from loot scan. --- Database.lua | 4 +--- UI.lua | 11 +++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Database.lua b/Database.lua index d7ded7c..b944e63 100644 --- a/Database.lua +++ b/Database.lua @@ -707,7 +707,7 @@ function Database:PopulateDynamicLoot() local EJ_GetLootInfoByIndex = EJ("GetLootInfoByIndex") if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetNumLoot or not EJ_GetLootInfoByIndex then - print("|cFFFF4444[EF Loot]|r Scan aborted: EJ APIs not available after LoadUI") + Utils.DebugPrint("Loot scan aborted: EJ APIs not available") return end @@ -764,7 +764,6 @@ function Database:PopulateDynamicLoot() end collectInstances(false) -- dungeons collectInstances(true) -- raids - print("|cFF55CCFF[EF Loot]|r Scan starting: " .. #instances .. " instances") local seen = {} -- deduplicate by itemID local SafeAfter = Utils.SafeAfter @@ -779,7 +778,6 @@ function Database:PopulateDynamicLoot() if myGen ~= lootScanGeneration then return end if idx > #instances then restoreState() - print("|cFF55CCFF[EF Loot]|r Scan complete: " .. #lootEntries .. " items from " .. #instances .. " instances") collectgarbage("collect") return end diff --git a/UI.lua b/UI.lua index 242ebef..5b7da8d 100644 --- a/UI.lua +++ b/UI.lua @@ -1406,7 +1406,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specFlyout:EnableMouse(true) specFlyout:Hide() local flyoutBg = specFlyout:CreateTexture(nil, "BACKGROUND") - flyoutBg:SetAllPoints() + flyoutBg:SetPoint("TOPLEFT", 4, -4) + flyoutBg:SetPoint("BOTTOMRIGHT", -4, 4) flyoutBg:SetColorTexture(0.1, 0.1, 0.1, 0.95) local flyoutBorder = CreateFrame("Frame", nil, specFlyout, "BackdropTemplate") flyoutBorder:SetAllPoints() @@ -1421,7 +1422,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specSubFlyout:EnableMouse(true) specSubFlyout:Hide() local subBg = specSubFlyout:CreateTexture(nil, "BACKGROUND") - subBg:SetAllPoints() + subBg:SetPoint("TOPLEFT", 4, -4) + subBg:SetPoint("BOTTOMRIGHT", -4, 4) subBg:SetColorTexture(0.1, 0.1, 0.1, 0.95) local subBorder = CreateFrame("Frame", nil, specSubFlyout, "BackdropTemplate") subBorder:SetAllPoints() @@ -4031,6 +4033,11 @@ function UI:HideResults() if not searchFrame then return end if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end + -- Close any open spec flyouts + local sf = _G["EasyFindSpecFlyout"] + if sf and sf:IsShown() then sf:Hide() end + local ssf = _G["EasyFindSpecSubFlyout"] + if ssf and ssf:IsShown() then ssf:Hide() end if not resultsFrame then return end resultsFrame:Hide() if resultsFrame.pinSeparator then From c2fbdc2becee0ee7463140f1a4f8c2fec1d8b147 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 28 Mar 2026 22:59:00 -0400 Subject: [PATCH 19/44] Darken flyout backgrounds, close flyouts on spec selection --- UI.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/UI.lua b/UI.lua index 5b7da8d..c67d11a 100644 --- a/UI.lua +++ b/UI.lua @@ -1408,7 +1408,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local flyoutBg = specFlyout:CreateTexture(nil, "BACKGROUND") flyoutBg:SetPoint("TOPLEFT", 4, -4) flyoutBg:SetPoint("BOTTOMRIGHT", -4, 4) - flyoutBg:SetColorTexture(0.1, 0.1, 0.1, 0.95) + flyoutBg:SetColorTexture(0.05, 0.05, 0.05, 0.95) local flyoutBorder = CreateFrame("Frame", nil, specFlyout, "BackdropTemplate") flyoutBorder:SetAllPoints() flyoutBorder:SetFrameLevel(specFlyout:GetFrameLevel()) @@ -1424,7 +1424,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local subBg = specSubFlyout:CreateTexture(nil, "BACKGROUND") subBg:SetPoint("TOPLEFT", 4, -4) subBg:SetPoint("BOTTOMRIGHT", -4, 4) - subBg:SetColorTexture(0.1, 0.1, 0.1, 0.95) + subBg:SetColorTexture(0.05, 0.05, 0.05, 0.95) local subBorder = CreateFrame("Frame", nil, specSubFlyout, "BackdropTemplate") subBorder:SetAllPoints() subBorder:SetFrameLevel(specSubFlyout:GetFrameLevel()) @@ -1505,6 +1505,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local selected = GetSelectedSpecs() EasyFind.db.lootSpecs = #selected > 0 and selected or nil UpdateSpecLabel() + -- Close all flyouts + specSubFlyout:Hide() + specFlyout:Hide() if ns.Database and ns.Database.PopulateDynamicLoot then ns.Database:PopulateDynamicLoot() end From 60afeeea8aa9a2ec8dee1eff0fafea31b5afd659 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 28 Mar 2026 23:35:25 -0400 Subject: [PATCH 20/44] Close all menus on spec selection, add class active indicators Hide flyouts and dropdown when a spec is selected. Green dot on class rows indicates at least one spec from that class is active. SyncFlyoutState updates both spec checkboxes and class indicators. --- UI.lua | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/UI.lua b/UI.lua index c67d11a..dbce295 100644 --- a/UI.lua +++ b/UI.lua @@ -1434,6 +1434,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) }) local specCheckRows = {} + local classButtons = {} -- classID -> button (for active indicators) local activeSubClass = nil -- Helper: get selected spec pairs @@ -1505,9 +1506,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local selected = GetSelectedSpecs() EasyFind.db.lootSpecs = #selected > 0 and selected or nil UpdateSpecLabel() - -- Close all flyouts + -- Close all flyouts and the filter dropdown specSubFlyout:Hide() specFlyout:Hide() + dropdown:Hide() if ns.Database and ns.Database.PopulateDynamicLoot then ns.Database:PopulateDynamicLoot() end @@ -1519,6 +1521,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Sync checkboxes from saved data local function SyncFlyoutState() local lootSpecs = EasyFind.db.lootSpecs + -- Sync spec checkboxes for _, scr in ipairs(specCheckRows) do local checked = false if lootSpecs then @@ -1530,6 +1533,18 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end scr:SetChecked(checked) end + -- Sync class active indicators + for cid, btn in pairs(classButtons) do + local hasActive = false + if lootSpecs then + for _, sp in ipairs(lootSpecs) do + if sp.classID == cid then hasActive = true; break end + end + end + if btn._activeIndicator then + btn._activeIndicator:SetShown(hasActive) + end + end end -- Build spec sub-flyout rows per class (created once, shown/hidden on hover) @@ -1657,9 +1672,18 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local clsHL = clsBtn:CreateTexture(nil, "HIGHLIGHT") clsHL:SetAllPoints() clsHL:SetColorTexture(1, 1, 1, 0.1) + -- Active indicator: green dot when any spec from this class is selected + local clsActive = clsBtn:CreateTexture(nil, "OVERLAY") + clsActive:SetSize(6, 6) + clsActive:SetPoint("LEFT", 1, 0) + clsActive:SetColorTexture(0.3, 1, 0.3, 1) + clsActive:Hide() + clsBtn._activeIndicator = clsActive + clsBtn._classID = cls.classID clsBtn:SetScript("OnEnter", function(self) ShowSubFlyout(cls.classID, self) end) + classButtons[cls.classID] = clsBtn flyoutRows[#flyoutRows + 1] = clsBtn end From 3fa3705f553f94b2dd09f49aff196cfd279c03c0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sat, 28 Mar 2026 23:42:04 -0400 Subject: [PATCH 21/44] Defer loot re-scan so flyouts fully close before UI rebuild --- UI.lua | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/UI.lua b/UI.lua index dbce295..de10781 100644 --- a/UI.lua +++ b/UI.lua @@ -1506,16 +1506,19 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local selected = GetSelectedSpecs() EasyFind.db.lootSpecs = #selected > 0 and selected or nil UpdateSpecLabel() - -- Close all flyouts and the filter dropdown + -- Close everything first specSubFlyout:Hide() specFlyout:Hide() dropdown:Hide() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end + -- Defer re-scan so hide completes before any UI rebuild + C_Timer.After(0, function() + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) end -- Sync checkboxes from saved data From bac25a19f4e963d20a45b6c5048cb13a744e0457 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 00:09:53 -0400 Subject: [PATCH 22/44] Fix flyout re-opening after spec selection via cooldown guard --- UI.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/UI.lua b/UI.lua index de10781..81dd495 100644 --- a/UI.lua +++ b/UI.lua @@ -1502,14 +1502,17 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end -- Apply selection, re-scan + local specFlyoutCooldown = false local function ApplySpecSelection() local selected = GetSelectedSpecs() EasyFind.db.lootSpecs = #selected > 0 and selected or nil UpdateSpecLabel() - -- Close everything first specSubFlyout:Hide() specFlyout:Hide() dropdown:Hide() + -- Block re-open from OnEnter for a brief window + specFlyoutCooldown = true + C_Timer.After(0.3, function() specFlyoutCooldown = false end) -- Defer re-scan so hide completes before any UI rebuild C_Timer.After(0, function() if ns.Database and ns.Database.PopulateDynamicLoot then @@ -1735,7 +1738,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specFlyout:SetPoint("TOPLEFT", specSelectRow, "TOPRIGHT", 2, 0) specFlyout:Show() end - specSelectRow:SetScript("OnEnter", function() OpenSpecFlyout() end) + specSelectRow:SetScript("OnEnter", function() + if specFlyoutCooldown then return end + OpenSpecFlyout() + end) specSelectRow:SetScript("OnLeave", function() C_Timer.After(0.15, function() if specFlyout:IsShown() and not specFlyout:IsMouseOver() From 3ae306c67f42bd1d9fec13b29739c3aa143b329c Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 01:42:21 -0400 Subject: [PATCH 23/44] Class click toggles all specs, menus stay open during selection Clicking a class row toggles all its specs on/off. Clicking a spec toggles just that spec. Nothing closes until the user clicks outside the menus. Loot re-scan deferred to next frame. --- UI.lua | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/UI.lua b/UI.lua index 81dd495..16f017a 100644 --- a/UI.lua +++ b/UI.lua @@ -1502,17 +1502,20 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end -- Apply selection, re-scan - local specFlyoutCooldown = false local function ApplySpecSelection() local selected = GetSelectedSpecs() EasyFind.db.lootSpecs = #selected > 0 and selected or nil UpdateSpecLabel() - specSubFlyout:Hide() - specFlyout:Hide() - dropdown:Hide() - -- Block re-open from OnEnter for a brief window - specFlyoutCooldown = true - C_Timer.After(0.3, function() specFlyoutCooldown = false end) + SyncFlyoutState() + -- Re-scan loot in background + C_Timer.After(0, function() + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) -- Defer re-scan so hide completes before any UI rebuild C_Timer.After(0, function() if ns.Database and ns.Database.PopulateDynamicLoot then @@ -1689,6 +1692,17 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) clsBtn:SetScript("OnEnter", function(self) ShowSubFlyout(cls.classID, self) end) + clsBtn:SetScript("OnClick", function() + -- Toggle all specs for this class + local crows = classSubRows[cls.classID] + if not crows then return end + local allChecked = true + for _, cr in ipairs(crows) do + if not cr:GetChecked() then allChecked = false; break end + end + for _, cr in ipairs(crows) do cr:SetChecked(not allChecked) end + ApplySpecSelection() + end) classButtons[cls.classID] = clsBtn flyoutRows[#flyoutRows + 1] = clsBtn end @@ -1738,10 +1752,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specFlyout:SetPoint("TOPLEFT", specSelectRow, "TOPRIGHT", 2, 0) specFlyout:Show() end - specSelectRow:SetScript("OnEnter", function() - if specFlyoutCooldown then return end - OpenSpecFlyout() - end) + specSelectRow:SetScript("OnEnter", function() OpenSpecFlyout() end) specSelectRow:SetScript("OnLeave", function() C_Timer.After(0.15, function() if specFlyout:IsShown() and not specFlyout:IsMouseOver() From ba16daa330b4cdb3da91b6f912e82734deed701d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 01:43:51 -0400 Subject: [PATCH 24/44] Keep results open during spec/class selection --- UI.lua | 5 ----- 1 file changed, 5 deletions(-) diff --git a/UI.lua b/UI.lua index 16f017a..fdbb28b 100644 --- a/UI.lua +++ b/UI.lua @@ -4080,11 +4080,6 @@ function UI:HideResults() if not searchFrame then return end if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end - -- Close any open spec flyouts - local sf = _G["EasyFindSpecFlyout"] - if sf and sf:IsShown() then sf:Hide() end - local ssf = _G["EasyFindSpecSubFlyout"] - if ssf and ssf:IsShown() then ssf:Hide() end if not resultsFrame then return end resultsFrame:Hide() if resultsFrame.pinSeparator then From 16446a75820e224d7a667c40d85e66ecce840f1d Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 17:22:58 -0400 Subject: [PATCH 25/44] Keep results visible during flyout interaction, fix indicators Prevent OnEditFocusLost from hiding results when mouse is over spec/class flyouts or filter dropdown. Change class active indicator from small dot to full row background tint. --- UI.lua | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/UI.lua b/UI.lua index fdbb28b..90df5c6 100644 --- a/UI.lua +++ b/UI.lua @@ -681,6 +681,12 @@ function UI:CreateSearchFrame() if searchFrame.editBox:HasFocus() then return end if navFrame and navFrame:IsKeyboardEnabled() then return end if strtrim(searchFrame.editBox:GetText()) ~= "" then return end + -- Don't hide if user is interacting with spec/class flyouts + local sf = _G["EasyFindSpecFlyout"] + local ssf = _G["EasyFindSpecSubFlyout"] + if (sf and sf:IsMouseOver()) or (ssf and ssf:IsMouseOver()) then return end + local dd = _G["EasyFindUIFilterDropdown"] + if dd and dd:IsMouseOver() then return end UI:HideResults() -- Now that results are hidden, let smart show fade the bar out if EasyFind.db.smartShow then @@ -1681,11 +1687,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local clsHL = clsBtn:CreateTexture(nil, "HIGHLIGHT") clsHL:SetAllPoints() clsHL:SetColorTexture(1, 1, 1, 0.1) - -- Active indicator: green dot when any spec from this class is selected - local clsActive = clsBtn:CreateTexture(nil, "OVERLAY") - clsActive:SetSize(6, 6) - clsActive:SetPoint("LEFT", 1, 0) - clsActive:SetColorTexture(0.3, 1, 0.3, 1) + -- Active indicator: subtle background tint when any spec from this class is selected + local clsActive = clsBtn:CreateTexture(nil, "BACKGROUND") + clsActive:SetAllPoints() + clsActive:SetColorTexture(0.15, 0.4, 0.15, 0.5) clsActive:Hide() clsBtn._activeIndicator = clsActive clsBtn._classID = cls.classID From 5db8ce0cb57c10c646619a52fd84c96e7e4767a4 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 21:35:03 -0400 Subject: [PATCH 26/44] Use IsShown instead of IsMouseOver to guard results visibility --- UI.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UI.lua b/UI.lua index 90df5c6..79beb50 100644 --- a/UI.lua +++ b/UI.lua @@ -681,12 +681,12 @@ function UI:CreateSearchFrame() if searchFrame.editBox:HasFocus() then return end if navFrame and navFrame:IsKeyboardEnabled() then return end if strtrim(searchFrame.editBox:GetText()) ~= "" then return end - -- Don't hide if user is interacting with spec/class flyouts + -- Don't hide if spec/class flyouts are open local sf = _G["EasyFindSpecFlyout"] local ssf = _G["EasyFindSpecSubFlyout"] - if (sf and sf:IsMouseOver()) or (ssf and ssf:IsMouseOver()) then return end + if (sf and sf:IsShown()) or (ssf and ssf:IsShown()) then return end local dd = _G["EasyFindUIFilterDropdown"] - if dd and dd:IsMouseOver() then return end + if dd and dd:IsShown() then return end UI:HideResults() -- Now that results are hidden, let smart show fade the bar out if EasyFind.db.smartShow then From 6b885d11a03591a53574593d94b748ec9b51e4d5 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 21:36:31 -0400 Subject: [PATCH 27/44] Fix forward reference error and remove duplicate re-scan --- UI.lua | 46 +++++++++++++++++----------------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/UI.lua b/UI.lua index 79beb50..9796c2a 100644 --- a/UI.lua +++ b/UI.lua @@ -1507,36 +1507,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - -- Apply selection, re-scan - local function ApplySpecSelection() - local selected = GetSelectedSpecs() - EasyFind.db.lootSpecs = #selected > 0 and selected or nil - UpdateSpecLabel() - SyncFlyoutState() - -- Re-scan loot in background - C_Timer.After(0, function() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) - -- Defer re-scan so hide completes before any UI rebuild - C_Timer.After(0, function() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) - end - - -- Sync checkboxes from saved data + -- Sync checkboxes and class indicators from saved data local function SyncFlyoutState() local lootSpecs = EasyFind.db.lootSpecs - -- Sync spec checkboxes for _, scr in ipairs(specCheckRows) do local checked = false if lootSpecs then @@ -1548,7 +1521,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end scr:SetChecked(checked) end - -- Sync class active indicators for cid, btn in pairs(classButtons) do local hasActive = false if lootSpecs then @@ -1562,6 +1534,22 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end + -- Apply selection, re-scan + local function ApplySpecSelection() + local selected = GetSelectedSpecs() + EasyFind.db.lootSpecs = #selected > 0 and selected or nil + UpdateSpecLabel() + SyncFlyoutState() + C_Timer.After(0, function() + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + end + -- Build spec sub-flyout rows per class (created once, shown/hidden on hover) local classSubRows = {} -- classID -> { rows } for _, cls in ipairs(allClassSpecs) do From 07071b3bf9a7fa8becba45df163bc7f3116b04b0 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 29 Mar 2026 21:53:09 -0400 Subject: [PATCH 28/44] Fix dropdown closing when clicking spec/class flyouts --- UI.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/UI.lua b/UI.lua index 9796c2a..01fc7fb 100644 --- a/UI.lua +++ b/UI.lua @@ -1951,10 +1951,16 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:SetScript("OnHide", function() end) - -- Close when clicking outside + -- Close when clicking outside (but not when interacting with spec/class flyouts) dropdown:SetScript("OnUpdate", function(self) if self:IsShown() and IsMouseButtonDown("LeftButton") then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then + local sf = _G["EasyFindSpecFlyout"] + local ssf = _G["EasyFindSpecSubFlyout"] + if (sf and sf:IsShown() and sf:IsMouseOver()) + or (ssf and ssf:IsShown() and ssf:IsMouseOver()) then + return + end self:Hide() end end From f3d78220bc5519f1da185f2a0022c764d57325ba Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 5 Apr 2026 16:07:22 -0400 Subject: [PATCH 29/44] Add custom radio button and flyout arrow textures --- flyout-arrow.tga | Bin 0 -> 16402 bytes radio-off.tga | Bin 0 -> 65554 bytes radio-on.tga | Bin 0 -> 65554 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 flyout-arrow.tga create mode 100644 radio-off.tga create mode 100644 radio-on.tga diff --git a/flyout-arrow.tga b/flyout-arrow.tga new file mode 100644 index 0000000000000000000000000000000000000000..ca7ef8827afb0e409632713bdc65d74d2508a653 GIT binary patch literal 16402 zcmeH~&2hsZ5Joj=AopC7V{R%T?m#8D1eZVwDnTWv1WHf|y5;H;l5d^t!5*2hk$`2j zo?$fRNARB61vkx0!=FQQXg)sqn?22d888E8zzmoHGhham4ZQj9cDZFAwcvLsGfKA) z;%mx({vSN4c$D|+9=gBX@Uw>=^{=|g|LE@UlZP|PNxOppKktPJeZI7Mcz~ZhjOeQ_ zH$1`59vsG~$qfG2E3=r+^`Wpd7zkZC{^W#4f%O5 z%y?&4OKwQO&mKJ9-Aeb6ke@vam_uw#WZ)+cm)sz6FJ$89y)a`wQRii5e)ixouZZ)q z5I=k9F@MqAP?(=Q6unCe;OD(CVJ+EngCKtPFk(&Fa)UsA_TaF#GYZDaKuB9i`eb?Ee9CB)`YR z`b)@9G2X_mE7!OL{1oF|uJx~-XG8u=jN^S`_xh5njrb|XMVx0#jG?-f{1oG&&a)8f zQd=Z{(L3uBM{HdLzr&cSVm##L3H}j%tBmoL{1dpVj`2u-it$rutcJEb`@cuMnivoL pPB;8?o~euR2l!`jccFe-+rZswWiu`_U9?|LRom^dN+t(mZ9LhkPPUv|uLAj^R)2eKTMGef3pt#flZPzx?vcX813He>?m;;NJ=Vb?{#g|BdbK?VI7h z1^!#%zYYG|;J+3ATj0ML{u|-H9{%g#-wFQ?__xD<(Hn2P(cIS7Hhba1g}K*Xe|_>e z&(<-0d*6KX&9sh=j)s*hSI&d~68Lw(zYG4|@ZSdi-SFQJ|G(ki3;$#AKMDU+@c#$? zeegd6|14Hn0X8=*9-NV*~rJfy3Cq32dMb{sI00{sI00 z{sI00{((9mz(2r0z(2r0z(2r0z(2q~Q1%1-1N;O01N{47e**T0VZRS;hkrNx*T8>S zYin!EE3drLfa=Ccc8{6c_~C~ia^HXdeG~kbqE2jJ6a06<{}B9-!#}`3z(2r0z(3#v zf;Lbtf69I_{sI00{sHy@_5t?C(INQng8wGicfS1c%S%yHOG`^`%w%8#9tp!PWdmBe}H{}y*dH^gRtKY`#;f&`Sa(u zy!6sbGf@^hmSKZG|NQez)C&Jq@b5-@;NJ`XGw=`a5Ax5qfkNd!)&ZsXe|>+Y{9zvy z{LjF?7xsHlH|$rv_~MJLXl5B%a9*CediCnuFTeaUA9cWgBmDnDN8umfAK;%TZ3BgT zK-&LD{`!D){Z9<|pZdQTf9n21zF+^}U;fXs53o-J`vCi+=r7oBL>=?y&6|&M=Ox?$ zZu-|>f2~7}@L!HLz`qCnM^S)(kbl|+2IC)NfczM+68>jnzk>WzzCV`ze)ebi2e_-F zaPL7IUU=b!<*2c_xjBmyv5C&k&U)A{MqRMqfqLN|ROBCRAo_sFKh^^S#Q^E@ZygZh z|NQbFWB;`OPsRQN@{eU7-`~%la&P&M`~&O*+MIC4>+>fB3BL93FC^Zg)(b=O_t_SPa{(swTw}m_IxFgJ*IWydQ z@4ca+p&`tkJv%)1*khrwu~A;hb&JLQ{$?!y@|XJn>=Rql-p~=!x)pVxx!B>P@ni?^ zo`RauYP1s_M+y6U8z|%h()B=$1M~sLfyQ?-mx+8HfBf-;o1CXlpB}Ee?z&J{R~Ih7 z{PJ+gC6|PYF1jd8oH#L%zvsyJ{Osb3FAkSoc3HUMiYvlZS6!8S|CU>BNo?kxd+rId zX3YwZKKf{45BhVkbjI@E&Jq8@Y# z1r^!HWAp=sY#`PF#sP9Sj8GDg>XkJAQp^5X_KB~8`6<+c)}j{brWPd?cnJCx%0^VkQNt3I@s`&)-v(bc54=KkPoxo^b={@}Pj@-B2NWCLj* zVA;3t#&H+R+Wq(6A8x++=ESFyG3WU4k^bNM!usW=n{EpF1Z~ooL%)<4?-%p?k$eA9 z+eZ7*AE=dkt|>nNW9xf;zU6*DHgHav=e_^@U(5z<#~Hs{HjLqJyz$1w7mqaO!G2Gl zVEthFq8-egJ2yYx?~nP4l>KwsBiaD$*P#|v6Y&6Sjq{E5tnc;t3F~rfK>u$)kK^;U zqm99h#^q63Y|J|fDleY)+i-ing;eVTjvbs-zDjqhA6+sXFR=tE;Z z*7{LjV7t+NC))}3B}M*e?!M0Bp8W6m=s4Pn+R>QmD~9`2WPiSWx<}Hii@i?UfOWm& zZr;27Z?#a*mur*uE!m%BpQCZCv0)kP6Ptni5!8*^(3lxBB3^5Pdl#}lzrSCn82k9X zHej3H@fO?8wxw&?-w$RJ)(6Hi#m~O+(-8DcmVcYsEOYj^*`9xBgb%e z!M-oe-`A0Kp`+{_XSL7Pc$u3yFJ#}c_iLY}abX$kwShk3;$3JZ8rAs&^wBtPXEE$I zqhlzjQfxq1(A-w^B@Q5SOVM#8=n z8!*`TBsFCZ9_#fl*Iplo39k4$w|9l%L z<_nDT^?BC!B>cRV@qqbYOjwM69`|Sir+I$|T8?rfZ2*+v`LN%BdQtz}jY4*it`BVA z+r~4_AJ4H~sdjBlXj|8QqS!V}+d&cb(FS_?d;^+4lKdHunF;?6)T1w#e^lgO$QRhJ zXWx$P`|;SuE0uZvdA1GhD|FnzKB5A?KJqSf?16s=8jx|$(6Rkpj!9SUs z=P}wq+8?BSfnzz|lYP3C=Fxcno^zCp2aN?vu3zH{5VT5+lht-v)fIi1zUQ3X~fz17OZ&6a4=~ zy{La()u|42;p<~9zf+eu`oHJ-UT8gUPC&*g626%R&gL0LB zRJ4J#Pq034uBQDwS(O`FrX6ZNoUh;i z-~axX^a(jmTn7I`oVx~9tqyRHHEf_89e1t0HV{?F7F?6%+;`VzoUiPvb>H@tI#%L3 zqY`c4IOo|HFpo40`H%I~lhG3QSaY8xr46JNZ6W#w*W^e@rR;vX z*g&7-IB7*&@EGmDy%*d!!8B*whsHhTpX~t01mZlg5^SK4bGD&HsNykU_D`!r?P#}q zskx_WT4`JGwRYedc*pq)?XeK=Cp(sB7mf=WOWPk@0{eK*Zq98-754?xj+>4;(0Y-0 z-@Z!sT;T^ZRWT174{%J-c>yKz|C{qVQN?Qnh~?*?b*R_4J}TNm{?T!M)0fTfs=nqKp$sYRg7;PbabiBv0-mK5J`uy!naU9kBg=r}| z#(C>e#p?r@yU~m`qLa?!D5Pi)9^>5JY`m}f{2dQ;erOqOfRnIyUTDQ_K%bSwINoM- z%JICxIDdaIIp4!JO2&Uo@OM6-eSmG1@@Ji<&qwqbc|IpC*`H;gW5$1Y zxX1ETX89j7f1ZoB5N$<$J|k@~W&Fn!{~`N-o{6^rZ9)H_8(v z(dhqybt77jd>(G{T+Cue_wup*@3Rw}vle5P>{uOJ7>)j4SkFQ0P_KN8$vwQ3{eRB; zbKZK!y*k`uK90ut54`J<&p`h>GR{9b<`-rEzjJ?F!yMC{9jjvpqcQ#q>*=TizWXEN z^wDP{rscd}*Li2-|J7lhE<2+!{{z-_s2#?;)0}ltTG}eE>I)^&oyf3o$ z7v;58=uyT1?qz73CF4Id{-eGA z6MhrmJr8xm`gr8+G4l4<+S=ML*M2)sF{=ZH#@@0s+UvjN59y@�G0C}EBn9b17O{PHlY*6 znEU+mD8~X^tKhR8vN*7O8*seO`qMHr+WSArALb3H6CIR)Eax60bC0(FqI|xCabVUL zP(J^$w*MQ<>tMYUZAWL)%=5Xs*ROIs*uDVQtzp@)aH*EXA${*%Av;zOWC;IqU*7Ldh{PXw>bbWyP z0A^zXgWG`ZrT82G>ra@Lsy&=LtouJ}18{Fa-S9pd*~hZ(F>;UW?F;Z8jANX?>{u3; z$y6W@%$xN7?`5zbk^SH05A&I575Xd9y%>9A0Oh)H=LTnUgNoaJeEx%b=9asGW89WBP)av#}ybZ)r!WqUBI2mWgV=81k^KUu~eAF{XXkJ$5H^Z_uRi8}D{ zJ<;FGJ1WiH@!%+9LGRCYZ8k^zUmGwltQ(y>UB><%(MR;a-+f+&{n;ld1k#6!-PD`Q`WSnf~5eFs{OaF^KqVlvA+GH&Xp@O-ZSsq+sC~<7Nd&y_ZupIzYqI*xUWRJP+yvT^!M=?*?Y7- zU_9u0k@L+4w0GGX3zspcE0%lL_4+F`yG9WRx={1x|;#Dw+{ z+7=#9eMH`i_hg$#JFg(tGk<)phx_@sr|*p6=2^-0VLl6Wpq-TUQ|YpwF7vV6NA@0V z6DZq;ju+Y&W{gzJ^?_;k9=sR(b}DG!C*=a(?&Y=<+0Qep5;i0C9oWx>`)bsK`m}+; z*r)kx1NISFKk6&(C#uCcgr?cFdk@BW74+*x_Qrem@vTO4M~Y*mzXR_{s2QzAd&^}X z{eV8wJ_7p+-TU6SsTSe_(`Z^P^QPS~j|%Jh$bT=`*P>=LsZuT@`8~K#K`pRfhxSJs zNc;QvI`U2*)6YD%+!!xecRFrh+i5J?foU>rrqMW7|22d%AKANyyL&jdpeZBCaHzip z^E%Xu{y_U9@ANUvJpDS&-`CuG@+@TisCjE1TgIFpO*%(aropsC?xxWgrp&n;k$3ti z`#(@C8r43YN^Jq|S8D?>Ux)UhzQ{k#JN-JJefodv1Y;=kSbw08wY}&X8P`dTlr8vM z{-(ce8cd66GHn&c_G#|c_xgOxz3i{9H2=rK_uyTJTF_e5gHEM+r(dU87kW)zB>7~2 zfzL*_@6fo~7|Zf!``Wd#mdR?i8@!g+^xFQ0zvXZG+or*^n5LM%AsmhKjrFYW_4zef z?(^*q=2K8JT8(zX{CJvsd|ilne2=^+n7^(wvfNqU%EK`w<3-!sva(Kg?;PWC>uC3- zC}bPH=VzYdxqip*dJV7THNCdK;cxkyWjvF!VA}KU$@aZ%JmY+0y_%};^O@)W5BEuE zF04DyR&*qC_ZT@BJCYxR=Ar$m*1h(z=#OQkZ?>%3->$E=?7EM(Ow;l`Kl2>V^*eso zYj`cM>9s5DvmGq$N4P)xc5L7KR}*o5KI2mUhxIJfj=IqS)K`poe4qRnEdL6AZoA7E zTyC}{WoTQ%@+;G{e9zB3$8-IT-yLGxtzf!R_hVgef4+UX?PyH*?Ucf|lyhJ{1GS+p z81F*IBI`m&@@24mti{hA!*v|bu^jvJ$7G*wDLgAV2li7@6TDZV&FCOHjn1hH$d_7V zJ5ZXPx8vLm$L}4(Z9-#eETo_^`N8VPp$DSmR|40cHIW#e7#0AF4}h0vH`CH^BkIwI*@bP zUDr^{c3|4Q2k*t$em=@oGtb83wO~J!e6sIr6*4y7gY=zeP_Z&LR^B&_rgaZ8?cRg; zGNSYIYuzr$uZiXvvL9>(a&7B&WbEYg&BqGwm=4oq+DxNqHO;2od#Lys+_lcJ8tEth za>zJ&De6R)vD%G|Huj#e@4S&nu^tVleX)#TvEsN{^MK)}&$meNi^Lrl0CCK>7c9l%F zq1|Xd@;TVn$H&k~60BxU5|{zH=`}c{&txvU;Cb)d5-7$9lz@}yq4E&M*fDs zHF?m4WhabE4#39P(Yj(fsz-BBBa)wfdI4I9>~~eZ_B}uI9MAPTe%EVwtt^Hc6`I+P jW;u}MK$Zhp4rDoy5~)`}&sk#lM4n2m4A7#{YU@F9muj&`W_{3iMK-mjb;M z=%qj}1$rsaOMzYr^irVHQs9@r{N=J={Nfk=KmYm9*X-G|XW(Z)``N~y{`99qyLay% z`N>ayGWz2m|9I<dAuY}>Z2^v!R6b48aYYg<3wQ%^m$dUA4daNoXto8i9`{*&;ZhW`xw zPr(00_@9IS>+qk2|0Vdp3ID6`e+&L|@V^fK>+qk0|6B0C3ja6Ze+mAxuzwx)=Rp4= z>`%ac22I0%68<~C@r`e6-nw<`;Mc$Y_0?^iSX~mi-~H}){lER~ZwL49-#?0WV*>}V zff;PzBsTC0Ht+^Ea0MHfgMWa3fPa90fPa90fPbJ42=EW^5AYB05AYB05AYAL59Iv- z{{a60{{a6v*k6JD8?b)`orM1k{13u^_t@Cj=-0mXwL#S1CCS}3kMV~;{Gs&w-~WCX z{=3i=HgFXFr{Vt^{4c{lz(2r0z(2r0PzD5TpjQ6m{Yv};`~&<0>;vor>@TC&;C~wa zM`1tp#1l{KLc^n@qouY0;2##_ zZy8Wa{;!dLfPH|yx&r_6us;d=XVKm*TeggT^{Zc9hkCwa4L11aKmU0h8iW6S_|Kp- z@SlbMb@&JP2l;1hpj`fsbwD-czh!@3{;&@U{?}nY3;Q!@2KM{E@|CZQp>;JR!A*JS zfddChfBDN_wxCJ)AA$c%=py_B`~&G8IUgjBY(?)bp4MH_)qnJCH~a?<;s4` z|M~oXgMEN~BG?DmUqmm#{s@}fym|8$RJtkQF7nWS{p(-*(GdLipu_M#3;&BKz(2@8 zZ39d3k3K-w2h79&hVNIFf2!<{d4Hb$4gLY{>LT3FqQhVQ@|X9Zp^=f1o}Y+KOifJ< zzEC=HMSxnt!wb^MAGs$ohfhd_a8w`*|$aEyr!ITRvMK%i!_H z9}kZ`_Sgaq4-W@8B)@x{$Lr&jY4) zlIvFT`@Lt(|MHjnBJ2}e)85bp;(8oSqD|Q0@@}#Nc&|hw=m2^FT}BD}tPPYa1JdfS+gd*_r328{r&yno$q{Sxc&Cq!>za88kQ|v7D(R( z@;iTf``h0h-tmriggfuNGrarV?@rGD;0HgL*vv;i`q8j{{rd3P&we(s2g`Hwe$4-A z_F4X^azBaZ4ep5zXs75hdI9za&I#=m*GM zKhAc!49uVA*Zc0fFWi0i-C@Ow6=fM`nOFP2wxR93>s{{(t5>g1?CQfG{%}&JeC9Ks z36DPdXi~4F`B$6wZ?I3wDwtnIXVKGWl=`Wo+lYVv``_<^^B6jWUPf~$VIRZD-Z1j7 zR1e7AcAIs%b+>uZGW$L6c~6pWtL0hrxaG^2hm|W=Cbpo>J^0{*;gg^IWO(F}N9KKp zYRdf#`v7w_hhFCS4xur07xC@5KlqyO$FPBC2+u{{<-&3{kS+tv`}W;9?qXj1_{TpU zKJbALBxO1Yv!EPr_W#xw)-UgW|NDbwf;OqoVY!s?_bZkAk^6k8ZKHGO88pT-cT_n5 zW9xg%eDnP|Y+ym2=evLUzmg5uj?;fPZ|K9_d+)tTS=?;SOWn^h!TQ1YMLXEEX;aqU zpZEDH?cxN9%*@%iVFu9WBl4 zOWAqaE`usJr4=|rYXVC$)iDzuvKX|W4w(o7@&HMR$ zo-gm0vH|OP+f(Lw%hI>K?QI>$9zaQ)Z@J}`q#dbmU|FznCfeCz&5^fv1}*XPh%Mx)3MV-%L3bt_B+{5 zurDd{PjmNkhI{hA$D_;WIGR9h)mIGnRmlE)`*bg)Sy%d;wgKyU$K8B(``Cae$iW%Ok~_OXv8K0&mBMe==QAH!E+KaGaDU(5esKY$#=Jq`Q0G=D!w*5yLk zJI-pKt^P6(b5qE^WuMnROZ~zc*lPoG_{FEuKGf3r1N6~2Z)XSWkD*H_sA_D$GT(eJ zd;O!Eg?)TZ`wuJG0E}Z^|ECb&IV`v9zS>F)shtM=~?nc7C8XM5pv%a^?znSuV zWFJFqz_BIk0{wx-xceKd(>l-p(`bn6HT{qA`4V!z-YM8$lYiC*DwPHL`IdRs_e8uY zx^3{`aL4BDVdaxgCA#a`{|Hg>JAe1MX2r?-=nt3<`h=DEXShcjxW@OV&>mFcT21{U z4{w3}VKj^8=Wdj z#kOJE4l1yZHZaTYhtZa1!e=~Y9sDQJS<7_ovo_)Ny-y?Tz&@gT?zty8$3&kPp4I9i#~#6d z7()$TX9WLsXg_)h{>j`t!)OEP@*rInIF{ox*{562Jaqdbo5SjV|L=x!&)R|a^Su&e$?^M2$8%0rz3P1PVE@1T zTT{7b?ZErjvn9WA@9KW~Ruh%%9K3TK#e3pIu-+0v1M|3{P``-7y#7B~G)&~5pg3j>$UQ}w70Wjxs z82-cYcV^}-*>FTK2PFRSeCnuuID4$YD42w&-%drLB|O`^{G$I^RK`>Z40v; z^DJsezc78Z{b(09a1vd2{;f6;Rjxd+f7dx%)@Swb_w~W6Wzve{j9)!$-1dOug^n5P z3s%A2W3Ho9{xbO3~-F7N*lPsaYs>I{Xo`Nt%m<@blSBxN*jnOX9v#DcWkq0|GxfpyZCyQ zm}kE?zV&Dqr2T*@`D-Vq(QZ_?J*ik#^*zOthvvU(gxCswh+q(*W?A62jY5B{qu+q0v4J-nzl(~tkhKTb zV7m_2whK4TCG&o#`1?!Qq|Z^*7hJV!RZ<7I?zl=Dc!T4n(S}0sIhnru5j2AhTyal@ zsAvZSg8L>I=e+#uJ$|3%(d_?yp1f({dA0)_6NvM~s<44MjyZw0qugV{ z?4Q<;CeVxSrRJWhX{Bw!&)R`&;2q~Hx5q*?X<*+;v-vdRent9(92eA=wm-NE_VJh( zId%f&?hB?Jw+2n3bGGTUfvnQ@;C?%+YeYw1cdoO<4EaSLvQB+%T7%cb84OZusBl%9|#h=Xii)g3b%5lK<-* zH-&Pq5x|$yQ01 zhtPBA5=!>ak72Zhbm(}GW4(F%e4Tq;Deb!9`yzA8?MrbS)%1mFHM+!c&!ODw1DLxp zf{vg!oySp5(H;!r+}^z7ecjgX%i3&_cA(>d&JV4j4e%!Hofn$B4d}a)7{@z?t~#E# zl-%=7-`}`W;MA;c_5Gq96!8IVPuUOX+*E=mfg%{Qafmd=J|wJ^tm_n>Crg^8xJx9M8+2b(+2( z(Rbwep0s3tmc_yz|MJ{hl>ZC!=e=m#&~Y^9JJOa?QU1O9zjFO=ncD2~pLgPIMbD$R zQ1XtvY-pcfl>Dv#d;VYfa-fL+-wOW^uA}G}x*8c9`kth6a-D~BzVf#JdhP$p`M(wI zKNydoBj`G`&mrH3o4gmZQs`bjw*P&1f^*jL zwgIBA)Qw@@H1WJv^#8(oBRYg; z9vNpt$NZw~|99?>Ynb!$@76Xz5#P_WY{mF5tk>+<$JQ6pW--m-Zr4N-kblo&<0vH{|nB`&~`YVKyzu{ zvQEo+pUS<990STb1}y(>YX=q?_cc9QHUE=)_rZJw&NJvrIo^@G>-?2-{~e<$ve!p% z{{O=K->UiF@`w2bG!5rBB5T9QJBH5vQO^H$t&(HaoSs|V;s+M>`%J@F_qSsG2dqnI z63(wg)`pRH4E6n@jHh#eeHThz8|bb7`~SSt>h)i+UV(O^laaMyWF14_g&w63;9iEd zS@QDtyx#iXzN9a$mFqv@&x~`_H2s3vjK1 z?{+BS2gda16knhodw%%Lj`vxAnul7w|C9V-K8U8!dHKhDZWx&x+Ww33{SNwpMfw6F zf5Ud~7b@D%lYZ8A+y4#b{jlDJPNM5+=2`CU^{X5YwlBbSEA=P?Ods=CyVV88r(XHq zXH4h)cG>@tc=f@37}n3C*-FgiZrLAY*<~3}4?n=PNz$i__y6_ztoQYuYgp^&dJxz9 zHEjP^;?)Q96531oekPXjF|TL2`~LIz4s^=^_W>+2CSbaxWxJr&ZA0&GJks$h`q}nU zd=G&2Crqo=8IEn}{?9yHAKXXL47@K!_A##;M(&ZleE~j$evI>19m^`3M(Oh{cbmOG zr1z_5c^{w0v`O#(UIY84?Eg+Y`(VBf?ME-AxmRMZ51?EZ?%d!abAw_!Wkd6z{p^kQ z3%tJfsdt`_&lBJO;GVfPu=m)ez5fC3%V0f*j-ZQ`xSQ`Idqd}j`&_mM>sb%Pv@?{8 zK6t(74|qMV+bHhF-88Y>x16kDj}O_K_nY?q7vkCn^L1#FGX88V@8unp=I(fKl)j+P zXS+7<9C6dC8rcV9Ud=n7(DOXMUVS->#o4s5ZglQ+4f}hR{;JpW(Lf4k!E_>GpE88_bZ@v13Dy3~Fe6K&`_+AbB zI>dZ`4eqDV9#rC4oBjv)A-GSY^O1in>kVU`PqUZ5*I!B-p{vZiau^ecp-|XYl zA97EJ8v1!6f8X`s9-q1QcPVNxFz-h@&@p&litN+ev+P|Xrigc9^Y#lAohQ&vOskEH zzP|mU&Xudt-!tvp+sC~JK#2CnWw& zttWgo>pRPQ^L-BX+Q6&Ga=#((`HD|faQSxkA2>b z6EwPQ*lh0J&u7xl*Uqi)bCmhAzkuA=c^s{(g;ArAh5K4G0rTVNa<%N`uXvusC$x{y zws52Rh+4tkXYrYA^JwQed_B|0_jKjyuU}2){dAs>`988Y zv`wIF8#-QSUzk2pC)WqY-DmJw?AyuFzEAQ6yxq&~1!Osi&=r37!I&NUwsU7XWI2kwNs2^+jRUpqt_U_^C9?qj^ zWiuHTy%)^;(HMFLor}EFVVZgRbDF=OdG_R8$d;p~t!Zo?bAB}OY^jWcaf#fGqdrWH zb2lRIbSV30&=_iIA5UIefcstA0L%}em(g70pXQzZoMoT>-#S4b$~3k-u#C06=zcS< zlWLYNcrWkiy^Vu$F;2!Ur*EI;Zhdc=Z@!oPU3t^L9nOPyKN>|(qqFF0ns@qhnsvF) zq(zcW_80hWbo&nVtM#$Wf3~k(D{G#tXS=~`c}=hFJ-nCq^xnq7xEQAxzXG9tzP_II zy=8t!=KHMO!F(keK?l$aFu$DU9zU039={_k%BHXDjLdh|xAJgINq^Dywydm^-8)Br z+&bEQDazS~-}#%zc&z7muGjEdUejxP5AWqYYj`JT*|=w($@aZ%JpFuqy^gByv&^&q z!+km01nWt399@Xq4I}4DA?dMX8rq*~-D@9<<*}?Ro6W2Cw_8@5cil%@rfK<|zj=(u zdXDFM4X@=jy>`w%+oj@uf#V_k25zJ0nAsIB{Us^MGBF)&|? z#?ds4Poqnbb-9pqSt=bn@ps2?9mjJl$Nv1b?9;7=XP#qVzX}b*dmlQ6&ZBE+L0v$) zbRyfu;_SQ~=WaND?-=edYOArFJUsKB1MdN}16dZFLbGVz21uL5(z4TjbH1K)w#@U+ z-Dr#P`@HPxbsoI?(Gc2$4x_W^BI(mqsa2zV;)1<1@&z^E#@O$J*t4<7iyZAmi>c z_$*DGpWo?rL3%AS&5-?Idy#8fPa=IM-*4V7ykk6!lW{YS#??3*cb_5mJGeWYV?E+e z`jwD=@-8%mjw0JmuOa)*B7ieUuJMl)AWi$xATnR=MhB5) zta0BT!ktL}%664Z zPM{ajIplk=t&cCEH<4p3Zy|YFS1UjJoxgdE$9j(EdJV7THNCd?@Lt~2d&_)v!ydGE z;U!am^k0EWXagES+tCD)lV!4Fsm~$(@MGwCWPiI%m7o32-#o@+J;!sshS&0%BglJr zuN6y1tam_5QUEskj@A`x&;Z(qhLHR$r?;YQ$bMJlXTS3|kMUT~@m#OrwR%2WOKA3P l+Dn063iMK-mjb;M=%qj}1$rsaOMzYr^irUg0`*9N{{v`d#03BV literal 0 HcmV?d00001 From dccc222b1635afef7d8bc664ffab4761df389fac Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Sun, 5 Apr 2026 16:07:31 -0400 Subject: [PATCH 30/44] Rewrite loot filters to match EJ style Cache loot scan at login so spec/difficulty toggles are instant. Replace multi-select spec system with single-select class/spec dropdown matching the Encounter Journal UI. Add single-select difficulty dropdown. Always search by name, slot, stats, and source (remove toggle checkboxes). Scale filter dropdown with font size. Extract shared radio button and popup styling. --- Core.lua | 13 +- Database.lua | 516 +++++++++++++++++---------- UI.lua | 968 +++++++++++++++++++++++++++++++-------------------- 3 files changed, 937 insertions(+), 560 deletions(-) diff --git a/Core.lua b/Core.lua index d8b154d..30e4d4d 100644 --- a/Core.lua +++ b/Core.lua @@ -112,6 +112,7 @@ local DB_DEFAULTS = { lootSearchSlots = true, -- Loot search: match by slot keywords (ring, helm, etc.) lootSearchStats = true, -- Loot search: match by stat keywords (haste, crit, etc.) lootUpgradesOnly = false, -- Loot search: only show items above equipped ilvl + lootDifficulty = "normal", uiMapSearchLocal = true, -- Map search in UI bar: true = local zone only, false = global } @@ -330,6 +331,15 @@ local function OnPlayerLogin() end SafeInit(ns.Options, "Options") end) + -- Loot scan runs synchronously during login (loading screen absorbs the cost). + -- Pass true to pre-cache all class/spec combos so spec toggles are instant. + if ns.Database and ns.Database.PopulateDynamicLoot then + local ok, err = xpcall(ns.Database.PopulateDynamicLoot, ErrorHandler, ns.Database, true) + if not ok then + EasyFind:Print("|cffff4444Loot scan failed: " .. tostring(err) .. "|r") + end + end + -- Populate dynamic currencies, reputations, mounts, and toys after a short delay (APIs need the character loaded) -- Spread dynamic population across frames to avoid a single-frame stutter. -- Currencies/reputations run first (they toggle collapsed headers, must be synchronous). @@ -346,9 +356,6 @@ local function OnPlayerLogin() ns.Database:PopulateDynamicPets() SafeAfter(0, function() ns.Database:PopulateDynamicOutfits() - SafeAfter(0, function() - ns.Database:PopulateDynamicLoot() - end) end) end) end) diff --git a/Database.lua b/Database.lua index b944e63..a820b3a 100644 --- a/Database.lua +++ b/Database.lua @@ -500,14 +500,107 @@ end local lootEntries = {} -- track injected entries for re-population local lootScanGeneration = 0 -- cancel stale scans when re-populating +local lootItemCache = {} -- itemID -> entry (persists across spec/diff toggles) +local lootSpecsScanned = {} -- ["classID-specID"] = true + +-- Maps user-facing difficulty keys to EJ difficulty IDs per source type +local LOOT_DIFF_IDS = { + lfr = { raid = 17 }, + normal = { dungeon = 1, raid = 14 }, + heroic = { dungeon = 2, raid = 15 }, + mythic = { dungeon = 23, raid = 16 }, +} + +-- Rebuild uiSearchData loot entries from cache based on current spec + difficulty selection. +-- No EJ scan needed: filters cached items by spec and difficulty match. +local function RebuildLootSearchData() + -- Remove old loot entries (filter in place to avoid O(n^2) tremove) + local writeIdx = 0 + for i = 1, #uiSearchData do + if uiSearchData[i].category ~= "Loot" then + writeIdx = writeIdx + 1 + uiSearchData[writeIdx] = uiSearchData[i] + end + end + for i = #uiSearchData, writeIdx + 1, -1 do + uiSearchData[i] = nil + end + wipe(lootEntries) + + -- Build spec lookup from current selection + -- lootFilter: nil = current spec, "all" = all classes, + -- {classID=N} = whole class, {classID=N, specID=M} = specific spec + local lootFilter = EasyFind.db.lootFilter + local wantSpec = {} + local wantAll = false + if not lootFilter then + local _, _, cid = UnitClass("player") + local si = GetSpecialization and GetSpecialization() + local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) + if cid and sid then + wantSpec[cid .. "-" .. sid] = true + end + elseif lootFilter == "all" then + wantAll = true + elseif lootFilter.specID then + wantSpec[lootFilter.classID .. "-" .. lootFilter.specID] = true + elseif lootFilter.classID then + for specIdx = 1, GetNumSpecializationsForClassID(lootFilter.classID) do + local specID = GetSpecializationInfoForClassID(lootFilter.classID, specIdx) + if specID then wantSpec[lootFilter.classID .. "-" .. specID] = true end + end + end + + -- Single difficulty selection + local wantDiff = EasyFind.db.lootDifficulty or "normal" + + -- Filter cache: include items matching selected spec AND selected difficulty + for _, entry in pairs(lootItemCache) do + local specMatch = wantAll + if not specMatch then + for _, spKey in ipairs(entry._cachedSpecs) do + if wantSpec[spKey] then specMatch = true; break end + end + end + if specMatch then + local diffMatch = false + for _, dk in ipairs(entry._cachedDiffs) do + if dk == wantDiff then diffMatch = true; break end + end + if diffMatch then + uiSearchData[#uiSearchData + 1] = entry + lootEntries[#lootEntries + 1] = entry + end + end + end +end -- Enrich a loot entry with stat keywords from its item link. -- Called lazily when the entry first appears in results. +-- Difficulty priority for selecting which item link to display/use. +local DIFF_PRIORITY = { "mythic", "heroic", "normal", "lfr" } + +-- Returns the item link for a loot entry at the selected difficulty. +-- Falls back to highest available if the selected difficulty has no link. +function Database:GetLootItemLink(entry) + local links = entry.lootItemLinks + if not links then return nil end + local selected = EasyFind.db.lootDifficulty or "normal" + if links[selected] then return links[selected] end + -- Fallback: any available link + for _, dk in ipairs(DIFF_PRIORITY) do + if links[dk] then return links[dk] end + end + return nil +end + function Database:EnrichLootStats(entry) - if entry._statsEnriched or not entry.lootItemLink then return end + if entry._statsEnriched then return end + local link = Database:GetLootItemLink(entry) + if not link then return end local GetItemStatsFn = GetItemStats or (C_Item and C_Item.GetItemStats) if not GetItemStatsFn then return end - local stats = GetItemStatsFn(entry.lootItemLink) + local stats = GetItemStatsFn(link) if not stats then return end local statKw = entry.lootStatKw or {} for statKey, searchWords in pairs(STAT_KEYWORD_MAP) do @@ -684,10 +777,76 @@ function Database:FindEmptyActionSlot() end -- Called after PLAYER_LOGIN. Scans the Encounter Journal for current-tier loot --- and injects searchable entries. Staggered across frames (one instance per frame). -function Database:PopulateDynamicLoot() +-- and injects searchable entries. Caches results so spec toggles only filter +-- in memory without re-scanning. Only scans specs not yet in cache. +-- Optional scanAllSpecs: when true, pre-caches every class/spec combo (for loading screen). +function Database:PopulateDynamicLoot(scanAllSpecs) if InCombatLockdown() then return end + -- Build list of {classID, specID} pairs for current selection + local specPairs = {} + if scanAllSpecs then + for classIdx = 1, GetNumClasses() do + local _, _, classID = GetClassInfo(classIdx) + if classID then + for specIdx = 1, GetNumSpecializationsForClassID(classID) do + local specID = GetSpecializationInfoForClassID(classID, specIdx) + if specID then + specPairs[#specPairs + 1] = { classID = classID, specID = specID } + end + end + end + end + else + -- Build from lootFilter (single selection) + local lootFilter = EasyFind.db.lootFilter + if not lootFilter then + local _, _, cid = UnitClass("player") + local si = GetSpecialization and GetSpecialization() + local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) + if cid and sid then + specPairs[1] = { classID = cid, specID = sid } + end + elseif lootFilter == "all" then + -- Scan all specs + for classIdx = 1, GetNumClasses() do + local _, _, classID = GetClassInfo(classIdx) + if classID then + for specIdx = 1, GetNumSpecializationsForClassID(classID) do + local specID = GetSpecializationInfoForClassID(classID, specIdx) + if specID then + specPairs[#specPairs + 1] = { classID = classID, specID = specID } + end + end + end + end + elseif lootFilter.specID then + specPairs[1] = { classID = lootFilter.classID, specID = lootFilter.specID } + elseif lootFilter.classID then + for specIdx = 1, GetNumSpecializationsForClassID(lootFilter.classID) do + local specID = GetSpecializationInfoForClassID(lootFilter.classID, specIdx) + if specID then + specPairs[#specPairs + 1] = { classID = lootFilter.classID, specID = specID } + end + end + end + end + + -- Find specs that haven't been scanned yet + local needScan = {} + for _, sp in ipairs(specPairs) do + local key = sp.classID .. "-" .. sp.specID + if not lootSpecsScanned[key] then + needScan[#needScan + 1] = sp + end + end + + -- All selected specs already cached: just rebuild from cache (instant) + if #needScan == 0 then + RebuildLootSearchData() + return + end + -- EJ loot tables require the UI to be loaded first if not EncounterJournal then EncounterJournal_LoadUI() @@ -703,22 +862,13 @@ function Database:PopulateDynamicLoot() local EJ_SetDifficulty = EJ("SetDifficulty") local EJ_SetLootFilter = EJ("SetLootFilter") local EJ_SetSlotFilter = EJ("SetSlotFilter") - local EJ_GetNumLoot = EJ("GetNumLoot") local EJ_GetLootInfoByIndex = EJ("GetLootInfoByIndex") - if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetNumLoot or not EJ_GetLootInfoByIndex then + if not EJ_GetCurrentTier or not EJ_GetInstanceByIndex or not EJ_GetLootInfoByIndex then Utils.DebugPrint("Loot scan aborted: EJ APIs not available") return end - -- Remove previous loot entries (handles spec toggle re-scan) - for i = #uiSearchData, 1, -1 do - if uiSearchData[i].category == "Loot" then - tremove(uiSearchData, i) - end - end - wipe(lootEntries) - -- Bump generation so any in-flight staggered scan aborts lootScanGeneration = lootScanGeneration + 1 local myGen = lootScanGeneration @@ -731,26 +881,8 @@ function Database:PopulateDynamicLoot() ejFrame:SetScript("OnEvent", nil) end - -- Save EJ state local savedTier = EJ_GetCurrentTier and EJ_GetCurrentTier() - -- Build list of {classID, specID} pairs to scan - local specPairs = {} - local lootSpecs = EasyFind.db.lootSpecs - if lootSpecs and #lootSpecs > 0 then - for _, pair in ipairs(lootSpecs) do - specPairs[#specPairs + 1] = pair - end - else - -- Default: current spec only - local _, _, cid = UnitClass("player") - local si = GetSpecialization and GetSpecialization() - local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) - if cid and sid then - specPairs[1] = { classID = cid, specID = sid } - end - end - -- Collect all instances in the current tier local instances = {} local function collectInstances(isRaid) @@ -762,115 +894,144 @@ function Database:PopulateDynamicLoot() idx = idx + 1 end end - collectInstances(false) -- dungeons - collectInstances(true) -- raids + collectInstances(false) + collectInstances(true) - local seen = {} -- deduplicate by itemID - local SafeAfter = Utils.SafeAfter local GetItemInfoInstant = GetItemInfoInstant - local function restoreState() - if savedTier and EJ_SelectTier then EJ_SelectTier(savedTier) end - if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end - end - - local function processInstance(idx) - if myGen ~= lootScanGeneration then return end - if idx > #instances then - restoreState() - collectgarbage("collect") - return + -- Build list of {diffKey, diffID} pairs per source type + local sourceType = { dungeon = "dungeon", raid = "raid" } + local function getDiffPairs(isRaid) + local pairs = {} + local st = isRaid and "raid" or "dungeon" + for diffKey, ids in _G.pairs(LOOT_DIFF_IDS) do + if ids[st] then + pairs[#pairs + 1] = { key = diffKey, id = ids[st] } + end end + return pairs + end - local inst = instances[idx] - EJ_SelectInstance(inst.id) -- select once to populate encounter list + -- Synchronous scan: runs during loading screen so no frame stutter + for _, inst in ipairs(instances) do + if myGen ~= lootScanGeneration then break end + EJ_SelectInstance(inst.id) + local diffPairs = getDiffPairs(inst.isRaid) - -- Iterate encounters in this instance local encIdx = 1 while true do local encName, _, encID = EJ_GetEncounterInfoByIndex(encIdx) if not encName then break end - -- Scan loot for each selected spec (dedup across specs via seen[]) - for _, sp in ipairs(specPairs) do - EJ_SelectInstance(inst.id) - EJ_SelectEncounter(encID) - if EJ_SetDifficulty then - EJ_SetDifficulty(inst.isRaid and 15 or 2) - end - if EJ_SetSlotFilter then - EJ_SetSlotFilter(Enum.ItemSlotFilterType.NoFilter) - end - if EJ_SetLootFilter then - EJ_SetLootFilter(sp.classID, sp.specID) - end - - local li = 1 - while true do - local lootInfo = EJ_GetLootInfoByIndex(li) - if not lootInfo or not lootInfo.name then break end - local itemID = lootInfo.itemID - if itemID and not seen[itemID] then - seen[itemID] = true - local itemName = lootInfo.name - local _, _, _, equipLoc, instIcon = GetItemInfoInstant(itemID) - local icon = lootInfo.icon or instIcon - if itemName and itemName ~= "" then - -- Build typed keyword arrays for toggle-aware scoring - local slotKws = {} - local slotKwVals = equipLoc and SLOT_KEYWORDS[equipLoc] - if slotKwVals then - for _, w in ipairs(slotKwVals) do slotKws[#slotKws + 1] = w end - end - - local sourceKws = {} - if encName then - for w in encName:lower():gmatch("%a+") do sourceKws[#sourceKws + 1] = w end - end - if inst.name then - for w in inst.name:lower():gmatch("%a+") do sourceKws[#sourceKws + 1] = w end - end + for _, diff in ipairs(diffPairs) do + for _, sp in ipairs(needScan) do + local spKey = sp.classID .. "-" .. sp.specID + EJ_SelectInstance(inst.id) + EJ_SelectEncounter(encID) + if EJ_SetDifficulty then + EJ_SetDifficulty(diff.id) + end + if EJ_SetSlotFilter then + EJ_SetSlotFilter(Enum.ItemSlotFilterType.NoFilter) + end + if EJ_SetLootFilter then + EJ_SetLootFilter(sp.classID, sp.specID) + end - -- Flat keywords/keywordsLower kept empty (lootEntry uses typed arrays) - local itemLink = lootInfo.link - local entry = setmetatable({ - name = itemName, - nameLower = slower(itemName), - icon = icon, - itemID = itemID, - encounterID = encID, - instanceID = inst.id, - keywords = {}, - keywordsLower = {}, - lootSlotKw = slotKws, - lootSourceKw = sourceKws, - lootStatKw = {}, - lootItemLink = itemLink, - lootSlotName = equipLoc and SLOT_DISPLAY[equipLoc], - lootSourceName = encName, - lootInstanceName = inst.name, - lootSourceType = inst.isRaid and "Raid" or "Dungeon", - }, LOOT_MT) - - -- Enrich with stat keywords if item link is available - if itemLink then - Database:EnrichLootStats(entry) + local li = 1 + while true do + local lootInfo = EJ_GetLootInfoByIndex(li) + if not lootInfo or not lootInfo.name then break end + local itemID = lootInfo.itemID + if itemID then + local cached = lootItemCache[itemID] + if cached then + -- Add spec tag if missing + local foundSp = false + for _, sk in ipairs(cached._cachedSpecs) do + if sk == spKey then foundSp = true; break end + end + if not foundSp then + cached._cachedSpecs[#cached._cachedSpecs + 1] = spKey + end + -- Add difficulty tag and link if missing + local foundDf = false + for _, dk in ipairs(cached._cachedDiffs) do + if dk == diff.key then foundDf = true; break end + end + if not foundDf then + cached._cachedDiffs[#cached._cachedDiffs + 1] = diff.key + if lootInfo.link and cached.lootItemLinks then + cached.lootItemLinks[diff.key] = lootInfo.link + end + end + else + local itemName = lootInfo.name + local _, _, _, equipLoc, instIcon = GetItemInfoInstant(itemID) + local icon = lootInfo.icon or instIcon + if itemName and itemName ~= "" then + local slotKws = {} + local slotKwVals = equipLoc and SLOT_KEYWORDS[equipLoc] + if slotKwVals then + for _, w in ipairs(slotKwVals) do slotKws[#slotKws + 1] = w end + end + + local sourceKws = {} + if encName then + for w in encName:lower():gmatch("%a+") do sourceKws[#sourceKws + 1] = w end + end + if inst.name then + for w in inst.name:lower():gmatch("%a+") do sourceKws[#sourceKws + 1] = w end + end + + local itemLink = lootInfo.link + local itemLinks = {} + if itemLink then itemLinks[diff.key] = itemLink end + local entry = setmetatable({ + name = itemName, + nameLower = slower(itemName), + icon = icon, + itemID = itemID, + encounterID = encID, + instanceID = inst.id, + keywords = {}, + keywordsLower = {}, + lootSlotKw = slotKws, + lootSourceKw = sourceKws, + lootStatKw = {}, + lootItemLinks = itemLinks, + lootSlotName = equipLoc and SLOT_DISPLAY[equipLoc], + lootSourceName = encName, + lootInstanceName = inst.name, + lootSourceType = inst.isRaid and "Raid" or "Dungeon", + _cachedSpecs = { spKey }, + _cachedDiffs = { diff.key }, + }, LOOT_MT) + + if itemLink then + Database:EnrichLootStats(entry) + end + + lootItemCache[itemID] = entry + end + end end - - uiSearchData[#uiSearchData + 1] = entry - lootEntries[#lootEntries + 1] = entry + li = li + 1 end end - li = li + 1 - end - end -- specPairs loop + end encIdx = encIdx + 1 end - - SafeAfter(0, function() processInstance(idx + 1) end) end - processInstance(1) + -- Restore EJ state, mark specs cached, rebuild search data + if savedTier and EJ_SelectTier then EJ_SelectTier(savedTier) end + if ejFrame and savedOnEvent then ejFrame:SetScript("OnEvent", savedOnEvent) end + for _, sp in ipairs(needScan) do + lootSpecsScanned[sp.classID .. "-" .. sp.specID] = true + end + RebuildLootSearchData() + collectgarbage("collect") end -- TREE FLATTENER @@ -2403,85 +2564,66 @@ function Database:SearchUI(query, skipCategories) local nameLower = data.nameLower local score if data.lootEntry then - -- Loot: toggle-aware keyword matching. - -- Each query word is matched against the best keyword TYPE - -- (slot or stat), then the item must satisfy all matched types. + -- Loot: match by item name, slot, stats, and source keywords. + -- Each query word scores against all keyword types and takes + -- the best match. Words that match nothing eliminate the item. score = 0 - local slotOn = EasyFind.db.lootSearchSlots ~= false - local statOn = EasyFind.db.lootSearchStats ~= false + local totalScore = 0 - if not slotOn and not statOn then - -- No toggles: match by item name prefix or source keywords - if ssub(nameLower, 1, queryLen) == query then - score = 100 + for qi = 1, #queryWords do + local qw = queryWords[qi] + local qwLen = #qw + local bestWord = 0 + + -- Score against item name (prefix match) + if qi == 1 and ssub(nameLower, 1, queryLen) == query then + bestWord = 100 end - if data.lootSourceKw then - for qi = 1, #queryWords do - local qw = queryWords[qi] - local qwLen = #qw - for ki = 1, #data.lootSourceKw do - local kw = data.lootSourceKw[ki] - if kw == qw then - score = score + 80; break - elseif ssub(kw, 1, qwLen) == qw then - score = score + 70; break - end + + -- Score against slot keywords + if data.lootSlotKw then + for ki = 1, #data.lootSlotKw do + local kw = data.lootSlotKw[ki] + if kw == qw then + bestWord = mmax(bestWord, qwLen <= 3 and 140 or 80) + elseif ssub(kw, 1, qwLen) == qw then + bestWord = mmax(bestWord, 70) end end end - else - -- Score each query word against each keyword type separately. - -- Assign word to whichever type scores higher. - -- Item must match ALL assigned types. - local slotMatched, statMatched = true, true - local needsSlot, needsStat = false, false - local totalScore = 0 - - for qi = 1, #queryWords do - local qw = queryWords[qi] - local qwLen = #qw - local slotScore, statScore = 0, 0 - - -- Score against slot keywords - if slotOn and data.lootSlotKw then - for ki = 1, #data.lootSlotKw do - local kw = data.lootSlotKw[ki] - if kw == qw then - slotScore = mmax(slotScore, qwLen <= 3 and 140 or 80) - elseif ssub(kw, 1, qwLen) == qw then - slotScore = mmax(slotScore, 70) - end - end - end - -- Score against stat keywords - if statOn and data.lootStatKw then - for ki = 1, #data.lootStatKw do - local kw = data.lootStatKw[ki] - if kw == qw then - statScore = mmax(statScore, qwLen <= 3 and 140 or 80) - elseif ssub(kw, 1, qwLen) == qw then - statScore = mmax(statScore, 70) - end + -- Score against stat keywords + if data.lootStatKw then + for ki = 1, #data.lootStatKw do + local kw = data.lootStatKw[ki] + if kw == qw then + bestWord = mmax(bestWord, qwLen <= 3 and 140 or 80) + elseif ssub(kw, 1, qwLen) == qw then + bestWord = mmax(bestWord, 70) end end + end - -- Assign word to best matching type - if slotScore == 0 and statScore == 0 then - -- Word matches neither type — item doesn't match - totalScore = 0 - break - elseif slotScore >= statScore then - needsSlot = true - totalScore = totalScore + slotScore - else - needsStat = true - totalScore = totalScore + statScore + -- Score against source keywords (boss/dungeon name) + if data.lootSourceKw then + for ki = 1, #data.lootSourceKw do + local kw = data.lootSourceKw[ki] + if kw == qw then + bestWord = mmax(bestWord, 80) + elseif ssub(kw, 1, qwLen) == qw then + bestWord = mmax(bestWord, 70) + end end end - score = totalScore + if bestWord == 0 then + totalScore = 0 + break + end + totalScore = totalScore + bestWord end + + score = totalScore else score = Database:ScoreName(nameLower, query, queryLen, queryWords) score = score + Database:ScoreKeywords(data.keywordsLower, query, queryLen, queryWords) diff --git a/UI.lua b/UI.lua index 01fc7fb..3fd5c80 100644 --- a/UI.lua +++ b/UI.lua @@ -1208,6 +1208,62 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local ICON_SIZE = 14 + -- Shared constants for dropdown popups and radio buttons (one place to tweak) + local RADIO_SIZE = 14 + local RADIO_OFF_TEX = "Interface\\AddOns\\EasyFind-loot-search\\radio-off" + local RADIO_ON_TEX = "Interface\\AddOns\\EasyFind-loot-search\\radio-on" + local DROPDOWN_BAR_SIZE = { 120, 27 } + local DROPDOWN_ARROW_SIZE = { 20, 20 } + local DROPDOWN_ARROW_DIM = 0.7 + local POPUP_BG_COLOR = { 0.05, 0.05, 0.05, 0.95 } + local POPUP_BORDER_COLOR = { 0.6, 0.6, 0.6, 1 } + local POPUP_BACKDROP = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + } + local ROW_HIGHLIGHT_COLOR = { 1, 1, 1, 0.1 } + + local function StylePopup(frame) + frame:SetBackdrop(POPUP_BACKDROP) + frame:SetBackdropColor(unpack(POPUP_BG_COLOR)) + frame:SetBackdropBorderColor(unpack(POPUP_BORDER_COLOR)) + end + + local function CreateDropdownBar(parent) + local bar = CreateFrame("Button", nil, parent) + bar:SetSize(unpack(DROPDOWN_BAR_SIZE)) + local bg = bar:CreateTexture(nil, "BACKGROUND") + bg:SetAtlas("common-dropdown-textholder") + bg:SetAllPoints() + local arrow = bar:CreateTexture(nil, "OVERLAY") + arrow:SetAtlas("common-dropdown-a-button-hover") + arrow:SetSize(unpack(DROPDOWN_ARROW_SIZE)) + arrow:SetPoint("RIGHT", -2, -1) + arrow:SetVertexColor(DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM) + local label = bar:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + label:SetPoint("LEFT", 8, 0) + label:SetPoint("RIGHT", arrow, "LEFT", -2, 0) + label:SetJustifyH("LEFT") + label:SetWordWrap(false) + bar:SetScript("OnEnter", function() arrow:SetVertexColor(1, 1, 1) end) + bar:SetScript("OnLeave", function() arrow:SetVertexColor(DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM) end) + bar:Hide() + return bar, label, arrow + end + + -- Creates a single radio texture that swaps between off/on states. + -- Returns the texture and a SetChecked(bool) function. + local function CreateRadioTexture(parent) + local tex = parent:CreateTexture(nil, "ARTWORK") + tex:SetSize(RADIO_SIZE, RADIO_SIZE) + tex:SetTexture(RADIO_OFF_TEX) + local function SetChecked(checked) + tex:SetTexture(checked and RADIO_ON_TEX or RADIO_OFF_TEX) + end + return tex, SetChecked + end + -- "Uncheck All" toggle at the top local uncheckRow = CreateFrame("Button", nil, dropdown) uncheckRow:SetSize(DROPDOWN_WIDTH - 16, ROW_HEIGHT) @@ -1316,9 +1372,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if opt.key == "loot" then local SUB_INDENT = 24 local lootSubDefs = { - { dbKey = "lootSearchSlots", label = "Search by Slot" }, - { dbKey = "lootSearchStats", label = "Search by Stats" }, - { dbKey = "lootUpgradesOnly", label = "Upgrades Only" }, + { dbKey = "lootUpgradesOnly", label = "iLvl Upgrades Only" }, } local lootSubRows = {} for si, sub in ipairs(lootSubDefs) do @@ -1349,30 +1403,133 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) subRow:SetScript("OnClick", function(self) EasyFind.db[sub.dbKey] = self:GetChecked() - -- Slot or stat toggle changes require loot re-scan - if sub.dbKey == "lootSearchSlots" or sub.dbKey == "lootSearchStats" then - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end + end) + end + + -- Separator line under Loot checkbox + local lootSep = dropdown:CreateTexture(nil, "ARTWORK") + lootSep:SetHeight(1) + lootSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) + lootSep:Hide() + row.lootSep = lootSep + + -- Difficulty dropdown (single-select, matches EJ style) + local DIFF_OPTIONS = { + { key = "lfr", label = "Raid Finder" }, + { key = "normal", label = "Normal" }, + { key = "heroic", label = "Heroic" }, + { key = "mythic", label = "Mythic" }, + } + local DIFF_LABELS = { lfr = "Raid Finder", normal = "Normal", heroic = "Heroic", mythic = "Mythic" } + + local diffBtn = CreateFrame("Button", nil, dropdown) + diffBtn:SetSize(120, 27) + local diffBg = diffBtn:CreateTexture(nil, "BACKGROUND") + diffBg:SetAtlas("common-dropdown-textholder") + diffBg:SetAllPoints() + local diffArrow = diffBtn:CreateTexture(nil, "OVERLAY") + diffArrow:SetAtlas("common-dropdown-a-button-hover") + diffArrow:SetSize(20, 20) + diffArrow:SetPoint("RIGHT", -2, -1) + diffArrow:SetVertexColor(0.7, 0.7, 0.7) + local diffText = diffBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + diffText:SetPoint("LEFT", 8, 0) + diffText:SetPoint("RIGHT", diffArrow, "LEFT", -2, 0) + diffText:SetJustifyH("LEFT") + diffText:SetWordWrap(false) + diffBtn:SetScript("OnEnter", function() + diffArrow:SetVertexColor(1, 1, 1) + end) + diffBtn:SetScript("OnLeave", function() + diffArrow:SetVertexColor(0.7, 0.7, 0.7) + end) + diffBtn:Hide() + + local function UpdateDiffLabel() + local key = EasyFind.db.lootDifficulty or "normal" + diffText:SetText(DIFF_LABELS[key] or "Normal") + end + + -- Difficulty popup menu + local diffPopup = CreateFrame("Frame", "EasyFindDiffPopup", UIParent, "BackdropTemplate") + diffPopup:SetFrameStrata("TOOLTIP") + StylePopup(diffPopup) + diffPopup:EnableMouse(true) + diffPopup:Hide() + + local diffPopupRows = {} + local py = -6 + for _, def in ipairs(DIFF_OPTIONS) do + local dRow = CreateFrame("Button", nil, diffPopup) + dRow:SetSize(130, 20) + dRow:SetPoint("TOPLEFT", 8, py) + local radio, setRadioChecked = CreateRadioTexture(dRow) + radio:SetPoint("LEFT", 0, 0) + local dLabel = dRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + dLabel:SetPoint("LEFT", radio, "RIGHT", 4, 0) + dLabel:SetText(def.label) + local dHL = dRow:CreateTexture(nil, "HIGHLIGHT") + dHL:SetAllPoints() + dHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + dRow._diffKey = def.key + dRow._setRadioChecked = setRadioChecked + dRow:SetScript("OnClick", function() + EasyFind.db.lootDifficulty = def.key + UpdateDiffLabel() + diffPopup:Hide() + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() end if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) end end) + diffPopupRows[#diffPopupRows + 1] = dRow + py = py - 20 + end + diffPopup:SetSize(146, -py + 6) + + local function SyncDiffRadios() + local key = EasyFind.db.lootDifficulty or "normal" + for _, dr in ipairs(diffPopupRows) do + dr._setRadioChecked(dr._diffKey == key) + end + end + + diffBtn:SetScript("OnClick", function() + if diffPopup:IsShown() then + diffPopup:Hide() + else + SyncDiffRadios() + diffPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + diffPopup:ClearAllPoints() + diffPopup:SetPoint("TOPLEFT", diffBtn, "BOTTOMLEFT", 0, 2) + diffPopup:Show() + end + end) + diffPopup:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + diffPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + end) + diffPopup:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() and not diffBtn:IsMouseOver() then + self:Hide() + end + end + end) + + row.diffBtn = diffBtn + row.diffPopup = diffPopup + row.UpdateDiffButtons = function() + UpdateDiffLabel() end - -- Spec/class selector row (clickable label, opens flyout) - local specRow = CreateFrame("Button", nil, dropdown) - specRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) - local specLabel = specRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - specLabel:SetPoint("LEFT", 8, 0) - local specHL = specRow:CreateTexture(nil, "HIGHLIGHT") - specHL:SetAllPoints() - specHL:SetColorTexture(1, 1, 1, 0.1) - local specArrow = specRow:CreateTexture(nil, "ARTWORK") - specArrow:SetSize(12, 12) - specArrow:SetPoint("RIGHT", -4, 0) - specArrow:SetAtlas("ForwardArrowIcon") -- Build class/spec data local CLASS_COLORS = RAID_CLASS_COLORS @@ -1396,392 +1553,425 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end - local FLYOUT_WIDTH = 160 - local FLYOUT_ROW_H = 20 - local SUBFLYOUT_WIDTH = 170 - local BACKDROP_DEF = { - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = TOOLTIP_BORDER, - edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, + local FLYOUT_ROW_H = 20 + local POPUP_WIDTH = 180 + local CLASSFLYOUT_WIDTH = 160 + local TOOLTIP_BORDER = "Interface\\Tooltips\\UI-Tooltip-Border" + local BACKDROP_POPUP = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = TOOLTIP_BORDER, edgeSize = 14, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, } - local specFlyout = CreateFrame("Frame", "EasyFindSpecFlyout", UIParent) - specFlyout:SetFrameStrata("FULLSCREEN_DIALOG") - specFlyout:EnableMouse(true) - specFlyout:Hide() - local flyoutBg = specFlyout:CreateTexture(nil, "BACKGROUND") - flyoutBg:SetPoint("TOPLEFT", 4, -4) - flyoutBg:SetPoint("BOTTOMRIGHT", -4, 4) - flyoutBg:SetColorTexture(0.05, 0.05, 0.05, 0.95) - local flyoutBorder = CreateFrame("Frame", nil, specFlyout, "BackdropTemplate") - flyoutBorder:SetAllPoints() - flyoutBorder:SetFrameLevel(specFlyout:GetFrameLevel()) - flyoutBorder:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - - local specSubFlyout = CreateFrame("Frame", "EasyFindSpecSubFlyout", UIParent) - specSubFlyout:SetFrameStrata("FULLSCREEN_DIALOG") - specSubFlyout:EnableMouse(true) - specSubFlyout:Hide() - local subBg = specSubFlyout:CreateTexture(nil, "BACKGROUND") - subBg:SetPoint("TOPLEFT", 4, -4) - subBg:SetPoint("BOTTOMRIGHT", -4, 4) - subBg:SetColorTexture(0.05, 0.05, 0.05, 0.95) - local subBorder = CreateFrame("Frame", nil, specSubFlyout, "BackdropTemplate") - subBorder:SetAllPoints() - subBorder:SetFrameLevel(specSubFlyout:GetFrameLevel()) - subBorder:SetBackdrop({ - edgeFile = TOOLTIP_BORDER, edgeSize = 16, - insets = { left = 4, right = 4, top = 4, bottom = 4 }, - }) - - local specCheckRows = {} - local classButtons = {} -- classID -> button (for active indicators) - local activeSubClass = nil - - -- Helper: get selected spec pairs - local function GetSelectedSpecs() - local selected = {} - for _, scr in ipairs(specCheckRows) do - if scr:GetChecked() then - selected[#selected + 1] = { classID = scr._classID, specID = scr._specID } + -- Determine which class to display in the spec popup. + -- Always returns a class (falls back to player's class for "all" or nil). + local function GetSelectedClass() + local lf = EasyFind.db.lootFilter + if type(lf) == "table" and lf.classID then + for _, cls in ipairs(allClassSpecs) do + if cls.classID == lf.classID then return cls end end end - return selected + -- Default: player's class + local _, _, playerClassID = UnitClass("player") + for _, cls in ipairs(allClassSpecs) do + if cls.classID == playerClassID then return cls end + end + return allClassSpecs[1] + end + + -- Apply single selection and rebuild from cache + local function ApplyFilterSelection() + if ns.Database and ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + if searchEditBox:GetText() ~= "" then + UI:OnSearchTextChanged(searchEditBox:GetText()) + end end - -- Helper: update spec label on the selector row + -- Update the spec selector label from lootFilter local function UpdateSpecLabel() local lbl = row.specSelectLabel if not lbl then return end - if row.label then row.label:SetText("Loot") end - local lootSpecs = EasyFind.db.lootSpecs - if not lootSpecs or #lootSpecs == 0 then + local lf = EasyFind.db.lootFilter + if not lf then + -- Default: player's class + current spec, matching EJ format local si = GetSpecialization and GetSpecialization() local _, sname if si then _, sname = GetSpecializationInfo(si) end - local _, classFile = UnitClass("player") + local className, classFile = UnitClass("player") local cc = classFile and CLASS_COLORS[classFile] local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" - lbl:SetText(colorStr .. (sname or "Current Spec") .. "|r") - elseif #lootSpecs == 1 then - local sp = lootSpecs[1] - local sname, classFile - for _, cls in ipairs(allClassSpecs) do - if cls.classID == sp.classID then - classFile = cls.classFile - for _, s in ipairs(cls.specs) do - if s.specID == sp.specID then sname = s.specName; break end - end - break - end + if sname and className then + lbl:SetText(colorStr .. className .. " (" .. sname .. ")|r") + else + lbl:SetText(colorStr .. (className or "Current Spec") .. "|r") end - local cc = classFile and CLASS_COLORS[classFile] + elseif lf == "all" then + lbl:SetText("All Classes") + elseif lf.classID then + local cls + for _, c in ipairs(allClassSpecs) do + if c.classID == lf.classID then cls = c; break end + end + if not cls then lbl:SetText("?"); return end + local cc = CLASS_COLORS[cls.classFile] local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" - lbl:SetText(colorStr .. (sname or "?") .. "|r") - else - local totalSpecs = 0 - for _, cls in ipairs(allClassSpecs) do totalSpecs = totalSpecs + #cls.specs end - if #lootSpecs >= totalSpecs then - lbl:SetText("All Specs") - elseif #lootSpecs <= 3 then - local names = {} - for _, sp in ipairs(lootSpecs) do - for _, cls in ipairs(allClassSpecs) do - if cls.classID == sp.classID then - for _, s in ipairs(cls.specs) do - if s.specID == sp.specID then names[#names + 1] = s.specName; break end - end - break - end - end + if lf.specID then + local sname + for _, s in ipairs(cls.specs) do + if s.specID == lf.specID then sname = s.specName; break end end - lbl:SetText(tconcat(names, ", ")) + lbl:SetText(colorStr .. cls.className .. " (" .. (sname or "?") .. ")|r") else - lbl:SetText(#lootSpecs .. " selected") + -- All specs for this class + lbl:SetText(colorStr .. cls.className .. "|r") end end end - -- Sync checkboxes and class indicators from saved data - local function SyncFlyoutState() - local lootSpecs = EasyFind.db.lootSpecs - for _, scr in ipairs(specCheckRows) do - local checked = false - if lootSpecs then - for _, sp in ipairs(lootSpecs) do - if sp.classID == scr._classID and sp.specID == scr._specID then - checked = true; break - end - end + -- Check if a filter value matches the current lootFilter + local function IsFilterMatch(filterVal) + local lf = EasyFind.db.lootFilter + -- nil lootFilter = current spec; resolve to player class+spec for comparison + if not lf then + if filterVal == nil then return true end + if type(filterVal) == "table" and filterVal.specID then + local _, _, cid = UnitClass("player") + local si = GetSpecialization and GetSpecialization() + local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) + return filterVal.classID == cid and filterVal.specID == sid end - scr:SetChecked(checked) + return false end - for cid, btn in pairs(classButtons) do - local hasActive = false - if lootSpecs then - for _, sp in ipairs(lootSpecs) do - if sp.classID == cid then hasActive = true; break end - end - end - if btn._activeIndicator then - btn._activeIndicator:SetShown(hasActive) + if filterVal == "all" and lf == "all" then return true end + if type(filterVal) == "table" and type(lf) == "table" then + if filterVal.classID == lf.classID then + if filterVal.specID == nil and lf.specID == nil then return true end + if filterVal.specID == lf.specID then return true end end end + return false end - -- Apply selection, re-scan - local function ApplySpecSelection() - local selected = GetSelectedSpecs() - EasyFind.db.lootSpecs = #selected > 0 and selected or nil - UpdateSpecLabel() - SyncFlyoutState() - C_Timer.After(0, function() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() - end - if searchEditBox:GetText() ~= "" then - UI:OnSearchTextChanged(searchEditBox:GetText()) - end - end) + ------------------------------------------------------------------- + -- Main spec popup (opens BELOW the bar) + -- Layout: "Class >" row, then class header, then specs, then "All Specializations" + ------------------------------------------------------------------- + local specPopup = CreateFrame("Frame", "EasyFindSpecPopup", UIParent, "BackdropTemplate") + specPopup:SetFrameStrata("TOOLTIP") + StylePopup(specPopup) + specPopup:EnableMouse(true) + specPopup:Hide() + + ------------------------------------------------------------------- + -- Class flyout (opens to the RIGHT of the "Class" row) + ------------------------------------------------------------------- + local classFlyout = CreateFrame("Frame", "EasyFindSpecFlyout", UIParent, "BackdropTemplate") + classFlyout:SetFrameStrata("TOOLTIP") + StylePopup(classFlyout) + classFlyout:EnableMouse(true) + classFlyout:Hide() + + -- Also keep "EasyFindSpecSubFlyout" name for the dropdown close guard + local specSubFlyout = classFlyout + + -- Helper: create a radio-style row + local function CreateRadioRow(parent, label, filterVal, width) + local btn = CreateFrame("Button", nil, parent) + btn:SetSize(width - 16, FLYOUT_ROW_H) + btn:SetFrameLevel(parent:GetFrameLevel() + 10) + local radio, setChecked = CreateRadioTexture(btn) + radio:SetPoint("LEFT", 4, 0) + local lbl = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + lbl:SetPoint("LEFT", radio, "RIGHT", 4, 0) + lbl:SetText(label) + local hl = btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + btn._setRadioChecked = setChecked + btn._filterVal = filterVal + return btn end - -- Build spec sub-flyout rows per class (created once, shown/hidden on hover) - local classSubRows = {} -- classID -> { rows } + ------------------------------------------------------------------- + -- Build class flyout rows (right panel): All Classes + each class + ------------------------------------------------------------------- + local classFlyoutRows = {} + -- "All Classes" + local allClassRow = CreateRadioRow(classFlyout, "All Classes", "all", CLASSFLYOUT_WIDTH) + allClassRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = "all" + UpdateSpecLabel() + classFlyout:Hide() + specPopup:Hide() + ApplyFilterSelection() + end) + classFlyoutRows[#classFlyoutRows + 1] = allClassRow + -- Each class for _, cls in ipairs(allClassSpecs) do - local rows = {} - for _, spec in ipairs(cls.specs) do - local sRow = CreateFrame("CheckButton", nil, specSubFlyout) - sRow:SetSize(SUBFLYOUT_WIDTH - 16, FLYOUT_ROW_H) - sRow:SetFrameLevel(specSubFlyout:GetFrameLevel() + 10) - sRow:SetNormalTexture("Interface\\Buttons\\UI-CheckBox-Up") - sRow:GetNormalTexture():SetSize(14, 14) - sRow:GetNormalTexture():ClearAllPoints() - sRow:GetNormalTexture():SetPoint("LEFT", 4, 0) - sRow:SetCheckedTexture("Interface\\Buttons\\UI-CheckBox-Check") - sRow:GetCheckedTexture():SetSize(14, 14) - sRow:GetCheckedTexture():ClearAllPoints() - sRow:GetCheckedTexture():SetPoint("LEFT", 4, 0) - local sIcon = sRow:CreateTexture(nil, "ARTWORK") - sIcon:SetSize(14, 14) - sIcon:SetPoint("LEFT", sRow:GetNormalTexture(), "RIGHT", 4, 0) - if spec.specIcon then sIcon:SetTexture(spec.specIcon) end - local sLbl = sRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - sLbl:SetPoint("LEFT", sIcon, "RIGHT", 4, 0) - sLbl:SetText(spec.specName) - local sHL = sRow:CreateTexture(nil, "HIGHLIGHT") - sHL:SetAllPoints() - sHL:SetColorTexture(1, 1, 1, 0.1) - sRow._classID = cls.classID - sRow._specID = spec.specID - sRow:Hide() - sRow:SetScript("OnClick", function() - ApplySpecSelection() - end) - rows[#rows + 1] = sRow - specCheckRows[#specCheckRows + 1] = sRow - end - classSubRows[cls.classID] = rows + local clsRow = CreateRadioRow(classFlyout, "", { classID = cls.classID }, CLASSFLYOUT_WIDTH) + -- Override label with class-colored text + local ccl = CLASS_COLORS[cls.classFile] + local csStr = ccl and string.format("|cff%02x%02x%02x", ccl.r * 255, ccl.g * 255, ccl.b * 255) or "" + local clsLabel = clsRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + clsLabel:SetPoint("LEFT", 22, 0) + clsLabel:SetText(csStr .. cls.className .. "|r") + clsRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = { classID = cls.classID } + UpdateSpecLabel() + classFlyout:Hide() + specPopup:Hide() + ApplyFilterSelection() + end) + classFlyoutRows[#classFlyoutRows + 1] = clsRow end - -- Show sub-flyout for a class, anchored to the class row - local function ShowSubFlyout(classID, anchorRow) - if activeSubClass == classID and specSubFlyout:IsShown() then return end - activeSubClass = classID - SyncFlyoutState() - -- Hide all sub-rows, show only this class - for cid, rows in pairs(classSubRows) do - for _, r in ipairs(rows) do r:SetShown(cid == classID) end - end - local rows = classSubRows[classID] - if not rows or #rows == 0 then specSubFlyout:Hide(); return end - local y = -6 - local subBtnLevel = specSubFlyout:GetFrameLevel() + 10 - for _, r in ipairs(rows) do + local function LayoutClassFlyout() + local fy = -6 + local lvl = classFlyout:GetFrameLevel() + 10 + for _, r in ipairs(classFlyoutRows) do r:ClearAllPoints() - r:SetPoint("TOPLEFT", specSubFlyout, "TOPLEFT", 8, y) - r:SetFrameLevel(subBtnLevel) - y = y - FLYOUT_ROW_H - end - specSubFlyout:SetSize(SUBFLYOUT_WIDTH, -y + 6) - specSubFlyout:ClearAllPoints() - specSubFlyout:SetPoint("TOPLEFT", anchorRow, "TOPRIGHT", 2, 6) - specSubFlyout:Show() - end - - -- Build main flyout rows: Current Spec, Toggle All, then class names - local flyoutRows = {} - local y = -6 - - -- "Current Spec" row - local curSpecBtn = CreateFrame("Button", nil, specFlyout) - curSpecBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) - curSpecBtn:SetFrameLevel(specFlyout:GetFrameLevel() + 10) - local csLbl = curSpecBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - csLbl:SetPoint("LEFT", 8, 0) - csLbl:SetText("Current Spec") - local csHL2 = curSpecBtn:CreateTexture(nil, "HIGHLIGHT") - csHL2:SetAllPoints() - csHL2:SetColorTexture(1, 1, 1, 0.1) - curSpecBtn:SetScript("OnClick", function() - EasyFind.db.lootSpecs = nil - SyncFlyoutState() - ApplySpecSelection() - end) - curSpecBtn:SetScript("OnEnter", function() specSubFlyout:Hide(); activeSubClass = nil end) - flyoutRows[#flyoutRows + 1] = curSpecBtn - - -- "Toggle All" row - local toggleAllBtn = CreateFrame("Button", nil, specFlyout) - toggleAllBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) - toggleAllBtn:SetFrameLevel(specFlyout:GetFrameLevel() + 10) - local taLbl = toggleAllBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - taLbl:SetPoint("LEFT", 8, 0) - taLbl:SetText("Toggle All") - local taHL2 = toggleAllBtn:CreateTexture(nil, "HIGHLIGHT") - taHL2:SetAllPoints() - taHL2:SetColorTexture(1, 1, 1, 0.1) - toggleAllBtn:SetScript("OnClick", function() - local allChecked = true - for _, scr in ipairs(specCheckRows) do - if not scr:GetChecked() then allChecked = false; break end + r:SetPoint("TOPLEFT", classFlyout, "TOPLEFT", 8, fy) + r:SetFrameLevel(lvl) + r:Show() + if r._setRadioChecked then + local lf = EasyFind.db.lootFilter + local match = false + if r._filterVal == "all" and lf == "all" then + match = true + elseif type(r._filterVal) == "table" then + if type(lf) == "table" and r._filterVal.classID == lf.classID then + match = true + elseif not lf then + -- nil = current spec; dot the player's class + local _, _, cid = UnitClass("player") + match = r._filterVal.classID == cid + end + end + r._setRadioChecked(match) + end + fy = fy - FLYOUT_ROW_H end - for _, scr in ipairs(specCheckRows) do scr:SetChecked(not allChecked) end - ApplySpecSelection() + classFlyout:SetSize(CLASSFLYOUT_WIDTH, -fy + 6) + end + + ------------------------------------------------------------------- + -- Build spec popup rows (main dropdown below bar) + ------------------------------------------------------------------- + -- Row 1: "Class" with arrow (opens class flyout to the right) + local classSelectBtn = CreateFrame("Button", nil, specPopup) + classSelectBtn:SetSize(POPUP_WIDTH - 16, FLYOUT_ROW_H) + classSelectBtn:SetFrameLevel(specPopup:GetFrameLevel() + 10) + local csLabel = classSelectBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + csLabel:SetPoint("LEFT", 8, 0) + csLabel:SetText("Class") + local csArrow = classSelectBtn:CreateTexture(nil, "ARTWORK") + csArrow:SetSize(16, 16) + csArrow:SetPoint("RIGHT", -4, 0) + csArrow:SetTexture("Interface\\AddOns\\EasyFind-loot-search\\flyout-arrow") + local csHL = classSelectBtn:CreateTexture(nil, "HIGHLIGHT") + csHL:SetAllPoints() + csHL:SetColorTexture(1, 1, 1, 0.1) + classSelectBtn:SetScript("OnEnter", function(self) + LayoutClassFlyout() + classFlyout:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + classFlyout:ClearAllPoints() + classFlyout:SetPoint("TOPLEFT", self, "TOPRIGHT", 2, 6) + classFlyout:Show() end) - toggleAllBtn:SetScript("OnEnter", function() specSubFlyout:Hide(); activeSubClass = nil end) - flyoutRows[#flyoutRows + 1] = toggleAllBtn - -- Class rows (hover to open spec sub-flyout) - for _, cls in ipairs(allClassSpecs) do - local clsBtn = CreateFrame("Button", nil, specFlyout) - clsBtn:SetSize(FLYOUT_WIDTH - 16, FLYOUT_ROW_H) - clsBtn:SetFrameLevel(specFlyout:GetFrameLevel() + 10) - clsBtn:EnableMouse(true) - clsBtn:RegisterForClicks("AnyUp") - local clsLbl = clsBtn:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") - clsLbl:SetPoint("LEFT", 8, 0) - local cc = CLASS_COLORS[cls.classFile] - local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "|cffffffff" - clsLbl:SetText(colorStr .. cls.className .. "|r") - local clsArrow = clsBtn:CreateTexture(nil, "ARTWORK") - clsArrow:SetSize(12, 12) - clsArrow:SetPoint("RIGHT", -4, 0) - clsArrow:SetAtlas("ForwardArrowIcon") - local clsHL = clsBtn:CreateTexture(nil, "HIGHLIGHT") - clsHL:SetAllPoints() - clsHL:SetColorTexture(1, 1, 1, 0.1) - -- Active indicator: subtle background tint when any spec from this class is selected - local clsActive = clsBtn:CreateTexture(nil, "BACKGROUND") - clsActive:SetAllPoints() - clsActive:SetColorTexture(0.15, 0.4, 0.15, 0.5) - clsActive:Hide() - clsBtn._activeIndicator = clsActive - clsBtn._classID = cls.classID - clsBtn:SetScript("OnEnter", function(self) - ShowSubFlyout(cls.classID, self) - end) - clsBtn:SetScript("OnClick", function() - -- Toggle all specs for this class - local crows = classSubRows[cls.classID] - if not crows then return end - local allChecked = true - for _, cr in ipairs(crows) do - if not cr:GetChecked() then allChecked = false; break end - end - for _, cr in ipairs(crows) do cr:SetChecked(not allChecked) end - ApplySpecSelection() - end) - classButtons[cls.classID] = clsBtn - flyoutRows[#flyoutRows + 1] = clsBtn + -- Spec rows (rebuilt each time popup opens based on selected class) + local specRadioRows = {} + local MAX_SPECS = 5 -- druid has 4 + "All Specializations" = 5 + for si = 1, MAX_SPECS do + local sRow = CreateRadioRow(specPopup, "", nil, POPUP_WIDTH) + sRow:Hide() + specRadioRows[si] = sRow end - specFlyout:Hide() -- hide after construction + -- Class header (non-clickable, shows selected class name) + local classHeader = CreateFrame("Frame", nil, specPopup) + classHeader:SetSize(POPUP_WIDTH - 16, FLYOUT_ROW_H) + classHeader:SetFrameLevel(specPopup:GetFrameLevel() + 10) + local classHeaderLabel = classHeader:CreateFontString(nil, "ARTWORK", "GameFontHighlight") + classHeaderLabel:SetPoint("LEFT", 8, 0) + + local function LayoutSpecPopup() + local selCls = GetSelectedClass() + local py = -6 + local lvl = specPopup:GetFrameLevel() + 10 + + -- Row 1: "Class >" + classSelectBtn:ClearAllPoints() + classSelectBtn:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + classSelectBtn:SetFrameLevel(lvl) + classSelectBtn:Show() + py = py - FLYOUT_ROW_H + + if selCls then + -- Class header + local cc = CLASS_COLORS[selCls.classFile] + local colorStr = cc and string.format("|cff%02x%02x%02x", cc.r * 255, cc.g * 255, cc.b * 255) or "" + classHeaderLabel:SetText(colorStr .. selCls.className .. "|r") + classHeader:ClearAllPoints() + classHeader:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + classHeader:SetFrameLevel(lvl) + classHeader:Show() + py = py - FLYOUT_ROW_H + + -- Spec rows + local ri = 1 + for _, spec in ipairs(selCls.specs) do + local sRow = specRadioRows[ri] + if sRow then + -- Update label and filter value + local children = { sRow:GetRegions() } + for _, child in ipairs(children) do + if child:GetObjectType() == "FontString" and child:GetText() ~= "" then + if child:GetPoint() then + local _, rel = child:GetPoint() + if rel and rel:GetObjectType() == "Texture" then + child:SetText(spec.specName) + end + end + end + end + sRow._filterVal = { classID = selCls.classID, specID = spec.specID } + sRow._setRadioChecked(IsFilterMatch(sRow._filterVal)) + sRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = { classID = selCls.classID, specID = spec.specID } + UpdateSpecLabel() + classFlyout:Hide() + specPopup:Hide() + ApplyFilterSelection() + end) + sRow:SetScript("OnEnter", function() + classFlyout:Hide() + end) + sRow:ClearAllPoints() + sRow:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + sRow:SetFrameLevel(lvl) + sRow:Show() + py = py - FLYOUT_ROW_H + ri = ri + 1 + end + end + + -- "All Specializations" row + local allRow = specRadioRows[ri] + if allRow then + local children = { allRow:GetRegions() } + for _, child in ipairs(children) do + if child:GetObjectType() == "FontString" and child:GetText() ~= "" then + if child:GetPoint() then + local _, rel = child:GetPoint() + if rel and rel:GetObjectType() == "Texture" then + child:SetText("All Specializations") + end + end + end + end + allRow._filterVal = { classID = selCls.classID } + allRow._setRadioChecked(IsFilterMatch(allRow._filterVal)) + allRow:SetScript("OnClick", function() + EasyFind.db.lootFilter = { classID = selCls.classID } + UpdateSpecLabel() + classFlyout:Hide() + specPopup:Hide() + ApplyFilterSelection() + end) + allRow:SetScript("OnEnter", function() + classFlyout:Hide() + end) + allRow:ClearAllPoints() + allRow:SetPoint("TOPLEFT", specPopup, "TOPLEFT", 8, py) + allRow:SetFrameLevel(lvl) + allRow:Show() + py = py - FLYOUT_ROW_H + ri = ri + 1 + end - -- Layout main flyout - local function LayoutFlyout() - local fy = -6 - local btnLevel = specFlyout:GetFrameLevel() + 10 - for _, r in ipairs(flyoutRows) do - r:ClearAllPoints() - r:SetPoint("TOPLEFT", specFlyout, "TOPLEFT", 8, fy) - r:SetFrameLevel(btnLevel) - r:Show() - fy = fy - FLYOUT_ROW_H + -- Hide unused rows + for hi = ri, MAX_SPECS do + specRadioRows[hi]:Hide() + end + else + classHeader:Hide() + for _, sr in ipairs(specRadioRows) do sr:Hide() end end - specFlyout:SetSize(FLYOUT_WIDTH, -fy + 6) + + specPopup:SetSize(POPUP_WIDTH, -py + 6) end - -- Spec selector row: clickable label (no checkbox), first item under Loot - specRow:Hide() + ------------------------------------------------------------------- + -- Spec selector dropdown bar + ------------------------------------------------------------------- local specSelectRow = CreateFrame("Button", nil, dropdown) - specSelectRow:SetSize(DROPDOWN_WIDTH - 16 - SUB_INDENT, ROW_HEIGHT) - local specSelectLabel = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall") + specSelectRow:SetSize(120, 27) + local specBg = specSelectRow:CreateTexture(nil, "BACKGROUND") + specBg:SetAtlas("common-dropdown-textholder") + specBg:SetAllPoints() + local specSelectArrow = specSelectRow:CreateTexture(nil, "OVERLAY") + specSelectArrow:SetAtlas("common-dropdown-a-button-hover") + specSelectArrow:SetSize(20, 20) + specSelectArrow:SetPoint("RIGHT", -2, -1) + specSelectArrow:SetVertexColor(0.7, 0.7, 0.7) + local specSelectLabel = specSelectRow:CreateFontString(nil, "ARTWORK", "GameFontHighlight") specSelectLabel:SetPoint("LEFT", 8, 0) - local specSelectArrow = specSelectRow:CreateTexture(nil, "ARTWORK") - specSelectArrow:SetSize(12, 12) - specSelectArrow:SetPoint("RIGHT", -4, 0) - specSelectArrow:SetAtlas("ForwardArrowIcon") - local specSelectHL = specSelectRow:CreateTexture(nil, "HIGHLIGHT") - specSelectHL:SetAllPoints() - specSelectHL:SetColorTexture(1, 1, 1, 0.1) - - local specSep = specSelectRow:CreateTexture(nil, "ARTWORK") - specSep:SetHeight(1) - specSep:SetColorTexture(0.5, 0.5, 0.5, 0.4) - specSep:SetPoint("BOTTOMLEFT", specSelectRow, "BOTTOMLEFT", 0, -2) - specSep:SetPoint("BOTTOMRIGHT", specSelectRow, "BOTTOMRIGHT", 0, -2) - - -- Hover opens flyout, click also works as fallback - local function OpenSpecFlyout() - if specFlyout:IsShown() then return end - SyncFlyoutState() - LayoutFlyout() - specFlyout:ClearAllPoints() - specFlyout:SetPoint("TOPLEFT", specSelectRow, "TOPRIGHT", 2, 0) - specFlyout:Show() - end - specSelectRow:SetScript("OnEnter", function() OpenSpecFlyout() end) + specSelectLabel:SetPoint("RIGHT", specSelectArrow, "LEFT", -2, 0) + specSelectLabel:SetJustifyH("LEFT") + specSelectLabel:SetWordWrap(false) + + specSelectRow:SetScript("OnEnter", function() + specSelectArrow:SetVertexColor(1, 1, 1) + end) specSelectRow:SetScript("OnLeave", function() - C_Timer.After(0.15, function() - if specFlyout:IsShown() and not specFlyout:IsMouseOver() - and not specSelectRow:IsMouseOver() - and not (specSubFlyout:IsShown() and specSubFlyout:IsMouseOver()) then - specFlyout:Hide() - end - end) + specSelectArrow:SetVertexColor(0.7, 0.7, 0.7) end) specSelectRow:SetScript("OnClick", function() - if specFlyout:IsShown() then - specFlyout:Hide() - specSubFlyout:Hide() + if specPopup:IsShown() then + specPopup:Hide() else - OpenSpecFlyout() + LayoutSpecPopup() + specPopup:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) + specPopup:ClearAllPoints() + specPopup:SetPoint("TOPLEFT", specSelectRow, "BOTTOMLEFT", 0, 2) + specPopup:Show() end end) row.specSelectRow = specSelectRow row.specSelectLabel = specSelectLabel - -- Close flyouts when dropdown hides or clicking outside - dropdown:HookScript("OnHide", function() - specSubFlyout:Hide() - specFlyout:Hide() + -- Close on outside click + specPopup:SetScript("OnShow", function(self) + self:RegisterEvent("GLOBAL_MOUSE_DOWN") + end) + specPopup:SetScript("OnHide", function(self) + self:UnregisterEvent("GLOBAL_MOUSE_DOWN") + classFlyout:Hide() + end) + specPopup:SetScript("OnEvent", function(self, event) + if event == "GLOBAL_MOUSE_DOWN" then + if not self:IsMouseOver() + and not classFlyout:IsMouseOver() + and not specSelectRow:IsMouseOver() then + self:Hide() + end + end end) - -- Also close sub-flyout when leaving both the class row and sub-flyout area - specSubFlyout:SetScript("OnUpdate", function(self) - if not self:IsMouseOver() and not specFlyout:IsMouseOver() and not dropdown:IsMouseOver() then - -- Brief delay to allow mouse to travel between panels + + -- Close class flyout when mouse leaves both panels + classFlyout:SetScript("OnUpdate", function(self) + if not self:IsMouseOver() and not specPopup:IsMouseOver() then if not self._leaveTimer then - self._leaveTimer = C_Timer.NewTimer(0.3, function() + self._leaveTimer = C_Timer.NewTimer(0.2, function() self._leaveTimer = nil - if not self:IsMouseOver() and not specFlyout:IsMouseOver() then + if not self:IsMouseOver() and not classSelectBtn:IsMouseOver() then self:Hide() - activeSubClass = nil end end) end @@ -1792,24 +1982,17 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end end) - specFlyout:SetScript("OnShow", function(self) - self:RegisterEvent("GLOBAL_MOUSE_DOWN") - end) - specFlyout:SetScript("OnHide", function(self) - self:UnregisterEvent("GLOBAL_MOUSE_DOWN") - specSubFlyout:Hide() - end) - specFlyout:SetScript("OnEvent", function(self, event) - if event == "GLOBAL_MOUSE_DOWN" then - if not self:IsMouseOver() and not specSubFlyout:IsMouseOver() and not dropdown:IsMouseOver() then - self:Hide() - end - end + + -- Close flyouts when dropdown hides + dropdown:HookScript("OnHide", function() + classFlyout:Hide() + specPopup:Hide() end) + -- Keep EasyFindSpecFlyout/EasyFindSpecSubFlyout names for dropdown close guard + local specFlyout = classFlyout row.specFlyout = specFlyout row.allClassSpecs = allClassSpecs - row.lootSubRows = lootSubRows row.updateLootToggle = function() local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false @@ -1819,11 +2002,27 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end sr:SetShown(lootChecked) end + if row.lootSep then + row.lootSep:SetShown(lootChecked) + end if row.specSelectRow then row.specSelectRow:SetShown(lootChecked) end + if row.diffBtn then + row.diffBtn:SetShown(lootChecked) + if lootChecked and row.UpdateDiffButtons then + row.UpdateDiffButtons() + end + if not lootChecked and row.diffPopup then + row.diffPopup:Hide() + end + end UpdateSpecLabel() - if not lootChecked then specFlyout:Hide() end + if not lootChecked then + local sp = _G["EasyFindSpecPopup"] + if sp then sp:Hide() end + specFlyout:Hide() + end end end @@ -1882,20 +2081,44 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end end - -- Loot sub-rows: spec selector first, then separator, then checkboxes + -- Loot sub-rows if row.lootSubRows then local lootChecked = EasyFind.db.uiSearchFilters and EasyFind.db.uiSearchFilters.loot ~= false - -- Spec selector row (first, with separator) + -- Separator line under Loot + if row.lootSep then + if lootChecked then + row.lootSep:ClearAllPoints() + row.lootSep:SetPoint("LEFT", 8 + SUB_INDENT, 0) + row.lootSep:SetPoint("RIGHT", dropdown, "RIGHT", -8, 0) + row.lootSep:SetPoint("TOP", 0, y - 2) + row.lootSep:Show() + y = y - 6 + else + row.lootSep:Hide() + end + end + -- Spec selector dropdown bar if row.specSelectRow then if lootChecked then row.specSelectRow:ClearAllPoints() row.specSelectRow:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) row.specSelectRow:Show() - y = y - ROW_HEIGHT - 4 -- extra 4px for the separator line + y = y - 28 else row.specSelectRow:Hide() end end + -- Difficulty dropdown button + if row.diffBtn then + if lootChecked then + row.diffBtn:ClearAllPoints() + row.diffBtn:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) + row.diffBtn:Show() + y = y - 28 + else + row.diffBtn:Hide() + end + end -- Checkbox sub-options for _, sr in ipairs(row.lootSubRows) do if lootChecked then @@ -1956,9 +2179,11 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if self:IsShown() and IsMouseButtonDown("LeftButton") then if not self:IsMouseOver() and not toggleBtn:IsMouseOver() then local sf = _G["EasyFindSpecFlyout"] - local ssf = _G["EasyFindSpecSubFlyout"] + local sp = _G["EasyFindSpecPopup"] + local dp = _G["EasyFindDiffPopup"] if (sf and sf:IsShown() and sf:IsMouseOver()) - or (ssf and ssf:IsShown() and ssf:IsMouseOver()) then + or (sp and sp:IsShown() and sp:IsMouseOver()) + or (dp and dp:IsShown() and dp:IsMouseOver()) then return end self:Hide() @@ -1971,7 +2196,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) if dropdown:IsShown() then dropdown:Hide() else - local scale = anchorFrame:GetEffectiveScale() / UIParent:GetEffectiveScale() + local barScale = (EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0) + dropdown:SetScale(barScale) + local scale = anchorFrame:GetEffectiveScale() / (UIParent:GetEffectiveScale() * barScale) local right = anchorFrame:GetRight() * scale local bottom = anchorFrame:GetBottom() * scale dropdown:ClearAllPoints() @@ -2731,7 +2958,7 @@ function UI:CreateResultButton(index) -- Loot item tooltip elseif self.icon.lootItemID then GameTooltip:SetOwner(self, "ANCHOR_RIGHT") - local itemLink = self.data and self.data.lootItemLink + local itemLink = self.data and ns.Database and ns.Database:GetLootItemLink(self.data) if itemLink then GameTooltip:SetHyperlink(itemLink) else @@ -4381,8 +4608,9 @@ function UI:SelectResult(data) -- Loot: Shift+click opens dressing room, regular click opens EJ to boss if data.itemID and data.category == "Loot" then - if IsShiftKeyDown() and data.lootItemLink then - DressUpItemLink(data.lootItemLink) + local lootLink = ns.Database and ns.Database:GetLootItemLink(data) + if IsShiftKeyDown() and lootLink then + DressUpItemLink(lootLink) else EncounterJournal_LoadUI() if data.instanceID and EJ_SelectInstance then From c5306aa54f3d76db8f573efe6f2aeb8d9fb8c7af Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Mon, 6 Apr 2026 00:53:38 -0400 Subject: [PATCH 31/44] Fix arrow key navigation reset during deferred result refresh --- UI.lua | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 217 insertions(+), 5 deletions(-) diff --git a/UI.lua b/UI.lua index 3fd5c80..69deafb 100644 --- a/UI.lua +++ b/UI.lua @@ -998,6 +998,9 @@ function UI:CreateSearchFrame() elseif key == "ESCAPE" then if toolbarFocus > 0 then ClearToolbarFocus() + if selectedIndex == 0 then + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + end elseif toggleFocused then toggleFocused = false UI:UpdateSelectionHighlight() @@ -1278,6 +1281,79 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local checkRows = {} local checkRowsByIndex = {} local LayoutDropdown -- forward declaration + local dropdownKeyboardMode = false + + -- Reusable keyboard nav for popup menus (diff popup, spec popup, class flyout). + -- Uses a single dropdownKeyboardMode flag: when true, any popup hiding returns + -- keyboard to the dropdown. No parent tracking needed. + local function AddPopupKeyboardNav(popup, getRows) + local popupFocus = 0 + local popupHL = popup:CreateTexture(nil, "BACKGROUND") + popupHL:SetColorTexture(unpack(ROW_HIGHLIGHT_COLOR)) + popupHL:Hide() + + local function SetPopupFocus(idx) + local rows = getRows() + popupFocus = idx + local target = rows[idx] + if target then + popupHL:SetParent(target) + popupHL:ClearAllPoints() + popupHL:SetAllPoints(target) + popupHL:Show() + else + popupHL:Hide() + end + end + + Utils.SafeCallMethod(popup, "EnableKeyboard", false) + Utils.SafeCallMethod(popup, "SetPropagateKeyboardInput", false) + + popup:HookScript("OnKeyDown", function(self, key) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + local rows = getRows() + if key == "DOWN" then + local next = popupFocus + 1 + if next > #rows then next = 1 end + SetPopupFocus(next) + elseif key == "UP" then + local prev = popupFocus - 1 + if prev < 1 then prev = #rows end + SetPopupFocus(prev) + elseif key == "ENTER" then + local target = rows[popupFocus] + if target and target.Click then target:Click() end + elseif key == "ESCAPE" then + self:Hide() + else + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + end + end) + + popup:HookScript("OnShow", function(self) + if dropdownKeyboardMode then + -- Disable keyboard on whoever currently has it + Utils.SafeCallMethod(dropdown, "EnableKeyboard", false) + local sp = _G["EasyFindSpecPopup"] + if sp then Utils.SafeCallMethod(sp, "EnableKeyboard", false) end + local cf = _G["EasyFindSpecFlyout"] + if cf then Utils.SafeCallMethod(cf, "EnableKeyboard", false) end + local dp = _G["EasyFindDiffPopup"] + if dp then Utils.SafeCallMethod(dp, "EnableKeyboard", false) end + Utils.SafeCallMethod(self, "EnableKeyboard", true) + SetPopupFocus(1) + end + end) + + popup:HookScript("OnHide", function(self) + popupFocus = 0 + popupHL:Hide() + Utils.SafeCallMethod(self, "EnableKeyboard", false) + if dropdownKeyboardMode and dropdown:IsShown() then + Utils.SafeCallMethod(dropdown, "EnableKeyboard", true) + end + end) + end for i, opt in ipairs(UI_FILTER_OPTIONS) do local row = CreateFrame("CheckButton", nil, dropdown) @@ -1524,6 +1600,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end) + AddPopupKeyboardNav(diffPopup, function() return diffPopupRows end) + row.diffBtn = diffBtn row.diffPopup = diffPopup row.UpdateDiffButtons = function() @@ -1706,8 +1784,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) EasyFind.db.lootFilter = "all" UpdateSpecLabel() classFlyout:Hide() - specPopup:Hide() + if not classFlyout._keyboardParent then specPopup:Hide() end ApplyFilterSelection() + if specPopup:IsShown() then LayoutSpecPopup() end end) classFlyoutRows[#classFlyoutRows + 1] = allClassRow -- Each class @@ -1723,8 +1802,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) EasyFind.db.lootFilter = { classID = cls.classID } UpdateSpecLabel() classFlyout:Hide() - specPopup:Hide() + if not classFlyout._keyboardParent then specPopup:Hide() end ApplyFilterSelection() + if specPopup:IsShown() then LayoutSpecPopup() end end) classFlyoutRows[#classFlyoutRows + 1] = clsRow end @@ -1775,13 +1855,15 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local csHL = classSelectBtn:CreateTexture(nil, "HIGHLIGHT") csHL:SetAllPoints() csHL:SetColorTexture(1, 1, 1, 0.1) - classSelectBtn:SetScript("OnEnter", function(self) + local function OpenClassFlyout() LayoutClassFlyout() classFlyout:SetScale((EasyFind.db.uiSearchScale or 1.0) * (EasyFind.db.fontSize or 1.0)) classFlyout:ClearAllPoints() - classFlyout:SetPoint("TOPLEFT", self, "TOPRIGHT", 2, 6) + classFlyout:SetPoint("TOPLEFT", classSelectBtn, "TOPRIGHT", 2, 6) classFlyout:Show() - end) + end + classSelectBtn:SetScript("OnEnter", function() OpenClassFlyout() end) + classSelectBtn:SetScript("OnClick", function() OpenClassFlyout() end) -- Spec rows (rebuilt each time popup opens based on selected class) local specRadioRows = {} @@ -1946,6 +2028,14 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.specSelectRow = specSelectRow row.specSelectLabel = specSelectLabel + local function GetSpecPopupNavRows() + local rows = { classSelectBtn } + for _, sr in ipairs(specRadioRows) do + if sr:IsShown() then rows[#rows + 1] = sr end + end + return rows + end + -- Close on outside click specPopup:SetScript("OnShow", function(self) self:RegisterEvent("GLOBAL_MOUSE_DOWN") @@ -1966,6 +2056,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Close class flyout when mouse leaves both panels classFlyout:SetScript("OnUpdate", function(self) + if self:IsKeyboardEnabled() then return end if not self:IsMouseOver() and not specPopup:IsMouseOver() then if not self._leaveTimer then self._leaveTimer = C_Timer.NewTimer(0.2, function() @@ -1989,6 +2080,10 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) specPopup:Hide() end) + -- Keyboard nav MUST be added AFTER SetScript calls above + AddPopupKeyboardNav(specPopup, GetSpecPopupNavRows) + AddPopupKeyboardNav(classFlyout, function() return classFlyoutRows end) + -- Keep EasyFindSpecFlyout/EasyFindSpecSubFlyout names for dropdown close guard local specFlyout = classFlyout row.specFlyout = specFlyout @@ -2055,17 +2150,46 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Layout: positions all rows including map sub-rows, adjusts dropdown height local SUB_INDENT = 24 + local dropdownNavRows = {} -- ordered list of navigable rows (rebuilt on layout) + local dropdownFocus = 0 + local dropdownKbHighlight = dropdown:CreateTexture(nil, "BACKGROUND") + dropdownKbHighlight:SetColorTexture(1, 1, 1, 0.1) + dropdownKbHighlight:Hide() + + local function SetDropdownFocus(idx) + dropdownFocus = idx + local target = dropdownNavRows[idx] + if target then + dropdownKbHighlight:SetParent(target) + dropdownKbHighlight:ClearAllPoints() + dropdownKbHighlight:SetAllPoints(target) + dropdownKbHighlight:Show() + else + dropdownKbHighlight:Hide() + end + end + + local function ClearDropdownFocus() + dropdownFocus = 0 + dropdownKbHighlight:Hide() + end + function LayoutDropdown() + local savedFocus = dropdownFocus + wipe(dropdownNavRows) + dropdownKbHighlight:Hide() local y = -PADDING_TOP -- Toggle All row uncheckRow:ClearAllPoints() uncheckRow:SetPoint("TOPLEFT", 8, y) + dropdownNavRows[#dropdownNavRows + 1] = uncheckRow y = y - ROW_HEIGHT -- Filter rows for i, opt in ipairs(UI_FILTER_OPTIONS) do local row = checkRowsByIndex[i] row:ClearAllPoints() row:SetPoint("TOPLEFT", 8, y) + dropdownNavRows[#dropdownNavRows + 1] = row y = y - ROW_HEIGHT -- Map sub-rows if row.mapSubRows then @@ -2075,6 +2199,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) sr:ClearAllPoints() sr:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) sr:Show() + dropdownNavRows[#dropdownNavRows + 1] = sr y = y - ROW_HEIGHT else sr:Hide() @@ -2103,6 +2228,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.specSelectRow:ClearAllPoints() row.specSelectRow:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) row.specSelectRow:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.specSelectRow y = y - 28 else row.specSelectRow:Hide() @@ -2114,6 +2240,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.diffBtn:ClearAllPoints() row.diffBtn:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) row.diffBtn:Show() + dropdownNavRows[#dropdownNavRows + 1] = row.diffBtn y = y - 28 else row.diffBtn:Hide() @@ -2125,6 +2252,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) sr:ClearAllPoints() sr:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) sr:Show() + dropdownNavRows[#dropdownNavRows + 1] = sr y = y - ROW_HEIGHT else sr:Hide() @@ -2133,8 +2261,42 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) end end dropdown:SetSize(DROPDOWN_WIDTH, -y + PADDING_BOTTOM) + -- Restore keyboard focus if it was active + if savedFocus > 0 and dropdown:IsKeyboardEnabled() then + if savedFocus > #dropdownNavRows then savedFocus = #dropdownNavRows end + SetDropdownFocus(savedFocus) + end end + -- Keyboard navigation for the dropdown + Utils.SafeCallMethod(dropdown, "EnableKeyboard", false) + Utils.SafeCallMethod(dropdown, "SetPropagateKeyboardInput", false) + + dropdown:SetScript("OnKeyDown", function(self, key) + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + if key == "DOWN" then + local next = dropdownFocus + 1 + if next > #dropdownNavRows then next = 1 end + SetDropdownFocus(next) + elseif key == "UP" then + local prev = dropdownFocus - 1 + if prev < 1 then prev = #dropdownNavRows end + SetDropdownFocus(prev) + elseif key == "ENTER" then + local target = dropdownNavRows[dropdownFocus] + if target and target.Click then + target:Click() + end + elseif key == "ESCAPE" then + self._escapedViaKeyboard = true + self:Hide() + else + Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) + end + end) + + -- (keyboard OnShow/OnHide hooks moved after SetScript calls below) + -- Uncheck All: toggles all checkboxes off, or all back on if already all unchecked uncheckRow:SetScript("OnClick", function() local filters = EasyFind.db.uiSearchFilters @@ -2174,6 +2336,39 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:SetScript("OnHide", function() end) + -- Keyboard: enable when opened via Enter on filter button + dropdown:HookScript("OnShow", function(self) + if searchFrame.filterBtn and searchFrame.filterBtn.keyboardFocused then + dropdownKeyboardMode = true + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + Utils.SafeCallMethod(self, "EnableKeyboard", true) + SetDropdownFocus(1) + end + end) + + -- Keyboard: cleanup on hide + dropdown:HookScript("OnHide", function(self) + ClearDropdownFocus() + Utils.SafeCallMethod(self, "EnableKeyboard", false) + if self._escapedViaKeyboard then + self._escapedViaKeyboard = nil + dropdownKeyboardMode = false + Utils.SafeCallMethod(navFrame, "EnableKeyboard", true) + else + dropdownKeyboardMode = false + if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + if searchFrame.filterBtn then + searchFrame.filterBtn.keyboardFocused = nil + if searchFrame.filterBtn.btnBg then searchFrame.filterBtn.btnBg:Hide() end + if searchFrame.filterBtn.UnlockHighlight then searchFrame.filterBtn:UnlockHighlight() end + end + if searchFrame.editBox and not searchFrame.editBox:IsMouseOver() then + searchFrame.editBox:ClearFocus() + end + end + end) + -- Close when clicking outside (but not when interacting with spec/class flyouts) dropdown:SetScript("OnUpdate", function(self) if self:IsShown() and IsMouseButtonDown("LeftButton") then @@ -4298,7 +4493,17 @@ function UI:RefreshResults() self:UpdateSearchBarTheme() -- Only re-render if results are currently visible; don't resurrect old results if cachedHierarchical and resultsFrame and resultsFrame:IsShown() then + local savedIndex = selectedIndex + local savedToggle = toggleFocused self:ShowHierarchicalResults(cachedHierarchical) + -- ShowHierarchicalResults resets selectedIndex to 0; restore it for + -- deferred re-renders (rep bar IsTruncated settle) so keyboard + -- navigation isn't disrupted. + if savedIndex > 0 then + selectedIndex = savedIndex + toggleFocused = savedToggle + self:UpdateSelectionHighlight() + end end end @@ -5298,7 +5503,14 @@ end function UI:RefreshResults() if cachedHierarchical and resultsFrame and resultsFrame:IsShown() then + local savedIndex = selectedIndex + local savedToggle = toggleFocused self:ShowHierarchicalResults(cachedHierarchical, true) + if savedIndex > 0 then + selectedIndex = savedIndex + toggleFocused = savedToggle + self:UpdateSelectionHighlight() + end end end From 4e39a6712515c3e0b5bdd45e763f4bdd24e77132 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 02:33:14 -0400 Subject: [PATCH 32/44] Fix search scoring, key input, and result row rendering Loot scoring: match query words against item name words individually instead of only checking full-query prefix. Fixes "bond of light" disappearing when typing "bond of". Keyword scoring: require all query words to match at least one keyword. Prevents common words like "of" from producing false positives across unrelated entries. Nav keyboard: propagate character keys to the game when no selection is active and editbox is unfocused, fixing WASD movement typing into the search bar after pressing Escape. Result rows: reset tabText anchors on header tab render so recycled rows don't inherit stale repBar anchors (100px lost). Skip height expansion for single-line header tabs. Also adds key-repeat support for dropdown/popup/map arrow navigation and tightens spec-to-difficulty dropdown spacing. --- Database.lua | 19 ++++++++--- MapSearch.lua | 18 +++++++---- UI.lua | 88 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 99 insertions(+), 26 deletions(-) diff --git a/Database.lua b/Database.lua index a820b3a..f2fd213 100644 --- a/Database.lua +++ b/Database.lua @@ -2463,7 +2463,9 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) return best end - -- For multi-word queries, match each word separately and take best match per word + -- For multi-word queries, match each word separately and take best match per word. + -- All query words must match at least one keyword; a single unmatched word + -- zeroes the total to prevent common words like "of" from producing false positives. local total = 0 for qwi = 1, #queryWords do local queryWord = queryWords[qwi] @@ -2501,6 +2503,9 @@ function Database:ScoreKeywords(keywordsLower, query, queryLen, optQueryWords) end end + if bestScore == 0 then + return 0 + end total = total + bestScore end @@ -2570,14 +2575,20 @@ function Database:SearchUI(query, skipCategories) score = 0 local totalScore = 0 + local nameWords = GetWords(nameLower) for qi = 1, #queryWords do local qw = queryWords[qi] local qwLen = #qw local bestWord = 0 - -- Score against item name (prefix match) - if qi == 1 and ssub(nameLower, 1, queryLen) == query then - bestWord = 100 + -- Score against item name words (prefix match per word) + for ni = 1, #nameWords do + local nw = nameWords[ni] + if nw == qw then + bestWord = mmax(bestWord, qi == 1 and 100 or 90) + elseif ssub(nw, 1, qwLen) == qw then + bestWord = mmax(bestWord, qi == 1 and 95 or 85) + end end -- Score against slot keywords diff --git a/MapSearch.lua b/MapSearch.lua index f716b44..18e8db8 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -2356,15 +2356,21 @@ function MapSearch:CreateSearchFrame() if dropdown then local rowCount = #dropdown.rows if key == "DOWN" then - local newIdx = dropdown.selectedRow + 1 - if newIdx > rowCount then newIdx = rowCount end - dropdown:SetSelectedRow(newIdx) + MapSearch.StartKeyRepeat(key, function() + local rc = #dropdown.rows + local newIdx = dropdown.selectedRow + 1 + if newIdx > rc then newIdx = rc end + dropdown:SetSelectedRow(newIdx) + end) elseif key == "UP" then - local newIdx = dropdown.selectedRow - 1 - if newIdx < 1 then + if dropdown.selectedRow <= 1 then dropdown:Hide() else - dropdown:SetSelectedRow(newIdx) + MapSearch.StartKeyRepeat(key, function() + local newIdx = dropdown.selectedRow - 1 + if newIdx < 1 then newIdx = 1 end + dropdown:SetSelectedRow(newIdx) + end) end elseif key == "ENTER" or key == "SPACE" then if dropdown.selectedRow > 0 then diff --git a/UI.lua b/UI.lua index 69deafb..8995cb9 100644 --- a/UI.lua +++ b/UI.lua @@ -291,6 +291,7 @@ end local selectedIndex = 0 -- 0 = none selected, 1..N = highlighted row local toggleFocused = false -- true = Tab moved focus to expand/collapse toggle local navFrame -- Keyboard capture frame for results navigation +local escCatcher -- UISpecialFrames fallback for second-ESC-to-close local unearnedTooltip -- Custom tooltip for unearned currencies -- THEME DEFINITIONS @@ -656,6 +657,7 @@ function UI:CreateSearchFrame() self:ClearFocus() return end + if escCatcher then escCatcher:Hide() end self.placeholder:Hide() if selectedIndex > 0 then selectedIndex = 0 @@ -709,7 +711,7 @@ function UI:CreateSearchFrame() editBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() - -- Text and results stay visible; user can click back in to resume + -- Text and results stay visible; user can click back in to resume. end) -- Clear-text X button (grey circle X, matching retail quest log style) @@ -807,6 +809,7 @@ function UI:CreateSearchFrame() repeatActive = false end searchFrame.StopKeyRepeat = StopKeyRepeat + searchFrame.IsRepeatKey = function(key) return repeatKey == key end local function StartKeyRepeat(key, action) action() @@ -816,6 +819,7 @@ function UI:CreateSearchFrame() repeatNext = REPEAT_INITIAL repeatActive = true end + searchFrame.StartKeyRepeat = StartKeyRepeat searchFrame:SetScript("OnUpdate", function(_, elapsed) if not repeatActive then return end @@ -1012,6 +1016,12 @@ function UI:CreateSearchFrame() UI:UpdateSelectionHighlight(true) end else + -- If no selection and editbox isn't focused, let the key propagate + -- to the game (e.g. WASD movement) instead of typing into the bar. + if selectedIndex == 0 and not searchFrame.editBox:HasFocus() then + Utils.SafeCallMethod(navFrame, "SetPropagateKeyboardInput", true) + return + end ClearToolbarFocus() selectedIndex = 0 toggleFocused = false @@ -1034,13 +1044,32 @@ function UI:CreateSearchFrame() return end end - HandleNavKeyDown(key) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) + HandleNavKeyDown(key) end) navFrame:SetScript("OnKeyUp", function(_, key) if repeatKey == key then StopKeyRepeat() end end) + -- UISpecialFrames fallback: WoW closes these on ESC before opening the + -- game menu. Shown after the editbox loses focus with results visible so + -- the next ESC clears+closes instead of toggling the game menu. + escCatcher = CreateFrame("Frame", "EasyFindEscCatcher", searchFrame) + escCatcher:SetSize(1, 1) + escCatcher:Hide() + tinsert(UISpecialFrames, "EasyFindEscCatcher") + escCatcher:SetScript("OnHide", function() + if searchFrame.editBox:HasFocus() then return end + if not resultsFrame or not resultsFrame:IsShown() then return end + Utils.SafeCallMethod(navFrame, "EnableKeyboard", false) + if searchFrame.StopKeyRepeat then searchFrame.StopKeyRepeat() end + selectedIndex = 0 + toggleFocused = false + searchFrame.editBox:SetText("") + searchFrame.editBox.placeholder:Show() + UI:HideResults() + end) + -- Tab/Shift+Tab from editbox: navigate toolbar controls -- Controls are ordered left-to-right: modeBtn, [clearTextBtn], filterBtn -- EditBox sits between modeBtn and clearTextBtn/filterBtn, so: @@ -1313,13 +1342,19 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) local rows = getRows() if key == "DOWN" then - local next = popupFocus + 1 - if next > #rows then next = 1 end - SetPopupFocus(next) + searchFrame.StartKeyRepeat(key, function() + local r = getRows() + local next = popupFocus + 1 + if next > #r then next = 1 end + SetPopupFocus(next) + end) elseif key == "UP" then - local prev = popupFocus - 1 - if prev < 1 then prev = #rows end - SetPopupFocus(prev) + searchFrame.StartKeyRepeat(key, function() + local r = getRows() + local prev = popupFocus - 1 + if prev < 1 then prev = #r end + SetPopupFocus(prev) + end) elseif key == "ENTER" then local target = rows[popupFocus] if target and target.Click then target:Click() end @@ -1329,6 +1364,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end end) + popup:HookScript("OnKeyUp", function(_, key) + if searchFrame.IsRepeatKey(key) then searchFrame.StopKeyRepeat() end + end) popup:HookScript("OnShow", function(self) if dropdownKeyboardMode then @@ -2229,7 +2267,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) row.specSelectRow:SetPoint("TOPLEFT", 8 + SUB_INDENT, y) row.specSelectRow:Show() dropdownNavRows[#dropdownNavRows + 1] = row.specSelectRow - y = y - 28 + y = y - 24 else row.specSelectRow:Hide() end @@ -2275,13 +2313,22 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) dropdown:SetScript("OnKeyDown", function(self, key) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", false) if key == "DOWN" then - local next = dropdownFocus + 1 - if next > #dropdownNavRows then next = 1 end - SetDropdownFocus(next) + searchFrame.StartKeyRepeat(key, function() + local next = dropdownFocus + 1 + if next > #dropdownNavRows then next = 1 end + SetDropdownFocus(next) + end) elseif key == "UP" then - local prev = dropdownFocus - 1 - if prev < 1 then prev = #dropdownNavRows end - SetDropdownFocus(prev) + if dropdownFocus <= 1 then + self._escapedViaKeyboard = true + self:Hide() + return + end + searchFrame.StartKeyRepeat(key, function() + local prev = dropdownFocus - 1 + if prev < 1 then prev = 1 end + SetDropdownFocus(prev) + end) elseif key == "ENTER" then local target = dropdownNavRows[dropdownFocus] if target and target.Click then @@ -2294,6 +2341,9 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) Utils.SafeCallMethod(self, "SetPropagateKeyboardInput", true) end end) + dropdown:SetScript("OnKeyUp", function(_, key) + if searchFrame.IsRepeatKey(key) then searchFrame.StopKeyRepeat() end + end) -- (keyboard OnShow/OnHide hooks moved after SetScript calls below) @@ -3746,6 +3796,10 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) local collapseAtlas = theme.collapseAtlas or "QuestLog-icon-shrink" local toggleAtlas = isCollapsed and expandAtlas or collapseAtlas resultRow.toggleIcon:SetAtlas(toggleAtlas) + -- Reset tabText anchors (may have been re-anchored to repBar) + resultRow.tabText:ClearAllPoints() + resultRow.tabText:SetPoint("LEFT", resultRow.headerTab, "LEFT", 10, 0) + resultRow.tabText:SetPoint("RIGHT", resultRow.toggleBtn, "LEFT", -4, 0) resultRow.tabText:SetText(entry.name) -- Matched path nodes get gold text; non-matches stay muted gray if resultRow._isMatch then @@ -4334,10 +4388,11 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) end -- Measure text height and expand row if text wraps + -- Skip header tabs: they have SetMaxLines(1) and can't wrap. local actualH = resultRow:GetHeight() local textObj if theme.showHeaderTab and entry.isPathNode and resultRow.headerTab:IsShown() then - textObj = resultRow.tabText + textObj = nil elseif not entry.isPinHeader then textObj = resultRow.text end @@ -4513,6 +4568,7 @@ function UI:HideResults() if searchFrame.ClearToolbarFocus then searchFrame.ClearToolbarFocus() end if not resultsFrame then return end resultsFrame:Hide() + if escCatcher then escCatcher:Hide() end if resultsFrame.pinSeparator then resultsFrame.pinSeparator:Hide() end From f0ee4befb00f607a25604d8343468b80b76cea96 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 08:25:09 -0400 Subject: [PATCH 33/44] Add EJ loot navigation guide and highlighting Add step handlers for navigating the Encounter Journal: ejInstance, ejBoss, ejLootTab, and ejLootItem. The item handler strips inline color codes (|cAARRGGBB...|r) from EJ loot text before name comparison. Add prerequisite validation for EJ boss selection and loot tab so the guide rewinds when the user clicks a different boss or switches tabs mid-guide. Add FastForwardSteps to advance through already-satisfied steps immediately instead of waiting one per ticker tick. Add /ef ejdump diagnostic for EJ ScrollBox inspection. --- Core.lua | 35 +++++++++ Highlight.lua | 200 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 222 insertions(+), 13 deletions(-) diff --git a/Core.lua b/Core.lua index 30e4d4d..f27c51f 100644 --- a/Core.lua +++ b/Core.lua @@ -289,6 +289,41 @@ local function OnInitialize() EasyFind.db.setupComplete = nil ns.UI:ShowFirstTimeSetup() end + elseif msg == "ejdump" then + local info = _G["EncounterJournalEncounterFrameInfo"] + if not info then print("No EncounterJournalEncounterFrameInfo"); return end + print("--- EJ Loot Container Dump ---") + local lc = info.LootContainer + print("LootContainer: " .. tostring(lc) .. " shown:" .. tostring(lc and lc:IsShown())) + if lc then + local sb = lc.ScrollBox + print(" .ScrollBox: " .. tostring(sb)) + if sb then + print(" .ScrollBox:IsShown(): " .. tostring(sb:IsShown())) + print(" has EnumerateFrames: " .. tostring(sb.EnumerateFrames ~= nil)) + if sb.EnumerateFrames then + local count = 0 + for _, btn in sb:EnumerateFrames() do + count = count + 1 + local text = ns.Utils.GetButtonText(btn) + print(" [" .. count .. "] " .. tostring(text) .. " shown:" .. tostring(btn:IsShown())) + end + print(" total frames: " .. count) + end + local st = sb.ScrollTarget + print(" .ScrollTarget: " .. tostring(st)) + if st then + local kids = { st:GetChildren() } + print(" ScrollTarget children: " .. #kids) + for i, kid in ipairs(kids) do + if i <= 5 then + local text = ns.Utils.GetButtonText(kid) + print(" [" .. i .. "] " .. tostring(text) .. " shown:" .. tostring(kid:IsShown())) + end + end + end + end + end elseif msg == "whatsnew" then if ns.UI then ns.UI:ShowWhatsNew(ns.version) end elseif msg == "help" or msg == "h" or msg == "?" then diff --git a/Highlight.lua b/Highlight.lua index 8d3dc9c..b18a2a0 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -214,12 +214,14 @@ function Highlight:StartGuide(guideData) currentGuide = guideData currentStepIndex = 1 - -- Use a ticker to continuously check step conditions + self:FastForwardSteps() + if not currentGuide then return end + stepTicker = C_Timer.NewTicker(0.1, function() local ok, err = xpcall(self.UpdateGuide, ErrorHandler, self) if not ok then if stepTicker then stepTicker:Cancel(); stepTicker = nil end - ns.DebugPrint("Guide error: " .. tostring(err)) + print("Guide error: " .. tostring(err)) end end) end @@ -239,25 +241,33 @@ function Highlight:StartGuideAtStep(guideData, stepIndex) currentGuide = guideData currentStepIndex = stepIndex - -- Attempt immediate highlight (avoids waiting for first ticker interval) - local ok, err = xpcall(self.UpdateGuide, ErrorHandler, self) - if not ok then - ns.DebugPrint("Guide error: " .. tostring(err)) - return - end - - -- If guide completed or was cancelled by the immediate attempt, no ticker needed + self:FastForwardSteps() if not currentGuide then return end stepTicker = C_Timer.NewTicker(0.1, function() - local ok2, err2 = xpcall(self.UpdateGuide, ErrorHandler, self) - if not ok2 then + local ok, err = xpcall(self.UpdateGuide, ErrorHandler, self) + if not ok then if stepTicker then stepTicker:Cancel(); stepTicker = nil end - ns.DebugPrint("Guide error: " .. tostring(err2)) + print("Guide error: " .. tostring(err)) end end) end +-- Advance through already-satisfied steps immediately instead of one-per-tick. +-- Loops UpdateGuide until the step index stops advancing or the guide completes. +function Highlight:FastForwardSteps() + local prevIdx + repeat + prevIdx = currentStepIndex + local ok, err = xpcall(self.UpdateGuide, ErrorHandler, self) + if not ok then + print("Guide error: " .. tostring(err)) + self:Cancel() + return + end + until not currentGuide or currentStepIndex == prevIdx +end + function Highlight:UpdateGuide() if not currentGuide or not currentStepIndex then self:Cancel() @@ -1053,7 +1063,171 @@ function Highlight:UpdateGuide() return end end + + -- Validate EJ boss (encounterID changed = user clicked different boss) + if prev.ejEncounterID then + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + local ej = _G["EncounterJournal"] + local currentEnc = (infoFrame and infoFrame.encounterID) + or (ej and ej.encounterID) + if not currentEnc then + local getEnc = (C_EncounterJournal and C_EncounterJournal.GetCurrentEncounter) + or _G["EJ_GetCurrentEncounter"] + if getEnc then currentEnc = getEnc() end + end + if currentEnc and currentEnc ~= prev.ejEncounterID then + currentStepIndex = i + self:HideHighlight() + return + end + end + + -- Validate EJ loot tab (user switched to Overview/Abilities/etc.) + if prev.ejLootTab then + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + local lootContainer = infoFrame and infoFrame.LootContainer + if lootContainer and not lootContainer:IsShown() then + currentStepIndex = i + self:HideHighlight() + return + end + end + end + end + + -- EJ instance button (ScrollBox with dynamic names, find by text) + if step.ejInstance then + -- Advance when instance select panel is hidden (user clicked an instance + -- and the EJ is now showing the encounter/boss view) + local instSelect = _G["EncounterJournalInstanceSelect"] + if instSelect and not instSelect:IsShown() then + self:AdvanceStep() + return + end + local scrollBox = _G["EncounterJournalInstanceSelect"] and _G["EncounterJournalInstanceSelect"].ScrollBox + if scrollBox then + local targetName = slower(step.ejInstance) + local instBtn = ScrollBoxFindButton(scrollBox, function(btn) + local text = GetButtonText(btn) + return text and slower(text) == targetName + end) + if instBtn then + self:HighlightFrame(instBtn) + end + end + return + end + + -- EJ boss button (ScrollBox with dynamic names, find by text) + if step.ejBoss then + -- Advance when the correct boss is selected + if step.ejEncounterID then + -- Try multiple ways to detect the selected encounter + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + local ej = _G["EncounterJournal"] + local currentEnc = (infoFrame and infoFrame.encounterID) + or (ej and ej.encounterID) + if not currentEnc then + local getEnc = (C_EncounterJournal and C_EncounterJournal.GetCurrentEncounter) + or _G["EJ_GetCurrentEncounter"] + if getEnc then currentEnc = getEnc() end + end + -- Fallback: check if the boss button itself shows a selected state + if not currentEnc then + local scrollBox = infoFrame and infoFrame.BossesScrollBox + if scrollBox then + local targetName = slower(step.ejBoss) + local bossBtn = ScrollBoxFindButton(scrollBox, function(btn) + local text = GetButtonText(btn) + return text and slower(text) == targetName + end) + if bossBtn then + local sel = bossBtn.selectedTexture or bossBtn.SelectedTexture + if sel and sel:IsShown() then currentEnc = step.ejEncounterID end + end + end + end + if currentEnc == step.ejEncounterID then + self:AdvanceStep() + return + end + end + local scrollBox = _G["EncounterJournalEncounterFrameInfo"] and _G["EncounterJournalEncounterFrameInfo"].BossesScrollBox + if scrollBox then + local targetName = slower(step.ejBoss) + local bossBtn = ScrollBoxFindButton(scrollBox, function(btn) + local text = GetButtonText(btn) + return text and slower(text) == targetName + end) + if bossBtn then + self:HighlightFrame(bossBtn) + end + end + return + end + + -- EJ loot tab: highlight if not selected, advance when loot content visible + if step.ejLootTab then + local lootTab = _G["EncounterJournalEncounterFrameInfoLootTab"] + if not lootTab or not lootTab:IsShown() then return end + -- Check if loot content is visible (tab already selected) + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + local lootVisible = infoFrame and ( + (infoFrame.LootContainer and infoFrame.LootContainer:IsShown()) + or (infoFrame.LootScrollBox and infoFrame.LootScrollBox:IsShown()) + ) + -- Fallback: check PanelTemplates or the tab's own selected state + if not lootVisible then + local selectedTab = infoFrame and PanelTemplates_GetSelectedTab and PanelTemplates_GetSelectedTab(infoFrame) + if selectedTab == 2 then lootVisible = true end + if lootTab.isSelected then lootVisible = true end + if lootTab.GetSelectedState and lootTab:GetSelectedState() then lootVisible = true end + end + if lootVisible then + self:AdvanceStep() + return + end + self:HighlightFrame(lootTab) + return + end + + -- EJ loot item: find by itemID or name in LootContainer.ScrollBox + if step.ejLootItem then + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + if not infoFrame then return end + local lootContainer = infoFrame.LootContainer + local scrollBox = (lootContainer and lootContainer:IsShown() and lootContainer.ScrollBox) + or infoFrame.LootScrollBox + if not scrollBox then return end + local targetID = step.ejLootItem + local targetName = step.ejLootItemName and slower(step.ejLootItemName) + + local itemBtn = ScrollBoxFindButton(scrollBox, function(btn) + local edata = btn.GetElementData and btn:GetElementData() + if edata then + if edata.itemID == targetID then return true end + if edata.link then + local id = edata.link:match("item:(%d+)") + if id and tonumber(id) == targetID then return true end + end + end + if targetName then + local text = GetButtonText(btn) + if text then + -- EJ loot text has inline color codes (|cAARRGGBB...|r) + local clean = slower(text):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "") + if clean == targetName then return true end + end + end + return false + end) + if itemBtn then + self:HighlightFrame(itemBtn) + if canHoverDismiss() and itemBtn:IsMouseOver() then + self:Cancel() + end end + return end -- Text-only final step (when we've navigated but can't highlight specific element) From 205a39cf9753b6392bbba479a2d49a3dc4a790fd Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 08:25:23 -0400 Subject: [PATCH 34/44] Sync EJ loot filter, sort categories by match score Add Database:SyncEJLootFilter() to set the EJ loot filter from EasyFind's filter selection. Called on filter change and before loot navigation so the target item is visible in the EJ. Strip color codes in the DirectOpen ejLootItem path. Sort result category groups by best match score instead of hardcoded order so the most relevant category (e.g. Loot for slot searches) appears first. Increase category separator from 1px to 1.5px and pool from 2 to 6 for all category boundaries. --- Database.lua | 29 +++++ UI.lua | 301 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 241 insertions(+), 89 deletions(-) diff --git a/Database.lua b/Database.lua index f2fd213..550718a 100644 --- a/Database.lua +++ b/Database.lua @@ -491,6 +491,13 @@ local SLOT_DISPLAY = { INVTYPE_HOLDABLE = "Off Hand", } +-- Lowercased slot display names for category score boosting (query "legs" → loot first) +local lootSlotNames = {} +for _, displayName in pairs(SLOT_DISPLAY) do + lootSlotNames[slower(displayName)] = true +end +ns.lootSlotNames = lootSlotNames + -- EJ API compatibility: some functions migrated from EJ_* globals to C_EncounterJournal.*. -- Prefer C_EncounterJournal (current) over globals (may be stale wrappers). -- Resolved at call time because C_EJ functions may not exist until EncounterJournal_LoadUI(). @@ -511,6 +518,28 @@ local LOOT_DIFF_IDS = { mythic = { dungeon = 23, raid = 16 }, } +-- Sync the EJ's internal loot filter to match EasyFind's lootFilter setting. +-- Called when the user changes the filter and before loot navigation. +function Database:SyncEJLootFilter() + local setFilter = EJ("SetLootFilter") + if not setFilter then return end + local lf = EasyFind.db.lootFilter + if not lf then + local _, _, cid = UnitClass("player") + local si = GetSpecialization and GetSpecialization() + local sid = si and GetSpecializationInfo and GetSpecializationInfo(si) + if cid and sid then + setFilter(cid, sid) + end + elseif lf == "all" then + setFilter(0, 0) + elseif lf.specID then + setFilter(lf.classID, lf.specID) + elseif lf.classID then + setFilter(lf.classID, 0) + end +end + -- Rebuild uiSearchData loot entries from cache based on current spec + difficulty selection. -- No EJ scan needed: filters cached items by spec and difficulty match. local function RebuildLootSearchData() diff --git a/UI.lua b/UI.lua index 8995cb9..c99dc2e 100644 --- a/UI.lua +++ b/UI.lua @@ -9,7 +9,7 @@ local SearchFrameTreeFuzzy = Utils.SearchFrameTreeFuzzy local ClickButton = Utils.ClickButton local select, ipairs, pairs = Utils.select, Utils.ipairs, Utils.pairs local sfind, slower = Utils.sfind, Utils.slower -local tinsert, tconcat, tremove = Utils.tinsert, Utils.tconcat, Utils.tremove +local tinsert, tconcat, tremove, tsort = Utils.tinsert, Utils.tconcat, Utils.tremove, Utils.tsort local mmin, mmax = Utils.mmin, Utils.mmax local GOLD_COLOR = ns.GOLD_COLOR @@ -1699,8 +1699,11 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Apply single selection and rebuild from cache local function ApplyFilterSelection() - if ns.Database and ns.Database.PopulateDynamicLoot then - ns.Database:PopulateDynamicLoot() + if ns.Database then + if ns.Database.PopulateDynamicLoot then + ns.Database:PopulateDynamicLoot() + end + ns.Database:SyncEJLootFilter() end if searchEditBox:GetText() ~= "" then UI:OnSearchTextChanged(searchEditBox:GetText()) @@ -2510,12 +2513,12 @@ function UI:CreateResultsFrame() pinSeparator:Hide() resultsFrame.pinSeparator = pinSeparator - -- Category separator lines (between UI, Mount, and Toy result groups) + -- Category separator lines (between result category groups) local categorySeps = {} - for sepIdx = 1, 2 do + for sepIdx = 1, 6 do local sep = scrollChild:CreateTexture(nil, "ARTWORK") - sep:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.25) - sep:SetHeight(1) + sep:SetColorTexture(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3], 0.3) + sep:SetHeight(1.5) sep:Hide() categorySeps[sepIdx] = sep end @@ -2537,6 +2540,10 @@ local expandedContainers = {} -- tracks which containers have had children inje -- Reusable tables for grouping results (wiped each search to avoid per-keystroke allocations) local groupUI, groupMounts, groupToys, groupPets, groupOutfits, groupLoot, groupMap = {}, {}, {}, {}, {}, {}, {} +local uiSectionHeader = { + name = "UI", depth = 0, isPathNode = true, + isMatch = false, isSectionHeader = true, +} local mountSectionHeader = { name = "Mounts", depth = 0, isPathNode = true, isMatch = false, isSectionHeader = true, @@ -3291,6 +3298,35 @@ function UI:OnSearchTextChanged(text) mapResults = ns.MapSearch:SearchForUI(text) end + -- Compute best score per category from flat results (before hierarchy loses scores) + local bestCatScore = {} + for _, r in ipairs(results) do + local d = r.data + local s = r.score or 0 + local cat + if d.mountID then cat = "mounts" + elseif d.toyItemID then cat = "toys" + elseif d.petID then cat = "pets" + elseif d.outfitID then cat = "outfits" + elseif d.itemID and d.category == "Loot" then cat = "loot" + else cat = "ui" + end + if s > (bestCatScore[cat] or 0) then bestCatScore[cat] = s end + end + if mapResults then + for _, r in ipairs(mapResults) do + local s = r.score or 0 + if s > (bestCatScore.map or 0) then bestCatScore.map = s end + end + end + -- Boost loot category when the query exactly matches a slot name (e.g., "legs", "ring") + if bestCatScore.loot and ns.lootSlotNames then + local queryLower = slower(text) + if ns.lootSlotNames[queryLower] then + bestCatScore.loot = mmax(bestCatScore.loot, 200) + end + end + local hierarchical = ns.Database:BuildHierarchicalResults(results) -- Container nodes (search results that have database children which didn't -- match the query) start collapsed - user can expand to browse children. @@ -3339,83 +3375,80 @@ function UI:OnSearchTextChanged(text) } end end + -- Sort categories by best match score so the most relevant category appears first + local catGroups = {} + if #groupUI > 0 then catGroups[#catGroups + 1] = { key = "ui", score = bestCatScore.ui or 0 } end + if #groupMounts > 0 then catGroups[#catGroups + 1] = { key = "mounts", score = bestCatScore.mounts or 0 } end + if #groupToys > 0 then catGroups[#catGroups + 1] = { key = "toys", score = bestCatScore.toys or 0 } end + if #groupPets > 0 then catGroups[#catGroups + 1] = { key = "pets", score = bestCatScore.pets or 0 } end + if #groupOutfits > 0 then catGroups[#catGroups + 1] = { key = "outfits", score = bestCatScore.outfits or 0 } end + if #groupLoot > 0 then catGroups[#catGroups + 1] = { key = "loot", score = bestCatScore.loot or 0 } end + if #groupMap > 0 then catGroups[#catGroups + 1] = { key = "map", score = bestCatScore.map or 0 } end + tsort(catGroups, function(a, b) + if a.score ~= b.score then return a.score > b.score end + return a.key < b.key + end) + hierarchical = {} - for _, e in ipairs(groupUI) do hierarchical[#hierarchical + 1] = e end - if #groupMounts > 0 then - hierarchical[#hierarchical + 1] = mountSectionHeader - for _, e in ipairs(groupMounts) do - e.depth = 1 - hierarchical[#hierarchical + 1] = e - end - end - if #groupToys > 0 then - hierarchical[#hierarchical + 1] = toySectionHeader - for _, e in ipairs(groupToys) do - e.depth = 1 - hierarchical[#hierarchical + 1] = e - end - end - if #groupPets > 0 then - hierarchical[#hierarchical + 1] = petSectionHeader - for _, e in ipairs(groupPets) do - e.depth = 1 - hierarchical[#hierarchical + 1] = e - end - end - if #groupOutfits > 0 then - hierarchical[#hierarchical + 1] = outfitSectionHeader - for _, e in ipairs(groupOutfits) do - e.depth = 1 - hierarchical[#hierarchical + 1] = e - end - end - if #groupLoot > 0 then - hierarchical[#hierarchical + 1] = lootSectionHeader - -- Group loot results: slot -> instance -> items - local slotGroups = {} - local slotOrder = {} - for _, e in ipairs(groupLoot) do - local slot = (e.data and e.data.lootSlotName) or "Other" - if not slotGroups[slot] then - slotGroups[slot] = {} - slotOrder[#slotOrder + 1] = slot - end - slotGroups[slot][#slotGroups[slot] + 1] = e - end - for _, slot in ipairs(slotOrder) do - -- Slot sub-header - hierarchical[#hierarchical + 1] = { - name = slot, depth = 1, isPathNode = true, - isMatch = false, isSectionHeader = false, - } - -- Group by instance within slot - local instGroups = {} - local instOrder = {} - for _, e in ipairs(slotGroups[slot]) do - local inst = (e.data and e.data.lootInstanceName) or "Unknown" - if not instGroups[inst] then - instGroups[inst] = {} - instOrder[#instOrder + 1] = inst + for _, cat in ipairs(catGroups) do + if cat.key == "ui" then + if #hierarchical > 0 then + hierarchical[#hierarchical + 1] = uiSectionHeader + end + for _, e in ipairs(groupUI) do hierarchical[#hierarchical + 1] = e end + elseif cat.key == "mounts" then + hierarchical[#hierarchical + 1] = mountSectionHeader + for _, e in ipairs(groupMounts) do e.depth = 1; hierarchical[#hierarchical + 1] = e end + elseif cat.key == "toys" then + hierarchical[#hierarchical + 1] = toySectionHeader + for _, e in ipairs(groupToys) do e.depth = 1; hierarchical[#hierarchical + 1] = e end + elseif cat.key == "pets" then + hierarchical[#hierarchical + 1] = petSectionHeader + for _, e in ipairs(groupPets) do e.depth = 1; hierarchical[#hierarchical + 1] = e end + elseif cat.key == "outfits" then + hierarchical[#hierarchical + 1] = outfitSectionHeader + for _, e in ipairs(groupOutfits) do e.depth = 1; hierarchical[#hierarchical + 1] = e end + elseif cat.key == "loot" then + hierarchical[#hierarchical + 1] = lootSectionHeader + local slotGroups = {} + local slotOrder = {} + for _, e in ipairs(groupLoot) do + local slot = (e.data and e.data.lootSlotName) or "Other" + if not slotGroups[slot] then + slotGroups[slot] = {} + slotOrder[#slotOrder + 1] = slot end - instGroups[inst][#instGroups[inst] + 1] = e + slotGroups[slot][#slotGroups[slot] + 1] = e end - for _, inst in ipairs(instOrder) do - -- Instance sub-header + for _, slot in ipairs(slotOrder) do hierarchical[#hierarchical + 1] = { - name = inst, depth = 2, isPathNode = true, + name = slot, depth = 1, isPathNode = true, isMatch = false, isSectionHeader = false, } - for _, e in ipairs(instGroups[inst]) do - e.depth = 3 - hierarchical[#hierarchical + 1] = e + local instGroups = {} + local instOrder = {} + for _, e in ipairs(slotGroups[slot]) do + local inst = (e.data and e.data.lootInstanceName) or "Unknown" + if not instGroups[inst] then + instGroups[inst] = {} + instOrder[#instOrder + 1] = inst + end + instGroups[inst][#instGroups[inst] + 1] = e + end + for _, inst in ipairs(instOrder) do + hierarchical[#hierarchical + 1] = { + name = inst, depth = 2, isPathNode = true, + isMatch = false, isSectionHeader = false, + } + for _, e in ipairs(instGroups[inst]) do + e.depth = 3 + hierarchical[#hierarchical + 1] = e + end end end - end - end - if #groupMap > 0 then - hierarchical[#hierarchical + 1] = mapSectionHeader - for _, e in ipairs(groupMap) do - hierarchical[#hierarchical + 1] = e + elseif cat.key == "map" then + hierarchical[#hierarchical + 1] = mapSectionHeader + for _, e in ipairs(groupMap) do hierarchical[#hierarchical + 1] = e end end end @@ -4867,22 +4900,34 @@ function UI:SelectResult(data) -- Outfit: equip handled by SecureActionButton (mouse click or Enter binding). if data.outfitID then return end - -- Loot: Shift+click opens dressing room, regular click opens EJ to boss + -- Loot: Shift+click opens dressing room, regular click navigates EJ if data.itemID and data.category == "Loot" then local lootLink = ns.Database and ns.Database:GetLootItemLink(data) if IsShiftKeyDown() and lootLink then DressUpItemLink(lootLink) + return + end + + -- Sync EJ loot filter so the item is visible when we navigate there + if ns.Database then ns.Database:SyncEJLootFilter() end + + local isRaid = data.lootSourceType == "Raid" + local tabIndex = isRaid and 5 or 4 + local guideData = { + steps = { + { buttonFrame = "EJMicroButton" }, + { waitForFrame = "EncounterJournal", tabIndex = tabIndex }, + { waitForFrame = "EncounterJournal", ejInstance = data.lootInstanceName }, + { waitForFrame = "EncounterJournal", ejBoss = data.lootSourceName, ejEncounterID = data.encounterID }, + { waitForFrame = "EncounterJournal", ejLootTab = true }, + { waitForFrame = "EncounterJournal", ejLootItem = data.itemID, ejLootItemName = data.name }, + }, + } + + if EasyFind.db.directOpen then + self:DirectOpen(guideData) else - EncounterJournal_LoadUI() - if data.instanceID and EJ_SelectInstance then - EJ_SelectInstance(data.instanceID) - end - if data.encounterID and EJ_SelectEncounter then - EJ_SelectEncounter(data.encounterID) - end - if EncounterJournal then - ShowUIPanel(EncounterJournal) - end + EasyFind:StartGuide(guideData) end return end @@ -5017,6 +5062,9 @@ function UI:DirectOpen(data) if step.factionID then return true end if step.searchButtonText then return true end if step.portraitMenuOption then return true end + if step.ejInstance then return true end + if step.ejBoss then return true end + if step.ejLootTab then return true end -- regionFrames alone (no searchButtonText) = highlight-only (e.g. PvP Talents) -- waitForFrame alone = just waiting for a frame to appear, not navigable -- text alone = instruction text, not navigable @@ -5074,8 +5122,10 @@ function UI:DirectOpen(data) EncounterJournal_LoadUI() EncounterJournal.selectedTab = nextStep.tabIndex ShowUIPanel(EncounterJournal) - -- Skip the tab step, continue from the step after it - executeFrom(i + 2) + -- Skip the tab step, continue from the step after it. + -- Defer one frame so the ScrollBox populates its items. + local resume = i + 2 + C_Timer.After(0, function() executeFrom(resume) end) return end end @@ -5129,6 +5179,79 @@ function UI:DirectOpen(data) self:ClickAchievementCategory(categoryToClick) end + -- EJ instance: find by name in ScrollBox and click + if step.ejInstance then + local scrollBox = _G["EncounterJournalInstanceSelect"] and _G["EncounterJournalInstanceSelect"].ScrollBox + if scrollBox then + local targetName = slower(step.ejInstance) + local instBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) + local text = Utils.GetButtonText(btn) + return text and slower(text) == targetName + end) + if instBtn then ClickButton(instBtn) end + end + end + + -- EJ boss: find by name in BossesScrollBox and click + if step.ejBoss then + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + local scrollBox = infoFrame and infoFrame.BossesScrollBox + if scrollBox then + local targetName = slower(step.ejBoss) + local bossBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) + local text = Utils.GetButtonText(btn) + return text and slower(text) == targetName + end) + if bossBtn then ClickButton(bossBtn) end + end + end + + -- EJ loot tab: click + if step.ejLootTab then + local lootTab = _G["EncounterJournalEncounterFrameInfoLootTab"] + if lootTab then ClickButton(lootTab) end + end + + -- EJ loot item: highlight only (last step) + if step.ejLootItem and i == executeCount then + local Highlight = ns.Highlight + local infoFrame = _G["EncounterJournalEncounterFrameInfo"] + local scrollBox = infoFrame and ( + (infoFrame.LootContainer and infoFrame.LootContainer.ScrollBox) + or infoFrame.LootScrollBox or infoFrame.ScrollBox + ) + if scrollBox then + local targetID = step.ejLootItem + local itemName = step.ejLootItemName + C_Timer.After(0.05, function() + local itemBtn = Utils.ScrollBoxFindButton(scrollBox, function(btn) + local edata = btn.GetElementData and btn:GetElementData() + if edata and edata.itemID == targetID then return true end + if itemName then + local text = Utils.GetButtonText(btn) + if text then + local clean = slower(text):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "") + if clean == slower(itemName) then return true end + end + end + return false + end) + if itemBtn and Highlight then + Highlight:HighlightFrame(itemBtn) + local checkHover + checkHover = function() + if itemBtn:IsMouseOver() then + Highlight:HideHighlight() + else + C_Timer.After(0.1, checkHover) + end + end + C_Timer.After(0.3, checkHover) + end + end) + end + end + -- Currency/faction headers pre-expanded via API, nothing to execute if step.currencyID then From bc85d461c63fdcc953a9dbe4fc3a8a85d8ef35b2 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 08:48:44 -0400 Subject: [PATCH 35/44] Hide vendor-only transmog controls in browse mode When opening the transmog window via search (not at a vendor), hide Purchase Outfit Slot, Save Outfit, the money frame, and the Situations tab. Disable right-click on outfit name buttons (Change Name/Icon doesn't work without a vendor). Show messages directing users to visit a transmogrifier. All restored when the frame closes. --- UI.lua | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/UI.lua b/UI.lua index c99dc2e..38bcd81 100644 --- a/UI.lua +++ b/UI.lua @@ -4876,6 +4876,144 @@ function UI:ActivateSelected() self:SelectFirstResult() end +-- Hide vendor-only transmog controls when opened via search (not at an NPC). +-- Shows a message explaining full functionality requires a transmogrifier. +-- Restores everything when the frame closes. +function UI:ApplyTransmogBrowseMode() + if not TransmogFrame then return end + + -- Collect vendor-only frames to hide + local hidden = {} + local outfitCollection = TransmogFrame.OutfitCollection + if outfitCollection then + if outfitCollection.PurchaseOutfitButton then + outfitCollection.PurchaseOutfitButton:Hide() + hidden[#hidden + 1] = outfitCollection.PurchaseOutfitButton + end + if outfitCollection.SaveOutfitButton then + outfitCollection.SaveOutfitButton:Hide() + hidden[#hidden + 1] = outfitCollection.SaveOutfitButton + end + end + if outfitCollection and outfitCollection.MoneyFrame then + outfitCollection.MoneyFrame:Hide() + hidden[#hidden + 1] = outfitCollection.MoneyFrame + end + + -- Hide the Situations tab (vendor-only feature) + local wardrobeCollection = TransmogFrame.WardrobeCollection + local tabHeaders = wardrobeCollection and wardrobeCollection.TabHeaders + local situationsTab + if tabHeaders then + for _, tab in ipairs({ tabHeaders:GetChildren() }) do + if tab.GetText and tab:GetText() == "Situations" then + tab:Hide() + hidden[#hidden + 1] = tab + situationsTab = tab + break + end + end + end + + -- Disable right-click on outfit name buttons (shows "Change Name/Icon" + -- which doesn't work without a vendor). ScrollBox items are recycled, + -- so re-register on each visible frame and hook the ScrollBox update. + local outfitScrollBox = outfitCollection and outfitCollection.OutfitList + and outfitCollection.OutfitList.ScrollBox + if outfitScrollBox and outfitScrollBox.EnumerateFrames then + local function disableOutfitRightClick() + for _, itemFrame in outfitScrollBox:EnumerateFrames() do + local outfitBtn = itemFrame.OutfitButton + if outfitBtn and outfitBtn.RegisterForClicks then + outfitBtn:RegisterForClicks("LeftButtonUp") + end + end + end + disableOutfitRightClick() + -- Re-apply when ScrollBox recycles frames (scroll, resize) + if not outfitScrollBox._efBrowseHooked then + outfitScrollBox._efBrowseHooked = true + hooksecurefunc(outfitScrollBox, "Update", function() + if TransmogFrame._efBrowseMode then + disableOutfitRightClick() + end + end) + end + end + TransmogFrame._efBrowseMode = true + + TransmogFrame._efHiddenFrames = hidden + + -- Browse-mode message (left panel, where vendor buttons were) + if not TransmogFrame._efBrowseMsg then + local msg = TransmogFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + msg:SetText("Visit a transmogrification vendor for full functionality.") + msg:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + msg:SetJustifyH("CENTER") + TransmogFrame._efBrowseMsg = msg + end + local msg = TransmogFrame._efBrowseMsg + msg:ClearAllPoints() + local anchor = outfitCollection and outfitCollection.PurchaseOutfitButton + if anchor then + msg:SetPoint("TOP", anchor, "TOP", 0, 0) + elseif outfitCollection then + msg:SetPoint("BOTTOM", outfitCollection, "BOTTOM", 0, 20) + else + msg:SetPoint("BOTTOM", TransmogFrame, "BOTTOM", 0, 30) + end + msg:SetWidth((outfitCollection and outfitCollection:GetWidth() - 20) or 280) + msg:Show() + + -- Situations message (top right, near the hidden tab) + if not TransmogFrame._efSituationsMsg then + local sitMsg = TransmogFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + sitMsg:SetText("See transmogrification vendor\nto adjust Situations settings.") + sitMsg:SetTextColor(GOLD_COLOR[1], GOLD_COLOR[2], GOLD_COLOR[3]) + sitMsg:SetJustifyH("RIGHT") + TransmogFrame._efSituationsMsg = sitMsg + end + local sitMsg = TransmogFrame._efSituationsMsg + sitMsg:ClearAllPoints() + if tabHeaders then + sitMsg:SetPoint("LEFT", tabHeaders, "RIGHT", 8, 6) + else + sitMsg:SetPoint("TOPRIGHT", TransmogFrame, "TOPRIGHT", -40, -55) + end + sitMsg:Show() + + -- Restore on hide (one-shot hook, reads _efHiddenFrames at fire time) + if not TransmogFrame._efBrowseHooked then + TransmogFrame._efBrowseHooked = true + TransmogFrame:HookScript("OnHide", function(self) + self._efBrowseMode = nil + if self._efHiddenFrames then + for _, frame in ipairs(self._efHiddenFrames) do + frame:Show() + end + self._efHiddenFrames = nil + end + if self._efBrowseMsg then + self._efBrowseMsg:Hide() + end + if self._efSituationsMsg then + self._efSituationsMsg:Hide() + end + -- Restore right-click on outfit buttons + local oc = self.OutfitCollection + local sb = oc and oc.OutfitList and oc.OutfitList.ScrollBox + if sb and sb.EnumerateFrames then + for _, itemFrame in sb:EnumerateFrames() do + local outfitBtn = itemFrame.OutfitButton + if outfitBtn and outfitBtn.RegisterForClicks then + outfitBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + end + end + end + end) + end +end + function UI:SelectResult(data) if not data then return end @@ -4893,6 +5031,7 @@ function UI:SelectResult(data) end if TransmogFrame then ShowUIPanel(TransmogFrame) + self:ApplyTransmogBrowseMode() end return end @@ -5109,6 +5248,7 @@ function UI:DirectOpen(data) end if TransmogFrame then ShowUIPanel(TransmogFrame) + UI:ApplyTransmogBrowseMode() end return end From 2392ad958a385924c51844172d8622ec256f2a04 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:14:08 -0400 Subject: [PATCH 36/44] Show outfit lock status in search results Display the transmog-outfit-spellFrame-active atlas overlay on outfit icons that are locked. Add lock status and usage hint to the outfit hover tooltip. Lock toggling requires Blizzard's secure ActionButton handler (ChangeDisplayedOutfit is protected), so the tooltip directs users to the action bar or transmog window for toggling. --- UI.lua | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/UI.lua b/UI.lua index 38bcd81..6f30777 100644 --- a/UI.lua +++ b/UI.lua @@ -268,6 +268,7 @@ local function SetRowIcon(btn, kind, value, iconSize) btn.icon.outfitID = nil btn.icon.lootItemID = nil if btn.iconCooldown then btn.iconCooldown:Hide() end + if btn._lockOverlay then btn._lockOverlay:Hide() end if kind == "atlas" then btn.icon:SetAtlas(value) elseif kind == "file" or kind == "path" then @@ -3198,14 +3199,29 @@ function UI:CreateResultButton(index) elseif self.icon.outfitID then GameTooltip:SetOwner(self, "ANCHOR_RIGHT") GameTooltip:SetText(self.data and self.data.name or "Outfit") + GameTooltip:AddLine("Instant", 1, 1, 1) + GameTooltip:AddLine("Transmogrify the appearance of your\nweapons and armor", 0, 1, 0) local activeID = lastEquippedOutfitID or (C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetActiveOutfitID and C_TransmogOutfitInfo.GetActiveOutfitID()) if activeID and activeID == self.icon.outfitID then + GameTooltip:AddLine(" ") GameTooltip:AddLine("Currently equipped", 0.3, 1, 0.3) else + GameTooltip:AddLine(" ") GameTooltip:AddLine("Click to equip", 1, 0.82, 0) end + if C_TransmogOutfitInfo and C_TransmogOutfitInfo.IsLockedOutfit then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Lock Appearance:", 1, 1, 1) + GameTooltip:AddLine("Prevent this appearance from being\nreplaced by a Situation", 1, 0.82, 0) + if C_TransmogOutfitInfo.IsLockedOutfit(self.icon.outfitID) then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("Currently locked", 0.3, 1, 0.3) + end + GameTooltip:AddLine(" ") + GameTooltip:AddLine("", 0.5, 0.5, 0.5) + end GameTooltip:Show() -- Loot item tooltip elseif self.icon.lootItemID then @@ -4096,6 +4112,13 @@ function UI:ShowHierarchicalResults(hierarchical, preserveScroll) SetRowIcon(resultRow, "hidden", nil, theme.iconSize) end + -- Outfit lock overlay (dashed border when locked) + if data.outfitID and C_TransmogOutfitInfo and C_TransmogOutfitInfo.IsLockedOutfit then + UI:UpdateOutfitLockOverlay(resultRow, C_TransmogOutfitInfo.IsLockedOutfit(data.outfitID)) + elseif resultRow._lockOverlay then + resultRow._lockOverlay:Hide() + end + -- Cooldown sweep overlay (toys and outfits) resultRow.amountText:Hide() if data.toyItemID and iconFileID and GetItemCooldown then @@ -4879,6 +4902,21 @@ end -- Hide vendor-only transmog controls when opened via search (not at an NPC). -- Shows a message explaining full functionality requires a transmogrifier. -- Restores everything when the frame closes. +-- Show or hide the lock overlay on a result row's outfit icon. +function UI:UpdateOutfitLockOverlay(resultRow, isLocked) + if not resultRow.icon then return end + if not resultRow._lockOverlay then + local overlay = resultRow:CreateTexture(nil, "OVERLAY") + overlay:SetAtlas("transmog-outfit-spellFrame-active") + overlay:SetPoint("CENTER", resultRow.icon, "CENTER", 0, 0) + resultRow._lockOverlay = overlay + + end + local size = (resultRow.icon:GetWidth() or 16) + 6 + resultRow._lockOverlay:SetSize(size, size) + resultRow._lockOverlay:SetShown(isLocked) +end + function UI:ApplyTransmogBrowseMode() if not TransmogFrame then return end From c248a289bbd7ce477caae7f3d26cd6b23a7766ac Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:16:34 -0400 Subject: [PATCH 37/44] Sync EJ difficulty with loot search filter SyncEJLootFilter now also calls EJ_SetDifficulty to match EasyFind's lootDifficulty setting, so the EJ shows loot for the correct difficulty when navigating. --- Database.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Database.lua b/Database.lua index 550718a..546f9ed 100644 --- a/Database.lua +++ b/Database.lua @@ -538,6 +538,16 @@ function Database:SyncEJLootFilter() elseif lf.classID then setFilter(lf.classID, 0) end + + -- Sync difficulty + local setDifficulty = EJ("SetDifficulty") + if setDifficulty then + local diffKey = EasyFind.db.lootDifficulty or "normal" + local diffIDs = LOOT_DIFF_IDS[diffKey] + if diffIDs then + setDifficulty(diffIDs.raid or diffIDs.dungeon) + end + end end -- Rebuild uiSearchData loot entries from cache based on current spec + difficulty selection. From 7db309955cb438d76a77c8f61bb4129d0a0a8a18 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:18:51 -0400 Subject: [PATCH 38/44] Revert "Sync EJ difficulty with loot search filter" This reverts commit c248a289bbd7ce477caae7f3d26cd6b23a7766ac. --- Database.lua | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Database.lua b/Database.lua index 546f9ed..550718a 100644 --- a/Database.lua +++ b/Database.lua @@ -538,16 +538,6 @@ function Database:SyncEJLootFilter() elseif lf.classID then setFilter(lf.classID, 0) end - - -- Sync difficulty - local setDifficulty = EJ("SetDifficulty") - if setDifficulty then - local diffKey = EasyFind.db.lootDifficulty or "normal" - local diffIDs = LOOT_DIFF_IDS[diffKey] - if diffIDs then - setDifficulty(diffIDs.raid or diffIDs.dungeon) - end - end end -- Rebuild uiSearchData loot entries from cache based on current spec + difficulty selection. From 306611ef23a71f0663556a6dec7b4776dffc1cef Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:34:10 -0400 Subject: [PATCH 39/44] Clear button dismisses active navigation highlights Show the clear (X) button whenever a navigation guide is active, not just when there is search text. Clicking it clears both text and active highlights/arrows. Expose Highlight:IsActive() for the visibility check and notify the clear button on guide start/cancel. --- Highlight.lua | 22 ++++++++++++++++++++++ UI.lua | 23 +++++++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Highlight.lua b/Highlight.lua index b18a2a0..fcb2f5c 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -224,6 +224,8 @@ function Highlight:StartGuide(guideData) print("Guide error: " .. tostring(err)) end end) + + self:NotifyClearButton() end -- Start the guide at a specific step (used by DirectOpen to skip to final highlight) @@ -251,6 +253,8 @@ function Highlight:StartGuideAtStep(guideData, stepIndex) print("Guide error: " .. tostring(err)) end end) + + self:NotifyClearButton() end -- Advance through already-satisfied steps immediately instead of one-per-tick. @@ -1168,6 +1172,11 @@ function Highlight:UpdateGuide() -- EJ loot tab: highlight if not selected, advance when loot content visible if step.ejLootTab then + -- Set difficulty after boss is selected (must come after instance/encounter selection) + if step.ejDifficultyID then + local setDiff = (C_EncounterJournal and C_EncounterJournal.SetDifficulty) or _G["EJ_SetDifficulty"] + if setDiff then setDiff(step.ejDifficultyID) end + end local lootTab = _G["EncounterJournalEncounterFrameInfoLootTab"] if not lootTab or not lootTab:IsShown() then return end -- Check if loot content is visible (tab already selected) @@ -2237,11 +2246,22 @@ function Highlight:HideHighlight() end end +function Highlight:IsActive() + return currentGuide ~= nil +end + +function Highlight:NotifyClearButton() + if ns.UI and ns.UI.searchFrame and ns.UI.searchFrame.UpdateClearButtonVisibility then + ns.UI.searchFrame.UpdateClearButtonVisibility() + end +end + function Highlight:ClearAll() self:HideHighlight() currentGuide = nil currentStepIndex = nil if stepTicker then stepTicker:Cancel(); stepTicker = nil end + self:NotifyClearButton() end -- Portrait menu helpers @@ -2634,4 +2654,6 @@ function Highlight:Cancel() currentGuide = nil currentStepIndex = nil + + self:NotifyClearButton() end diff --git a/UI.lua b/UI.lua index 6f30777..9522e84 100644 --- a/UI.lua +++ b/UI.lua @@ -517,6 +517,7 @@ end function UI:CreateSearchFrame() searchFrame = CreateFrame("Frame", "EasyFindSearchFrame", UIParent, "BackdropTemplate") + UI.searchFrame = searchFrame searchFrame:SetSize(250, ns.SEARCHBAR_HEIGHT) searchFrame:SetFrameStrata("MEDIUM") searchFrame:SetMovable(true) @@ -725,10 +726,11 @@ function UI:CreateSearchFrame() editBox:ClearFocus() editBox.placeholder:Show() UI:HideResults() + EasyFind:ClearAll() end) clearTextBtn:SetScript("OnEnter", function(self) GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") - GameTooltip:SetText("Clear search text") + GameTooltip:SetText("Clear search text and active highlights") GameTooltip:Show() end) clearTextBtn:SetScript("OnLeave", GameTooltip_Hide) @@ -791,10 +793,14 @@ function UI:CreateSearchFrame() end end) - -- Show/hide the clear-text X based on whether there's text - editBox:HookScript("OnTextChanged", function(self) - clearTextBtn:SetShown(self:GetText() ~= "") - end) + -- Show/hide the clear X based on whether there's text or an active guide + local function UpdateClearButtonVisibility() + local hasText = editBox:GetText() ~= "" + local guideActive = ns.Highlight and ns.Highlight:IsActive() + clearTextBtn:SetShown(hasText or guideActive) + end + editBox:HookScript("OnTextChanged", UpdateClearButtonVisibility) + searchFrame.UpdateClearButtonVisibility = UpdateClearButtonVisibility -- Key repeat with progressive acceleration for held arrow/tab keys. -- Starts at REPEAT_INITIAL delay, accelerates toward REPEAT_FAST over REPEAT_ACCEL seconds. @@ -5090,13 +5096,14 @@ function UI:SelectResult(data) local isRaid = data.lootSourceType == "Raid" local tabIndex = isRaid and 5 or 4 + local ejDiffID = ns.Database and ns.Database:GetEJDifficultyID(data.lootSourceType) local guideData = { steps = { { buttonFrame = "EJMicroButton" }, { waitForFrame = "EncounterJournal", tabIndex = tabIndex }, { waitForFrame = "EncounterJournal", ejInstance = data.lootInstanceName }, { waitForFrame = "EncounterJournal", ejBoss = data.lootSourceName, ejEncounterID = data.encounterID }, - { waitForFrame = "EncounterJournal", ejLootTab = true }, + { waitForFrame = "EncounterJournal", ejLootTab = true, ejDifficultyID = ejDiffID }, { waitForFrame = "EncounterJournal", ejLootItem = data.itemID, ejLootItemName = data.name }, }, } @@ -5386,6 +5393,10 @@ function UI:DirectOpen(data) -- EJ loot tab: click if step.ejLootTab then + if step.ejDifficultyID then + local setDiff = (C_EncounterJournal and C_EncounterJournal.SetDifficulty) or _G["EJ_SetDifficulty"] + if setDiff then setDiff(step.ejDifficultyID) end + end local lootTab = _G["EncounterJournalEncounterFrameInfoLootTab"] if lootTab then ClickButton(lootTab) end end From ef982648efbe51050622ac8093ce2b26b544a46c Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:34:23 -0400 Subject: [PATCH 40/44] Reset clears loot filter, difficulty, and all pins Reset All Settings now resets lootFilter to current spec, lootDifficulty to normal, and wipes all pinned items (UI and map). Add GetEJDifficultyID helper for setting EJ difficulty during loot navigation. --- Database.lua | 10 ++++++++++ Options.lua | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/Database.lua b/Database.lua index 550718a..59be530 100644 --- a/Database.lua +++ b/Database.lua @@ -518,6 +518,16 @@ local LOOT_DIFF_IDS = { mythic = { dungeon = 23, raid = 16 }, } +-- Get the EJ difficulty ID for the current loot difficulty setting. +-- sourceType: "Raid" or "Dungeon" +function Database:GetEJDifficultyID(sourceType) + local diffKey = EasyFind.db.lootDifficulty or "normal" + local diffIDs = LOOT_DIFF_IDS[diffKey] + if not diffIDs then return nil end + local srcKey = sourceType == "Raid" and "raid" or "dungeon" + return diffIDs[srcKey] or diffIDs.raid or diffIDs.dungeon +end + -- Sync the EJ's internal loot filter to match EasyFind's lootFilter setting. -- Called when the user changes the filter and before loot navigation. function Database:SyncEJLootFilter() diff --git a/Options.lua b/Options.lua index daef26f..a6797e6 100644 --- a/Options.lua +++ b/Options.lua @@ -1722,6 +1722,11 @@ function Options:DoResetAll() EasyFind.db.globalSearchFilters = { zones = true, dungeons = true, raids = true, delves = true } EasyFind.db.localSearchFilters = { instances = true, travel = true, services = true } EasyFind.db.uiSearchFilters = { ui = true, mounts = false, toys = false, pets = false, outfits = false, loot = false, map = false } + EasyFind.db.lootFilter = nil -- nil = current spec + EasyFind.db.lootDifficulty = "normal" + EasyFind.db.pinnedUIItems = {} + EasyFind.db.pinnedUIItemsPerChar = {} + EasyFind.db.pinnedMapItems = {} EasyFind.db.uiMapSearchLocal = true EasyFind.db.alwaysShowRares = false EasyFind.db.optionsPosition = nil From 7c1910058f6a6510fb35ff38a9d4f9e672dd88df Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:41:56 -0400 Subject: [PATCH 41/44] Extract SetEJDifficulty helper, fix per-tick overhead Move EJ difficulty resolution into Database:SetEJDifficulty so both DirectOpen and Highlight ticker share one call site. Use a per-step flag to avoid re-calling SetDifficulty every 0.1s tick while on the ejLootTab step. --- Database.lua | 7 ++++++- Highlight.lua | 7 +++---- UI.lua | 5 ++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Database.lua b/Database.lua index 59be530..32afff5 100644 --- a/Database.lua +++ b/Database.lua @@ -519,7 +519,6 @@ local LOOT_DIFF_IDS = { } -- Get the EJ difficulty ID for the current loot difficulty setting. --- sourceType: "Raid" or "Dungeon" function Database:GetEJDifficultyID(sourceType) local diffKey = EasyFind.db.lootDifficulty or "normal" local diffIDs = LOOT_DIFF_IDS[diffKey] @@ -528,6 +527,12 @@ function Database:GetEJDifficultyID(sourceType) return diffIDs[srcKey] or diffIDs.raid or diffIDs.dungeon end +function Database:SetEJDifficulty(diffID) + if not diffID then return end + local setDiff = EJ("SetDifficulty") + if setDiff then setDiff(diffID) end +end + -- Sync the EJ's internal loot filter to match EasyFind's lootFilter setting. -- Called when the user changes the filter and before loot navigation. function Database:SyncEJLootFilter() diff --git a/Highlight.lua b/Highlight.lua index fcb2f5c..0d54322 100644 --- a/Highlight.lua +++ b/Highlight.lua @@ -1172,10 +1172,9 @@ function Highlight:UpdateGuide() -- EJ loot tab: highlight if not selected, advance when loot content visible if step.ejLootTab then - -- Set difficulty after boss is selected (must come after instance/encounter selection) - if step.ejDifficultyID then - local setDiff = (C_EncounterJournal and C_EncounterJournal.SetDifficulty) or _G["EJ_SetDifficulty"] - if setDiff then setDiff(step.ejDifficultyID) end + if step.ejDifficultyID and not step._diffSet then + step._diffSet = true + if ns.Database then ns.Database:SetEJDifficulty(step.ejDifficultyID) end end local lootTab = _G["EncounterJournalEncounterFrameInfoLootTab"] if not lootTab or not lootTab:IsShown() then return end diff --git a/UI.lua b/UI.lua index 9522e84..900cc3e 100644 --- a/UI.lua +++ b/UI.lua @@ -5393,9 +5393,8 @@ function UI:DirectOpen(data) -- EJ loot tab: click if step.ejLootTab then - if step.ejDifficultyID then - local setDiff = (C_EncounterJournal and C_EncounterJournal.SetDifficulty) or _G["EJ_SetDifficulty"] - if setDiff then setDiff(step.ejDifficultyID) end + if step.ejDifficultyID and ns.Database then + ns.Database:SetEJDifficulty(step.ejDifficultyID) end local lootTab = _G["EncounterJournalEncounterFrameInfoLootTab"] if lootTab then ClickButton(lootTab) end From 1badb55969476173f9517f25f2f65fa08aa96c2c Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:43:26 -0400 Subject: [PATCH 42/44] [worktree-rename] Revert addon rename (EasyFind-loot-search -> EasyFind) --- EasyFind-loot-search.toc => EasyFind.toc | 4 ++-- MapSearch.lua | 6 +++--- UI.lua | 8 ++++---- Utils.lua | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) rename EasyFind-loot-search.toc => EasyFind.toc (88%) diff --git a/EasyFind-loot-search.toc b/EasyFind.toc similarity index 88% rename from EasyFind-loot-search.toc rename to EasyFind.toc index 537736c..d618ab8 100644 --- a/EasyFind-loot-search.toc +++ b/EasyFind.toc @@ -1,8 +1,8 @@ ## Interface: 120005, 120001, 120000 -## Title: EasyFind (loot-search) +## Title: EasyFind ## Notes: Search and locate UI elements and map locations with ease ## Author: justawower -## Version: 1.4.0 +## Version: 1.5.0 ## IconTexture: 136460 ## Category: UI ## AddonCompartmentFunc: EasyFind_OnAddonCompartmentClick diff --git a/MapSearch.lua b/MapSearch.lua index 18e8db8..17a0ad3 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -12,7 +12,7 @@ local mmin, mmax, mpi = Utils.mmin, Utils.mmax, Utils.mpi local pcall, xpcall, tostring = Utils.pcall, Utils.xpcall, Utils.tostring local ErrorHandler = Utils.ErrorHandler -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-loot-search\\textures\\lightning-bolt" +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" local GOLD_COLOR = ns.GOLD_COLOR local YELLOW_HIGHLIGHT = ns.YELLOW_HIGHLIGHT local DEFAULT_OPACITY = ns.DEFAULT_OPACITY @@ -100,7 +100,7 @@ local INDICATOR_STYLES = { preRotated = false, -- Needs mpi rotation to point down }, ["EasyFind Arrow"] = { - texture = "Interface\\AddOns\\EasyFind-loot-search\\Images\\arrow-hq", + texture = "Interface\\AddOns\\EasyFind\\Images\\arrow-hq", texCoord = nil, preRotated = true, -- Already points down, no rotation needed }, @@ -479,7 +479,7 @@ local function CreateWaypointTracker() local layer = nearTrackFrame:CreateTexture(nil, "OVERLAY", nil, i) layer:SetSize(ringSize, ringSize) layer:SetPoint("CENTER", Minimap, "CENTER", 0, 0) - layer:SetTexture("Interface\\AddOns\\EasyFind-loot-search\\textures\\near-track-ring") + layer:SetTexture("Interface\\AddOns\\EasyFind\\textures\\near-track-ring") layer:SetBlendMode("ADD") layer:SetVertexColor(1, 1, 0.3, 1) ringLayers[i] = layer diff --git a/UI.lua b/UI.lua index 900cc3e..e96ee32 100644 --- a/UI.lua +++ b/UI.lua @@ -26,7 +26,7 @@ local IsShiftKeyDown = IsShiftKeyDown local GetCursorPosition = GetCursorPosition local wipe = wipe -local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind-loot-search\\textures\\lightning-bolt" +local LIGHTNING_BOLT_TEX = "Interface\\AddOns\\EasyFind\\textures\\lightning-bolt" local REP_BAR_WIDTH = 100 local searchFrame @@ -1249,8 +1249,8 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) -- Shared constants for dropdown popups and radio buttons (one place to tweak) local RADIO_SIZE = 14 - local RADIO_OFF_TEX = "Interface\\AddOns\\EasyFind-loot-search\\radio-off" - local RADIO_ON_TEX = "Interface\\AddOns\\EasyFind-loot-search\\radio-on" + local RADIO_OFF_TEX = "Interface\\AddOns\\EasyFind\\radio-off" + local RADIO_ON_TEX = "Interface\\AddOns\\EasyFind\\radio-on" local DROPDOWN_BAR_SIZE = { 120, 27 } local DROPDOWN_ARROW_SIZE = { 20, 20 } local DROPDOWN_ARROW_DIM = 0.7 @@ -1899,7 +1899,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local csArrow = classSelectBtn:CreateTexture(nil, "ARTWORK") csArrow:SetSize(16, 16) csArrow:SetPoint("RIGHT", -4, 0) - csArrow:SetTexture("Interface\\AddOns\\EasyFind-loot-search\\flyout-arrow") + csArrow:SetTexture("Interface\\AddOns\\EasyFind\\flyout-arrow") local csHL = classSelectBtn:CreateTexture(nil, "HIGHLIGHT") csHL:SetAllPoints() csHL:SetColorTexture(1, 1, 1, 0.1) diff --git a/Utils.lua b/Utils.lua index 0e3b638..aa05a70 100644 --- a/Utils.lua +++ b/Utils.lua @@ -126,9 +126,9 @@ ns.SEARCHBAR_FONT = "EasyFindSearchFont" -- Custom 3-part search bar border with chamfered corners. -- Fill (BACKGROUND) and border (ARTWORK) use identical shapes from custom TGA textures. -- Fill is tinted black with tunable opacity; border uses Blizzard's action bar gray. -local SEARCH_TEX_FILL = "Interface\\AddOns\\EasyFind-loot-search\\Textures\\SearchBarFill" -local SEARCH_TEX_BORDER = "Interface\\AddOns\\EasyFind-loot-search\\Textures\\SearchBarBorder" -local CLEAR_BTN_TEX = "Interface\\AddOns\\EasyFind-loot-search\\Textures\\clear-button" +local SEARCH_TEX_FILL = "Interface\\AddOns\\EasyFind\\Textures\\SearchBarFill" +local SEARCH_TEX_BORDER = "Interface\\AddOns\\EasyFind\\Textures\\SearchBarBorder" +local CLEAR_BTN_TEX = "Interface\\AddOns\\EasyFind\\Textures\\clear-button" local SEARCH_CAP_W = 8 local SEARCH_TEX_W = 64 local TC_LEFT = {0, SEARCH_CAP_W / SEARCH_TEX_W, 0, 1} From edaf59f9dd768fddc8e5432a6de8fefe87658271 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:44:38 -0400 Subject: [PATCH 43/44] Bump to 1.5.0, update changelog and What's New Add 1.5.0 changelog covering loot search, transmog outfits, browse mode, EJ filter sync, dynamic category ordering, and related fixes. Update What's New popup and README features list. Bump TOC version. --- CHANGELOG.md | 20 ++++++++++++++++++++ README.md | 2 ++ UI.lua | 12 +++++++----- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d705901..f5ed93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to EasyFind will be documented in this file. --- +## [1.5.0] - 2026-04-07 + +### Added +- **Loot Search**: Search dungeon and raid loot by item name, slot, stats, boss, or instance. Filter by class, specialization, and difficulty. Click a result to navigate directly to the item in the Encounter Journal +- **Transmog Outfit Search**: Your saved transmog outfits appear in search results. Click to equip, with cooldown tracking and active-outfit indicators +- **Outfit Lock Status**: Locked outfits display a visual overlay on their icon with lock details on hover +- **Transmog Browse Mode**: Opening the transmog window via search hides vendor-only controls and shows guidance messages. All controls restore when the window closes or when visiting a transmogrifier +- **Clear Button for Navigation**: The search bar clear button now appears during active step-by-step guides and dismisses highlights and arrows in addition to clearing text +- **Dynamic Category Ordering**: Search result categories (UI, Mounts, Loot, etc.) sort by best match score instead of a fixed order + +### Fixed +- **Loot Navigation**: Fixed item highlighting in the Encounter Journal loot tab and added recovery when clicking a different boss mid-guide + +### Technical +- EJ class/spec and difficulty filters sync with search filter selection before loot navigation +- Guides fast-forward through already-satisfied steps instead of advancing one per ticker tick +- Slightly thicker gold separator lines between result category groups + +--- + ## [1.4.0] - 2026-03-20 ### Added diff --git a/README.md b/README.md index 16db2bc..1b58058 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Find and navigate to any interface element such as: * Professions * All currencies from your character's Currency tab, including seasonal and legacy currencies (shows current amounts inline) * Your collected mounts, toys, and battle pets (click to summon or use) +* Dungeon and raid loot filtered by class, spec, and difficulty (click to open in the Encounter Journal) +* Transmog outfits with click-to-equip, cooldown tracking, and lock status display * Player portrait menu options (Set Focus, Loot Specialization, Dungeon/Raid Difficulty, Edit Mode, PvP Flag, etc.) * Map search results (zones, dungeons, POIs) via the filter dropdown * Coverage is always expanding. If a panel exists in the default UI, the goal is for EasyFind to reach it diff --git a/UI.lua b/UI.lua index e96ee32..50c5e6f 100644 --- a/UI.lua +++ b/UI.lua @@ -5995,11 +5995,13 @@ function UI:ShowWhatsNew(version) body:SetJustifyH("LEFT") body:SetSpacing(4) body:SetText( - "|cffFFD100\226\128\162|r |cffffffffRare Mob Tracking|r\n" .. - " Active rares appear as searchable pins on the world map\n" .. - " Toggle auto-tracking to pin all nearby rares automatically\n" .. - "|cffFFD100\226\128\162|r |cffffffffGreat Vault|r\n" .. - " Rewards panel now searchable in UI search" + "|cffFFD100\226\128\162|r |cffffffffLoot Search|r\n" .. + " Search dungeon and raid loot by name, slot, stats, or source\n" .. + " Filter by class, spec, and difficulty\n" .. + " Click to navigate directly to the item in the Encounter Journal\n" .. + "|cffFFD100\226\128\162|r |cffffffffTransmog Outfits|r\n" .. + " Saved outfits appear in search results, click to equip\n" .. + " Browse the transmog window from search without a vendor" ) -- Footer - anchored below body so it can't overlap From 57df9ade8feb7eac8fa18181226a5b0c63b48063 Mon Sep 17 00:00:00 2001 From: wowaddonmaker Date: Tue, 7 Apr 2026 09:57:47 -0400 Subject: [PATCH 44/44] Fix luacheck warnings for CI Add C_Item, SetOverrideBindingClick, ClearOverrideBindings to luacheckrc. Move TransmogFrame to globals for field writes. Remove unused variables and dead code. Add forward declaration for LayoutSpecPopup. --- .luacheckrc | 6 ++++-- Database.lua | 2 -- MapSearch.lua | 1 - UI.lua | 39 ++------------------------------------- 4 files changed, 6 insertions(+), 42 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 44a6855..9e45d5e 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -20,6 +20,7 @@ globals = { "BINDING_NAME_EASYFIND_TOGGLE_FOCUS", "BINDING_NAME_EASYFIND_CLEAR", "EncounterJournal", + "TransmogFrame", } -- WoW API globals (read-only) @@ -48,7 +49,7 @@ read_globals = { "CharacterFrame", "PaperDollFrame", "AchievementFrame", "CharacterStatsPane", "PaperDollTitlesPane", "PaperDollEquipmentManagerPane", "CurrencyFrame", - "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", "TransmogFrame", + "SpellBookFrame", "PlayerSpellsFrame", "CollectionsJournal", "PVEFrame", "ReputationFrame", "TokenFrame", "GroupFinderFrame", "LFGListFrame", "HelpFrame", "ClassTalentFrame", "GuildMicroButton", "StoreMicroButton", "PlayerFrame", "StoreFrame", @@ -63,7 +64,7 @@ read_globals = { "Menu", "ScrollBoxConstants", -- C_* namespaces - "C_AddOns", "C_AchievementInfo", "C_AreaPoiInfo", "C_CurrencyInfo", + "C_AddOns", "C_AchievementInfo", "C_AreaPoiInfo", "C_CurrencyInfo", "C_Item", "C_EncounterJournal", "C_GossipInfo", "C_MajorFactions", "C_Map", "C_Minimap", "C_MountJournal", "C_Navigation", "C_PetJournal", "C_Reputation", "C_SuperTrack", "C_TaxiMap", "C_Texture", "C_Timer", @@ -83,6 +84,7 @@ read_globals = { "EJ_SetDifficulty", "EJ_SetLootFilter", "EJ_SetSlotFilter", "EJ_GetNumLoot", "EJ_GetLootInfoByIndex", "HasAction", "PlaceAction", "PickupAction", "ClearCursor", "GetActionCooldown", + "SetOverrideBindingClick", "ClearOverrideBindings", -- Data types "UiMapPoint", diff --git a/Database.lua b/Database.lua index 32afff5..677b6bd 100644 --- a/Database.lua +++ b/Database.lua @@ -944,7 +944,6 @@ function Database:PopulateDynamicLoot(scanAllSpecs) local GetItemInfoInstant = GetItemInfoInstant -- Build list of {diffKey, diffID} pairs per source type - local sourceType = { dungeon = "dungeon", raid = "raid" } local function getDiffPairs(isRaid) local pairs = {} local st = isRaid and "raid" or "dungeon" @@ -2616,7 +2615,6 @@ function Database:SearchUI(query, skipCategories) -- Loot: match by item name, slot, stats, and source keywords. -- Each query word scores against all keyword types and takes -- the best match. Words that match nothing eliminate the item. - score = 0 local totalScore = 0 local nameWords = GetWords(nameLower) diff --git a/MapSearch.lua b/MapSearch.lua index 17a0ad3..1879aa3 100644 --- a/MapSearch.lua +++ b/MapSearch.lua @@ -2354,7 +2354,6 @@ function MapSearch:CreateSearchFrame() -- Filter dropdown keyboard navigation takes priority if dropdown then - local rowCount = #dropdown.rows if key == "DOWN" then MapSearch.StartKeyRepeat(key, function() local rc = #dropdown.rows diff --git a/UI.lua b/UI.lua index 50c5e6f..7312763 100644 --- a/UI.lua +++ b/UI.lua @@ -1251,9 +1251,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local RADIO_SIZE = 14 local RADIO_OFF_TEX = "Interface\\AddOns\\EasyFind\\radio-off" local RADIO_ON_TEX = "Interface\\AddOns\\EasyFind\\radio-on" - local DROPDOWN_BAR_SIZE = { 120, 27 } - local DROPDOWN_ARROW_SIZE = { 20, 20 } - local DROPDOWN_ARROW_DIM = 0.7 local POPUP_BG_COLOR = { 0.05, 0.05, 0.05, 0.95 } local POPUP_BORDER_COLOR = { 0.6, 0.6, 0.6, 1 } local POPUP_BACKDROP = { @@ -1269,28 +1266,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) frame:SetBackdropBorderColor(unpack(POPUP_BORDER_COLOR)) end - local function CreateDropdownBar(parent) - local bar = CreateFrame("Button", nil, parent) - bar:SetSize(unpack(DROPDOWN_BAR_SIZE)) - local bg = bar:CreateTexture(nil, "BACKGROUND") - bg:SetAtlas("common-dropdown-textholder") - bg:SetAllPoints() - local arrow = bar:CreateTexture(nil, "OVERLAY") - arrow:SetAtlas("common-dropdown-a-button-hover") - arrow:SetSize(unpack(DROPDOWN_ARROW_SIZE)) - arrow:SetPoint("RIGHT", -2, -1) - arrow:SetVertexColor(DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM) - local label = bar:CreateFontString(nil, "ARTWORK", "GameFontHighlight") - label:SetPoint("LEFT", 8, 0) - label:SetPoint("RIGHT", arrow, "LEFT", -2, 0) - label:SetJustifyH("LEFT") - label:SetWordWrap(false) - bar:SetScript("OnEnter", function() arrow:SetVertexColor(1, 1, 1) end) - bar:SetScript("OnLeave", function() arrow:SetVertexColor(DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM, DROPDOWN_ARROW_DIM) end) - bar:Hide() - return bar, label, arrow - end - -- Creates a single radio texture that swaps between off/on states. -- Returns the texture and a SetChecked(bool) function. local function CreateRadioTexture(parent) @@ -1680,13 +1655,6 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local FLYOUT_ROW_H = 20 local POPUP_WIDTH = 180 local CLASSFLYOUT_WIDTH = 160 - local TOOLTIP_BORDER = "Interface\\Tooltips\\UI-Tooltip-Border" - local BACKDROP_POPUP = { - bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", - edgeFile = TOOLTIP_BORDER, edgeSize = 14, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - } - -- Determine which class to display in the spec popup. -- Always returns a class (falls back to player's class for "all" or nil). local function GetSelectedClass() @@ -1801,8 +1769,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) classFlyout:EnableMouse(true) classFlyout:Hide() - -- Also keep "EasyFindSpecSubFlyout" name for the dropdown close guard - local specSubFlyout = classFlyout + local LayoutSpecPopup -- forward declaration for closures below -- Helper: create a radio-style row local function CreateRadioRow(parent, label, filterVal, width) @@ -1929,7 +1896,7 @@ function UI:CreateUIFilterDropdown(toggleBtn, anchorFrame, searchEditBox) local classHeaderLabel = classHeader:CreateFontString(nil, "ARTWORK", "GameFontHighlight") classHeaderLabel:SetPoint("LEFT", 8, 0) - local function LayoutSpecPopup() + LayoutSpecPopup = function() local selCls = GetSelectedClass() local py = -6 local lvl = specPopup:GetFrameLevel() + 10 @@ -4947,13 +4914,11 @@ function UI:ApplyTransmogBrowseMode() -- Hide the Situations tab (vendor-only feature) local wardrobeCollection = TransmogFrame.WardrobeCollection local tabHeaders = wardrobeCollection and wardrobeCollection.TabHeaders - local situationsTab if tabHeaders then for _, tab in ipairs({ tabHeaders:GetChildren() }) do if tab.GetText and tab:GetText() == "Situations" then tab:Hide() hidden[#hidden + 1] = tab - situationsTab = tab break end end