-- Search the player's known and currently exposed game content. -- Nothing in this module scans the full spell database or chooses an item. -- -- VimFinder is a standalone addon: it needs only the client. Another addon -- -- (VimWow) may replace the hooks below to fit the finder into its own -- -- keys, bag and window focus. -- VimFinder is a standalone addon: it needs only the client and the shared -- options window (VimWowSettings), where its tab sets its key. Another addon -- (VimWow) may replace the hooks below to fit the finder into its bag and -- window focus. -- VimWowFinder = {} -- local Finder = VimWowFinder -- Finder.owner = CreateFrame("Frame", "VimWowFinderOwner", UIParent) -- Finder.capture = CreateFrame("Frame", "VimWowFinderCapture", UIParent) -- Finder.toggle = CreateFrame("Button", "VimWowFinderToggle", UIParent) -- Finder.closeButton = CreateFrame("Button", "VimWowFinderClose", UIParent) -- Finder.up = CreateFrame("Button", "VimWowFinderUp", UIParent) -- Finder.down = CreateFrame("Button", "VimWowFinderDown", UIParent) VimFinder = {} local Finder = VimFinder Finder.owner = CreateFrame("Frame", "VimFinderOwner", UIParent) Finder.capture = CreateFrame("Frame", "VimFinderCapture", UIParent) Finder.toggle = CreateFrame("Button", "VimFinderToggle", UIParent) Finder.closeButton = CreateFrame("Button", "VimFinderClose", UIParent) Finder.up = CreateFrame("Button", "VimFinderUp", UIParent) Finder.down = CreateFrame("Button", "VimFinderDown", UIParent) Finder.results = {} Finder.rows = {} -- -- The key binding window lists the finder under its own header. -- BINDING_HEADER_VIMFINDER = "VimFinder" -- BINDING_NAME_VIMFINDER = "Open the finder" -- -- Alt+Space opens the finder until the player binds the finder a key of -- -- their own in the key binding window. -- The finder's key is set on its tab in VimWow Settings, which names it -- by this; the game's key binding window does not list it. -- BINDING_NAME_VIMFINDER = "Open the finder" -- The finder's key is set on its tab in VimWow Settings. Without -- ClassicAPI's override bindings that key is a game key binding to the -- Bindings.xml command, which the key binding window lists too. BINDING_HEADER_VIMFINDER = "VimFinder" BINDING_NAME_VIMFINDER = "Open the finder" local DEFAULT_KEYS = {"ALT-SPACE"} -- Hooks. Alone, the finder takes its keys unless a key is being set in -- VimWow Settings, opens the bags and claims no window focus. function Finder:IsActive() -- return true return not VimWowBindingsCapturing end -- The keys the finder binds itself while closed, as first, second: those -- set on its tab in VimWow Settings, else Alt+Space. function Finder:OpenKeys() -- if type(GetBindingKey) == "function" and GetBindingKey("VIMFINDER") then -- return nil, nil -- end -- return DEFAULT_KEYS[1], DEFAULT_KEYS[2] if VimWowSettings and VimWowSettings.BindingKeys then return VimWowSettings:BindingKeys("VimFinder", "VIMFINDER", DEFAULT_KEYS) end return DEFAULT_KEYS[1], DEFAULT_KEYS[2] end -- A choice is about to open a window (now, or once the server answers -- within the given seconds). function Finder:ExpectWindow(seconds) end -- Opens the bags, before the finder closes. Opening, never toggling, keeps -- an open bag open. function Finder:ShowBags() if type(OpenBackpack) == "function" then OpenBackpack() end if type(OpenBag) == "function" and type(GetContainerNumSlots) == "function" then for bag = 1, 4 do if (GetContainerNumSlots(bag) or 0) > 0 then OpenBag(bag) end end end end -- After the finder closed on a bag choice: the item's bag and slot, or -- nothing for Disenchant, whose item the player picks. function Finder:SelectBagSlot(bag, slot) end local function visible(name) local frame = getglobal(name) return frame and frame:IsVisible() end local function inCombat() return type(UnitAffectingCombat) == "function" and UnitAffectingCombat("player") end local function add(list, kind, name, detail, index, book) if name and name ~= "" then local item = {kind = kind, name = name, detail = detail or kind, index = index, book = book} table.insert(list, item) return item end end -- local professions = {"Alchemy", "Blacksmithing", "Cooking", "Enchanting", -- "Engineering", "First Aid", "Leatherworking", "Tailoring"} -- Fishing casts toward the player's facing and needs no target. local professions = {"Alchemy", "Blacksmithing", "Cooking", "Enchanting", "Engineering", "First Aid", "Fishing", "Leatherworking", "Tailoring"} -- These spellbook entries are combat controls or require a chosen target. -- They add noise to a finder intended for inspection and safe utility casts. local hiddenSpells = {attack = true, shoot = true, attach = true, ["auto attack"] = true, ["auto shot"] = true} -- -- The spell API does not identify target-free casts. Keep this list narrow: -- -- these summon a companion beside the player, with no unit or ground target. -- local companionSummons = {["summon imp"] = true, -- ["summon voidwalker"] = true, ["summon succubus"] = true, -- ["summon incubus"] = true, ["summon felhunter"] = true, -- ["summon felguard"] = true} -- The spell API does not identify target-free casts. Keep this list narrow: -- these summon a companion or place an object beside the player, with no -- unit or ground target. local companionSummons = {["summon imp"] = true, ["summon voidwalker"] = true, ["summon succubus"] = true, ["summon incubus"] = true, ["summon felhunter"] = true, ["summon felguard"] = true, ["basic campfire"] = true} local function isProfession(name) for _, profession in ipairs(professions) do if name == profession or name == getglobal(string.upper( string.gsub(profession, " ", "_"))) then return true end end end local function canActivate(item) -- if item.kind == "tracking" or item.kind == "trade" -- or item.kind == "craft" then return true end -- if item.kind == "tracking" or item.kind == "trade" -- or item.kind == "craft" or item.kind == "bag" then return true end -- if item.kind == "tracking" or item.kind == "trade" -- or item.kind == "craft" or item.kind == "bag" -- or item.kind == "window" then return true end if item.kind == "tracking" or item.kind == "trade" or item.kind == "craft" or item.kind == "bag" or item.kind == "window" or item.kind == "recipe" then return true end if item.kind ~= "spell" then return false end if hiddenSpells[string.lower(item.name)] then return false end if item.name == "Disenchant" or isProfession(item.name) then return true end if type(IsPassiveSpell) == "function" and IsPassiveSpell(item.index, "spell") then return false end if type(IsHarmfulSpell) == "function" and IsHarmfulSpell(item.index, "spell") then return false end if companionSummons[string.lower(item.name)] then return true end local spellID if type(GetSpellInfo) == "function" then local _, _, _, _, _, _, _, _, _, id = GetSpellInfo(item.index, "spell") spellID = id end if C_Spell and type(C_Spell.IsSelfBuff) == "function" and spellID and C_Spell.IsSelfBuff(spellID) then return true end if type(IsHelpfulSpell) == "function" and type(IsHarmfulSpell) == "function" and IsHelpfulSpell(item.index, "spell") then return "self" end return false end -- Every known recipe of the character's professions, their windows open -- or not (the user, 2026-10-18: derived from the professions the -- character has, never listed by hand). With ClassicAPI: a profession is a -- spellbook spell whose skill line (C_SpellBook.GetSpellSkillLine) the -- character has (GetSkillLineRank) and that has craftable recipes -- (C_TradeSkillUI.GetTradeSkillListRecipes, the line's full list from the -- client's data); the recipes known are IsPlayerSpell's. A scan runs at -- each opening, so a recipe learned since shows at once. local recipeLines = {} -- skillID -> {{spellID, createdItem}, ...}, read once local skillOfSpell = {} -- spellID -> {skillID, line name} or false, read once local function recipeAPI() return C_SpellBook and type(C_SpellBook.GetSpellSkillLine) == "function" and type(C_SpellBook.GetSkillLineRank) == "function" and C_TradeSkillUI and type(C_TradeSkillUI.GetTradeSkillListRecipes) == "function" and type(GetSpellInfo) == "function" and type(IsPlayerSpell) == "function" end local function lineRecipes(skillID) if recipeLines[skillID] == nil then local recipes = {} for _, row in ipairs(C_TradeSkillUI.GetTradeSkillListRecipes(skillID, "") or {}) do local spellID = row.spellID or row[1] -- if spellID then -- A recipe has skill-up levels; a class line's item spells -- (Demonology's Create Soulstone) have none, so that line is no -- profession and Enter never casts one of its spells to open -- it (FD11, 2026-10-24). VimWow's native recipe icons take the -- same rows. local grey = row.trivialLevel or row[3] or 0 local green = row.greenLevel or row[4] or 0 if spellID and (grey > 0 or green > 0) then table.insert(recipes, {spellID = spellID, createdItem = row.createdItem or row[5]}) end end recipeLines[skillID] = recipes end return recipeLines[skillID] end -- The professions with recipes among the spellbook's spells (list's -- "spell" entries): {{name, skillID, slot}, ...}, the spell named as its -- line (Cooking, not Basic Campfire) opening the window. local function professionLines(list) local lines, byID = {}, {} if not recipeAPI() then return lines end for _, item in ipairs(list) do if item.kind == "spell" then local _, _, _, _, _, _, _, _, _, spellID = GetSpellInfo(item.index, "spell") if spellID and skillOfSpell[spellID] == nil then local lineName, skillID = C_SpellBook.GetSpellSkillLine(spellID) skillOfSpell[spellID] = skillID and {skillID, lineName} or false end local skill = spellID and skillOfSpell[spellID] if skill and C_SpellBook.GetSkillLineRank(skill[1]) and table.getn(lineRecipes(skill[1])) > 0 then local line = byID[skill[1]] if not line then line = {name = skill[2] or item.name, skillID = skill[1], slot = item.index} byID[skill[1]] = line table.insert(lines, line) end if item.name == skill[2] then line.slot = item.index end end end end return lines end Finder.ProfessionLines = professionLines function Finder:Scan() local list = {} if type(GetNumSpellTabs) == "function" and type(GetSpellTabInfo) == "function" and type(GetSpellName) == "function" then for tab = 1, GetNumSpellTabs() or 0 do local _, _, offset, count = GetSpellTabInfo(tab) for slot = (offset or 0) + 1, (offset or 0) + (count or 0) do local name, rank = GetSpellName(slot, "spell") local detail = "Known spell" if rank and rank ~= "" then detail = detail .. " · " .. rank end if name and not hiddenSpells[string.lower(name)] then add(list, "spell", name, detail, slot, "spell") end end end end if type(HasPetSpells) == "function" and type(GetSpellName) == "function" then local count = HasPetSpells() if type(count) == "number" then local book = BOOKTYPE_PET or "pet" for slot = 1, count do local name, rank = GetSpellName(slot, book) local detail = "Known pet spell" if rank and rank ~= "" then detail = detail .. " · " .. rank end add(list, "petspell", name, detail, slot, book) end end end if type(GetNumTrackingTypes) == "function" and type(GetTrackingInfo) == "function" then for i = 1, GetNumTrackingTypes() or 0 do local name, _, active, _, spellID = GetTrackingInfo(i) local tracker = add(list, "tracking", name, active and "Tracking is on · Enter to turn off" or "Tracking is off · Enter to turn on", i, spellID) if tracker then tracker.active = active for _, spell in ipairs(list) do if spell.kind == "spell" and spell.name == name then tracker.spellSlot = spell.index break end end end end end if visible("TradeSkillFrame") and type(GetNumTradeSkills) == "function" and type(GetTradeSkillInfo) == "function" then for i = 1, GetNumTradeSkills() or 0 do local name, kind = GetTradeSkillInfo(i) if kind ~= "header" then add(list, "trade", name, "Profession recipe · Enter to select", i) end end end if visible("CraftFrame") and type(GetNumCrafts) == "function" and type(GetCraftInfo) == "function" then for i = 1, GetNumCrafts() or 0 do local name, _, kind = GetCraftInfo(i) if kind ~= "header" then add(list, "craft", name, "Craft recipe · Enter to select", i) end end end -- the known recipes of every profession (above), but those of the -- window open, listed already local listed = {} for _, item in ipairs(list) do if item.kind == "trade" or item.kind == "craft" then listed[item.name] = true end end for _, line in ipairs(professionLines(list)) do for _, recipe in ipairs(lineRecipes(line.skillID)) do if IsPlayerSpell(recipe.spellID) then local name = GetSpellInfo(recipe.spellID) if name and not listed[name] then listed[name] = true local item = add(list, "recipe", name, line.name .. " recipe · Enter to open", recipe.spellID, line.slot) if item then item.createdItem = recipe.createdItem end end end end end if type(GetContainerNumSlots) == "function" and type(GetContainerItemLink) == "function" then for bag = 0, 4 do for slot = 1, GetContainerNumSlots(bag) or 0 do local link = GetContainerItemLink(bag, slot) -- local name = link and string.match(link, "%[([^%]]+)%]") -- Lua 5.0 has no string.match (ClassicAPI adds it). local _, _, name = string.find(link or "", "%[([^%]]+)%]") -- if name then add(list, "bag", name, -- "In bag " .. bag .. ", slot " .. slot, slot, bag) end if name then add(list, "bag", name, "In bag " .. bag .. ", slot " .. slot .. " · Enter to show", slot, bag) end end end end -- Every window the client's key bindings open, keyed or not: the -- toggles under the Interface header of its key binding list, by the -- client's own names. if type(GetNumBindings) == "function" and type(GetBinding) == "function" and type(RunBinding) == "function" then local header for i = 1, GetNumBindings() or 0 do local command = GetBinding(i) if command and string.sub(command, 1, 6) == "HEADER" then header = command elseif command and header == "HEADER_INTERFACE" and (string.find(command, "^TOGGLE") or string.find(command, "^OPEN")) then add(list, "window", getglobal("BINDING_NAME_" .. command) or command, "Window · Enter to open or close", i, command) end end end self.catalog = list self:Search() end -- A contiguous match ranks ahead of initials/subsequence matches. local function score(name, query) if query == "" then return 0 end local lower = string.lower(name) local start = string.find(lower, query, 1, true) if start then return 100 - start end local from, gaps = 1, 0 for i = 1, string.len(query) do local found = string.find(lower, string.sub(query, i, i), from, true) if not found then return nil end gaps = gaps + found - from from = found + 1 end return 20 - gaps end -- The bag search highlights slots with the same fuzzy match. Finder.Score = score -- Use a content identity rather than a spell slot or bag position, which can -- change between sessions. Kind keeps similarly named actions separate. local function usageKey(item) return item.kind .. ":" .. item.name end -- local function useCount(item) -- local counts = type(VimWowDB) == "table" and VimWowDB.finderUsage local function useCount(item) local counts = type(VimFinderDB) == "table" and VimFinderDB.usage local count = type(counts) == "table" and counts[usageKey(item)] return type(count) == "number" and count > 0 and count or 0 end -- function Finder:RecordUse(item) -- if type(VimWowDB) ~= "table" then return end -- if type(VimWowDB.finderUsage) ~= "table" then -- VimWowDB.finderUsage = {} -- end -- local key = usageKey(item) -- VimWowDB.finderUsage[key] = useCount(item) + 1 -- end -- How often each choice was used, saved per account in VimFinderDB. function Finder:RecordUse(item) if type(VimFinderDB) ~= "table" then VimFinderDB = {} end if type(VimFinderDB.usage) ~= "table" then VimFinderDB.usage = {} end local key = usageKey(item) VimFinderDB.usage[key] = useCount(item) + 1 end function Finder:Search() local query = string.lower(self.edit:GetText() or "") local matches = {} for _, item in ipairs(self.catalog or {}) do local rank = score(item.name, query) if rank then item.usage = useCount(item) -- Familiar choices can break close search matches, but cannot -- overtake a much better text match. item.score = rank + (query ~= "" and math.min(item.usage * 2, 10) or 0) table.insert(matches, item) end end table.sort(matches, function(a, b) if query == "" and a.usage ~= b.usage then return a.usage > b.usage end if a.score ~= b.score then return a.score > b.score end if a.usage ~= b.usage then return a.usage > b.usage end if a.name ~= b.name then return a.name < b.name end if a.kind ~= b.kind then if a.kind == "tracking" then return true end if b.kind == "tracking" then return false end end return a.kind < b.kind end) self.results = matches self.selected = 1 self:Draw() end function Finder:Tooltip(item) if not GameTooltip then return end GameTooltip:SetOwner(self.frame, "ANCHOR_NONE") GameTooltip:ClearAllPoints() GameTooltip:SetPoint("BOTTOMLEFT", self.frame, "BOTTOMRIGHT", 12, 0) local shown, supported if (item.kind == "spell" or item.kind == "petspell") and GameTooltip.SetSpell then supported = true shown = GameTooltip:SetSpell(item.index, item.book) elseif item.kind == "tracking" and item.spellSlot and GameTooltip.SetSpell then supported = true shown = GameTooltip:SetSpell(item.spellSlot, "spell") elseif item.kind == "tracking" and item.book and GameTooltip.SetSpellByID then supported = true shown = GameTooltip:SetSpellByID(item.book) elseif item.kind == "trade" and GameTooltip.SetTradeSkillItem then supported = true shown = GameTooltip:SetTradeSkillItem(item.index) elseif item.kind == "craft" and GameTooltip.SetCraftSpell then supported = true shown = GameTooltip:SetCraftSpell(item.index) elseif item.kind == "bag" and GameTooltip.SetBagItem then supported = true shown = GameTooltip:SetBagItem(item.book, item.index) -- a recipe: the item it makes, else the recipe's spell elseif item.kind == "recipe" and item.createdItem and item.createdItem > 0 and GameTooltip.SetHyperlink then supported = true shown = GameTooltip:SetHyperlink("item:" .. item.createdItem .. ":0:0:0") elseif item.kind == "recipe" and GameTooltip.SetSpellByID then supported = true shown = GameTooltip:SetSpellByID(item.index) end if not supported or shown == false then if GameTooltip.SetText then GameTooltip:SetText(item.name) end if GameTooltip.AddLine then GameTooltip:AddLine(item.detail) end end GameTooltip:Show() end -- The window's layout: the dialog look of the Game Menu and popups. local WIDTH, HEIGHT = 520, 300 local ROW_LEFT, ROW_TOP, ROW_HEIGHT = 26, 60, 24 local ROW_WIDTH = WIDTH - 2 * ROW_LEFT local WINDOW_ICON = "Interface\\Icons\\INV_Misc_Book_09" local UNKNOWN_ICON = "Interface\\Icons\\INV_Misc_QuestionMark" -- Each result's icon, as the client's own windows show it. local function iconFor(item) local texture if (item.kind == "spell" or item.kind == "petspell") and type(GetSpellTexture) == "function" then texture = GetSpellTexture(item.index, item.book) elseif item.kind == "tracking" and type(GetTrackingInfo) == "function" then local _ _, texture = GetTrackingInfo(item.index) elseif item.kind == "trade" and type(GetTradeSkillIcon) == "function" then texture = GetTradeSkillIcon(item.index) elseif item.kind == "craft" and type(GetCraftIcon) == "function" then texture = GetCraftIcon(item.index) elseif item.kind == "bag" and type(GetContainerItemInfo) == "function" then texture = GetContainerItemInfo(item.book, item.index) elseif item.kind == "window" then texture = WINDOW_ICON -- elseif item.kind == "recipe" and type(GetSpellInfo) == "function" then -- local _ -- _, _, texture = GetSpellInfo(item.index) -- A recipe shows the item it makes, as the profession window does: many -- crafting spells have only the client's placeholder face as their own -- icon. The spell's icon is kept for a recipe that makes no item -- (enchanting) or whose item the client has not cached yet. elseif item.kind == "recipe" then local _ if item.createdItem and item.createdItem > 0 and type(GetItemInfo) == "function" then _, _, _, _, _, _, _, _, texture = GetItemInfo(item.createdItem) end if not texture and type(GetSpellInfo) == "function" then _, _, texture = GetSpellInfo(item.index) end end return texture or UNKNOWN_ICON end -- function Finder:Draw() -- local count = table.getn(self.results) -- if self.selected > count then self.selected = count end -- if self.selected < 1 then self.selected = 1 end -- local first = math.max(1, self.selected - 5) -- self.selectionBackground:Hide() -- for i, row in ipairs(self.rows) do -- local item = self.results[first + i - 1] -- if item then -- row:SetText((first + i - 1 == self.selected and "> " or " ") -- .. item.name .. " — " .. item.detail) -- if canActivate(item) then row:SetTextColor(1, 1, 1) -- else row:SetTextColor(0.52, 0.52, 0.52) end -- if first + i - 1 == self.selected then -- self.selectionBackground:ClearAllPoints() -- self.selectionBackground:SetPoint("TOPLEFT", self.frame, -- "TOPLEFT", 13, -40 - i * 23 + 2) -- self.selectionBackground:Show() -- end -- row:Show() -- else row:Hide() end -- end -- Each row: the result's icon, its name in white (grey when Enter only -- shows it), and what it is on the right; the gold quest log bar marks the -- selected one. function Finder:Draw() local count = table.getn(self.results) if self.selected > count then self.selected = count end if self.selected < 1 then self.selected = 1 end local first = math.max(1, self.selected - 5) self.selectionBackground:Hide() for i, row in ipairs(self.rows) do local item = self.results[first + i - 1] local detail, icon = self.details[i], self.icons[i] if item then row:SetText(item.name) detail:SetText(item.detail) icon:SetTexture(iconFor(item)) if canActivate(item) then row:SetTextColor(1, 1, 1) detail:SetTextColor(0.65, 0.65, 0.65) else -- row:SetTextColor(0.52, 0.52, 0.52) -- detail:SetTextColor(0.52, 0.52, 0.52) -- A lighter grey, readable under the gold selection bar too. row:SetTextColor(0.7, 0.7, 0.7) detail:SetTextColor(0.7, 0.7, 0.7) end if first + i - 1 == self.selected then self.selectionBackground:ClearAllPoints() self.selectionBackground:SetPoint("TOPLEFT", self.frame, "TOPLEFT", ROW_LEFT - 4, -ROW_TOP - (i - 1) * ROW_HEIGHT) self.selectionBackground:Show() end row:Show(); detail:Show(); icon:Show() else row:Hide(); detail:Hide(); icon:Hide() end end -- self.count:SetText(count .. " results") self.count:SetText(count .. (count == 1 and " result" or " results")) local selected = self.results[self.selected] if selected then self:Tooltip(selected) elseif GameTooltip and GameTooltip.IsOwned and GameTooltip:IsOwned(self.frame) then GameTooltip:Hide() end end function Finder:Move(delta) if not self.frame:IsVisible() then return end self.selected = math.max(1, math.min(table.getn(self.results), (self.selected or 1) + delta)) self:Draw() end local function scrollRecipe(kind, index, total) local frameName = kind == "trade" and "TradeSkillListScrollFrame" or "CraftListScrollFrame" local displayed = kind == "trade" and (TRADE_SKILLS_DISPLAYED or 19) or (CRAFTS_DISPLAYED or 8) local height = kind == "trade" and (TRADE_SKILL_HEIGHT or 16) or (CRAFT_SKILL_HEIGHT or 16) local scroll = getglobal(frameName) if not scroll or type(FauxScrollFrame_GetOffset) ~= "function" or type(FauxScrollFrame_SetOffset) ~= "function" then return end local offset = FauxScrollFrame_GetOffset(scroll) or 0 if index <= offset then offset = index - 1 elseif index > offset + displayed then offset = index - displayed end offset = math.max(0, math.min(total - displayed, offset)) FauxScrollFrame_SetOffset(scroll, offset) local bar = getglobal(frameName .. "ScrollBar") if bar then bar:SetValue(offset * height) end end -- A recipe chosen from a closed (or another) profession window: its -- window opens, then the recipe is selected in it, as Enter on an open -- window's recipe does. True once it is selected. function Finder:SelectPendingRecipe() local pending = self.pendingRecipe if not pending then return false end if visible("TradeSkillFrame") and type(GetNumTradeSkills) == "function" and type(TradeSkillFrame_SetSelection) == "function" then for i = 1, GetNumTradeSkills() or 0 do local name, kind = GetTradeSkillInfo(i) if kind ~= "header" and name == pending.name then scrollRecipe("trade", i, GetNumTradeSkills()) TradeSkillFrame_SetSelection(i) if type(TradeSkillFrame_Update) == "function" then TradeSkillFrame_Update() end self.pendingRecipe = nil return true end end end if visible("CraftFrame") and type(GetNumCrafts) == "function" and type(CraftFrame_SetSelection) == "function" then for i = 1, GetNumCrafts() or 0 do local name, _, kind = GetCraftInfo(i) if kind ~= "header" and name == pending.name then scrollRecipe("craft", i, GetNumCrafts()) CraftFrame_SetSelection(i) if type(CraftFrame_Update) == "function" then CraftFrame_Update() end self.pendingRecipe = nil return true end end end return false end -- Waits for the window a recipe opened (its server answer), a few seconds. local recipeWaiter = CreateFrame("Frame", "VimFinderRecipeWaiter", UIParent) recipeWaiter:Hide() recipeWaiter:SetScript("OnUpdate", function() local pending = Finder.pendingRecipe if not pending or GetTime() > pending.untilTime then Finder.pendingRecipe = nil recipeWaiter:Hide() elseif Finder:SelectPendingRecipe() then recipeWaiter:Hide() end end) function Finder:Activate() local item = self.results[self.selected] -- if not item or inCombat() then return end -- -- Windows open in combat, as their keys do. -- if not item or (inCombat() and item.kind ~= "window") then return end -- Windows open and spells (pet spells, tracking) cast in combat, as -- their keys do (the user, 2026-10-15: Rockbiter Weapon); bag items, -- trade skills and crafts still wait. local castable = item and (item.kind == "spell" or item.kind == "petspell" or item.kind == "tracking") if not item or (inCombat() and item.kind ~= "window" and not castable) then return end local action = canActivate(item) if not action then return end if item.kind == "window" then self:RecordUse(item) self:Close() -- The window it opens takes focus; one it closes claims nothing. -- if VimWowPanelFocus then VimWowPanelFocus:ClaimNext(0.5) end self:ExpectWindow(0.5) RunBinding(item.book) elseif item.kind == "tracking" and item.active and type(CancelTrackingBuff) == "function" then CancelTrackingBuff() self:RecordUse(item) self:Scan() elseif item.kind == "tracking" and type(SetTracking) == "function" then SetTracking(item.index) self:RecordUse(item) self:Scan() elseif item.kind == "trade" and visible("TradeSkillFrame") and type(TradeSkillFrame_SetSelection) == "function" then scrollRecipe("trade", item.index, GetNumTradeSkills()) TradeSkillFrame_SetSelection(item.index) if type(TradeSkillFrame_Update) == "function" then TradeSkillFrame_Update() end self:RecordUse(item) self:Close() elseif item.kind == "craft" and visible("CraftFrame") and type(CraftFrame_SetSelection) == "function" then scrollRecipe("craft", item.index, GetNumCrafts()) CraftFrame_SetSelection(item.index) if type(CraftFrame_Update) == "function" then CraftFrame_Update() end self:RecordUse(item) self:Close() elseif item.kind == "recipe" then -- its profession's window (cast as Enter on the profession does), -- then the recipe selected there; at once when it is open already self.pendingRecipe = {name = item.name, untilTime = GetTime() + 3} self:RecordUse(item) self:Close() if not self:SelectPendingRecipe() and type(CastSpell) == "function" then self:ExpectWindow(3) CastSpell(item.book, "spell") recipeWaiter:Show() end -- elseif item.kind == "bag" then -- -- Opens the combined bag with that item's slot selected. -- if not VimWowCombinedBags then return end -- VimWowCombinedBags:Show() -- self:RecordUse(item) -- self:Close() -- if VimWowPanelFocus and VimWowPanelFocus.Select -- and VimWowPanelFocus:Select("bags") and VimWowNavigator then -- VimWowNavigator:Refresh() -- VimWowNavigator:SelectBagSlot(item.book, item.index) -- end elseif item.kind == "bag" then -- Opens the bags; a host (VimWow) selects that item's slot. self:ShowBags() self:RecordUse(item) self:Close() self:SelectBagSlot(item.book, item.index) elseif item.kind == "spell" and item.name == "Disenchant" then -- The player must click an item; never auto-select or destroy one. if type(CastSpell) ~= "function" then return end CastSpell(item.index, "spell") self:RecordUse(item) -- VimWowCombinedBags:Show() -- self:Close() -- if VimWowPanelFocus and VimWowPanelFocus.Select -- and VimWowPanelFocus:Select("bags") -- and VimWowNavigator then VimWowNavigator:Refresh() end self:ShowBags() self:Close() self:SelectBagSlot() elseif item.kind == "spell" and action == "self" then if type(CastSpellByName) == "function" then -- A spell that opens a window (Smelting, Poisons) gives it focus -- once the server answers. -- if VimWowPanelFocus then VimWowPanelFocus:ClaimNext(3) end self:ExpectWindow(3) CastSpellByName(item.name, true) self:RecordUse(item) self:Close() end elseif item.kind == "spell" and type(CastSpell) == "function" then -- A profession (Tailoring, Enchanting) gives its window focus once -- the server answers. -- if VimWowPanelFocus then VimWowPanelFocus:ClaimNext(3) end self:ExpectWindow(3) CastSpell(item.index, "spell") self:RecordUse(item) self:Close() end end function Finder:KeyMove(key, delta) local now = GetTime() if self.lastKey == key and now - (self.lastKeyAt or 0) < 0.02 then return end self.lastKey, self.lastKeyAt = key, now self:Move(delta) end function Finder:Close() self.frame:Hide() self.edit:ClearFocus() self.capture:EnableKeyboard(false) self.ctrlLeft, self.ctrlRight = nil, nil self:RefreshBindings() if GameTooltip and GameTooltip.IsOwned and GameTooltip:IsOwned(self.frame) then GameTooltip:Hide() end end function Finder:Toggle() if self.frame:IsVisible() then return end -- if not VimWow_IsEnabled() then return end if not self:IsActive() then return end local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() if focus and focus ~= self.edit then return end self.frame:Show() self:RefreshBindings() self.edit:SetText("") self:Scan() self.edit:SetFocus() self.ctrlLeft = IsLeftControlKeyDown and IsLeftControlKeyDown() or nil self.ctrlRight = IsRightControlKeyDown and IsRightControlKeyDown() or nil if self.ctrlLeft or self.ctrlRight then self.edit:ClearFocus() self.capture:EnableKeyboard(true) end end -- Finder.frame = CreateFrame("Frame", "VimWowFinderFrame", UIParent) Finder.frame = CreateFrame("Frame", "VimFinderFrame", UIParent) -- Finder.frame:SetWidth(600) -- Finder.frame:SetHeight(260) Finder.frame:SetWidth(WIDTH) Finder.frame:SetHeight(HEIGHT) Finder.frame:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 95) -- Finder.frame:SetFrameStrata("DIALOG") -- Over every window and unit frame (some unit frame bars draw above DIALOG), -- under tooltips. Finder.frame:SetFrameStrata("FULLSCREEN_DIALOG") -- Finder.frame:SetBackdrop({bgFile = "Interface/Tooltips/UI-Tooltip-Background", -- edgeFile = "Interface/Tooltips/UI-Tooltip-Border", edgeSize = 12, -- insets = {left = 3, right = 3, top = 3, bottom = 3}}) -- The dialog look of the Game Menu and the popups: dark marble, silver -- border, and the title on a header plate. Finder.frame:SetBackdrop({bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", tile = true, tileSize = 32, edgeSize = 32, insets = {left = 11, right = 12, top = 12, bottom = 11}}) -- The marble lets about 40% of the world through; a black layer under it -- (one layer below the finder, still over every window and unit frame) -- leaves about 15%, enough to notice a change behind the finder. As the -- finder's child it shows, hides and fades with it. Finder.shade = CreateFrame("Frame", "VimFinderShade", Finder.frame) Finder.shade:SetFrameStrata("FULLSCREEN") Finder.shade:SetPoint("TOPLEFT", Finder.frame, "TOPLEFT", 11, -12) Finder.shade:SetPoint("BOTTOMRIGHT", Finder.frame, "BOTTOMRIGHT", -12, 11) Finder.shadeTexture = Finder.shade:CreateTexture(nil, "BACKGROUND") Finder.shadeTexture:SetTexture("Interface/Buttons/WHITE8X8") Finder.shadeTexture:SetVertexColor(0, 0, 0, 0.63) Finder.shadeTexture:SetAllPoints(Finder.shade) Finder.header = Finder.frame:CreateTexture(nil, "ARTWORK") Finder.header:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header") Finder.header:SetWidth(256) Finder.header:SetHeight(64) Finder.header:SetPoint("TOP", Finder.frame, "TOP", 0, 12) Finder.title = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") Finder.title:SetPoint("TOP", Finder.header, "TOP", 0, -14) Finder.title:SetText("Finder") -- Finder.selectionBackground = Finder.frame:CreateTexture(nil, "ARTWORK") -- Finder.selectionBackground:SetTexture("Interface/Buttons/WHITE8X8") -- Finder.selectionBackground:SetVertexColor(0.03, 0.03, 0.03, 0.55) -- Finder.selectionBackground:SetWidth(574) -- Finder.selectionBackground:SetHeight(22) -- The quest log's selection bar, in gold. Finder.selectionBackground = Finder.frame:CreateTexture(nil, "ARTWORK") Finder.selectionBackground:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") Finder.selectionBackground:SetBlendMode("ADD") Finder.selectionBackground:SetVertexColor(1, 0.82, 0, 1) Finder.selectionBackground:SetWidth(ROW_WIDTH + 8) Finder.selectionBackground:SetHeight(ROW_HEIGHT) Finder.selectionBackground:Hide() Finder.capture:SetFrameStrata("TOOLTIP") Finder.capture:EnableKeyboard(false) -- Finder.edit = CreateFrame("EditBox", "VimWowFinderInput", Finder.frame, -- "InputBoxTemplate") Finder.edit = CreateFrame("EditBox", "VimFinderInput", Finder.frame, "InputBoxTemplate") Finder.edit:SetAutoFocus(false) -- Finder.edit:SetWidth(550) -- Finder.edit:SetHeight(28) -- Finder.edit:SetPoint("TOP", Finder.frame, "TOP", 0, -15) -- The template's left cap reaches 5 pixels left of the box. Finder.edit:SetWidth(ROW_WIDTH - 13) Finder.edit:SetHeight(20) Finder.edit:SetPoint("TOPLEFT", Finder.frame, "TOPLEFT", ROW_LEFT + 9, -30) -- Finder.count = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") -- Finder.count:SetPoint("BOTTOMRIGHT", Finder.frame, "BOTTOMRIGHT", -15, 10) Finder.count = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") Finder.count:SetPoint("BOTTOMRIGHT", Finder.frame, "BOTTOMRIGHT", -ROW_LEFT, 20) -- The keys, in green, as the client's own key labels. Finder.hints = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") Finder.hints:SetPoint("BOTTOMLEFT", Finder.frame, "BOTTOMLEFT", ROW_LEFT, 20) Finder.hints:SetTextColor(0.65, 0.65, 0.65) Finder.hints:SetText("|cff00ff00Enter|r Use |cff00ff00Tab|r / " .. "|cff00ff00Ctrl+J/K|r Move |cff00ff00Esc|r Close") Finder.details = {} Finder.icons = {} -- for i = 1, 8 do -- local row = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") -- row:SetPoint("TOPLEFT", Finder.frame, "TOPLEFT", 18, -40 - i * 23) -- row:SetWidth(560) -- Finder.rows[i] = row -- end for i = 1, 8 do local top = -ROW_TOP - (i - 1) * ROW_HEIGHT local icon = Finder.frame:CreateTexture(nil, "OVERLAY") icon:SetWidth(20) icon:SetHeight(20) icon:SetPoint("TOPLEFT", Finder.frame, "TOPLEFT", ROW_LEFT, top - 2) icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) Finder.icons[i] = icon local row = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") row:SetPoint("TOPLEFT", Finder.frame, "TOPLEFT", ROW_LEFT + 28, top - 6) row:SetWidth(ROW_WIDTH / 2 - 28) row:SetHeight(14) row:SetJustifyH("LEFT") Finder.rows[i] = row local detail = Finder.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") detail:SetPoint("TOPRIGHT", Finder.frame, "TOPRIGHT", -ROW_LEFT - 2, top - 7) detail:SetWidth(ROW_WIDTH / 2) detail:SetHeight(12) detail:SetJustifyH("RIGHT") Finder.details[i] = detail end Finder.edit:SetScript("OnTextChanged", function() Finder:Search() end) Finder.edit:SetScript("OnEscapePressed", function() Finder:Close() end) Finder.edit:SetScript("OnEnterPressed", function() Finder:Activate() end) Finder.edit:SetScript("OnTabPressed", function() Finder:Move(IsShiftKeyDown and IsShiftKeyDown() and -1 or 1) end) -- The 1.12 EditBox consumes arrows before OnKeyDown in the stock client. -- Keep this route for clients that forward them; Tab is the native fallback. Finder.edit:SetScript("OnKeyDown", function(_, key) -- The 1.12 client passes the key in arg1 (ClassicAPI also passes it). key = key or arg1 if key == "UP" then Finder:Move(-1) elseif key == "DOWN" then Finder:Move(1) elseif key == "J" and IsControlKeyDown and IsControlKeyDown() then Finder:Move(1) elseif key == "K" and IsControlKeyDown and IsControlKeyDown() then Finder:Move(-1) end end) Finder.capture:SetScript("OnKeyDown", function(_, key) key = key or arg1 if not Finder.frame:IsVisible() or not (Finder.ctrlLeft or Finder.ctrlRight) then return end if key == "J" or key == "DOWN" then Finder:KeyMove("next", 1) elseif key == "K" or key == "UP" then Finder:KeyMove("previous", -1) elseif key == "ESCAPE" then Finder:Close() elseif key == "ENTER" then Finder:Activate() end end) Finder.toggle:SetScript("OnClick", function() Finder:Toggle() end) Finder.closeButton:SetScript("OnClick", function() Finder:Close() end) Finder.up:SetScript("OnClick", function() Finder:KeyMove("previous", -1) end) Finder.down:SetScript("OnClick", function() Finder:KeyMove("next", 1) end) Finder.frame:Hide() -- Whether the finder may be open and take its keys: its host allows it and -- no other text box has the keyboard. function Finder:Allowed() if not self:IsActive() then return false end local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() return not (focus and focus ~= self.edit) end -- function Finder:RefreshBindings(active) -- if active == nil then -- active = VimWow_IsEnabled() and not VimWowBindingsCapturing -- and type(SetOverrideBindingClick) == "function" -- and type(ClearOverrideBindings) == "function" -- local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() -- if focus and focus ~= self.edit then active = false end -- end -- local binding = active and VimWow_BindingSignature("VIMWOWFINDER", -- {"ALT-SPACE"}) or "" -- Override bindings come from ClassicAPI; without them the finder still -- opens from its key binding and keeps Esc, Enter, Tab and Ctrl+J/K. -- Without ClassicAPI's override bindings the keys set on the finder's tab -- are game key bindings to its Bindings.xml command: a key is taken only -- while no other binding has it, and a key the tab no longer names is given -- back. VimFinderDB.gameKeys keeps the keys bound this way. Nothing is set -- before the client's own bindings have loaded (PLAYER_ENTERING_WORLD). function Finder:ApplyGameBindings(first, second) if type(SetBinding) ~= "function" or type(GetBindingAction) ~= "function" then return end if type(VimFinderDB) ~= "table" then VimFinderDB = {} end if type(VimFinderDB.gameKeys) ~= "table" then VimFinderDB.gameKeys = {} end local held = VimFinderDB.gameKeys local wanted = {} if first then wanted[first] = true end if second then wanted[second] = true end local changed = false for key in pairs(held) do if not wanted[key] then if GetBindingAction(key) == "VIMFINDER" then SetBinding(key) changed = true end held[key] = nil end end for key in pairs(wanted) do local action = GetBindingAction(key) if action == nil or action == "" then SetBinding(key, "VIMFINDER") changed = true held[key] = true elseif action == "VIMFINDER" then held[key] = true end end if changed and type(SaveBindings) == "function" then SaveBindings(type(GetCurrentBindingSet) == "function" and GetCurrentBindingSet() or 1) end end function Finder:RefreshBindings(active) if type(SetOverrideBindingClick) ~= "function" then if not self.bindingsReadyAt or GetTime() < self.bindingsReadyAt then return end local first, second = self:OpenKeys() local wanted = tostring(first) .. "," .. tostring(second) if wanted ~= self.gameBinding then self.gameBinding = wanted self:ApplyGameBindings(first, second) end return end -- With ClassicAPI the key is an override binding. The game key bindings -- the finder set in a session without it are given back once: kept, -- the old key still opened the finder after a key change on the Finder -- tab. Bindings the player set in the key binding window stay. if not self.gameKeysReleased and self.bindingsReadyAt and GetTime() >= self.bindingsReadyAt then self.gameKeysReleased = true self:ApplyGameBindings(nil, nil) end if active == nil then active = self:Allowed() end active = active and type(SetOverrideBindingClick) == "function" and type(ClearOverrideBindings) == "function" local first, second if active then first, second = self:OpenKeys() end local binding = active and (tostring(first) .. "," .. tostring(second)) or "" if active and self.frame:IsVisible() then binding = binding .. ":navigation" end if binding == self.binding then return end if type(ClearOverrideBindings) == "function" then ClearOverrideBindings(self.owner) end if active then -- VimWow_Override(self.owner, "VIMWOWFINDER", -- {"ALT-SPACE"}, self.toggle, true) local toggle = self.toggle:GetName() if first then SetOverrideBindingClick(self.owner, true, first, toggle) end if second then SetOverrideBindingClick(self.owner, true, second, toggle) end if self.frame:IsVisible() then SetOverrideBindingClick(self.owner, true, "ESCAPE", self.closeButton:GetName()) SetOverrideBindingClick(self.owner, true, "CTRL-K", self.up:GetName()) SetOverrideBindingClick(self.owner, true, "CTRL-J", self.down:GetName()) SetOverrideBindingClick(self.owner, true, "CTRL-UP", self.up:GetName()) SetOverrideBindingClick(self.owner, true, "CTRL-DOWN", self.down:GetName()) end end self.binding = binding end Finder.owner:SetScript("OnUpdate", function() -- Ctrl was released last frame (MODIFIER_STATE_CHANGED): typing again. if Finder.refocus then Finder.refocus = nil if Finder.frame:IsVisible() and not (Finder.ctrlLeft or Finder.ctrlRight) then Finder.capture:EnableKeyboard(false) Finder.edit:SetFocus() end end -- Without ClassicAPI (which brings IsLeftControlKeyDown and the -- MODIFIER_STATE_CHANGED event) Ctrl is read each frame while the -- finder is open, so Ctrl+J/K still move. if type(IsLeftControlKeyDown) ~= "function" and Finder.frame:IsVisible() and type(IsControlKeyDown) == "function" then local held = IsControlKeyDown() and true or false if held ~= ((Finder.ctrlLeft or Finder.ctrlRight) and true or false) then Finder.ctrlLeft, Finder.ctrlRight = held or nil, nil if held then Finder.edit:ClearFocus() Finder.capture:EnableKeyboard(true) else Finder.capture:EnableKeyboard(false) Finder.edit:SetFocus() end end end local now = GetTime() if now < (Finder.nextUpdate or 0) then return end Finder.nextUpdate = now + 0.5 -- local active = VimWow_IsEnabled() and not VimWowBindingsCapturing -- and type(SetOverrideBindingClick) == "function" -- and type(ClearOverrideBindings) == "function" -- local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() -- if focus and focus ~= Finder.edit then active = false end local active = Finder:Allowed() if not active and Finder.frame:IsVisible() then Finder:Close() end Finder:RefreshBindings(active) if Finder.frame:IsVisible() then -- Finder.frame:SetAlpha(inCombat() and 0.22 or 0.86) -- Opaque out of combat: the dialog background's own see-through -- shows enough of the world to notice a change there. -- Finder.frame:SetAlpha(inCombat() and 0.22 or 1) -- 60% in combat: 22% was too faint to read (the user, 2026-10-15). Finder.frame:SetAlpha(inCombat() and 0.6 or 1) end end) -- for _, event in ipairs({"SPELLS_CHANGED", "TRADE_SKILL_UPDATE", -- "CRAFT_UPDATE", "BAG_UPDATE", "UNIT_PET", "PET_BAR_UPDATE", -- "MODIFIER_STATE_CHANGED"}) do for _, event in ipairs({"SPELLS_CHANGED", "TRADE_SKILL_UPDATE", "CRAFT_UPDATE", "BAG_UPDATE", "UNIT_PET", "PET_BAR_UPDATE", "MODIFIER_STATE_CHANGED", "PLAYER_ENTERING_WORLD"}) do Finder.owner:RegisterEvent(event) end Finder.owner:SetScript("OnEvent", function() -- The client's key bindings have loaded: game bindings may be set a -- second later (RefreshBindings, without ClassicAPI). if event == "PLAYER_ENTERING_WORLD" then Finder.bindingsReadyAt = Finder.bindingsReadyAt or GetTime() + 1 return end if not Finder.frame:IsVisible() then return end if event == "MODIFIER_STATE_CHANGED" then local focus = GetCurrentKeyBoardFocus and GetCurrentKeyBoardFocus() if focus and focus ~= Finder.edit then Finder:Close(); return end if arg1 == "LCTRL" then Finder.ctrlLeft = arg2 == 1 elseif arg1 == "RCTRL" then Finder.ctrlRight = arg2 == 1 else return end -- A focused 1.12 EditBox consumes Ctrl+J/K before bindings dispatch. -- Release focus while Ctrl is held, then resume typing on release. if Finder.ctrlLeft or Finder.ctrlRight then Finder.edit:ClearFocus() Finder.capture:EnableKeyboard(true) else -- Finder.capture:EnableKeyboard(false) -- Finder.edit:SetFocus() -- The search box takes the keyboard back on the next frame -- (OnUpdate). This event fires before the client handles the -- Ctrl release; a search box focused now takes that release, -- and the client then keeps Ctrl held (IsControlKeyDown) after -- a Ctrl key opened the finder, until Ctrl is pressed again. Finder.refocus = true end -- else Finder:Scan() end else -- A rescan keeps the selected result, so Enter still uses the one -- shown after a bag, spell or pet update. local current = Finder.results and Finder.results[Finder.selected] local key = current and usageKey(current) Finder:Scan() for i, item in ipairs(key and Finder.results or {}) do if usageKey(item) == key then Finder.selected = i Finder:Draw() break end end end end) -- The finder's tab in VimWow Settings: its key, kept in VimFinderDB. Finder.settings = { id = "VimFinder", name = "Finder", order = 150, db = function() if type(VimFinderDB) ~= "table" then VimFinderDB = {} end return VimFinderDB end, bindings = {{action = "VIMFINDER", defaults = DEFAULT_KEYS}}, -- A new key is bound at once. onChange = function() Finder.binding = nil end, } if VimWowSettings and VimWowSettings.Register then VimWowSettings:Register(Finder.settings) end